diff --git a/src/EFCore.PG.NTS/Storage/Internal/NpgsqlGeometryTypeMapping.cs b/src/EFCore.PG.NTS/Storage/Internal/NpgsqlGeometryTypeMapping.cs index 857ebc65d..9467af77b 100644 --- a/src/EFCore.PG.NTS/Storage/Internal/NpgsqlGeometryTypeMapping.cs +++ b/src/EFCore.PG.NTS/Storage/Internal/NpgsqlGeometryTypeMapping.cs @@ -7,6 +7,23 @@ // ReSharper disable once CheckNamespace namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal; +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +public interface INpgsqlGeometryTypeMapping +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + RelationalTypeMapping CloneWithElementTypeMapping(RelationalTypeMapping elementTypeMapping); +} + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -14,7 +31,8 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal; /// doing so can result in application failures when updating to a new Entity Framework Core release. /// [UsedImplicitly] -public class NpgsqlGeometryTypeMapping : RelationalGeometryTypeMapping, INpgsqlTypeMapping +public class NpgsqlGeometryTypeMapping : RelationalGeometryTypeMapping, + INpgsqlTypeMapping, INpgsqlGeometryTypeMapping { private readonly bool _isGeography; @@ -49,6 +67,16 @@ protected NpgsqlGeometryTypeMapping(RelationalTypeMappingParameters parameters) protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) => new NpgsqlGeometryTypeMapping(parameters); + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + RelationalTypeMapping INpgsqlGeometryTypeMapping.CloneWithElementTypeMapping(RelationalTypeMapping elementTypeMapping) + => new NpgsqlGeometryTypeMapping( + Parameters.WithCoreParameters(Parameters.CoreParameters.WithElementTypeMapping(elementTypeMapping))); + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.PG.NTS/Storage/Internal/NpgsqlNetTopologySuiteTypeMappingSourcePlugin.cs b/src/EFCore.PG.NTS/Storage/Internal/NpgsqlNetTopologySuiteTypeMappingSourcePlugin.cs index 09558b3e6..3955b16fd 100644 --- a/src/EFCore.PG.NTS/Storage/Internal/NpgsqlNetTopologySuiteTypeMappingSourcePlugin.cs +++ b/src/EFCore.PG.NTS/Storage/Internal/NpgsqlNetTopologySuiteTypeMappingSourcePlugin.cs @@ -57,9 +57,15 @@ public NpgsqlNetTopologySuiteTypeMappingSourcePlugin(INpgsqlNetTopologySuiteOpti var storeTypeName = mappingInfo.StoreTypeName; var isGeography = _options.IsGeographyDefault; - if (clrType is not null && !typeof(Geometry).IsAssignableFrom(clrType)) + if (clrType is not null) { - return null; + if (!clrType.IsAssignableTo(typeof(Geometry))) + { + return null; + } + + // TODO: if store type is null, consider setting it based on the CLR type, i.e. create GEOMETRY(Point) instead of Geometry when + // the CLR property is NTS Point. } if (storeTypeName is not null) @@ -69,18 +75,34 @@ public NpgsqlNetTopologySuiteTypeMappingSourcePlugin(INpgsqlNetTopologySuiteOpti return null; } - if (clrType is null) - { - clrType = parsedSubtype; - } + clrType ??= parsedSubtype; + } + + storeTypeName ??= isGeography ? "geography" : "geometry"; + + Check.DebugAssert(clrType is not null, "clrType is not null"); + + var typeMapping = (RelationalTypeMapping)Activator.CreateInstance( + typeof(NpgsqlGeometryTypeMapping<>).MakeGenericType(clrType), storeTypeName, isGeography)!; + + // TODO: Also restrict the element type mapping based on the user-specified store type? + var elementType = clrType == typeof(MultiPoint) + ? typeof(Point) + : clrType == typeof(MultiLineString) + ? typeof(LineString) + : clrType == typeof(MultiPolygon) + ? typeof(Polygon) + : clrType == typeof(GeometryCollection) + ? typeof(Geometry) + : null; + + if (elementType is not null) + { + var elementTypeMapping = FindMapping(new() { ClrType = elementType })!; + typeMapping = ((INpgsqlGeometryTypeMapping)typeMapping).CloneWithElementTypeMapping(elementTypeMapping); } - return clrType is not null || storeTypeName is not null - ? (RelationalTypeMapping)Activator.CreateInstance( - typeof(NpgsqlGeometryTypeMapping<>).MakeGenericType(clrType ?? typeof(Geometry)), - storeTypeName ?? (isGeography ? "geography" : "geometry"), - isGeography)! - : null; + return typeMapping; } /// diff --git a/src/EFCore.PG/Extensions/NpgsqlServiceCollectionExtensions.cs b/src/EFCore.PG/Extensions/NpgsqlServiceCollectionExtensions.cs index 265e1ec46..a98389e99 100644 --- a/src/EFCore.PG/Extensions/NpgsqlServiceCollectionExtensions.cs +++ b/src/EFCore.PG/Extensions/NpgsqlServiceCollectionExtensions.cs @@ -1,4 +1,3 @@ -using System.Data.Common; using Npgsql.EntityFrameworkCore.PostgreSQL.Diagnostics.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal; @@ -114,6 +113,7 @@ public static IServiceCollection AddEntityFrameworkNpgsql(this IServiceCollectio .TryAdd() .TryAdd() .TryAdd() + .TryAdd() .TryAdd() .TryAdd() .TryAdd() @@ -130,4 +130,4 @@ public static IServiceCollection AddEntityFrameworkNpgsql(this IServiceCollectio return serviceCollection; } -} \ No newline at end of file +} diff --git a/src/EFCore.PG/Infrastructure/Internal/INpgsqlSingletonOptions.cs b/src/EFCore.PG/Infrastructure/Internal/INpgsqlSingletonOptions.cs index aab2b1cff..7da9f06a4 100644 --- a/src/EFCore.PG/Infrastructure/Internal/INpgsqlSingletonOptions.cs +++ b/src/EFCore.PG/Infrastructure/Internal/INpgsqlSingletonOptions.cs @@ -13,9 +13,9 @@ public interface INpgsqlSingletonOptions : ISingletonOptions Version PostgresVersion { get; } /// - /// The backend version to target, but returns unless the user explicitly specified a version. + /// Whether the user has explicitly set the backend version to target. /// - Version? PostgresVersionWithoutDefault { get; } + bool IsPostgresVersionSet { get; } /// /// Whether to target Redshift. @@ -41,4 +41,4 @@ public interface INpgsqlSingletonOptions : ISingletonOptions /// The root service provider for the application, if available. />. /// IServiceProvider? ApplicationServiceProvider { get; } -} \ No newline at end of file +} diff --git a/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs b/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs index 171615a3f..65b9494a0 100644 --- a/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs +++ b/src/EFCore.PG/Infrastructure/Internal/NpgsqlOptionsExtension.cs @@ -32,8 +32,8 @@ public virtual Version PostgresVersion /// /// The backend version to target, but returns unless the user explicitly specified a version. /// - public virtual Version? PostgresVersionWithoutDefault - => _postgresVersion; + public virtual bool IsPostgresVersionSet + => _postgresVersion is not null; /// /// The , or if a connection string or was used diff --git a/src/EFCore.PG/Internal/NpgsqlSingletonOptions.cs b/src/EFCore.PG/Internal/NpgsqlSingletonOptions.cs index 3cb8a4093..3b057b93b 100644 --- a/src/EFCore.PG/Internal/NpgsqlSingletonOptions.cs +++ b/src/EFCore.PG/Internal/NpgsqlSingletonOptions.cs @@ -21,7 +21,7 @@ public class NpgsqlSingletonOptions : INpgsqlSingletonOptions /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - public virtual Version? PostgresVersionWithoutDefault { get; private set; } + public virtual bool IsPostgresVersionSet { get; private set; } /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -79,7 +79,7 @@ public virtual void Initialize(IDbContextOptions options) var coreOptions = options.FindExtension() ?? new(); PostgresVersion = npgsqlOptions.PostgresVersion; - PostgresVersionWithoutDefault = npgsqlOptions.PostgresVersionWithoutDefault; + IsPostgresVersionSet = npgsqlOptions.IsPostgresVersionSet; UseRedshift = npgsqlOptions.UseRedshift; ReverseNullOrderingEnabled = npgsqlOptions.ReverseNullOrdering; UserRangeDefinitions = npgsqlOptions.UserRangeDefinitions; diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMemberTranslatorProvider.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMemberTranslatorProvider.cs index 36ab7f7fa..56971ce73 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMemberTranslatorProvider.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMemberTranslatorProvider.cs @@ -29,8 +29,8 @@ public NpgsqlMemberTranslatorProvider( : base(dependencies) { var npgsqlOptions = contextOptions.FindExtension() ?? new(); - var supportsMultiranges = npgsqlOptions.PostgresVersionWithoutDefault is null - || npgsqlOptions.PostgresVersionWithoutDefault.AtLeast(14); + var supportsMultiranges = !npgsqlOptions.IsPostgresVersionSet + || npgsqlOptions.IsPostgresVersionSet && npgsqlOptions.PostgresVersion.AtLeast(14); var sqlExpressionFactory = (NpgsqlSqlExpressionFactory)dependencies.SqlExpressionFactory; JsonPocoTranslator = new NpgsqlJsonPocoTranslator(typeMappingSource, sqlExpressionFactory, model); diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs index 885d4db45..f2de4aa1b 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs @@ -32,8 +32,8 @@ public NpgsqlMethodCallTranslatorProvider( : base(dependencies) { var npgsqlOptions = contextOptions.FindExtension() ?? new(); - var supportsMultiranges = npgsqlOptions.PostgresVersionWithoutDefault is null - || npgsqlOptions.PostgresVersionWithoutDefault.AtLeast(14); + var supportsMultiranges = !npgsqlOptions.IsPostgresVersionSet + || npgsqlOptions.IsPostgresVersionSet && npgsqlOptions.PostgresVersion.AtLeast(14); var sqlExpressionFactory = (NpgsqlSqlExpressionFactory)dependencies.SqlExpressionFactory; var typeMappingSource = (NpgsqlTypeMappingSource)dependencies.RelationalTypeMappingSource; diff --git a/src/EFCore.PG/Query/Expressions/Internal/PostgresArraySliceExpression.cs b/src/EFCore.PG/Query/Expressions/Internal/PostgresArraySliceExpression.cs new file mode 100644 index 000000000..44d9a201b --- /dev/null +++ b/src/EFCore.PG/Query/Expressions/Internal/PostgresArraySliceExpression.cs @@ -0,0 +1,98 @@ +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal; + +/// +/// A SQL expression that represents a slicing into a PostgreSQL array (e.g. array[2:3]). +/// +/// +/// . +/// +public class PostgresArraySliceExpression : SqlExpression, IEquatable +{ + /// + /// The array being sliced. + /// + public virtual SqlExpression Array { get; } + + /// + /// The lower bound of the slice. + /// + public virtual SqlExpression? LowerBound { get; } + + /// + /// The upper bound of the slice. + /// + public virtual SqlExpression? UpperBound { get; } + + /// + /// Creates a new instance of the class. + /// + /// The array tp slice into. + /// The lower bound of the slice. + /// The upper bound of the slice. + public PostgresArraySliceExpression( + SqlExpression array, + SqlExpression? lowerBound, + SqlExpression? upperBound) + : base(array.Type, array.TypeMapping) + { + Check.NotNull(array, nameof(array)); + + if (lowerBound is null && upperBound is null) + { + throw new ArgumentException("At least one of lowerBound or upperBound must be provided"); + } + + Array = array; + LowerBound = lowerBound; + UpperBound = upperBound; + } + + /// + /// Creates a new expression that is like this one, but using the supplied children. If all of the children are the same, it will + /// return this expression. + /// + /// The property of the result. + /// The lower bound of the slice. + /// The upper bound of the slice. + /// This expression if no children changed, or an expression with the updated children. + public virtual PostgresArraySliceExpression Update(SqlExpression array, SqlExpression? lowerBound, SqlExpression? upperBound) + => array == Array && lowerBound == LowerBound && upperBound == UpperBound + ? this + : new PostgresArraySliceExpression(array, lowerBound, upperBound); + + /// + protected override Expression VisitChildren(ExpressionVisitor visitor) + => Update( + (SqlExpression)visitor.Visit(Array), + (SqlExpression?)visitor.Visit(LowerBound), + (SqlExpression?)visitor.Visit(UpperBound)); + + /// + public virtual bool Equals(PostgresArraySliceExpression? other) + => ReferenceEquals(this, other) + || other is not null + && base.Equals(other) + && Array.Equals(other.Array) + && (LowerBound is null ? other.LowerBound is null : LowerBound.Equals(other.LowerBound)) + && (UpperBound is null ? other.UpperBound is null : UpperBound.Equals(other.UpperBound)); + + /// + public override bool Equals(object? obj) => obj is PostgresArraySliceExpression e && Equals(e); + + /// + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), Array, LowerBound, UpperBound); + + /// + protected override void Print(ExpressionPrinter expressionPrinter) + { + expressionPrinter.Visit(Array); + expressionPrinter.Append("["); + expressionPrinter.Visit(LowerBound); + expressionPrinter.Append(":"); + expressionPrinter.Visit(UpperBound); + expressionPrinter.Append("]"); + } + + /// + public override string ToString() => $"{Array}[{LowerBound}:{UpperBound}]"; +} diff --git a/src/EFCore.PG/Query/Expressions/Internal/PostgresBinaryExpression.cs b/src/EFCore.PG/Query/Expressions/Internal/PostgresBinaryExpression.cs index a26093dfe..13ad583a8 100644 --- a/src/EFCore.PG/Query/Expressions/Internal/PostgresBinaryExpression.cs +++ b/src/EFCore.PG/Query/Expressions/Internal/PostgresBinaryExpression.cs @@ -123,7 +123,7 @@ protected override void Print(ExpressionPrinter expressionPrinter) PostgresExpressionType.LTreeMatches when Right.TypeMapping?.StoreType == "lquery" || Right.TypeMapping is NpgsqlArrayTypeMapping arrayMapping && - arrayMapping.ElementMapping.StoreType == "lquery" + arrayMapping.ElementTypeMapping.StoreType == "lquery" => "~", PostgresExpressionType.LTreeMatches when Right.TypeMapping?.StoreType == "ltxtquery" diff --git a/src/EFCore.PG/Query/Expressions/Internal/PostgresUnnestExpression.cs b/src/EFCore.PG/Query/Expressions/Internal/PostgresUnnestExpression.cs new file mode 100644 index 000000000..5a6fe9c0d --- /dev/null +++ b/src/EFCore.PG/Query/Expressions/Internal/PostgresUnnestExpression.cs @@ -0,0 +1,113 @@ +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal; + +/// +/// An expression that represents a PostgreSQL unnest function call in a SQL tree. +/// +/// +/// +/// This expression is just a , adding the ability to provide an explicit column name +/// for its output (SELECT * FROM unnest(array) AS f(foo)). This is necessary since when the column name isn't explicitly +/// specified, it is automatically identical to the table alias (f above); since the table alias may get uniquified by +/// EF, this would break queries. +/// +/// +/// See unnest for more +/// information and examples. +/// +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +/// +public class PostgresUnnestExpression : TableValuedFunctionExpression +{ + /// + /// The array to be un-nested into a table. + /// + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual SqlExpression Array + => Arguments[0]; + + /// + /// The name of the column to be projected out from the unnest call. + /// + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual string ColumnName { get; } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public PostgresUnnestExpression(string alias, SqlExpression array, string columnName) + : base(alias, "unnest", schema: null, builtIn: true, new[] { array }) + { + ColumnName = columnName; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override TableValuedFunctionExpression Update(IReadOnlyList arguments) + => arguments is [var singleArgument] + ? Update(singleArgument) + : throw new ArgumentException(); + + /// + /// Creates a new expression that is like this one, but using the supplied children. If all of the children are the same, it will + /// return this expression. + /// + /// The property of the result. + /// This expression if no children changed, or an expression with the updated children. + public virtual PostgresUnnestExpression Update(SqlExpression array) + => array != Array + ? new PostgresUnnestExpression(Alias, array, ColumnName) + : this; + + /// + protected override void Print(ExpressionPrinter expressionPrinter) + { + expressionPrinter.Append(Name); + expressionPrinter.Append("("); + expressionPrinter.VisitCollection(Arguments); + expressionPrinter.Append(")"); + + PrintAnnotations(expressionPrinter); + expressionPrinter + .Append(" AS ") + .Append(Alias) + .Append("(") + .Append(ColumnName) + .Append(")"); + } + + /// + public override bool Equals(object? obj) + => obj != null + && (ReferenceEquals(this, obj) + || obj is PostgresUnnestExpression unnestExpression + && Equals(unnestExpression)); + + private bool Equals(PostgresUnnestExpression unnestExpression) + => base.Equals(unnestExpression) && ColumnName == unnestExpression.ColumnName; + + /// + public override int GetHashCode() + => base.GetHashCode(); +} diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQueryRootProcessor.cs b/src/EFCore.PG/Query/Internal/NpgsqlQueryRootProcessor.cs new file mode 100644 index 000000000..cc9cd7937 --- /dev/null +++ b/src/EFCore.PG/Query/Internal/NpgsqlQueryRootProcessor.cs @@ -0,0 +1,37 @@ +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal; + +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +public class NpgsqlQueryRootProcessor : RelationalQueryRootProcessor +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public NpgsqlQueryRootProcessor( + QueryTranslationPreprocessorDependencies dependencies, + RelationalQueryTranslationPreprocessorDependencies relationalDependencies, + QueryCompilationContext queryCompilationContext) + : base(dependencies, relationalDependencies, queryCompilationContext) + { + } + + /// + /// Converts a to a , to be later translated to + /// PostgreSQL unnest over an array parameter. + /// + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override bool ShouldConvertToParameterQueryRoot(ParameterExpression parameterExpression) + => true; +} diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs index 5d9afc43c..321b16917 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs @@ -47,19 +47,21 @@ public NpgsqlQuerySqlGenerator( protected override Expression VisitExtension(Expression extensionExpression) => extensionExpression switch { - PostgresAllExpression allExpression => VisitArrayAll(allExpression), - PostgresAnyExpression anyExpression => VisitArrayAny(anyExpression), - PostgresArrayIndexExpression arrayIndexExpression => VisitArrayIndex(arrayIndexExpression), - PostgresBinaryExpression binaryExpression => VisitPostgresBinary(binaryExpression), - PostgresDeleteExpression deleteExpression => VisitPostgresDelete(deleteExpression), - PostgresFunctionExpression functionExpression => VisitPostgresFunction(functionExpression), - PostgresILikeExpression iLikeExpression => VisitILike(iLikeExpression), - PostgresJsonTraversalExpression jsonTraversalExpression => VisitJsonPathTraversal(jsonTraversalExpression), - PostgresNewArrayExpression newArrayExpression => VisitPostgresNewArray(newArrayExpression), - PostgresRegexMatchExpression regexMatchExpression => VisitRegexMatch(regexMatchExpression), - PostgresRowValueExpression rowValueExpression => VisitRowValue(rowValueExpression), - PostgresUnknownBinaryExpression unknownBinaryExpression => VisitUnknownBinary(unknownBinaryExpression), - _ => base.VisitExtension(extensionExpression) + PostgresAllExpression e => VisitArrayAll(e), + PostgresAnyExpression e => VisitArrayAny(e), + PostgresArrayIndexExpression e => VisitArrayIndex(e), + PostgresArraySliceExpression e => VisitArraySlice(e), + PostgresBinaryExpression e => VisitPostgresBinary(e), + PostgresDeleteExpression e => VisitPostgresDelete(e), + PostgresFunctionExpression e => VisitPostgresFunction(e), + PostgresILikeExpression e => VisitILike(e), + PostgresJsonTraversalExpression e => VisitJsonPathTraversal(e), + PostgresNewArrayExpression e => VisitPostgresNewArray(e), + PostgresRegexMatchExpression e => VisitRegexMatch(e), + PostgresRowValueExpression e => VisitRowValue(e), + PostgresUnknownBinaryExpression e => VisitUnknownBinary(e), + PostgresUnnestExpression e => VisitUnnestExpression(e), + _ => base.VisitExtension(extensionExpression) }; /// @@ -110,12 +112,14 @@ protected override string GetOperator(SqlBinaryExpression e) => e.OperatorType switch { // PostgreSQL has a special string concatenation operator: || - // We switch to it if the expression itself has type string, or if one of the sides has a - // string type mapping. Same for full-text search's TsVector. + // We switch to it if the expression itself has type string, or if one of the sides has a string type mapping. + // Same for full-text search's TsVector, arrays. ExpressionType.Add when e.Type == typeof(string) || e.Left.TypeMapping?.ClrType == typeof(string) || e.Right.TypeMapping?.ClrType == typeof(string) || - e.Type == typeof(NpgsqlTsVector) || e.Left.TypeMapping?.ClrType == typeof(NpgsqlTsVector) || e.Right.TypeMapping?.ClrType == typeof(NpgsqlTsVector) + e.Type == typeof(NpgsqlTsVector) || e.Left.TypeMapping?.ClrType == typeof(NpgsqlTsVector) || e.Right.TypeMapping?.ClrType == typeof(NpgsqlTsVector) || + e.Left.TypeMapping is NpgsqlArrayTypeMapping && e.Right.TypeMapping is NpgsqlArrayTypeMapping => " || ", + ExpressionType.And when e.Type == typeof(bool) => " AND ", ExpressionType.Or when e.Type == typeof(bool) => " OR ", _ => base.GetOperator(e) @@ -497,7 +501,7 @@ binaryExpression.Left.TypeMapping is NpgsqlCidrTypeMapping PostgresExpressionType.LTreeMatches when binaryExpression.Right.TypeMapping.StoreType == "lquery" || binaryExpression.Right.TypeMapping is NpgsqlArrayTypeMapping arrayMapping && - arrayMapping.ElementMapping.StoreType == "lquery" + arrayMapping.ElementTypeMapping.StoreType == "lquery" => "~", PostgresExpressionType.LTreeMatches when binaryExpression.Right.TypeMapping.StoreType == "ltxtquery" @@ -638,19 +642,139 @@ protected override void GenerateSetOperationOperand(SetOperationBase setOperatio /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - protected override Expression VisitCollate(CollateExpression collateExpresion) + protected override Expression VisitCollate(CollateExpression collateExpression) { - Check.NotNull(collateExpresion, nameof(collateExpresion)); + Check.NotNull(collateExpression, nameof(collateExpression)); - Visit(collateExpresion.Operand); + Visit(collateExpression.Operand); + // In PG, collation names are regular identifiers which need to be quoted for case-sensitivity. Sql .Append(" COLLATE ") - .Append(_sqlGenerationHelper.DelimitIdentifier(collateExpresion.Collation)); + .Append(_sqlGenerationHelper.DelimitIdentifier(collateExpression.Collation)); + + return collateExpression; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override bool TryGenerateWithoutWrappingSelect(SelectExpression selectExpression) + // PostgreSQL supports VALUES as a top-level statement - and directly under set operations. + // However, when on the left side of a set operation, we need the column coming out of VALUES to be named, so we need the wrapping + // SELECT for that. + => selectExpression.Tables is not [ValuesExpression] + && base.TryGenerateWithoutWrappingSelect(selectExpression); + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override void GenerateSetOperation(SetOperationBase setOperation) + { + GenerateSetOperationOperand(setOperation, setOperation.Source1); + + Sql + .AppendLine() + .Append( + setOperation switch + { + ExceptExpression => "EXCEPT", + IntersectExpression => "INTERSECT", + UnionExpression => "UNION", + _ => throw new InvalidOperationException(CoreStrings.UnknownEntity("SetOperationType")) + }) + .AppendLine(setOperation.IsDistinct ? string.Empty : " ALL"); + + // For ValuesExpression, we can remove its wrapping SelectExpression but only if on the right side of a set operation, since on + // the left side we need the column name to be specified. + if (setOperation.Source2 is + { + Tables: [ValuesExpression valuesExpression], + Offset: null, + Limit: null, + IsDistinct: false, + Predicate: null, + Having: null, + Orderings.Count: 0, + GroupBy.Count: 0, + } rightSelectExpression + && rightSelectExpression.Projection.Count == valuesExpression.ColumnNames.Count + && rightSelectExpression.Projection.Select( + (pe, index) => pe.Expression is ColumnExpression column + && column.Name == valuesExpression.ColumnNames[index]) + .All(e => e)) + { + GenerateValues(valuesExpression); + } + else + { + GenerateSetOperationOperand(setOperation, setOperation.Source2); + } + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override Expression VisitValues(ValuesExpression valuesExpression) + { + base.VisitValues(valuesExpression); + + // PostgreSQL VALUES supports setting the projects column names: FROM (VALUES (1), (2)) AS v(foo) + Sql.Append("("); + + for (var i = 0; i < valuesExpression.ColumnNames.Count; i++) + { + if (i > 0) + { + Sql.Append(", "); + } + + Sql.Append(_sqlGenerationHelper.DelimitIdentifier(valuesExpression.ColumnNames[i])); + } + + Sql.Append(")"); - return collateExpresion; + return valuesExpression; } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override void GenerateValues(ValuesExpression valuesExpression) + { + // PostgreSQL supports providing the names of columns projected out of VALUES: (VALUES (1, 3), (2, 4)) AS x(a, b). + // But since other databases sometimes don't, the default relational implementation is complex, involving a SELECT for the first row + // and a UNION All on the rest. Override to do the nice simple thing. + var rowValues = valuesExpression.RowValues; + + Sql.Append("VALUES "); + + for (var i = 0; i < rowValues.Count; i++) + { + // TODO: Do we want newlines here? + if (i > 0) + { + Sql.Append(", "); + } + + Visit(valuesExpression.RowValues[i]); + } + } + + #region PostgreSQL-specific expression types + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -718,6 +842,20 @@ public virtual Expression VisitArrayIndex(PostgresArrayIndexExpression expressio return expression; } + /// + /// Produces SQL array slice expression (e.g. arr[1:2]). + /// + public virtual Expression VisitArraySlice(PostgresArraySliceExpression expression) + { + Visit(expression.Array); + Sql.Append("["); + Visit(expression.LowerBound); + Sql.Append(":"); + Visit(expression.UpperBound); + Sql.Append("]"); + return expression; + } + /// /// Visits the children of a . /// @@ -891,6 +1029,27 @@ public virtual Expression VisitJsonPathTraversal(PostgresJsonTraversalExpression return expression; } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected virtual Expression VisitUnnestExpression(PostgresUnnestExpression unnestExpression) + { + // unnest docs: https://www.postgresql.org/docs/current/functions-array.html#ARRAY-FUNCTIONS-TABLE + + // unnest is a regular table-valued function with a special AS foo(bar) at the end + base.VisitTableValuedFunction(unnestExpression); + + Sql + .Append("(") + .Append(unnestExpression.ColumnName) + .Append(")"); + + return unnestExpression; + } + /// /// Visits the children of a . /// @@ -1023,6 +1182,8 @@ public virtual Expression VisitPostgresFunction(PostgresFunctionExpression e) return e; } + #endregion PostgreSQL-specific expression types + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPreprocessor.cs b/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPreprocessor.cs new file mode 100644 index 000000000..4f274561d --- /dev/null +++ b/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPreprocessor.cs @@ -0,0 +1,27 @@ +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal; + +public class NpgsqlQueryTranslationPreprocessor : RelationalQueryTranslationPreprocessor +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public NpgsqlQueryTranslationPreprocessor( + QueryTranslationPreprocessorDependencies dependencies, + RelationalQueryTranslationPreprocessorDependencies relationalDependencies, + QueryCompilationContext queryCompilationContext) + : base(dependencies, relationalDependencies, queryCompilationContext) + { + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override Expression ProcessQueryRoots(Expression expression) + => new NpgsqlQueryRootProcessor(Dependencies, RelationalDependencies, QueryCompilationContext).Visit(expression); +} diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPreprocessorFactory.cs b/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPreprocessorFactory.cs new file mode 100644 index 000000000..502a6348a --- /dev/null +++ b/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPreprocessorFactory.cs @@ -0,0 +1,43 @@ +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal; + +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +public class NpgsqlQueryTranslationPreprocessorFactory : IQueryTranslationPreprocessorFactory +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public NpgsqlQueryTranslationPreprocessorFactory( + QueryTranslationPreprocessorDependencies dependencies, + RelationalQueryTranslationPreprocessorDependencies relationalDependencies) + { + Dependencies = dependencies; + RelationalDependencies = relationalDependencies; + } + + /// + /// Dependencies for this service. + /// + protected virtual QueryTranslationPreprocessorDependencies Dependencies { get; } + + /// + /// Relational provider-specific dependencies for this service. + /// + protected virtual RelationalQueryTranslationPreprocessorDependencies RelationalDependencies { get; } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public QueryTranslationPreprocessor Create(QueryCompilationContext queryCompilationContext) + => new NpgsqlQueryTranslationPreprocessor(Dependencies, RelationalDependencies, queryCompilationContext); +} diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs index b25007ac5..97f47c3df 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs @@ -1,4 +1,8 @@ using System.Diagnostics.CodeAnalysis; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal; +using static Npgsql.EntityFrameworkCore.PostgreSQL.Utilities.Statics; namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal; @@ -10,6 +14,22 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal; /// public class NpgsqlQueryableMethodTranslatingExpressionVisitor : RelationalQueryableMethodTranslatingExpressionVisitor { + private readonly NpgsqlTypeMappingSource _typeMappingSource; + private readonly NpgsqlSqlExpressionFactory _sqlExpressionFactory; + + #region MethodInfos + + private static readonly MethodInfo Like2MethodInfo = + typeof(DbFunctionsExtensions).GetRuntimeMethod( + nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) })!; + + // ReSharper disable once InconsistentNaming + private static readonly MethodInfo ILike2MethodInfo + = typeof(NpgsqlDbFunctionsExtensions).GetRuntimeMethod( + nameof(NpgsqlDbFunctionsExtensions.ILike), new[] { typeof(DbFunctions), typeof(string), typeof(string) })!; + + #endregion + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -22,6 +42,633 @@ public NpgsqlQueryableMethodTranslatingExpressionVisitor( QueryCompilationContext queryCompilationContext) : base(dependencies, relationalDependencies, queryCompilationContext) { + _typeMappingSource = (NpgsqlTypeMappingSource)relationalDependencies.TypeMappingSource; + _sqlExpressionFactory = (NpgsqlSqlExpressionFactory)relationalDependencies.SqlExpressionFactory; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected NpgsqlQueryableMethodTranslatingExpressionVisitor(NpgsqlQueryableMethodTranslatingExpressionVisitor parentVisitor) + : base(parentVisitor) + { + _typeMappingSource = parentVisitor._typeMappingSource; + _sqlExpressionFactory = parentVisitor._sqlExpressionFactory; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override QueryableMethodTranslatingExpressionVisitor CreateSubqueryVisitor() + => new NpgsqlQueryableMethodTranslatingExpressionVisitor(this); + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override ShapedQueryExpression TranslateCollection( + SqlExpression sqlExpression, + RelationalTypeMapping? elementTypeMapping, + string tableAlias) + { + var elementClrType = sqlExpression.Type.GetSequenceType(); + + // We support two kinds of primitive collections: the standard one with PostgreSQL arrays (where we use the unnest function), and + // a special case for geometry collections, where we use + SelectExpression selectExpression; + + // TODO: Parameters have no type mapping. We can check whether the expression type is one of the NTS geometry collection types, + // though in a perfect world we'd actually infer this. In other words, when the type mapping of the element is inferred further on, + // we'd replace the unnest expression with ST_Dump. We could even have a special expression type which means "indeterminate, must be + // inferred". + if (sqlExpression.TypeMapping is { StoreTypeNameBase: "geometry" or "geography" }) + { + selectExpression = new SelectExpression( + new TableValuedFunctionExpression(tableAlias, "ST_Dump", new[] { sqlExpression }), + "geom", elementClrType, elementTypeMapping, isColumnNullable: false); + } + else + { + // Note that for unnest we have a special expression type extending TableValuedFunctionExpression, adding the ability to provide + // an explicit column name for its output (SELECT * FROM unnest(array) AS f(foo)). + // This is necessary since when the column name isn't explicitly specified, it is automatically identical to the table alias + // (f above); since the table alias may get uniquified by EF, this would break queries. + + // TODO: When we have metadata to determine if the element is nullable, pass that here to SelectExpression + // Note also that with PostgreSQL unnest, the output ordering is guaranteed to be the same as the input array, so we don't need + // to add ordering like in most other providers (https://www.postgresql.org/docs/current/functions-array.html) + // We also don't need to apply any casts or typing, since PG arrays are fully typed (unlike e.g. a JSON string). + selectExpression = new SelectExpression( + new PostgresUnnestExpression(tableAlias, sqlExpression, "value"), + "value", elementClrType, elementTypeMapping, isColumnNullable: null); + } + + Expression shaperExpression = new ProjectionBindingExpression( + selectExpression, new ProjectionMember(), elementClrType.MakeNullable()); + + if (elementClrType != shaperExpression.Type) + { + Check.DebugAssert( + elementClrType.MakeNullable() == shaperExpression.Type, + "expression.Type must be nullable of targetType"); + + shaperExpression = Expression.Convert(shaperExpression, elementClrType); + } + + return new ShapedQueryExpression(selectExpression, shaperExpression); + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override Expression ApplyInferredTypeMappings( + Expression expression, + IReadOnlyDictionary<(TableExpressionBase, string), RelationalTypeMapping> inferredTypeMappings) + => new NpgsqlInferredTypeMappingApplier(_typeMappingSource, _sqlExpressionFactory, inferredTypeMappings).Visit(expression); + + protected override ShapedQueryExpression? TranslateAll(ShapedQueryExpression source, LambdaExpression predicate) + { + if (source.QueryExpression is SelectExpression + { + Tables: [(PostgresUnnestExpression or ValuesExpression { ColumnNames: ["_ord", "Value"] }) and var sourceTable], + GroupBy: [], + Having: null, + IsDistinct: false, + Limit: null, + Offset: null + } + && TranslateLambdaExpression(source, predicate) is { } translatedPredicate) + { + switch (translatedPredicate) + { + // Pattern match for: new[] { "a", "b", "c" }.All(p => EF.Functions.Like(e.SomeText, p)), + // which we translate to WHERE s.""SomeText"" LIKE ALL (ARRAY['a','b','c']) + case LikeExpression + { + Match: var match, + Pattern: ColumnExpression pattern, + EscapeChar: SqlConstantExpression { Value: "" } + } + when pattern.Table == sourceTable: + { + return BuildSimplifiedShapedQuery( + source, + _sqlExpressionFactory.All(match, GetArray(sourceTable), PostgresAllOperatorType.Like)); + } + + // Pattern match for: new[] { "a", "b", "c" }.All(p => EF.Functions.Like(e.SomeText, p)), + // which we translate to WHERE s.""SomeText"" LIKE ALL (ARRAY['a','b','c']) + case PostgresILikeExpression + { + Match: var match, + Pattern: ColumnExpression pattern, + EscapeChar: SqlConstantExpression { Value: "" } + } + when pattern.Table == sourceTable: + { + return BuildSimplifiedShapedQuery( + source, + _sqlExpressionFactory.All(match, GetArray(sourceTable), PostgresAllOperatorType.ILike)); + } + + // Pattern match for: e.SomeArray.All(p => ints.Contains(p)) over non-column, + // using array containment (<@) + case PostgresAnyExpression + { + Item: ColumnExpression sourceColumn, + Array: var otherArray + } + when sourceColumn.Table == sourceTable: + { + //s.IntArray @> ARRAY[v.Value] + return BuildSimplifiedShapedQuery(source, _sqlExpressionFactory.ContainedBy(GetArray(sourceTable), otherArray)); + } + + // Pattern match for: new[] { 4, 5 }.All(p => e.SomeArray.Contains(p)) over column, + // using array containment (<@) + case PostgresBinaryExpression + { + OperatorType: PostgresExpressionType.Contains, + Left: var otherArray, + Right: PostgresNewArrayExpression { Expressions: [ColumnExpression sourceColumn] } + } + when sourceColumn.Table == sourceTable: + { + return BuildSimplifiedShapedQuery(source, _sqlExpressionFactory.ContainedBy(GetArray(sourceTable), otherArray)); + } + } + } + + return base.TranslateAll(source, predicate); + } + + protected override ShapedQueryExpression? TranslateAny(ShapedQueryExpression source, LambdaExpression? predicate) + { + if (source.QueryExpression is SelectExpression + { + Tables: [(PostgresUnnestExpression or ValuesExpression { ColumnNames: ["_ord", "Value"] }) and var sourceTable], + GroupBy: [], + Having: null, + IsDistinct: false, + Limit: null, + Offset: null + }) + { + // x.Array.Any() => cardinality(x.array) > 0 instead of EXISTS (SELECT 1 FROM FROM unnest(x.Array)) + if (predicate is null) + { + return BuildSimplifiedShapedQuery( + source, + _sqlExpressionFactory.GreaterThan( + _sqlExpressionFactory.Function( + "cardinality", + new[] { GetArray(sourceTable) }, + nullable: true, + argumentsPropagateNullability: TrueArrays[1], + typeof(int)), + _sqlExpressionFactory.Constant(0))); + } + + var translatedPredicate = TranslateLambdaExpression(source, predicate); + if (translatedPredicate is null) + { + return null; + } + + // Simplify Contains / array.Any(i => i == x) + // Note that most other simplifications here convert ValuesExpression to unnest over array constructor, but we avoid doing that + // here, since the relational translation for ValuesExpression is better. + if (sourceTable is PostgresUnnestExpression + && translatedPredicate is SqlBinaryExpression + { + OperatorType: ExpressionType.Equal, + Left: var left, + Right: var right + }) + { + var item = + left is ColumnExpression leftColumn && ReferenceEquals(leftColumn.Table, sourceTable) + ? right + : right is ColumnExpression rightColumn && ReferenceEquals(rightColumn.Table, sourceTable) + ? left + : null; + + if (item is not null) + { + var array = GetArray(sourceTable); + + // When the array is a column, we translate Contains to array @> ARRAY[item]. GIN indexes on array are used, but null + // semantics is impossible without preventing index use. + switch (array) + { + case ColumnExpression: + if (item is SqlConstantExpression { Value: null }) + { + // We special-case null constant item and use array_position instead, since it does + // nulls correctly (but doesn't use indexes) + // TODO: once lambda-based caching is implemented, move this to NpgsqlSqlNullabilityProcessor + // (https://github.com/dotnet/efcore/issues/17598) and do for parameters as well. + return BuildSimplifiedShapedQuery( + source, + _sqlExpressionFactory.IsNotNull( + _sqlExpressionFactory.Function( + "array_position", + new[] { array, item }, + nullable: true, + argumentsPropagateNullability: FalseArrays[2], + typeof(int)))); + } + + return BuildSimplifiedShapedQuery( + source, + _sqlExpressionFactory.Contains( + array, + _sqlExpressionFactory.NewArrayOrConstant(new[] { item }, array.Type))); + + // Don't do anything PG-specific for constant arrays since the general EF Core mechanism is fine + // for that case: item IN (1, 2, 3). + // After https://github.com/aspnet/EntityFrameworkCore/issues/16375 is done we may not need the + // check any more. + case SqlConstantExpression: + return null; + + // Similar to ParameterExpression below, but when a bare subquery is present inside ANY(), PostgreSQL just compares + // against each of its resulting rows (just like IN). To "extract" the array result of the scalar subquery, we need + // to add an explicit cast (see #1803). + case ScalarSubqueryExpression subqueryExpression: + return BuildSimplifiedShapedQuery( + source, + _sqlExpressionFactory.Any( + item, + _sqlExpressionFactory.Convert( + subqueryExpression, subqueryExpression.Type, subqueryExpression.TypeMapping), + PostgresAnyOperatorType.Equal)); + + // For ParameterExpression, and for all other cases - e.g. array returned from some function - + // translate to e.SomeText = ANY (@p). This is superior to the general solution which will expand + // parameters to constants, since non-PG SQL does not support arrays. + // Note that this will allow indexes on the item to be used. + default: + return BuildSimplifiedShapedQuery( + source, _sqlExpressionFactory.Any(item, array, PostgresAnyOperatorType.Equal)); + } + } + } + + switch (translatedPredicate) + { + // Pattern match: new[] { "a", "b", "c" }.Any(p => EF.Functions.Like(e.SomeText, p)) + // Translation: s.SomeText LIKE ANY (ARRAY['a','b','c']) + case LikeExpression + { + Match: var match, + Pattern: ColumnExpression pattern, + EscapeChar: SqlConstantExpression { Value: "" } + } + when pattern.Table == sourceTable: + { + return BuildSimplifiedShapedQuery( + source, _sqlExpressionFactory.Any(match, GetArray(sourceTable), PostgresAnyOperatorType.Like)); + } + + // Pattern match: new[] { "a", "b", "c" }.Any(p => EF.Functions.Like(e.SomeText, p)) + // Translation: s.SomeText LIKE ANY (ARRAY['a','b','c']) + case PostgresILikeExpression + { + Match: var match, + Pattern: ColumnExpression pattern, + EscapeChar: SqlConstantExpression { Value: "" } + } + when pattern.Table == sourceTable: + { + return BuildSimplifiedShapedQuery( + source, _sqlExpressionFactory.Any(match, GetArray(sourceTable), PostgresAnyOperatorType.ILike)); + } + + // Array overlap over non-column + // Pattern match: e.SomeArray.Any(p => ints.Contains(p)) + // Translation: @ints && s.SomeArray + case PostgresAnyExpression + { + Item: ColumnExpression sourceColumn, + Array: var otherArray + } + when sourceColumn.Table == sourceTable: + { + return BuildSimplifiedShapedQuery(source, _sqlExpressionFactory.Overlaps(GetArray(sourceTable), otherArray)); + } + + // Array overlap over column + // Pattern match: new[] { 4, 5 }.Any(p => e.SomeArray.Contains(p)) + // Translation: s.SomeArray && ARRAY[4, 5] + case PostgresBinaryExpression + { + OperatorType: PostgresExpressionType.Contains, + Left: var otherArray, + Right: PostgresNewArrayExpression { Expressions: [ColumnExpression sourceColumn] } + } + when sourceColumn.Table == sourceTable: + { + return BuildSimplifiedShapedQuery(source, _sqlExpressionFactory.Overlaps(GetArray(sourceTable), otherArray)); + } + } + } + + // x.Array1.Intersect(x.Array2).Any() => x.Array1 && x.Array2 + if (source.QueryExpression is SelectExpression + { + Tables: [IntersectExpression + { + Source1: + { + Tables: [PostgresUnnestExpression { Array: var array1 }], + GroupBy: [], + Having: null, + IsDistinct: false, + Limit: null, + Offset: null + }, + Source2: + { + Tables: [PostgresUnnestExpression { Array: var array2 }], + GroupBy: [], + Having: null, + IsDistinct: false, + Limit: null, + Offset: null + } + }], + GroupBy: [], + Having: null, + IsDistinct: false, + Limit: null, + Offset: null + }) + { + return BuildSimplifiedShapedQuery(source, _sqlExpressionFactory.Overlaps(array1, array2)); + } + + return base.TranslateAny(source, predicate); + } + + protected override ShapedQueryExpression? TranslateCount(ShapedQueryExpression source, LambdaExpression? predicate) + { + // TODO: Does json_array_length pass through here? Most probably not, since it's not mapped with ElementTypeMapping... + // Simplify x.Array.Count() => cardinality(x.Array) instead of SELECT COUNT(*) FROM unnest(x.Array) + if (predicate is null && source.QueryExpression is SelectExpression + { + Tables: [PostgresUnnestExpression { Array: var array }], + GroupBy: [], + Having: null, + IsDistinct: false, + Limit: null, + Offset: null + }) + { + var translation = _sqlExpressionFactory.Function( + "cardinality", + new[] { array }, + nullable: true, + argumentsPropagateNullability: TrueArrays[1], + typeof(int)); + + return source.Update( + _sqlExpressionFactory.Select(translation), + Expression.Convert( + new ProjectionBindingExpression(source.QueryExpression, new ProjectionMember(), typeof(int?)), + typeof(int))); + } + + return base.TranslateCount(source, predicate); + } + + protected override ShapedQueryExpression? TranslateConcat(ShapedQueryExpression source1, ShapedQueryExpression source2) + { + // Simplify x.Array.Concat(y.Array) => x.Array || y.Array instead of: + // SELECT u.value FROM unnest(x.Array) UNION ALL SELECT u.value FROM unnest(y.Array) + if (source1.QueryExpression is SelectExpression + { + Tables: [PostgresUnnestExpression { Array: var array1 } unnestExpression1], + GroupBy: [], + Having: null, + IsDistinct: false, + Limit: null, + Offset: null, + Orderings: [] + } select1 + && source2.QueryExpression is SelectExpression + { + Tables: [PostgresUnnestExpression { Array: var array2 }], + GroupBy: [], + Having: null, + IsDistinct: false, + Limit: null, + Offset: null, + Orderings: [] + } select2) + { + // TODO: Allow peeking into projection mapping + var (clonedSelect1, clonedSelect2) = (select1.Clone(), select2.Clone()); + clonedSelect1.ApplyProjection(); + clonedSelect2.ApplyProjection(); + + Check.DebugAssert(clonedSelect1.Projection.Count == 1 && clonedSelect2.Projection.Count == 1, + "Multiple projections out of unnest"); + var elementClrType = clonedSelect1.Projection[0].Expression.Type; + var typeMapping1 = clonedSelect1.Projection[0].Expression.TypeMapping; + var typeMapping2 = clonedSelect2.Projection[0].Expression.TypeMapping; + + Check.DebugAssert(typeMapping1 is not null || typeMapping2 is not null, + "Concat with no type mapping on either side (operation should be client-evaluated over parameters/constants"); + + // TODO: Conflicting type mappings from both sides? + + var inferredTypeMapping = typeMapping1 ?? typeMapping2; + var unnestExpression = new PostgresUnnestExpression( + unnestExpression1.Alias, _sqlExpressionFactory.Add(array1, array2), "value"); + var selectExpression = new SelectExpression(unnestExpression, "value", elementClrType, inferredTypeMapping); + + return source1.UpdateQueryExpression(selectExpression); + } + + return base.TranslateConcat(source1, source2); + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override ShapedQueryExpression? TranslateElementAtOrDefault( + ShapedQueryExpression source, + Expression index, + bool returnDefault) + { + // TODO: Does json_array_length pass through here? Most probably not, since it's not mapped with ElementTypeMapping... + // Simplify x.Array[1] => x.Array[1] (using the PG array subscript operator) instead of a subquery with LIMIT/OFFSET + if (!returnDefault && source.QueryExpression is SelectExpression + { + Tables: [PostgresUnnestExpression { Array: var array }], + GroupBy: [], + Having: null, + IsDistinct: false, + Orderings: [], + Limit: null, + Offset: null + }) + { + var translatedIndex = TranslateExpression(index); + if (translatedIndex == null) + { + return base.TranslateElementAtOrDefault(source, index, returnDefault); + } + + // Index on array - but PostgreSQL arrays are 1-based, so adjust the index. + var translation = _sqlExpressionFactory.ArrayIndex(array, GenerateOneBasedIndexExpression(translatedIndex)); + return source.Update(_sqlExpressionFactory.Select(translation), source.ShaperExpression); + } + + return base.TranslateElementAtOrDefault(source, index, returnDefault); + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override ShapedQueryExpression? TranslateSkip(ShapedQueryExpression source, Expression count) + { + // Translate Skip over array to the PostgreSQL slice operator (array.Skip(2) -> array[3,]) + if (source.QueryExpression is SelectExpression + { + Tables: [PostgresUnnestExpression { Array: var array } unnestExpression], + GroupBy: [], + Having: null, + IsDistinct: false, + Orderings: [], + Limit: null, + Offset: null + } selectExpression + && TranslateExpression(count) is { } translatedCount) + { + // Extract the column projected out of the source, and simplify the subquery to a simple JsonScalarExpression + var shaperExpression = source.ShaperExpression; + if (shaperExpression is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression + && unaryExpression.Operand.Type.IsNullableType() + && unaryExpression.Operand.Type.UnwrapNullableType() == unaryExpression.Type) + { + shaperExpression = unaryExpression.Operand; + } + + if (shaperExpression is ProjectionBindingExpression projectionBindingExpression + && selectExpression.GetProjection(projectionBindingExpression) is SqlExpression projection) + { + selectExpression = new SelectExpression( + new PostgresUnnestExpression( + unnestExpression.Alias, + new PostgresArraySliceExpression( + array, + lowerBound: GenerateOneBasedIndexExpression(translatedCount), + upperBound: null), + "value"), + "value", + projection.Type, + projection.TypeMapping); + + return source.Update( + selectExpression, + new ProjectionBindingExpression(selectExpression, new ProjectionMember(), projection.Type)); + } + } + + return base.TranslateSkip(source, count); + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override ShapedQueryExpression? TranslateTake(ShapedQueryExpression source, Expression count) + { + // Translate Take over array to the PostgreSQL slice operator (array.Take(2) -> array[,2]) + if (source.QueryExpression is SelectExpression + { + Tables: [PostgresUnnestExpression { Array: var array } unnestExpression], + GroupBy: [], + Having: null, + IsDistinct: false, + Orderings: [], + Limit: null, + Offset: null + } selectExpression) + { + var translatedCount = TranslateExpression(count); + if (translatedCount == null) + { + return base.TranslateTake(source, count); + } + + PostgresArraySliceExpression sliceExpression; + + // If Skip has been called before, an array slice expression is already there; try to integrate this Take into it. + // Note that we need to take the Skip (lower bound) into account for the Take (upper bound), since the slice upper bound + // operates on the original array (Skip hasn't yet taken place). + if (array is PostgresArraySliceExpression existingSliceExpression) + { + if (existingSliceExpression is + { + LowerBound: SqlConstantExpression { Value: int lowerBoundValue } lowerBound, + UpperBound: null + }) + { + sliceExpression = existingSliceExpression.Update( + existingSliceExpression.Array, + existingSliceExpression.LowerBound, + translatedCount is SqlConstantExpression { Value: int takeCount } + ? _sqlExpressionFactory.Constant(lowerBoundValue + takeCount - 1, lowerBound.TypeMapping) + : _sqlExpressionFactory.Subtract( + _sqlExpressionFactory.Add(lowerBound, translatedCount), + _sqlExpressionFactory.Constant(1, lowerBound.TypeMapping))); + } + else + { + // For any other case, we allow relational to translate with normal querying. For non-constant lower bounds, we could + // duplicate them into the upper bound, but that could cause expensive double evaluation. + return base.TranslateTake(source, count); + } + } + else + { + sliceExpression = new PostgresArraySliceExpression(array, lowerBound: null, upperBound: translatedCount); + } + + var cloned = selectExpression.Clone(); + cloned.ApplyProjection(); + + return source.UpdateQueryExpression( + new SelectExpression( + new PostgresUnnestExpression(unnestExpression.Alias, sliceExpression, "value"), + "value", + cloned.Projection[0].Expression.Type, + cloned.Projection[0].Expression.TypeMapping)); + } + + return base.TranslateTake(source, count); } /// @@ -127,6 +774,63 @@ protected override bool IsValidSelectExpressionForExecuteDelete( return false; } + // PostgreSQL unnest is guaranteed to return output rows in the same order as its input array, + // https://www.postgresql.org/docs/current/functions-array.html. + /// + protected override bool IsOrdered(SelectExpression selectExpression) + => base.IsOrdered(selectExpression) || selectExpression.Tables is [PostgresUnnestExpression]; + + /// + /// PostgreSQL array indexing is 1-based. If the index happens to be a constant, just increment it. Otherwise, append a +1 in the + /// SQL. + /// + private SqlExpression GenerateOneBasedIndexExpression(SqlExpression expression) + => expression is SqlConstantExpression constant + ? _sqlExpressionFactory.Constant(Convert.ToInt32(constant.Value) + 1, constant.TypeMapping) + : _sqlExpressionFactory.Add(expression, _sqlExpressionFactory.Constant(1)); + + private ShapedQueryExpression BuildSimplifiedShapedQuery(ShapedQueryExpression source, SqlExpression translation) + => source.Update( + _sqlExpressionFactory.Select(translation), + Expression.Convert( + new ProjectionBindingExpression(translation, new ProjectionMember(), typeof(bool?)), typeof(bool))); + + /// + /// Extracts the out of . + /// If a is given, converts its literal values into a . + /// + private SqlExpression GetArray(TableExpressionBase tableExpression) + { + Check.DebugAssert( + tableExpression is PostgresUnnestExpression or ValuesExpression { ColumnNames: ["_ord", "Value"] }, + "Bad tableExpression"); + + switch (tableExpression) + { + case PostgresUnnestExpression unnest: + return unnest.Array; + + case ValuesExpression valuesExpression: + { + // The source table was a constant collection, so translated by default to ValuesExpression. Convert it to an unnest over + // an array constructor. + var elements = new SqlExpression[valuesExpression.RowValues.Count]; + + for (var i = 0; i < elements.Length; i++) + { + // Skip the first column (_ord) and copy the second (Value) + elements[i] = valuesExpression.RowValues[i].Values[1]; + } + + return new PostgresNewArrayExpression( + elements, valuesExpression.RowValues[0].Values[1].Type.MakeArrayType(), typeMapping: null); + } + + default: + throw new ArgumentException(nameof(tableExpression)); + } + } + private sealed class OuterReferenceFindingExpressionVisitor : ExpressionVisitor { private readonly TableExpression _mainTable; @@ -163,4 +867,56 @@ public bool ContainsReferenceToMainTable(TableExpressionBase tableExpression) return base.Visit(expression); } } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected class NpgsqlInferredTypeMappingApplier : RelationalInferredTypeMappingApplier + { + private readonly NpgsqlTypeMappingSource _typeMappingSource; + private readonly NpgsqlSqlExpressionFactory _sqlExpressionFactory; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public NpgsqlInferredTypeMappingApplier( + NpgsqlTypeMappingSource typeMappingSource, + NpgsqlSqlExpressionFactory sqlExpressionFactory, + IReadOnlyDictionary<(TableExpressionBase, string), RelationalTypeMapping> inferredTypeMappings) + : base(inferredTypeMappings) + { + _typeMappingSource = typeMappingSource; + _sqlExpressionFactory = sqlExpressionFactory; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override Expression VisitExtension(Expression expression) + { + switch (expression) + { + case PostgresUnnestExpression unnestExpression when InferredTypeMappings.TryGetValue( + (unnestExpression, unnestExpression.ColumnName), out var elementTypeMapping): + { + var collectionTypeMapping = _typeMappingSource.FindContainerMapping(unnestExpression.Array.Type, elementTypeMapping); + + return unnestExpression.Update( + _sqlExpressionFactory.ApplyTypeMapping(unnestExpression.Array, collectionTypeMapping)); + } + + default: + return base.VisitExtension(expression); + } + } + } } diff --git a/src/EFCore.PG/Query/Internal/NpgsqlSqlNullabilityProcessor.cs b/src/EFCore.PG/Query/Internal/NpgsqlSqlNullabilityProcessor.cs index ffb4a7caa..b7c20e5c1 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlSqlNullabilityProcessor.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlSqlNullabilityProcessor.cs @@ -176,26 +176,17 @@ protected override SqlExpression VisitCustomSqlExpression( out bool nullable) => sqlExpression switch { - PostgresAnyExpression postgresAnyExpression - => VisitAny(postgresAnyExpression, allowOptimizedExpansion, out nullable), - PostgresAllExpression postgresAllExpression - => VisitAll(postgresAllExpression, allowOptimizedExpansion, out nullable), - PostgresArrayIndexExpression arrayIndexExpression - => VisitArrayIndex(arrayIndexExpression, allowOptimizedExpansion, out nullable), - PostgresBinaryExpression binaryExpression - => VisitPostgresBinary(binaryExpression, allowOptimizedExpansion, out nullable), - PostgresILikeExpression ilikeExpression - => VisitILike(ilikeExpression, allowOptimizedExpansion, out nullable), - PostgresJsonTraversalExpression postgresJsonTraversalExpression - => VisitJsonTraversal(postgresJsonTraversalExpression, allowOptimizedExpansion, out nullable), - PostgresNewArrayExpression newArrayExpression - => VisitNewArray(newArrayExpression, allowOptimizedExpansion, out nullable), - PostgresRegexMatchExpression regexMatchExpression - => VisitRegexMatch(regexMatchExpression, allowOptimizedExpansion, out nullable), - PostgresRowValueExpression postgresRowValueExpression - => VisitRowValueExpression(postgresRowValueExpression, allowOptimizedExpansion, out nullable), - PostgresUnknownBinaryExpression postgresUnknownBinaryExpression - => VisitUnknownBinary(postgresUnknownBinaryExpression, allowOptimizedExpansion, out nullable), + PostgresAnyExpression e => VisitAny(e, allowOptimizedExpansion, out nullable), + PostgresAllExpression e => VisitAll(e, allowOptimizedExpansion, out nullable), + PostgresArrayIndexExpression e => VisitArrayIndex(e, allowOptimizedExpansion, out nullable), + PostgresArraySliceExpression e => VisitArraySlice(e, allowOptimizedExpansion, out nullable), + PostgresBinaryExpression e => VisitPostgresBinary(e, allowOptimizedExpansion, out nullable), + PostgresILikeExpression e => VisitILike(e, allowOptimizedExpansion, out nullable), + PostgresJsonTraversalExpression e => VisitJsonTraversal(e, allowOptimizedExpansion, out nullable), + PostgresNewArrayExpression e => VisitNewArray(e, allowOptimizedExpansion, out nullable), + PostgresRegexMatchExpression e => VisitRegexMatch(e, allowOptimizedExpansion, out nullable), + PostgresRowValueExpression e => VisitRowValueExpression(e, allowOptimizedExpansion, out nullable), + PostgresUnknownBinaryExpression e => VisitUnknownBinary(e, allowOptimizedExpansion, out nullable), // PostgresFunctionExpression is visited via the SqlFunctionExpression override below @@ -321,6 +312,29 @@ protected virtual SqlExpression VisitArrayIndex( return arrayIndexExpression.Update(array, index); } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected virtual SqlExpression VisitArraySlice( + PostgresArraySliceExpression arraySliceExpression, bool allowOptimizedExpansion, out bool nullable) + { + Check.NotNull(arraySliceExpression, nameof(arraySliceExpression)); + + var array = Visit(arraySliceExpression.Array, allowOptimizedExpansion, out var arrayNullable); + var lowerBound = Visit(arraySliceExpression.LowerBound, allowOptimizedExpansion, out var lowerBoundNullable); + var upperBound = Visit(arraySliceExpression.UpperBound, allowOptimizedExpansion, out var upperBoundNullable); + + nullable = arrayNullable + || lowerBoundNullable + || upperBoundNullable + || ((NpgsqlArrayTypeMapping)arraySliceExpression.Array.TypeMapping!).IsElementNullable; + + return arraySliceExpression.Update(array, lowerBound, upperBound); + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.PG/Query/Internal/NpgsqlSqlTranslatingExpressionVisitor.cs b/src/EFCore.PG/Query/Internal/NpgsqlSqlTranslatingExpressionVisitor.cs index 44bd37b73..5787fb4f5 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlSqlTranslatingExpressionVisitor.cs @@ -115,179 +115,6 @@ protected override Expression VisitUnary(UnaryExpression unaryExpression) return base.VisitUnary(unaryExpression); } - /// - /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to - /// the same compatibility standards as public APIs. It may be changed or removed without notice in - /// any release. You should only use it directly in your code with extreme caution and knowing that - /// doing so can result in application failures when updating to a new Entity Framework Core release. - /// - protected override Expression VisitMethodCall(MethodCallExpression methodCall) - { - if (methodCall.Arguments.Count > 0 && - methodCall.Arguments[0].Type.IsArrayOrGenericList() && - VisitArrayMethodCall(methodCall.Method, methodCall.Arguments) is { } visited) - { - return visited; - } - - return base.VisitMethodCall(methodCall); - } - - /// - /// Identifies complex array-related constructs which cannot be translated in regular method translators, since - /// they require accessing lambdas. - /// - private Expression? VisitArrayMethodCall(MethodInfo method, ReadOnlyCollection arguments) - { - var array = arguments[0]; - { - if (method.IsClosedFormOf(EnumerableMethods.AnyWithPredicate) && - arguments[1] is LambdaExpression wherePredicate) - { - if (wherePredicate.Body is MethodCallExpression wherePredicateMethodCall) - { - var predicateMethod = wherePredicateMethodCall.Method; - var predicateArguments = wherePredicateMethodCall.Arguments; - - // Pattern match: new[] { "a", "b", "c" }.Any(p => EF.Functions.Like(e.SomeText, p)) - // Translation: s.SomeText LIKE ANY (ARRAY['a','b','c']) - // Note: we also handle the this equality instead of Like, see NpgsqlArrayMethodTranslator - if ((predicateMethod == Like2MethodInfo || predicateMethod == ILike2MethodInfo) && - predicateArguments[2] == wherePredicate.Parameters[0]) - { - return _sqlExpressionFactory.Any( - (SqlExpression)Visit(predicateArguments[1]), - (SqlExpression)Visit(array), - wherePredicateMethodCall.Method == Like2MethodInfo - ? PostgresAnyOperatorType.Like - : PostgresAnyOperatorType.ILike); - } - - // Pattern match: new[] { 4, 5 }.Any(p => e.SomeArray.Contains(p)) - // Translation: s.SomeArray && ARRAY[4, 5] (array overlap). - if (predicateMethod.IsClosedFormOf(EnumerableMethods.Contains) && - predicateArguments[0].Type.IsArrayOrGenericList() && - predicateArguments[1] is ParameterExpression parameterExpression1 && - parameterExpression1 == wherePredicate.Parameters[0]) - { - return _sqlExpressionFactory.Overlaps( - (SqlExpression)Visit(arguments[0]), - (SqlExpression)Visit(wherePredicateMethodCall.Arguments[0])); - } - - // As above, but for Contains on List - if (predicateMethod.DeclaringType?.IsGenericType == true && - predicateMethod.DeclaringType.GetGenericTypeDefinition() == typeof(List<>) && - predicateMethod.Name == nameof(List.Contains) && - predicateMethod.GetParameters().Length == 1 && - predicateArguments[0] is ParameterExpression parameterExpression2 && - parameterExpression2 == wherePredicate.Parameters[0]) - { - return _sqlExpressionFactory.Overlaps( - (SqlExpression)Visit(arguments[0]), - (SqlExpression)Visit(wherePredicateMethodCall.Object!)); - } - } - - // Pattern match for: array.Any(e => e == x) (and other equality patterns) - // Transform this to Contains. - if (TryMatchEquality(wherePredicate.Body, out var left, out var right) && - (left == wherePredicate.Parameters[0] || right == wherePredicate.Parameters[0])) - { - var item = left == wherePredicate.Parameters[0] - ? right - : right == wherePredicate.Parameters[0] - ? left - : null; - - return item is null - ? null - : Visit(Expression.Call(EnumerableMethods.Contains.MakeGenericMethod(method.GetGenericArguments()[0]), array, - item)); - } - - static bool TryMatchEquality( - Expression expression, - [NotNullWhen(true)] out Expression? left, - [NotNullWhen(true)] out Expression? right) - { - if (expression is BinaryExpression binary) - { - (left, right) = (binary.Left, binary.Right); - return true; - } - - if (expression is MethodCallExpression methodCall) - { - if (methodCall.Method == ObjectEquals) - { - (left, right) = (methodCall.Arguments[0], methodCall.Arguments[1]); - return true; - } - - if (methodCall.Method.Name == nameof(object.Equals) && methodCall.Arguments.Count == 1) - { - (left, right) = (methodCall.Object!, methodCall.Arguments[0]); - return true; - } - } - - (left, right) = (null, null); - return false; - } - } - } - - { - if (method.IsClosedFormOf(EnumerableMethods.All) && - arguments[1] is LambdaExpression wherePredicate && - wherePredicate.Body is MethodCallExpression wherePredicateMethodCall) - { - var predicateMethod = wherePredicateMethodCall.Method; - var predicateArguments = wherePredicateMethodCall.Arguments; - - // Pattern match for: new[] { "a", "b", "c" }.All(p => EF.Functions.Like(e.SomeText, p)), - // which we translate to WHERE s.""SomeText"" LIKE ALL (ARRAY['a','b','c']) - if ((predicateMethod == Like2MethodInfo || predicateMethod == ILike2MethodInfo) && - predicateArguments[2] == wherePredicate.Parameters[0]) - { - return _sqlExpressionFactory.All( - (SqlExpression)Visit(predicateArguments[1]), - (SqlExpression)Visit(arguments[0]), - wherePredicateMethodCall.Method == Like2MethodInfo - ? PostgresAllOperatorType.Like : PostgresAllOperatorType.ILike); - } - - // Pattern match for: new[] { 4, 5 }.All(p => e.SomeArray.Contains(p)), - // using array containment (<@) - if (predicateMethod.IsClosedFormOf(EnumerableMethods.Contains) && - predicateArguments[0].Type.IsArrayOrGenericList() && - predicateArguments[1] is ParameterExpression parameterExpression && - parameterExpression == wherePredicate.Parameters[0]) - { - return _sqlExpressionFactory.ContainedBy( - (SqlExpression)Visit(arguments[0]), - (SqlExpression)Visit(predicateArguments[0])); - } - - // As above, but for Contains on List - if (predicateMethod.DeclaringType?.IsGenericType == true && - predicateMethod.DeclaringType.GetGenericTypeDefinition() == typeof(List<>) && - predicateMethod.Name == nameof(List.Contains) && - predicateMethod.GetParameters().Length == 1 && - predicateArguments[0] is ParameterExpression parameterExpression2 && - parameterExpression2 == wherePredicate.Parameters[0]) - { - return _sqlExpressionFactory.ContainedBy( - (SqlExpression)Visit(arguments[0]), - (SqlExpression)Visit(wherePredicateMethodCall.Object!)); - } - } - } - - return _ltreeTranslator.VisitArrayMethodCall(this, method, arguments); - } - /// protected override Expression VisitNewArray(NewArrayExpression newArrayExpression) { diff --git a/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs b/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs index f5717d339..4691cc64f 100644 --- a/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs +++ b/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs @@ -60,6 +60,7 @@ public virtual PostgresAllExpression All( PostgresAllOperatorType operatorType) => (PostgresAllExpression)ApplyDefaultTypeMapping(new PostgresAllExpression(item, array, operatorType, null)); + /// /// Creates a new , corresponding to the PostgreSQL-specific array subscripting operator. /// @@ -381,6 +382,7 @@ public virtual PostgresFunctionExpression AggregateFunction( PostgresAnyExpression e => ApplyTypeMappingOnAny(e), PostgresAllExpression e => ApplyTypeMappingOnAll(e), PostgresArrayIndexExpression e => ApplyTypeMappingOnArrayIndex(e, typeMapping), + PostgresArraySliceExpression e => ApplyTypeMappingOnArraySlice(e, typeMapping), PostgresBinaryExpression e => ApplyTypeMappingOnPostgresBinary(e, typeMapping), PostgresFunctionExpression e => e.ApplyTypeMapping(typeMapping), PostgresILikeExpression e => ApplyTypeMappingOnILike(e), @@ -605,7 +607,7 @@ private SqlExpression ApplyTypeMappingOnAll(PostgresAllExpression postgresAllExp ? unary.Operand.TypeMapping : null) // If we couldn't find a type mapping on the item, try inferring it from the array - ?? arrayMapping?.ElementMapping + ?? arrayMapping?.ElementTypeMapping ?? _typeMappingSource.FindMapping(itemExpression.Type, Dependencies.Model); if (itemMapping is null) @@ -662,11 +664,32 @@ private SqlExpression ApplyTypeMappingOnArrayIndex( postgresArrayIndexExpression.Type, // If the array has a type mapping (i.e. column), prefer that just like we prefer column mappings in general postgresArrayIndexExpression.Array.TypeMapping is NpgsqlArrayTypeMapping arrayMapping - ? arrayMapping.ElementMapping + ? arrayMapping.ElementTypeMapping : typeMapping ?? _typeMappingSource.FindMapping(postgresArrayIndexExpression.Type, Dependencies.Model)); } + private SqlExpression ApplyTypeMappingOnArraySlice( + PostgresArraySliceExpression postgresArraySliceExpression, + RelationalTypeMapping? typeMapping) + { + // If the slice operand has a type mapping, that bubbles up (slice is just a view over that). Otherwise apply the external type + // mapping down. The bounds are always ints and don't participate in any inference. + var lowerBound = postgresArraySliceExpression.LowerBound is null + ? null + : ApplyDefaultTypeMapping(postgresArraySliceExpression.LowerBound); + var upperBound = postgresArraySliceExpression.UpperBound is null + ? null + : ApplyDefaultTypeMapping(postgresArraySliceExpression.UpperBound); + + var inferredTypeMapping = postgresArraySliceExpression.TypeMapping ?? typeMapping; + + return new PostgresArraySliceExpression( + ApplyTypeMapping(postgresArraySliceExpression.Array, inferredTypeMapping), + lowerBound, + upperBound); + } + private SqlExpression ApplyTypeMappingOnILike(PostgresILikeExpression ilikeExpression) { var inferredTypeMapping = (ilikeExpression.EscapeChar is null @@ -970,7 +993,7 @@ static bool IsTextualTypeMapping(RelationalTypeMapping mapping) return postgresNewArrayExpression; } - elementTypeMapping = arrayTypeMapping.ElementMapping; + elementTypeMapping = arrayTypeMapping.ElementTypeMapping; } else { diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayArrayTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayArrayTypeMapping.cs index b7008ee22..33d47da03 100644 --- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayArrayTypeMapping.cs +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayArrayTypeMapping.cs @@ -20,31 +20,31 @@ 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()) {} + /// The element type mapping. + public NpgsqlArrayArrayTypeMapping(string storeType, RelationalTypeMapping elementTypeMapping) + : this(storeType, elementTypeMapping, elementTypeMapping.ClrType.MakeArrayType()) {} /// /// Creates the default array mapping (i.e. for the single-dimensional CLR array type) /// /// The array type to map. - /// The element type mapping. - public NpgsqlArrayArrayTypeMapping(Type arrayType, RelationalTypeMapping elementMapping) - : this(elementMapping.StoreType + "[]", elementMapping, arrayType) {} + /// The element type mapping. + public NpgsqlArrayArrayTypeMapping(Type arrayType, RelationalTypeMapping elementTypeMapping) + : this(elementTypeMapping.StoreType + "[]", elementTypeMapping, arrayType) {} - private NpgsqlArrayArrayTypeMapping(string storeType, RelationalTypeMapping elementMapping, Type arrayType) - : this(CreateParameters(storeType, elementMapping, arrayType), elementMapping) + private NpgsqlArrayArrayTypeMapping(string storeType, RelationalTypeMapping elementTypeMapping, Type arrayType) + : this(CreateParameters(storeType, elementTypeMapping, arrayType)) { } private static RelationalTypeMappingParameters CreateParameters( string storeType, - RelationalTypeMapping elementMapping, + RelationalTypeMapping elementTypeMapping, Type arrayType) { ValueConverter? converter = null; - if (elementMapping.Converter is { } elementConverter) + if (elementTypeMapping.Converter is { } elementConverter) { var isNullable = arrayType.GetElementType()!.IsNullableValueType(); @@ -64,7 +64,11 @@ private static RelationalTypeMappingParameters CreateParameters( } return new RelationalTypeMappingParameters( - new CoreTypeMappingParameters(arrayType, converter, CreateComparer(elementMapping, arrayType)), + new CoreTypeMappingParameters( + arrayType, + converter, + CreateComparer(elementTypeMapping, arrayType), + elementTypeMapping: elementTypeMapping), storeType); } @@ -74,13 +78,9 @@ private static RelationalTypeMappingParameters CreateParameters( /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - protected NpgsqlArrayArrayTypeMapping( - RelationalTypeMappingParameters parameters, - RelationalTypeMapping elementMapping, - bool? isElementNullable = null) + protected NpgsqlArrayArrayTypeMapping(RelationalTypeMappingParameters parameters, bool? isElementNullable = null) : base( parameters, - elementMapping, CalculateElementNullability( // Note that the ClrType on elementMapping has been unwrapped for nullability, so we consult the array's CLR type instead parameters.CoreParameters.ClrType.GetElementType() @@ -100,7 +100,7 @@ protected NpgsqlArrayArrayTypeMapping( /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public override NpgsqlArrayTypeMapping MakeNonNullable() - => new NpgsqlArrayArrayTypeMapping(Parameters, ElementMapping, isElementNullable: false); + => new NpgsqlArrayArrayTypeMapping(Parameters, isElementNullable: false); /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -109,7 +109,7 @@ public override NpgsqlArrayTypeMapping MakeNonNullable() /// doing so can result in application failures when updating to a new Entity Framework Core release. /// protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters, RelationalTypeMapping elementMapping) - => new NpgsqlArrayArrayTypeMapping(parameters, elementMapping); + => new NpgsqlArrayArrayTypeMapping(parameters.WithCoreParameters(parameters.CoreParameters.WithElementTypeMapping(elementMapping))); /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -135,7 +135,7 @@ public override NpgsqlArrayTypeMapping FlipArrayListClrType(Type newType) var listElementType = newType.GetGenericArguments()[0]; return listElementType == elementType - ? new NpgsqlArrayListTypeMapping(newType, ElementMapping) + ? new NpgsqlArrayListTypeMapping(newType, ElementTypeMapping) : throw new ArgumentException( "Mismatch in array element CLR types when converting a type mapping: " + $"{listElementType} and {elementType.Name}"); diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayListTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayListTypeMapping.cs index cc53387d3..17c699b3a 100644 --- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayListTypeMapping.cs +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayListTypeMapping.cs @@ -18,31 +18,31 @@ public class NpgsqlArrayListTypeMapping : NpgsqlArrayTypeMapping /// Creates the default list mapping. /// /// The database type to map. - /// The element type mapping. - public NpgsqlArrayListTypeMapping(string storeType, RelationalTypeMapping elementMapping) - : this(storeType, elementMapping, typeof(List<>).MakeGenericType(elementMapping.ClrType)) {} + /// The element type mapping. + public NpgsqlArrayListTypeMapping(string storeType, RelationalTypeMapping elementTypeMapping) + : this(storeType, elementTypeMapping, typeof(List<>).MakeGenericType(elementTypeMapping.ClrType)) {} /// /// Creates the default list mapping. /// /// The database type to map. - /// The element type mapping. - public NpgsqlArrayListTypeMapping(Type listType, RelationalTypeMapping elementMapping) - : this(elementMapping.StoreType + "[]", elementMapping, listType) {} + /// The element type mapping. + public NpgsqlArrayListTypeMapping(Type listType, RelationalTypeMapping elementTypeMapping) + : this(elementTypeMapping.StoreType + "[]", elementTypeMapping, listType) {} - private NpgsqlArrayListTypeMapping(string storeType, RelationalTypeMapping elementMapping, Type listType) - : this(CreateParameters(storeType, elementMapping, listType), elementMapping) + private NpgsqlArrayListTypeMapping(string storeType, RelationalTypeMapping elementTypeMapping, Type listType) + : this(CreateParameters(storeType, elementTypeMapping, listType)) { } private static RelationalTypeMappingParameters CreateParameters( string storeType, - RelationalTypeMapping elementMapping, + RelationalTypeMapping elementTypeMapping, Type listType) { ValueConverter? converter = null; - if (elementMapping.Converter is { } elementConverter) + if (elementTypeMapping.Converter is { } elementConverter) { var isNullable = listType.TryGetElementType(out var elementType) && elementType.IsNullableValueType(); @@ -62,7 +62,11 @@ private static RelationalTypeMappingParameters CreateParameters( } return new RelationalTypeMappingParameters( - new CoreTypeMappingParameters(listType, converter, CreateComparer(elementMapping, listType)), + new CoreTypeMappingParameters( + listType, + converter, + CreateComparer(elementTypeMapping, listType), + elementTypeMapping: elementTypeMapping), storeType); } @@ -72,11 +76,9 @@ private static RelationalTypeMappingParameters CreateParameters( /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - protected NpgsqlArrayListTypeMapping( - RelationalTypeMappingParameters parameters, RelationalTypeMapping elementMapping, bool? isElementNullable = null) + protected NpgsqlArrayListTypeMapping(RelationalTypeMappingParameters parameters, bool? isElementNullable = null) : base( parameters, - elementMapping, CalculateElementNullability( // Note that the ClrType on elementMapping has been unwrapped for nullability, so we consult the List's CLR type instead parameters.CoreParameters.ClrType.GetGenericArguments()[0], @@ -95,7 +97,7 @@ protected NpgsqlArrayListTypeMapping( /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public override NpgsqlArrayTypeMapping MakeNonNullable() - => new NpgsqlArrayListTypeMapping(Parameters, ElementMapping, isElementNullable: false); + => new NpgsqlArrayListTypeMapping(Parameters, isElementNullable: false); /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -104,7 +106,7 @@ public override NpgsqlArrayTypeMapping MakeNonNullable() /// doing so can result in application failures when updating to a new Entity Framework Core release. /// protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters, RelationalTypeMapping elementMapping) - => new NpgsqlArrayListTypeMapping(parameters, elementMapping); + => new NpgsqlArrayListTypeMapping(parameters.WithCoreParameters(parameters.CoreParameters.WithElementTypeMapping(elementMapping))); /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -130,7 +132,7 @@ public override NpgsqlArrayTypeMapping FlipArrayListClrType(Type newType) var arrayElementType = newType.GetElementType()!; return arrayElementType == elementType - ? new NpgsqlArrayArrayTypeMapping(newType, ElementMapping) + ? new NpgsqlArrayArrayTypeMapping(newType, ElementTypeMapping) : throw new ArgumentException( "Mismatch in list element CLR types when converting a type mapping: " + $"{arrayElementType} and {elementType.Name}"); diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs index 7e3b89fdf..9158e0b43 100644 --- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs @@ -13,18 +13,13 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping; /// public abstract class NpgsqlArrayTypeMapping : RelationalTypeMapping { - /// - /// The relational type mapping used to initialize the array mapping. - /// - public virtual RelationalTypeMapping ElementMapping { get; } - /// /// The database type used by Npgsql. /// public virtual NpgsqlDbType? NpgsqlDbType { get; } /// - /// Whether the array's element is nullable. This is required since and do not + /// Whether the array's element is nullable. This is required since and do not /// contain nullable reference type information. /// public virtual bool IsElementNullable { get; } @@ -35,24 +30,38 @@ public abstract class NpgsqlArrayTypeMapping : RelationalTypeMapping /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - protected NpgsqlArrayTypeMapping( - RelationalTypeMappingParameters parameters, RelationalTypeMapping elementMapping, bool isElementNullable) + protected NpgsqlArrayTypeMapping(RelationalTypeMappingParameters parameters, bool isElementNullable) : base(parameters) { - ElementMapping = elementMapping; IsElementNullable = isElementNullable; // If the element mapping has an NpgsqlDbType or DbType, set our own NpgsqlDbType as an array of that. // Otherwise let the ADO.NET layer infer the PostgreSQL type. We can't always let it infer, otherwise // when given a byte[] it will infer byte (but we want smallint[]) NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Array | - (elementMapping is INpgsqlTypeMapping elementNpgsqlTypeMapping + (ElementTypeMapping is INpgsqlTypeMapping elementNpgsqlTypeMapping ? elementNpgsqlTypeMapping.NpgsqlDbType - : elementMapping.DbType.HasValue - ? new NpgsqlParameter { DbType = elementMapping.DbType.Value }.NpgsqlDbType + : ElementTypeMapping.DbType.HasValue + ? new NpgsqlParameter { DbType = ElementTypeMapping.DbType.Value }.NpgsqlDbType : default(NpgsqlDbType?)); } + /// + /// The element's type mapping. + /// + public override RelationalTypeMapping ElementTypeMapping + { + get + { + var elementTypeMapping = base.ElementTypeMapping; + Check.DebugAssert(elementTypeMapping is not null, + "NpgsqlArrayTypeMapping without an element type mapping"); + Check.DebugAssert(elementTypeMapping is RelationalTypeMapping, + "NpgsqlArrayTypeMapping with a non-relational element type mapping"); + return (RelationalTypeMapping)elementTypeMapping; + } + } + /// /// Returns a copy of this type mapping with set to . /// @@ -74,7 +83,7 @@ public override CoreTypeMapping Clone(ValueConverter? converter) { return Clone( Parameters.WithComposedConverter(converter), - (RelationalTypeMapping)ElementMapping.Clone(converter is INpgsqlArrayConverter arrayConverter + (RelationalTypeMapping)ElementTypeMapping.Clone(converter is INpgsqlArrayConverter arrayConverter ? arrayConverter.ElementConverter : null)); } @@ -99,7 +108,7 @@ public override CoreTypeMapping Clone(ValueConverter? converter) /// protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) { - var elementMapping = ElementMapping; + var elementMapping = ElementTypeMapping; // Apply precision, scale and size to the element mapping, not to the array if (parameters.Size is not null) @@ -155,7 +164,7 @@ protected override string GenerateNonNullSqlLiteral(object value) sb.Append("ARRAY["); for (var i = 0; i < list.Count; i++) { - sb.Append(ElementMapping.GenerateProviderValueSqlLiteral(list[i])); + sb.Append(ElementTypeMapping.GenerateProviderValueSqlLiteral(list[i])); if (i < list.Count - 1) { sb.Append(","); @@ -163,7 +172,7 @@ protected override string GenerateNonNullSqlLiteral(object value) } sb.Append("]::"); - sb.Append(ElementMapping.StoreType); + sb.Append(ElementTypeMapping.StoreType); sb.Append("[]"); return sb.ToString(); } diff --git a/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs b/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs index b0cbf226a..d1aa4aeeb 100644 --- a/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs +++ b/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs @@ -47,7 +47,7 @@ static NpgsqlTypeMappingSource() /// doing so can result in application failures when updating to a new Entity Framework Core release. /// protected virtual ConcurrentDictionary StoreTypeMappings { get; } - + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -214,7 +214,8 @@ public NpgsqlTypeMappingSource( INpgsqlSingletonOptions options) : base(dependencies, relationalDependencies) { - _supportsMultiranges = options.PostgresVersionWithoutDefault is null || options.PostgresVersionWithoutDefault.AtLeast(14); + _supportsMultiranges = !options.IsPostgresVersionSet + || options.IsPostgresVersionSet && options.PostgresVersion.AtLeast(14); _sqlGenerationHelper = Check.NotNull(sqlGenerationHelper, nameof(sqlGenerationHelper)); @@ -684,7 +685,7 @@ protected virtual void SetupEnumMappings(ISqlGenerationHelper sqlGenerationHelpe // If no mapping was found for the element, there's no mapping for the array. // Also, arrays of arrays aren't supported (as opposed to multidimensional arrays) by PostgreSQL - if (elementMapping is null || elementMapping is NpgsqlArrayTypeMapping) + if (elementMapping is null or NpgsqlArrayTypeMapping) { return null; } @@ -708,7 +709,7 @@ protected virtual void SetupEnumMappings(ISqlGenerationHelper sqlGenerationHelpe // If no mapping was found for the element, there's no mapping for the array. // Also, arrays of arrays aren't supported (as opposed to multidimensional arrays) by PostgreSQL - if (elementMapping is null || elementMapping is NpgsqlArrayTypeMapping) + if (elementMapping is null or NpgsqlArrayTypeMapping) { return null; } @@ -824,11 +825,16 @@ protected virtual void SetupEnumMappings(ISqlGenerationHelper sqlGenerationHelpe } /// - /// Finds the mapping for a container given its CLR type and its containee's type mapping; this is currently used to infer type - /// mappings for ranges and multiranges from their values. + /// Finds the mapping for a container given its CLR type and its containee's type mapping; this is used when inferring type mappings + /// for arrays and ranges/multiranges. /// public virtual RelationalTypeMapping? FindContainerMapping(Type containerClrType, RelationalTypeMapping containeeTypeMapping) { + if (containerClrType.TryGetElementType(out _)) + { + return FindMapping(containerClrType, containeeTypeMapping.StoreType + "[]"); + } + if (containerClrType.TryGetRangeSubtype(out var subtypeType)) { return _rangeTypeMappings.TryGetValue(subtypeType, out var candidateMappings) @@ -951,7 +957,7 @@ private static bool NameBasesUsesPrecision(ReadOnlySpan span) // For arrays over reference types, the CLR type doesn't convey nullability (unlike with arrays over value types). // We decode NRT annotations here to return the correct type mapping. - if (mapping is NpgsqlArrayTypeMapping { ElementMapping.ClrType.IsValueType: false } arrayMapping + if (mapping is NpgsqlArrayTypeMapping { ElementTypeMapping.ClrType.IsValueType: false } arrayMapping && !property.IsShadowProperty()) { var nullabilityInfo = diff --git a/test/EFCore.PG.FunctionalTests/Query/ArrayArrayQueryTest.cs b/test/EFCore.PG.FunctionalTests/Query/ArrayArrayQueryTest.cs index 475b29583..8f54f70fc 100644 --- a/test/EFCore.PG.FunctionalTests/Query/ArrayArrayQueryTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/ArrayArrayQueryTest.cs @@ -817,14 +817,17 @@ public override async Task All_Contains(bool async) public override async Task Append(bool async) { - await base.Append(async); - - AssertSql( -""" -SELECT s."Id", s."ArrayContainerEntityId", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IntArray", s."IntList", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArray", s."ValueConvertedList", s."Varchar10", s."Varchar15" -FROM "SomeEntities" AS s -WHERE array_append(s."IntArray", 5) = ARRAY[3,4,5]::integer[] -"""); + // TODO: https://github.com/dotnet/efcore/issues/30669 + await AssertTranslationFailed(() => base.Append(async)); + +// await base.Append(async); +// +// AssertSql( +// """ +// SELECT s."Id", s."ArrayContainerEntityId", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IntArray", s."IntList", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArray", s."ValueConvertedList", s."Varchar10", s."Varchar15" +// FROM "SomeEntities" AS s +// WHERE array_append(s."IntArray", 5) = ARRAY[3,4,5]::integer[] +// """); } public override async Task Concat(bool async) diff --git a/test/EFCore.PG.FunctionalTests/Query/ArrayListQueryTest.cs b/test/EFCore.PG.FunctionalTests/Query/ArrayListQueryTest.cs index 0333ece2f..3f374d5a4 100644 --- a/test/EFCore.PG.FunctionalTests/Query/ArrayListQueryTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/ArrayListQueryTest.cs @@ -820,14 +820,17 @@ public override async Task All_Contains(bool async) public override async Task Append(bool async) { - await base.Append(async); - - AssertSql( -""" -SELECT s."Id", s."ArrayContainerEntityId", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IntArray", s."IntList", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArray", s."ValueConvertedList", s."Varchar10", s."Varchar15" -FROM "SomeEntities" AS s -WHERE array_append(s."IntList", 5) = ARRAY[3,4,5]::integer[] -"""); + // TODO: https://github.com/dotnet/efcore/issues/30669 + await AssertTranslationFailed(() => base.Append(async)); + +// await base.Append(async); +// +// AssertSql( +// """ +// SELECT s."Id", s."ArrayContainerEntityId", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IntArray", s."IntList", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArray", s."ValueConvertedList", s."Varchar10", s."Varchar15" +// FROM "SomeEntities" AS s +// WHERE array_append(s."IntList", 5) = ARRAY[3,4,5]::integer[] +// """); } public override async Task Concat(bool async) diff --git a/test/EFCore.PG.FunctionalTests/Query/ArrayQueryTest.cs b/test/EFCore.PG.FunctionalTests/Query/ArrayQueryTest.cs index 1a5c9a62c..34fe9c503 100644 --- a/test/EFCore.PG.FunctionalTests/Query/ArrayQueryTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/ArrayQueryTest.cs @@ -13,7 +13,7 @@ public ArrayQueryTest(TFixture fixture, ITestOutputHelper testOutputHelper) : base(fixture) { Fixture.TestSqlLoggerFactory.Clear(); - // Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); } #region Roundtrip @@ -372,15 +372,15 @@ public virtual async Task All_Contains(bool async) ss => ss.Set().Where(e => new[] { 5, 6 }.All(p => e.IntArray.Contains(p))), entryCount: 1); - [ConditionalFact] - public virtual async Task Any_like_column() + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Any_like_column(bool async) { - using var ctx = CreateContext(); - - await AssertTranslationFailed( - () => ctx.SomeEntities - .Where(e => e.StringArray.Any(p => EF.Functions.Like(p, "3"))) - .ToListAsync()); + await AssertQuery( + async, + ss => ss.Set().Where(e => e.StringArray.Any(s => EF.Functions.Like(s, "3"))), + ss => ss.Set().Where(e => e.StringArray.Any(s => s.Contains("3"))), + entryCount: 1); } #endregion Any/All diff --git a/test/EFCore.PG.FunctionalTests/Query/NonSharedPrimitiveCollectionsQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/NonSharedPrimitiveCollectionsQueryNpgsqlTest.cs new file mode 100644 index 000000000..088c1dab1 --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/Query/NonSharedPrimitiveCollectionsQueryNpgsqlTest.cs @@ -0,0 +1,63 @@ +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.TestUtilities; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query; + +public class NonSharedPrimitiveCollectionsQueryNpgsqlTest : NonSharedPrimitiveCollectionsQueryRelationalTestBase +{ + #region Support for specific element types + + // Since we just use arrays for primitive collections, there's no need to test each and every element type; arrays are fully typed + // and don't need any special conversion/handling like in providers which use JSON. + + // Npgsql maps DateTime to timestamp with time zone by default, which requires UTC timestamps. + public override Task Array_of_DateTime() + => TestArray( + new DateTime(2023, 1, 1, 12, 30, 0), + new DateTime(2023, 1, 2, 12, 30, 0), + mb => mb.Entity() + .Property(typeof(DateTime[]), "SomeArray") + .HasColumnType("timestamp without time zone[]")); + + [ConditionalFact] + public virtual Task Array_of_DateTime_utc() + => TestArray( + new DateTime(2023, 1, 1, 12, 30, 0, DateTimeKind.Utc), + new DateTime(2023, 1, 2, 12, 30, 0, DateTimeKind.Utc)); + + // Npgsql only supports DateTimeOffset with Offset 0 (mapped to timestamp with time zone) + public override Task Array_of_DateTimeOffset() + => TestArray( + new DateTimeOffset(2023, 1, 1, 12, 30, 0, TimeSpan.Zero), + new DateTimeOffset(2023, 1, 2, 12, 30, 0, TimeSpan.Zero)); + + public override Task Array_of_geometry_is_not_supported() + { + throw new NotImplementedException(); + } + + // [ConditionalFact] // #30630 + // public override async Task Array_of_geometry() + // { + // var exception = await Assert.ThrowsAsync( + // () => InitializeAsync( + // onConfiguring: options => options.UseNpgsql(o => o.UseNetTopologySuite()), + // addServices: s => s.AddEntityFrameworkNpgsqlNetTopologySuite(), + // onModelCreating: mb => mb.Entity().Property("Points"))); + // + // Assert.Equal(CoreStrings.PropertyNotMapped("Point[]", "MyEntity", "Points"), exception.Message); + // } + + #endregion Support for specific element types + + public override async Task Column_collection_inside_json_owned_entity() + { + var exception = await Assert.ThrowsAsync(() => base.Column_collection_inside_json_owned_entity()); + + Assert.Equal(exception.Message, NpgsqlStrings.Ef7JsonMappingNotSupported); + } + + protected override ITestStoreFactory TestStoreFactory + => NpgsqlTestStoreFactory.Instance; +} diff --git a/test/EFCore.PG.FunctionalTests/Query/NorthwindCompiledQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/NorthwindCompiledQueryNpgsqlTest.cs index 7f3e7219e..8fa81bc7b 100644 --- a/test/EFCore.PG.FunctionalTests/Query/NorthwindCompiledQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/NorthwindCompiledQueryNpgsqlTest.cs @@ -10,8 +10,4 @@ public NorthwindCompiledQueryNpgsqlTest( Fixture.TestSqlLoggerFactory.Clear(); //Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); } - - // This one fails in a different way than what EF expects, since we do support array indexing, - // but not over object arrays. - public override void MakeBinary_does_not_throw_for_unsupported_operator() {} -} \ No newline at end of file +} diff --git a/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs new file mode 100644 index 000000000..992584a4c --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs @@ -0,0 +1,805 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Npgsql.EntityFrameworkCore.PostgreSQL.TestUtilities; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query; + +public class PrimitiveCollectionsQueryNpgsqlTest : PrimitiveCollectionsQueryTestBase< + PrimitiveCollectionsQueryNpgsqlTest.PrimitiveCollectionsQueryNpgsqlFixture> +{ + public PrimitiveCollectionsQueryNpgsqlTest(PrimitiveCollectionsQueryNpgsqlFixture fixture, ITestOutputHelper testOutputHelper) + : base(fixture) + { + Fixture.TestSqlLoggerFactory.Clear(); + Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + public override async Task Inline_collection_of_ints_Contains(bool async) + { + await base.Inline_collection_of_ints_Contains(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Int" IN (10, 999) +"""); + } + + public override async Task Inline_collection_of_nullable_ints_Contains(bool async) + { + await base.Inline_collection_of_nullable_ints_Contains(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."NullableInt" IN (10, 999) +"""); + } + + public override async Task Inline_collection_of_nullable_ints_Contains_null(bool async) + { + await base.Inline_collection_of_nullable_ints_Contains_null(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."NullableInt" = 999 OR p."NullableInt" IS NULL +"""); + } + + public override Task Inline_collection_Count_with_zero_values(bool async) + => AssertTranslationFailedWithDetails( + () => base.Inline_collection_Count_with_zero_values(async), + RelationalStrings.EmptyCollectionNotSupportedAsInlineQueryRoot); + + public override async Task Inline_collection_Count_with_one_value(bool async) + { + await base.Inline_collection_Count_with_one_value(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE ( + SELECT count(*)::int + FROM (VALUES (2::int)) AS v("Value") + WHERE v."Value" > p."Id") = 1 +"""); + } + + public override async Task Inline_collection_Count_with_two_values(bool async) + { + await base.Inline_collection_Count_with_two_values(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE ( + SELECT count(*)::int + FROM (VALUES (2::int), (999)) AS v("Value") + WHERE v."Value" > p."Id") = 1 +"""); + } + + public override async Task Inline_collection_Count_with_three_values(bool async) + { + await base.Inline_collection_Count_with_three_values(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE ( + SELECT count(*)::int + FROM (VALUES (2::int), (999), (1000)) AS v("Value") + WHERE v."Value" > p."Id") = 2 +"""); + } + + public override Task Inline_collection_Contains_with_zero_values(bool async) + => AssertTranslationFailedWithDetails( + () => base.Inline_collection_Contains_with_zero_values(async), + RelationalStrings.EmptyCollectionNotSupportedAsInlineQueryRoot); + + public override async Task Inline_collection_Contains_with_one_value(bool async) + { + await base.Inline_collection_Contains_with_one_value(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Id" = 2 +"""); + } + + public override async Task Inline_collection_Contains_with_two_values(bool async) + { + await base.Inline_collection_Contains_with_two_values(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Id" IN (2, 999) +"""); + } + + public override async Task Inline_collection_Contains_with_three_values(bool async) + { + await base.Inline_collection_Contains_with_three_values(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Id" IN (2, 999, 1000) +"""); + } + + public override async Task Inline_collection_Contains_with_all_parameters(bool async) + { + await base.Inline_collection_Contains_with_all_parameters(async); + + // See #30732 for making this better + + AssertSql( +""" +@__p_0={ '2', '999' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Id" = ANY (@__p_0) +"""); + } + + public override async Task Inline_collection_Contains_with_parameter_and_column_based_expression(bool async) + { + var i = 2; + + await AssertQuery( + async, + ss => ss.Set().Where(c => new[] { i, c.Int }.Contains(c.Id)), + entryCount: 1); + + AssertSql( +""" +@__i_0='2' + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Id" = ANY (ARRAY[@__i_0,p."Int"]::integer[]) +"""); + } + + public override async Task Parameter_collection_Count(bool async) + { + await base.Parameter_collection_Count(async); + + AssertSql( +""" +@__ids_0={ '2', '999' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE ( + SELECT count(*)::int + FROM unnest(@__ids_0) AS i(value) + WHERE i.value > p."Id") = 1 +"""); + } + + public override async Task Parameter_collection_of_ints_Contains(bool async) + { + await base.Parameter_collection_of_ints_Contains(async); + + AssertSql( +""" +@__ints_0={ '10', '999' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Int" = ANY (@__ints_0) +"""); + } + + public override async Task Parameter_collection_of_nullable_ints_Contains(bool async) + { + await base.Parameter_collection_of_nullable_ints_Contains(async); + + AssertSql( +""" +@__nullableInts_0={ '10', '999' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."NullableInt" = ANY (@__nullableInts_0) OR (p."NullableInt" IS NULL AND array_position(@__nullableInts_0, NULL) IS NOT NULL) +"""); + } + + public override async Task Parameter_collection_of_nullable_ints_Contains_null(bool async) + { + await base.Parameter_collection_of_nullable_ints_Contains_null(async); + + AssertSql( +""" +@__nullableInts_0={ NULL, '999' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."NullableInt" = ANY (@__nullableInts_0) OR (p."NullableInt" IS NULL AND array_position(@__nullableInts_0, NULL) IS NOT NULL) +"""); + } + + public override async Task Parameter_collection_of_strings_Contains(bool async) + { + await base.Parameter_collection_of_strings_Contains(async); + + AssertSql( +""" +@__strings_0={ '10', '999' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."String" = ANY (@__strings_0) OR (p."String" IS NULL AND array_position(@__strings_0, NULL) IS NOT NULL) +"""); + } + + public override async Task Parameter_collection_of_DateTimes_Contains(bool async) + { + await base.Parameter_collection_of_DateTimes_Contains(async); + + AssertSql( +""" +@__dateTimes_0={ '2020-01-10T12:30:00.0000000Z', '9999-01-01T00:00:00.0000000Z' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."DateTime" = ANY (@__dateTimes_0) +"""); + } + + public override async Task Parameter_collection_of_bools_Contains(bool async) + { + await base.Parameter_collection_of_bools_Contains(async); + + AssertSql( +""" +@__bools_0={ 'True' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Bool" = ANY (@__bools_0) +"""); + } + + public override async Task Parameter_collection_of_enums_Contains(bool async) + { + await base.Parameter_collection_of_enums_Contains(async); + + AssertSql( +""" +@__enums_0={ '0', '3' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Enum" = ANY (@__enums_0) +"""); + } + + public override async Task Parameter_collection_null_Contains(bool async) + { + await base.Parameter_collection_null_Contains(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Int" = ANY (NULL) +"""); + } + + public override async Task Column_collection_of_ints_Contains(bool async) + { + await base.Column_collection_of_ints_Contains(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Ints" @> ARRAY[10]::integer[] +"""); + } + + public override async Task Column_collection_of_nullable_ints_Contains(bool async) + { + await base.Column_collection_of_nullable_ints_Contains(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."NullableInts" @> ARRAY[10]::integer[] +"""); + } + + public override async Task Column_collection_of_nullable_ints_Contains_null(bool async) + { + await base.Column_collection_of_nullable_ints_Contains_null(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE array_position(p."NullableInts", NULL) IS NOT NULL +"""); + } + + public override async Task Column_collection_of_bools_Contains(bool async) + { + await base.Column_collection_of_bools_Contains(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Bools" @> ARRAY[TRUE]::boolean[] +"""); + } + + public override async Task Column_collection_Count_method(bool async) + { + await base.Column_collection_Count_method(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE cardinality(p."Ints") = 2 +"""); + } + + public override async Task Column_collection_Length(bool async) + { + await base.Column_collection_Length(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE cardinality(p."Ints") = 2 +"""); + } + + public override async Task Column_collection_index_int(bool async) + { + await base.Column_collection_index_int(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Ints"[2] = 10 +"""); + } + + public override async Task Column_collection_index_string(bool async) + { + await base.Column_collection_index_string(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Strings"[2] = '10' +"""); + } + + public override async Task Column_collection_index_datetime(bool async) + { + await base.Column_collection_index_datetime(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."DateTimes"[2] = TIMESTAMPTZ '2020-01-10 12:30:00Z' +"""); + } + + public override async Task Column_collection_index_beyond_end(bool async) + { + await base.Column_collection_index_beyond_end(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Ints"[1000] = 10 +"""); + } + + public override async Task Inline_collection_index_Column(bool async) + { + await base.Inline_collection_index_Column(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE ( + SELECT v."Value" + FROM (VALUES (0, 1::int), (1, 2), (2, 3)) AS v(_ord, "Value") + ORDER BY v._ord NULLS FIRST + LIMIT 1 OFFSET p."Int") = 1 +"""); + } + + public override async Task Parameter_collection_index_Column_equal_Column(bool async) + { + await base.Parameter_collection_index_Column_equal_Column(async); + + AssertSql( +""" +@__ints_0={ '0', '2', '3' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE @__ints_0[p."Int" + 1] = p."Int" +"""); + } + + public override async Task Parameter_collection_index_Column_equal_constant(bool async) + { + await base.Parameter_collection_index_Column_equal_constant(async); + + AssertSql( +""" +@__ints_0={ '1', '2', '3' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE @__ints_0[p."Int" + 1] = 1 +"""); + } + + public override async Task Column_collection_ElementAt(bool async) + { + await base.Column_collection_ElementAt(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Ints"[2] = 10 +"""); + } + + public override async Task Column_collection_Skip(bool async) + { + await base.Column_collection_Skip(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE cardinality(p."Ints"[2:]) = 2 +"""); + } + + public override async Task Column_collection_Take(bool async) + { + await base.Column_collection_Take(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE EXISTS ( + SELECT 1 + FROM unnest(p."Ints"[:2]) AS i(value) + WHERE i.value = 11) +"""); + } + + public override async Task Column_collection_Skip_Take(bool async) + { + await base.Column_collection_Skip_Take(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE EXISTS ( + SELECT 1 + FROM unnest(p."Ints"[2:3]) AS i(value) + WHERE i.value = 11) +"""); + } + + public override async Task Column_collection_Any(bool async) + { + await base.Column_collection_Any(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE cardinality(p."Ints") > 0 +"""); + } + + public override async Task Column_collection_projection_from_top_level(bool async) + { + await base.Column_collection_projection_from_top_level(async); + + AssertSql( +""" +SELECT p."Ints" +FROM "PrimitiveCollectionsEntity" AS p +ORDER BY p."Id" NULLS FIRST +"""); + } + + public override async Task Column_collection_and_parameter_collection_Join(bool async) + { + await base.Column_collection_and_parameter_collection_Join(async); + + AssertSql( +""" +@__ints_0={ '11', '111' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE ( + SELECT count(*)::int + FROM unnest(p."Ints") AS i(value) + INNER JOIN unnest(@__ints_0) AS i0(value) ON i.value = i0.value) = 2 +"""); + } + + public override async Task Parameter_collection_Concat_column_collection(bool async) + { + await base.Parameter_collection_Concat_column_collection(async); + + AssertSql( +""" +@__ints_0={ '11', '111' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE cardinality(@__ints_0 || p."Ints") = 2 +"""); + } + + public override async Task Column_collection_Union_parameter_collection(bool async) + { + await base.Column_collection_Union_parameter_collection(async); + + AssertSql( +""" +@__ints_0={ '11', '111' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE ( + SELECT count(*)::int + FROM ( + SELECT i.value + FROM unnest(p."Ints") AS i(value) + UNION + SELECT i0.value + FROM unnest(@__ints_0) AS i0(value) + ) AS t) = 2 +"""); + } + + public override async Task Column_collection_Intersect_inline_collection(bool async) + { + await base.Column_collection_Intersect_inline_collection(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE ( + SELECT count(*)::int + FROM ( + SELECT i.value + FROM unnest(p."Ints") AS i(value) + INTERSECT + VALUES (11::int), (111) + ) AS t) = 2 +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Column_collection_Intersect_Parameter_collection_Any(bool async) + { + var ints = new[] { 11, 12 }; + + await AssertQuery( + async, + ss => ss.Set().Where(c => c.Ints.Intersect(ints).Any()), + entryCount: 1); + + AssertSql( +""" +@__ints_0={ '11', '12' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Ints" && @__ints_0 +"""); + } + + public override async Task Inline_collection_Except_column_collection(bool async) + { + await base.Inline_collection_Except_column_collection(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE ( + SELECT count(*)::int + FROM ( + SELECT v."Value" + FROM (VALUES (11::int), (111)) AS v("Value") + EXCEPT + SELECT i.value AS "Value" + FROM unnest(p."Ints") AS i(value) + ) AS t + WHERE t."Value" % 2 = 1) = 2 +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Parameter_collection_Concat_Column_collection_Concat_parameter(bool async) + { + var ints1 = new[] { 11 }; + var ints2 = new[] { 12 }; + + await AssertQuery( + async, + ss => ss.Set().Where(c => ints1.Concat(c.Ints).Concat(ints2).Count() == 4), + entryCount: 1); + + AssertSql( +""" +@__ints1_0={ '11' } (DbType = Object) +@__ints2_1={ '12' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE cardinality(@__ints1_0 || p."Ints" || @__ints2_1) = 4 +"""); + } + + public override async Task Column_collection_Concat_parameter_collection_equality_inline_collection_not_supported(bool async) + { + await base.Column_collection_Concat_parameter_collection_equality_inline_collection_not_supported(async); + + AssertSql(); + } + + public override async Task Column_collection_equality_parameter_collection(bool async) + { + await base.Column_collection_equality_parameter_collection(async); + + AssertSql( +""" +@__ints_0={ '1', '10' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Ints" = @__ints_0 +"""); + } + + public override async Task Column_collection_equality_inline_collection(bool async) + { + await base.Column_collection_equality_inline_collection(async); + + AssertSql( +""" +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE p."Ints" = ARRAY[1,10]::integer[] +"""); + } + + public override async Task Parameter_collection_in_subquery_Union_column_collection_as_compiled_query(bool async) + { + await base.Parameter_collection_in_subquery_Union_column_collection_as_compiled_query(async); + + AssertSql( + """ +@__ints={ '10', '111' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE ( + SELECT count(*)::int + FROM ( + SELECT i.value + FROM unnest(@__ints[2:]) AS i(value) + UNION + SELECT i0.value + FROM unnest(p."Ints") AS i0(value) + ) AS t) = 3 +"""); + } + + public override void Parameter_collection_in_subquery_and_Convert_as_compiled_query() + { + base.Parameter_collection_in_subquery_and_Convert_as_compiled_query(); + + AssertSql(); + } + + public override async Task Parameter_collection_in_subquery_Count_as_compiled_query(bool async) + { + await base.Parameter_collection_in_subquery_Count_as_compiled_query(async); + + AssertSql( +""" +@__ints={ '10', '111' } (DbType = Object) + +SELECT count(*)::int +FROM "PrimitiveCollectionsEntity" AS p +WHERE ( + SELECT count(*)::int + FROM unnest(@__ints[2:]) AS i(value) + WHERE i.value > p."Id") = 1 +"""); + } + + public override async Task Column_collection_in_subquery_Union_parameter_collection(bool async) + { + await base.Column_collection_in_subquery_Union_parameter_collection(async); + + AssertSql( +""" +@__ints_0={ '10', '111' } (DbType = Object) + +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."String", p."Strings" +FROM "PrimitiveCollectionsEntity" AS p +WHERE ( + SELECT count(*)::int + FROM ( + SELECT i.value + FROM unnest(p."Ints"[2:]) AS i(value) + UNION + SELECT i0.value + FROM unnest(@__ints_0) AS i0(value) + ) AS t) = 3 +"""); + } + + [ConditionalFact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); + + private void AssertSql(params string[] expected) + => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); + + private PrimitiveCollectionsContext CreateContext() + => Fixture.CreateContext(); + + public class PrimitiveCollectionsQueryNpgsqlFixture : PrimitiveCollectionsQueryFixtureBase + { + public TestSqlLoggerFactory TestSqlLoggerFactory + => (TestSqlLoggerFactory)ListLoggerFactory; + + protected override ITestStoreFactory TestStoreFactory + => NpgsqlTestStoreFactory.Instance; + } +} diff --git a/test/EFCore.PG.FunctionalTests/Query/SpatialQueryNpgsqlGeometryTest.cs b/test/EFCore.PG.FunctionalTests/Query/SpatialQueryNpgsqlGeometryTest.cs index ec773270d..31f3fbe61 100644 --- a/test/EFCore.PG.FunctionalTests/Query/SpatialQueryNpgsqlGeometryTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/SpatialQueryNpgsqlGeometryTest.cs @@ -735,6 +735,34 @@ public override async Task Z(bool async) """); } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task MultiString_Any(bool async) + { + var lineString = Fixture.GeometryFactory.CreateLineString(new[] { new Coordinate(1, 0), new Coordinate(1, 1) }); + + // Note the subtle difference between Contains any Any here: Contains resolves to Geometry.Contains, which checks whether a geometry + // is contained in another; this is different from .NET collection/enumerable Contains, which checks whether an item is in a + // collection. + await AssertQuery( + async, + ss => ss.Set().Where(e => e.MultiLineString.Any(ls => ls == lineString)), + ss => ss.Set().Where(e => e.MultiLineString != null && e.MultiLineString.Any(ls => GeometryComparer.Instance.Equals(ls, lineString))), + elementSorter: e => e.Id); + + AssertSql( +""" +@__lineString_0='LINESTRING (1 0, 1 1)' (DbType = Object) + +SELECT m."Id", m."MultiLineString" +FROM "MultiLineStringEntity" AS m +WHERE EXISTS ( + SELECT 1 + FROM ST_Dump(m."MultiLineString") AS m0 + WHERE m0.geom = @__lineString_0) +"""); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); public class SpatialQueryNpgsqlGeometryFixture : SpatialQueryNpgsqlFixture diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs index a8af10d90..3406b7d56 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs @@ -68,7 +68,7 @@ public void Varchar32_Array() Assert.Equal("varchar(32)[]", arrayMapping.StoreType); Assert.Null(arrayMapping.Size); - var elementMapping = arrayMapping.ElementMapping; + var elementMapping = arrayMapping.ElementTypeMapping; Assert.Same(typeof(string), elementMapping.ClrType); Assert.Equal("varchar(32)", elementMapping.StoreType); Assert.Equal(32, elementMapping.Size); @@ -90,7 +90,7 @@ public void Timestamp_without_time_zone_Array_5() Assert.Same(typeof(DateTime[]), arrayMapping.ClrType); Assert.Equal("timestamp(5) without time zone[]", arrayMapping.StoreType); - var elementMapping = arrayMapping.ElementMapping; + var elementMapping = arrayMapping.ElementTypeMapping; Assert.Same(typeof(DateTime), elementMapping.ClrType); Assert.Equal("timestamp(5) without time zone", elementMapping.StoreType); } @@ -210,7 +210,7 @@ private void Array_over_type_mapping_with_value_converter(CoreTypeMapping mappin Assert.Equal("ltree[]", arrayMapping.StoreType); Assert.Same(expectedType, arrayMapping.ClrType); - var elementMapping = arrayMapping.ElementMapping; + var elementMapping = arrayMapping.ElementTypeMapping; Assert.NotNull(elementMapping); Assert.Equal("ltree", elementMapping.StoreType); Assert.Same(typeof(LTree), elementMapping.ClrType);