From 07aa199f4580fb1546c8bb373bb56e895050b431 Mon Sep 17 00:00:00 2001 From: Austin Drenski Date: Tue, 29 May 2018 12:47:03 -0400 Subject: [PATCH] Refactors method identification for translation - Improves how we verify that: - an array or list is supported - the function for an array or list is supported. - Renames `NpgsqlListTranslator` to `NpgsqlArrayTranslator` - Adds `NpgsqlArrayExtensions` --- .../Extensions/NpgsqlArrayExtensions.cs | 92 +++++++++ .../Internal/NpgsqlArrayTranslator.cs | 191 ++++++++++++++++++ .../NpgsqlCompositeMethodCallTranslator.cs | 2 +- .../Internal/NpgsqlListTranslator.cs | 105 ---------- .../NpgsqlSqlTranslatingExpressionVisitor.cs | 29 ++- .../Sql/Internal/NpgsqlQuerySqlGenerator.cs | 2 - .../Query/ArrayQueryTest.cs | 31 ++- 7 files changed, 332 insertions(+), 120 deletions(-) create mode 100644 src/EFCore.PG/Extensions/NpgsqlArrayExtensions.cs create mode 100644 src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlArrayTranslator.cs delete mode 100644 src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlListTranslator.cs diff --git a/src/EFCore.PG/Extensions/NpgsqlArrayExtensions.cs b/src/EFCore.PG/Extensions/NpgsqlArrayExtensions.cs new file mode 100644 index 0000000000..405431cece --- /dev/null +++ b/src/EFCore.PG/Extensions/NpgsqlArrayExtensions.cs @@ -0,0 +1,92 @@ +#region License + +// The PostgreSQL License +// +// Copyright (C) 2016 The Npgsql Development Team +// +// Permission to use, copy, modify, and distribute this software and its +// documentation for any purpose, without fee, and without a written +// agreement is hereby granted, provided that the above copyright notice +// and this paragraph and the following two paragraphs appear in all copies. +// +// IN NO EVENT SHALL THE NPGSQL DEVELOPMENT TEAM BE LIABLE TO ANY PARTY +// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +// INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS +// DOCUMENTATION, EVEN IF THE NPGSQL DEVELOPMENT TEAM HAS BEEN ADVISED OF +// THE POSSIBILITY OF SUCH DAMAGE. +// +// THE NPGSQL DEVELOPMENT TEAM SPECIFICALLY DISCLAIMS ANY WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +// ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS +// TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +#endregion + +using System; +using System.Collections.Generic; +using JetBrains.Annotations; + +// ReSharper disable once CheckNamespace +namespace Microsoft.EntityFrameworkCore +{ + /// + /// Provides extension methods for supporting PostgreSQL translation. + /// + public static class NpgsqlArrayExtensions + { + /// + /// Determines whether a range contains a specified value. + /// + /// The DbFunctions instance. + /// The list to conver to a string in which to locate the value. + /// The value used to delimit the elements. + /// The type of the elements of . + /// + /// true if the range contains the specified value; otherwise, false. + /// + public static string ArrayToString([CanBeNull] this DbFunctions _, [NotNull] T[] array, [CanBeNull] string delimiter) + => throw new NotSupportedException(); + + /// + /// Determines whether a range contains a specified value. + /// + /// The DbFunctions instance. + /// The list to conver to a string in which to locate the value. + /// The value used to delimit the elements. + /// The value used to represent a null value. + /// The type of the elements of . + /// + /// true if the range contains the specified value; otherwise, false. + /// + public static string ArrayToString([CanBeNull] this DbFunctions _, [NotNull] T[] array, [CanBeNull] string delimiter, [CanBeNull] string nullString) + => throw new NotSupportedException(); + + /// + /// Determines whether a range contains a specified value. + /// + /// The DbFunctions instance. + /// The list to conver to a string in which to locate the value. + /// The value used to delimit the elements. + /// The type of the elements of . + /// + /// true if the range contains the specified value; otherwise, false. + /// + public static string ArrayToString([CanBeNull] this DbFunctions _, [NotNull] List list, [CanBeNull] string delimiter) + => throw new NotSupportedException(); + + /// + /// Determines whether a range contains a specified value. + /// + /// The DbFunctions instance. + /// The list to conver to a string in which to locate the value. + /// The value used to delimit the elements. + /// The value used to represent a null value. + /// The type of the elements of . + /// + /// true if the range contains the specified value; otherwise, false. + /// + public static string ArrayToString([CanBeNull] this DbFunctions _, [NotNull] List list, [CanBeNull] string delimiter, [CanBeNull] string nullString) + => throw new NotSupportedException(); + } +} diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlArrayTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlArrayTranslator.cs new file mode 100644 index 0000000000..4656523e3f --- /dev/null +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlArrayTranslator.cs @@ -0,0 +1,191 @@ +#region License + +// The PostgreSQL License +// +// Copyright (C) 2016 The Npgsql Development Team +// +// Permission to use, copy, modify, and distribute this software and its +// documentation for any purpose, without fee, and without a written +// agreement is hereby granted, provided that the above copyright notice +// and this paragraph and the following two paragraphs appear in all copies. +// +// IN NO EVENT SHALL THE NPGSQL DEVELOPMENT TEAM BE LIABLE TO ANY PARTY +// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +// INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS +// DOCUMENTATION, EVEN IF THE NPGSQL DEVELOPMENT TEAM HAS BEEN ADVISED OF +// THE POSSIBILITY OF SUCH DAMAGE. +// +// THE NPGSQL DEVELOPMENT TEAM SPECIFICALLY DISCLAIMS ANY WARRANTIES, +// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +// ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS +// TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + +#endregion + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query.Expressions; +using Microsoft.EntityFrameworkCore.Query.ExpressionTranslators; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.ExpressionTranslators.Internal +{ + /// + /// Provides translation services for PostgreSQL array operators mapped to methods declared on + /// , , , and . + /// + /// + /// See: https://www.postgresql.org/docs/current/static/functions-array.html + /// + public class NpgsqlArrayTranslator : IMethodCallTranslator + { + /// + [CanBeNull] + public Expression Translate(MethodCallExpression expression) + { + if (!IsTypeSupported(expression)) + return null; + + if (!IsMethodSupported(expression.Method)) + return null; + + // TODO: use #430 to map @> to source.All(x => other.Contains(x)); + // TODO: use #430 to map && to soucre.Any(x => other.Contains(x)); + + switch (expression.Method.Name) + { + #region Instance + + case "get_Item" when expression.Object is Expression instance: + return Expression.MakeIndex(instance, instance.Type.GetRuntimeProperty("Item"), expression.Arguments); + + #endregion + + #region Enumerable + + case nameof(Enumerable.ElementAt): + return Expression.MakeIndex(expression.Arguments[0], expression.Arguments[0].Type.GetRuntimeProperty("Item"), new[] { expression.Arguments[1] }); + + // TODO: Currently handled in NpgsqlSqlTranslatingExpressionVisitor. + case nameof(Enumerable.Append): + case nameof(Enumerable.Concat) when IsArrayOrList(expression.Arguments[1].Type): + return new CustomBinaryExpression(expression.Arguments[0], expression.Arguments[1], "||", expression.Arguments[0].Type); + + case nameof(Enumerable.Count): + return new SqlFunctionExpression("array_length", typeof(int), new[] { expression.Arguments[0], Expression.Constant(1) }); + + case nameof(Enumerable.Prepend): + return new CustomBinaryExpression(expression.Arguments[1], expression.Arguments[0], "||", expression.Arguments[0].Type); + + case nameof(Enumerable.SequenceEqual): + return Expression.MakeBinary(ExpressionType.Equal, expression.Arguments[0], expression.Arguments[1]); + + #endregion + + #region NpgsqlArrayExtensions + + case nameof(NpgsqlArrayExtensions.ArrayToString): + return new SqlFunctionExpression("array_to_string", typeof(string), expression.Arguments.Skip(1)); + + #endregion + + #region ArrayStatic + + case nameof(Array.IndexOf) when expression.Method.DeclaringType == typeof(Array): + return + new SqlFunctionExpression( + "COALESCE", + typeof(int), + new Expression[] + { + new SqlFunctionExpression("array_position", typeof(int), expression.Arguments), + Expression.Constant(-1) + }); + + #endregion + + #region ListInstance + + case nameof(IList.IndexOf) when IsArrayOrList(expression.Method.DeclaringType): + return + new SqlFunctionExpression( + "COALESCE", + typeof(int), + new Expression[] + { + new SqlFunctionExpression("array_position", typeof(int), new[] { expression.Object, expression.Arguments[0] }), + Expression.Constant(-1) + }); + + #endregion + + default: + return null; + } + } + + #region Helpers + + /// + /// Tests if the instance or argument types are supported. + /// + /// + /// The to test. + /// + /// + /// True if the instance or argument types are supported; otherwise, false. + /// + static bool IsTypeSupported([NotNull] MethodCallExpression expression) + { + if (expression.Object is Expression instance) + return IsArrayOrList(instance.Type); + + if (expression.Object is null) + { + if (expression.Arguments.Count == 0) + return false; + + if (expression.Arguments.Count > 1 && + expression.Arguments[0].Type == typeof(DbFunctions)) + return IsArrayOrList(expression.Arguments[1].Type); + + return IsArrayOrList(expression.Arguments[0].Type); + } + + return false; + } + + /// + /// Tests if the type is an array or a . + /// + /// + /// The type to test. + /// + /// + /// True if is an array or a ; otherwise, false. + /// + static bool IsArrayOrList(Type type) + => type.IsArray || type.IsGenericType && typeof(List<>) == type.GetGenericTypeDefinition(); + + /// + /// Tests if the method is declared on an array, a , or . + /// + /// + /// The method to test. + /// + /// + /// True if is declared on an array, a , or ; otherwise, false. + /// + static bool IsMethodSupported([NotNull] MethodInfo method) + => method.DeclaringType is Type t && (IsArrayOrList(t) || t == typeof(Array) || t == typeof(Enumerable) || t == typeof(NpgsqlArrayExtensions)); + + #endregion + } +} diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlCompositeMethodCallTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlCompositeMethodCallTranslator.cs index e8a1cee5a4..f5fdd49448 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlCompositeMethodCallTranslator.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlCompositeMethodCallTranslator.cs @@ -60,7 +60,7 @@ public class NpgsqlCompositeMethodCallTranslator : RelationalCompositeMethodCall new NpgsqlRegexIsMatchTranslator(), new NpgsqlFullTextSearchMethodTranslator(), new NpgsqlRangeTranslator(), - new NpgsqlListTranslator() + new NpgsqlArrayTranslator() }; /// diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlListTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlListTranslator.cs deleted file mode 100644 index 953241d9e9..0000000000 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlListTranslator.cs +++ /dev/null @@ -1,105 +0,0 @@ -#region License - -// The PostgreSQL License -// -// Copyright (C) 2016 The Npgsql Development Team -// -// Permission to use, copy, modify, and distribute this software and its -// documentation for any purpose, without fee, and without a written -// agreement is hereby granted, provided that the above copyright notice -// and this paragraph and the following two paragraphs appear in all copies. -// -// IN NO EVENT SHALL THE NPGSQL DEVELOPMENT TEAM BE LIABLE TO ANY PARTY -// FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, -// INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS -// DOCUMENTATION, EVEN IF THE NPGSQL DEVELOPMENT TEAM HAS BEEN ADVISED OF -// THE POSSIBILITY OF SUCH DAMAGE. -// -// THE NPGSQL DEVELOPMENT TEAM SPECIFICALLY DISCLAIMS ANY WARRANTIES, -// INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS -// ON AN "AS IS" BASIS, AND THE NPGSQL DEVELOPMENT TEAM HAS NO OBLIGATIONS -// TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. - -#endregion - -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore.Query.Expressions; -using Microsoft.EntityFrameworkCore.Query.ExpressionTranslators; -using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal; - -namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.ExpressionTranslators.Internal -{ - /// - /// Provides translation services for methods as PostgreSQL array operators. - /// - /// - /// See: https://www.postgresql.org/docs/current/static/functions-array.html - /// - public class NpgsqlListTranslator : IMethodCallTranslator - { - /// - [CanBeNull] - public Expression Translate(MethodCallExpression expression) - { - if (expression.Object != null && !typeof(IList).IsAssignableFrom(expression.Object.Type)) - return null; - if (expression.Object == null && expression.Arguments.Count > 0 && !typeof(IList).IsAssignableFrom(expression.Arguments[0].Type)) - return null; - - // TODO: use #430 to map @> to source.All(x => other.Contains(x)); - // TODO: use #430 to map && to soucre.Any(x => other.Contains(x)); - - switch (expression.Method.Name) - { - // TODO: Currently handled in NpgsqlSqlTranslatingExpressionVisitor. - case nameof(Enumerable.Append): - case nameof(Enumerable.Concat): - return new CustomBinaryExpression(expression.Arguments[0], expression.Arguments[1], "||", expression.Arguments[0].Type); - - // TODO: Currently handled in NpgsqlSqlTranslatingExpressionVisitor. - case nameof(Enumerable.Count): - return new SqlFunctionExpression("array_length", typeof(int), new[] { expression.Arguments[0], Expression.Constant(1) }); - - case "get_Item" when expression.Object is Expression instance: - return Expression.MakeIndex(instance, instance.Type.GetRuntimeProperty("Item"), expression.Arguments); - - case nameof(Enumerable.ElementAt): - return Expression.MakeIndex(expression.Arguments[0], expression.Arguments[0].Type.GetRuntimeProperty("Item"), new[] { expression.Arguments[1] }); - - case nameof(Enumerable.Prepend): - return new CustomBinaryExpression(expression.Arguments[1], expression.Arguments[0], "||", expression.Arguments[0].Type); - - case nameof(Enumerable.SequenceEqual): - return Expression.MakeBinary(ExpressionType.Equal, expression.Arguments[0], expression.Arguments[1]); - - case nameof(ToString): - return new SqlFunctionExpression("array_to_string", typeof(string), new[] { expression.Object, Expression.Constant(",") }); - - case nameof(IList.IndexOf): - return - new SqlFunctionExpression( - "COALESCE", - typeof(int), - new Expression[] - { - new SqlFunctionExpression( - "array_position", - typeof(int), - expression.Object is null - ? (IEnumerable)expression.Arguments - : new[] { expression.Object, expression.Arguments[0] }), - Expression.Constant(-1) - }); - - default: - return null; - } - } - } -} diff --git a/src/EFCore.PG/Query/ExpressionVisitors/NpgsqlSqlTranslatingExpressionVisitor.cs b/src/EFCore.PG/Query/ExpressionVisitors/NpgsqlSqlTranslatingExpressionVisitor.cs index 9abdeb5645..2f3ac81f6a 100644 --- a/src/EFCore.PG/Query/ExpressionVisitors/NpgsqlSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.PG/Query/ExpressionVisitors/NpgsqlSqlTranslatingExpressionVisitor.cs @@ -23,7 +23,8 @@ #endregion -using System.Collections; +using System; +using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using JetBrains.Annotations; @@ -61,20 +62,19 @@ public NpgsqlSqlTranslatingExpressionVisitor( protected override Expression VisitSubQuery(SubQueryExpression expression) { // Prefer the default EF Core translation if one exists - var result = base.VisitSubQuery(expression); - if (result != null) + if (base.VisitSubQuery(expression) is Expression result) return result; var subQueryModel = expression.QueryModel; var fromExpression = subQueryModel.MainFromClause.FromExpression; var properties = MemberAccessBindingExpressionVisitor.GetPropertyPath( - fromExpression, _queryModelVisitor.QueryCompilationContext, out var qsre); + fromExpression, _queryModelVisitor.QueryCompilationContext, out _); if (properties.Count == 0) return null; var lastPropertyType = properties[properties.Count - 1].ClrType; - if (typeof(IList).IsAssignableFrom(lastPropertyType) && subQueryModel.ResultOperators.Count > 0) + if (IsArrayOrList(lastPropertyType) && subQueryModel.ResultOperators.Count > 0) { if (subQueryModel.ResultOperators.First() is ConcatResultOperator concatResultOperator && Visit(fromExpression) is Expression first && @@ -83,7 +83,12 @@ protected override Expression VisitSubQuery(SubQueryExpression expression) // Translate someArray.Length if (subQueryModel.ResultOperators.First() is CountResultOperator) + { + if (lastPropertyType.IsArray) + return Expression.ArrayLength(Visit(fromExpression)); + return new SqlFunctionExpression("array_length", typeof(int), new[] { Visit(fromExpression), Expression.Constant(1) }); + } // Translate someArray.Contains(someValue) if (subQueryModel.ResultOperators.First() is ContainsResultOperator contains) @@ -100,7 +105,7 @@ protected override Expression VisitBinary(BinaryExpression expression) if (expression.NodeType == ExpressionType.ArrayIndex) { var properties = MemberAccessBindingExpressionVisitor.GetPropertyPath( - expression.Left, _queryModelVisitor.QueryCompilationContext, out var qsre); + expression.Left, _queryModelVisitor.QueryCompilationContext, out _); if (properties.Count == 0) return base.VisitBinary(expression); var lastPropertyType = properties[properties.Count - 1].ClrType; @@ -117,5 +122,17 @@ protected override Expression VisitBinary(BinaryExpression expression) return base.VisitBinary(expression); } + + /// + /// Tests if the type is an array or a . + /// + /// + /// The type to test. + /// + /// + /// True if is an array or a ; otherwise, false. + /// + static bool IsArrayOrList([NotNull] Type type) + => type.IsArray || type.IsGenericType && typeof(List<>) == type.GetGenericTypeDefinition(); } } diff --git a/src/EFCore.PG/Query/Sql/Internal/NpgsqlQuerySqlGenerator.cs b/src/EFCore.PG/Query/Sql/Internal/NpgsqlQuerySqlGenerator.cs index e447943b97..4b261df3ed 100644 --- a/src/EFCore.PG/Query/Sql/Internal/NpgsqlQuerySqlGenerator.cs +++ b/src/EFCore.PG/Query/Sql/Internal/NpgsqlQuerySqlGenerator.cs @@ -146,8 +146,6 @@ protected override Expression VisitBinary(BinaryExpression expression) /// protected override Expression VisitUnary(UnaryExpression expression) { - // TODO: I don't think this is called any longer. - // Handled by NpgsqlSqlTranslatingExpressionVisitor.VisitSubQuery. if (expression.NodeType == ExpressionType.ArrayLength) { VisitSqlFunction(new SqlFunctionExpression("array_length", typeof(int), new[] { expression.Operand, Expression.Constant(1) })); diff --git a/test/EFCore.PG.FunctionalTests/Query/ArrayQueryTest.cs b/test/EFCore.PG.FunctionalTests/Query/ArrayQueryTest.cs index 60cc59c0e1..4bc7b9d44d 100644 --- a/test/EFCore.PG.FunctionalTests/Query/ArrayQueryTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/ArrayQueryTest.cs @@ -3,7 +3,6 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Internal; using Microsoft.EntityFrameworkCore.TestUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -452,7 +451,7 @@ public void Array_IndexOf_constant() { using (var ctx = CreateContext()) { - var _ = ctx.SomeEntities.Select(e => e.SomeArray.IndexOf(0)).ToList(); + var _ = ctx.SomeEntities.Select(e => Array.IndexOf(e.SomeArray, 0)).ToList(); AssertContainsInSql(@"SELECT COALESCE(array_position(e.""SomeArray"", 0), -1)"); } } @@ -468,25 +467,45 @@ public void List_IndexOf_constant() } [Fact] - public void Array_ToString() + public void Array_ArrayToString() { using (var ctx = CreateContext()) { - var _ = ctx.SomeEntities.Select(e => e.SomeArray.ToString()).ToList(); + var _ = ctx.SomeEntities.Select(e => EF.Functions.ArrayToString(e.SomeArray, ",")).ToList(); AssertContainsInSql(@"SELECT array_to_string(e.""SomeArray"", ',')"); } } [Fact] - public void List_ToString() + public void List_ArrayToString() { using (var ctx = CreateContext()) { - var _ = ctx.SomeEntities.Select(e => e.SomeList.ToString()).ToList(); + var _ = ctx.SomeEntities.Select(e => EF.Functions.ArrayToString(e.SomeList, ",")).ToList(); AssertContainsInSql(@"SELECT array_to_string(e.""SomeList"", ',')"); } } + [Fact] + public void Array_ArrayToString_with_null() + { + using (var ctx = CreateContext()) + { + var _ = ctx.SomeEntities.Select(e => EF.Functions.ArrayToString(e.SomeArray, ",", "*")).ToList(); + AssertContainsInSql(@"SELECT array_to_string(e.""SomeArray"", ',', '*')"); + } + } + + [Fact] + public void List_ArrayToString_with_null() + { + using (var ctx = CreateContext()) + { + var _ = ctx.SomeEntities.Select(e => EF.Functions.ArrayToString(e.SomeList, ",", "*")).ToList(); + AssertContainsInSql(@"SELECT array_to_string(e.""SomeList"", ',', '*')"); + } + } + #if NETCOREAPP2_1 [Fact] public void Array_Append_constant()