diff --git a/.editorconfig b/.editorconfig index fa58f05..1307867 100644 --- a/.editorconfig +++ b/.editorconfig @@ -20,6 +20,10 @@ tab_width = 4 # New line preferences insert_final_newline = false +[*.csproj] +indent_size = 2 +tab_width = 2 + #### .NET Coding Conventions #### [*.{cs,vb}] diff --git a/EntityFrameworkCore.ClickHouse.FunctionalTests/ProviderTests.cs b/EntityFrameworkCore.ClickHouse.FunctionalTests/ProviderTests.cs new file mode 100644 index 0000000..874fff0 --- /dev/null +++ b/EntityFrameworkCore.ClickHouse.FunctionalTests/ProviderTests.cs @@ -0,0 +1,51 @@ +using ClickHouse.Driver.ADO; +using System.Data; +using Xunit; + +namespace EntityFrameworkCore.ClickHouse.FunctionalTests; + +public class ProviderTests +{ + [Fact] + public void Fact() + { + const string connectionStringBase = "Host=localhost;Protocol=http;Port=8123;Username=default;Password=changeme"; + + var connection = new ClickHouseConnection(connectionStringBase); + connection.SetFormDataParameters(true); + + var command = connection.CreateCommand(); + command.CommandText = """ + CREATE DATABASE IF NOT EXISTS array_with_null_item; + """; + + connection.Open(); + command.ExecuteNonQuery(); + connection.Close(); + + connection.ConnectionString = $"{connectionStringBase};Database=array_with_null_item"; + connection.Open(); + + command.CommandText = """ + CREATE TABLE IF NOT EXISTS my_table + ( + Id Int32, + Value Array(Nullable(String)) + ) + ENGINE = MergeTree() + ORDER BY Id; + """; + command.ExecuteNonQuery(); + + command.CommandText = "INSERT INTO my_table VALUES (1, {p0:Array(Nullable(String))})"; + var array = new string[] { "1", "2", "3", null }; + var arrayParameter = command.CreateParameter(); + arrayParameter.ParameterName = "p0"; + arrayParameter.Value = array; + arrayParameter.DbType = DbType.Object; + arrayParameter.ClickHouseType = "Array(Nullable(String))"; + command.Parameters.Add(arrayParameter); + + command.ExecuteNonQuery(); + } +} \ No newline at end of file diff --git a/EntityFrameworkCore.ClickHouse.FunctionalTests/Query/ArrayArrayQueryTest.cs b/EntityFrameworkCore.ClickHouse.FunctionalTests/Query/ArrayArrayQueryTest.cs new file mode 100644 index 0000000..6356b85 --- /dev/null +++ b/EntityFrameworkCore.ClickHouse.FunctionalTests/Query/ArrayArrayQueryTest.cs @@ -0,0 +1,916 @@ +using EntityFrameworkCore.ClickHouse.FunctionalTests.TestModels.Array; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace EntityFrameworkCore.ClickHouse.FunctionalTests.Query; + +public class ArrayArrayQueryTest(ArrayArrayQueryTest.ArrayArrayQueryFixture fixture, ITestOutputHelper testOutputHelper) + : ArrayQueryTest(fixture, testOutputHelper) +{ + #region Indexers + + public override async Task Index_with_constant(bool async) + { + await base.Index_with_constant(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."IntArray"[1] = 3 +"""); + } + + public override async Task Index_with_parameter(bool async) + { + await base.Index_with_parameter(async); + + AssertSql( + """ +@__x_0='0' + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."IntArray"[@__x_0 + 1] = 3 +"""); + } + + public override async Task Nullable_index_with_constant(bool async) + { + await base.Nullable_index_with_constant(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."NullableIntArray"[1] = 3 +"""); + } + + public override async Task Nullable_value_array_index_compare_to_null(bool async) + { + await base.Nullable_value_array_index_compare_to_null(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."NullableIntArray"[3] IS NULL +"""); + } + + public override async Task Non_nullable_value_array_index_compare_to_null(bool async) + { + await base.Non_nullable_value_array_index_compare_to_null(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE FALSE +"""); + } + + public override async Task Nullable_reference_array_index_compare_to_null(bool async) + { + await base.Nullable_reference_array_index_compare_to_null(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."NullableStringArray"[3] IS NULL +"""); + } + + public override async Task Non_nullable_reference_array_index_compare_to_null(bool async) + { + await base.Non_nullable_reference_array_index_compare_to_null(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE FALSE +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Index_bytea_with_constant(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Where(e => e.Bytea[0] == 3)); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE get_byte(s."Bytea", 0) = 3 +"""); + } + + #endregion + + #region SequenceEqual + + public override async Task SequenceEqual_with_parameter(bool async) + { + await base.SequenceEqual_with_parameter(async); + + AssertSql( + """ +@__arr_0={ '3', '4' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."IntArray" = @__arr_0 +"""); + } + + public override async Task SequenceEqual_with_array_literal(bool async) + { + await base.SequenceEqual_with_array_literal(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 s."IntArray" = ARRAY[3,4]::integer[] +"""); + } + + public override async Task SequenceEqual_over_nullable_with_parameter(bool async) + { + await base.SequenceEqual_over_nullable_with_parameter(async); + + AssertSql( + """ +@__arr_0={ '3', '4', NULL } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."NullableIntArray" = @__arr_0 +"""); + } + + #endregion SequenceEqual + + #region Containment + + public override async Task Array_column_Any_equality_operator(bool async) + { + await base.Array_column_Any_equality_operator(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."StringArray" @> ARRAY['3']::text[] +"""); + } + + public override async Task Array_column_Any_Equals(bool async) + { + await base.Array_column_Any_Equals(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."StringArray" @> ARRAY['3']::text[] +"""); + } + + public override async Task Array_column_Contains_literal_item(bool async) + { + await base.Array_column_Contains_literal_item(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."IntArray" @> ARRAY[3]::integer[] +"""); + } + + public override async Task Array_column_Contains_parameter_item(bool async) + { + await base.Array_column_Contains_parameter_item(async); + + AssertSql( + """ +@__p_0='3' + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."IntArray" @> ARRAY[@__p_0]::integer[] +"""); + } + + public override async Task Array_column_Contains_column_item(bool async) + { + await base.Array_column_Contains_column_item(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."IntArray" @> ARRAY[s."Id" + 2]::integer[] +"""); + } + + public override async Task Array_column_Contains_null_constant(bool async) + { + await base.Array_column_Contains_null_constant(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE array_position(s."NullableStringArray", NULL) IS NOT NULL +"""); + } + + public override void Array_column_Contains_null_parameter_does_not_work() + { + using var ctx = CreateContext(); + + string p = null; + + // We incorrectly miss arrays containing non-constant nulls, because detecting those + // would prevent index use. + Assert.Equal( + 0, + ctx.SomeEntities.Count(e => e.StringArray.Contains(p))); + + AssertSql( + """ +SELECT count(*)::int +FROM "SomeEntities" AS s +WHERE s."StringArray" @> ARRAY[NULL]::text[] +"""); + } + + public override async Task Nullable_array_column_Contains_literal_item(bool async) + { + await base.Nullable_array_column_Contains_literal_item(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."NullableIntArray" @> ARRAY[3]::integer[] +"""); + } + + public override async Task Array_constant_Contains_column(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Where(e => new[] { "foo", "xxx" }.Contains(e.NullableText))); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."NullableText" IN ('foo', 'xxx') +"""); + } + + public override async Task Array_param_Contains_nullable_column(bool async) + { + var array = new[] { "foo", "xxx" }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => array.Contains(e.NullableText))); + + AssertSql( + """ +@__array_0={ 'foo', 'xxx' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."NullableText" = ANY (@__array_0) OR (s."NullableText" IS NULL AND array_position(@__array_0, NULL) IS NOT NULL) +"""); + } + + public override async Task Array_param_Contains_non_nullable_column(bool async) + { + var array = new[] { 1 }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => array.Contains(e.Id))); + + AssertSql( + """ +@__array_0={ '1' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."Id" = ANY (@__array_0) +"""); + } + + public override void Array_param_with_null_Contains_non_nullable_not_found() + { + using var ctx = CreateContext(); + + var array = new[] { "unknown1", "unknown2", null }; + + Assert.Equal(0, ctx.SomeEntities.Count(e => array.Contains(e.NonNullableText))); + + AssertSql( + """ +@__array_0={ 'unknown1', 'unknown2', NULL } (DbType = Object) + +SELECT count(*)::int +FROM "SomeEntities" AS s +WHERE s."NonNullableText" = ANY (@__array_0) +"""); + } + + public override void Array_param_with_null_Contains_non_nullable_not_found_negated() + { + using var ctx = CreateContext(); + + var array = new[] { "unknown1", "unknown2", null }; + + Assert.Equal(2, ctx.SomeEntities.Count(e => !array.Contains(e.NonNullableText))); + + AssertSql( + """ +@__array_0={ 'unknown1', 'unknown2', NULL } (DbType = Object) + +SELECT count(*)::int +FROM "SomeEntities" AS s +WHERE NOT (s."NonNullableText" = ANY (@__array_0) AND s."NonNullableText" = ANY (@__array_0) IS NOT NULL) +"""); + } + + public override void Array_param_with_null_Contains_nullable_not_found() + { + using var ctx = CreateContext(); + + var array = new[] { "unknown1", "unknown2", null }; + + Assert.Equal(0, ctx.SomeEntities.Count(e => array.Contains(e.NullableText))); + + AssertSql( + """ +@__array_0={ 'unknown1', 'unknown2', NULL } (DbType = Object) + +SELECT count(*)::int +FROM "SomeEntities" AS s +WHERE s."NullableText" = ANY (@__array_0) OR (s."NullableText" IS NULL AND array_position(@__array_0, NULL) IS NOT NULL) +"""); + } + + public override void Array_param_with_null_Contains_nullable_not_found_negated() + { + using var ctx = CreateContext(); + + var array = new[] { "unknown1", "unknown2", null }; + + Assert.Equal(2, ctx.SomeEntities.Count(e => !array.Contains(e.NullableText))); + + AssertSql( + """ +@__array_0={ 'unknown1', 'unknown2', NULL } (DbType = Object) + +SELECT count(*)::int +FROM "SomeEntities" AS s +WHERE NOT (s."NullableText" = ANY (@__array_0) AND s."NullableText" = ANY (@__array_0) IS NOT NULL) AND (s."NullableText" IS NOT NULL OR array_position(@__array_0, NULL) IS NULL) +"""); + } + + public override async Task Array_param_Contains_column_with_ToString(bool async) + { + var values = new[] { "1", "999" }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => values.Contains(e.Id.ToString()))); + + AssertSql( + """ +@__values_0={ '1', '999' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."Id"::text = ANY (@__values_0) +"""); + } + + public override async Task Byte_array_parameter_contains_column(bool async) + { + var values = new byte[] { 20 }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => values.Contains(e.Byte))); + + // Note: EF Core prints the parameter as a bytea, but it's actually a smallint[] (otherwise ANY would fail) + AssertSql( + """ +@__values_0='0x14' (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."Byte" = ANY (@__values_0) +"""); + } + + public override async Task Array_param_Contains_value_converted_column_enum_to_int(bool async) + { + var array = new[] { SomeEnum.Two, SomeEnum.Three }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => array.Contains(e.EnumConvertedToInt))); + + AssertSql( + """ +@__array_0={ '-2', '-3' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."EnumConvertedToInt" = ANY (@__array_0) +"""); + } + + public override async Task Array_param_Contains_value_converted_column_enum_to_string(bool async) + { + var array = new[] { SomeEnum.Two, SomeEnum.Three }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => array.Contains(e.EnumConvertedToString))); + + AssertSql( + """ +@__array_0={ 'Two', 'Three' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."EnumConvertedToString" = ANY (@__array_0) +"""); + } + + public override async Task Array_param_Contains_value_converted_column_nullable_enum_to_string(bool async) + { + var array = new SomeEnum?[] { SomeEnum.Two, SomeEnum.Three }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => array.Contains(e.NullableEnumConvertedToString))); + + AssertSql( + """ +@__array_0={ 'Two', 'Three' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."NullableEnumConvertedToString" = ANY (@__array_0) OR (s."NullableEnumConvertedToString" IS NULL AND array_position(@__array_0, NULL) IS NOT NULL) +"""); + } + + public override async Task Array_param_Contains_value_converted_column_nullable_enum_to_string_with_non_nullable_lambda(bool async) + { + var array = new SomeEnum?[] { SomeEnum.Two, SomeEnum.Three }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => array.Contains(e.NullableEnumConvertedToStringWithNonNullableLambda))); + + AssertSql( + """ +@__array_0={ 'Two', 'Three' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."NullableEnumConvertedToStringWithNonNullableLambda" = ANY (@__array_0) OR (s."NullableEnumConvertedToStringWithNonNullableLambda" IS NULL AND array_position(@__array_0, NULL) IS NOT NULL) +"""); + } + + public override async Task Array_column_Contains_value_converted_param(bool async) + { + var item = SomeEnum.Eight; + + await AssertQuery( + async, + ss => ss.Set().Where(e => e.ValueConvertedArrayOfEnum.Contains(item))); + + AssertSql( + """ +@__item_0='Eight' (Nullable = false) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."ValueConvertedArrayOfEnum" @> ARRAY[@__item_0]::text[] +"""); + } + + public override async Task Array_column_Contains_value_converted_constant(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Where(e => e.ValueConvertedArrayOfEnum.Contains(SomeEnum.Eight))); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."ValueConvertedArrayOfEnum" @> ARRAY['Eight']::text[] +"""); + } + + public override async Task Array_param_Contains_value_converted_array_column(bool async) + { + var p = new[] { SomeEnum.Eight, SomeEnum.Nine }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => e.ValueConvertedArrayOfEnum.All(x => p.Contains(x)))); + + AssertSql( + """ +@__p_0={ 'Eight', 'Nine' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."ValueConvertedArrayOfEnum" <@ @__p_0 +"""); + } + +// public override async Task Array_column_Contains_in_scalar_subquery(bool async) +// { +// await base.Array_column_Contains_in_scalar_subquery(async); +// +// AssertSql( +// """ +// SELECT s."Id" +// FROM "SomeEntityContainers" AS s +// WHERE 3 = ANY (( +// SELECT s0."NullableIntArray" +// FROM "SomeEntities" AS s0 +// WHERE s."Id" = s0."ArrayContainerEntityId" +// ORDER BY s0."Id" NULLS FIRST +// LIMIT 1)::integer[]) +// """); +// } + + public override async Task IList_column_contains_constant(bool async) + { + await base.IList_column_contains_constant(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."IList" @> ARRAY[10]::integer[] +"""); + } + + #endregion Containment + + #region Length/Count + + public override async Task Array_Length(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Where(e => e.IntArray.Length == 2)); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE cardinality(s."IntArray") = 2 +"""); + } + + public override async Task Nullable_array_Length(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Where(e => e.NullableIntArray.Length == 3)); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE cardinality(s."NullableIntArray") = 3 +"""); + } + + public override async Task Array_Length_on_EF_Property(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Where(e => EF.Property(e, nameof(ArrayEntity.IntArray)).Length == 2)); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE cardinality(s."IntArray") = 2 +"""); + } + + #endregion Length/Count + + #region Any/All + + public override async Task Any_no_predicate(bool async) + { + await base.Any_no_predicate(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE cardinality(s."IntArray") > 0 +"""); + } + + public override async Task Any_like(bool async) + { + await AssertQuery( + async, + ss => ss.Set() + .Where(e => new[] { "a%", "b%", "c%" }.Any(p => EF.Functions.Like(e.NullableText, p))), + ss => ss.Set() + .Where(e => new[] { "a", "b", "c" }.Any(p => e.NullableText.StartsWith(p, StringComparison.Ordinal)))); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."NullableText" LIKE ANY (ARRAY['a%','b%','c%']::text[]) +"""); + } + + public override async Task Any_ilike(bool async) + { + await Task.Delay(100); +// await AssertQuery( +// async, +// ss => ss.Set() +// .Where(e => new[] { "a%", "b%", "c%" }.Any(p => EF.Functions.ILike(e.NullableText, p))), +// ss => ss.Set() +// .Where(e => new[] { "a", "b", "c" }.Any(p => e.NullableText.StartsWith(p, StringComparison.OrdinalIgnoreCase)))); +// +// AssertSql( +// """ +// SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +// FROM "SomeEntities" AS s +// WHERE s."NullableText" ILIKE ANY (ARRAY['a%','b%','c%']::text[]) +// """); + } + + public override async Task Any_like_anonymous(bool async) + { + await using var ctx = CreateContext(); + + var patternsActual = new[] { "a%", "b%", "c%" }; + var patternsExpected = new[] { "a", "b", "c" }; + + await AssertQuery( + async, + ss => ss.Set() + .Where(e => patternsActual.Any(p => EF.Functions.Like(e.NullableText, p))), + ss => ss.Set() + .Where(e => patternsExpected.Any(p => e.NullableText.StartsWith(p, StringComparison.Ordinal)))); + + AssertSql( + """ +@__patternsActual_1={ 'a%', 'b%', 'c%' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."NullableText" LIKE ANY (@__patternsActual_1) +"""); + } + + public override async Task All_like(bool async) + { + await AssertQuery( + async, + ss => ss.Set() + .Where(e => new[] { "b%", "ba%" }.All(p => EF.Functions.Like(e.NullableText, p))), + ss => ss.Set() + .Where(e => new[] { "b", "ba" }.All(p => e.NullableText.StartsWith(p, StringComparison.Ordinal)))); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."NullableText" LIKE ALL (ARRAY['b%','ba%']::text[]) +"""); + } + + + public override async Task All_ilike(bool async) + { + await Task.Delay(0); + throw new NotImplementedException(); +// await AssertQuery( +// async, +// ss => ss.Set() +// .Where(e => new[] { "B%", "ba%" }.All(p => EF.Functions.ILike(e.NullableText, p))), +// ss => ss.Set() +// .Where(e => new[] { "B", "ba" }.All(p => e.NullableText.StartsWith(p, StringComparison.OrdinalIgnoreCase)))); +// +// AssertSql( +// """ +// SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +// FROM "SomeEntities" AS s +// WHERE s."NullableText" ILIKE ALL (ARRAY['B%','ba%']::text[]) +// """); + } + + public override async Task Any_Contains_on_constant_array(bool async) + { + await base.Any_Contains_on_constant_array(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE ARRAY[2,3]::integer[] && s."IntArray" +"""); + } + + public override async Task Any_Contains_between_column_and_List(bool async) + { + await base.Any_Contains_between_column_and_List(async); + + AssertSql( + """ +@__ints_0={ '2', '3' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."IntArray" && @__ints_0 +"""); + } + + public override async Task Any_Contains_between_column_and_array(bool async) + { + await base.Any_Contains_between_column_and_array(async); + + AssertSql( + """ +@__ints_0={ '2', '3' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."IntArray" && @__ints_0 +"""); + } + + public override async Task Any_Contains_between_column_and_other_type(bool async) + { + var list = new List { SomeEnum.Eight }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => e.ValueConvertedArrayOfEnum.Any(i => list.Contains(i)))); + + AssertSql( + """ +@__list_0={ 'Eight' } (DbType = Object) + +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE s."ValueConvertedArrayOfEnum" && @__list_0 +"""); + } + + public override async Task All_Contains(bool async) + { + await base.All_Contains(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE ARRAY[5,6]::integer[] <@ s."IntArray" +"""); + } + + #endregion Any/All + + #region Other translations + + public override async Task Append(bool async) + // 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) + { + await base.Concat(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_cat(s."IntArray", ARRAY[5,6]::integer[]) = ARRAY[3,4,5,6]::integer[] +"""); + } + + public override async Task Array_IndexOf1(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Where(e => Array.IndexOf(e.IntArray, 6) == 1)); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE COALESCE(array_position(s."IntArray", 6) - 1, -1) = 1 +"""); + } + + public override async Task Array_IndexOf2(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Where(e => Array.IndexOf(e.IntArray, 6, 1) == 1)); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE COALESCE(array_position(s."IntArray", 6, 2) - 1, -1) = 1 +"""); + } + + // Note: see NorthwindFunctionsQueryNpgsqlTest.String_Join_non_aggregate for regular use without an array column/parameter + public override async Task String_Join_with_array_of_int_column(bool async) + { + await base.String_Join_with_array_of_int_column(async); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE array_to_string(s."IntArray", ', ', '') = '3, 4' +"""); + } + + public override async Task String_Join_with_array_of_string_column(bool async) + { + // This is not in ArrayQueryTest because string.Join uses another overload for string[] than for List and thus + // ArrayToListReplacingExpressionVisitor won't work. + await AssertQuery( + async, + ss => ss.Set() + .Where(e => string.Join(", ", e.StringArray) == "3, 4")); + + AssertSql( + """ +SELECT s."Id", s."ArrayContainerEntityId", s."ArrayOfStringConvertedToDelimitedString", s."Byte", s."ByteArray", s."Bytea", s."EnumConvertedToInt", s."EnumConvertedToString", s."IList", s."IntArray", s."IntList", s."ListOfStringConvertedToDelimitedString", s."NonNullableText", s."NullableEnumConvertedToString", s."NullableEnumConvertedToStringWithNonNullableLambda", s."NullableIntArray", s."NullableIntList", s."NullableStringArray", s."NullableStringList", s."NullableText", s."StringArray", s."StringList", s."ValueConvertedArrayOfEnum", s."ValueConvertedListOfEnum", s."Varchar10", s."Varchar15" +FROM "SomeEntities" AS s +WHERE array_to_string(s."StringArray", ', ', '') = '3, 4' +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public override async Task String_Join_disallow_non_array_type_mapped_parameter(bool async) + { + // This is not in ArrayQueryTest because string.Join uses another overload for string[] than for List and thus + // ArrayToListReplacingExpressionVisitor won't work. + await AssertTranslationFailed(() => AssertQuery( + async, + ss => ss.Set() + .Where(e => string.Join(", ", e.ArrayOfStringConvertedToDelimitedString) == "3, 4"))); + } + + #endregion Other translations + + public class ArrayArrayQueryFixture : ArrayQueryFixture + { + protected override string StoreName + => "ArrayQueryTest"; + } +} \ No newline at end of file diff --git a/EntityFrameworkCore.ClickHouse.FunctionalTests/Query/ArrayQueryFixture.cs b/EntityFrameworkCore.ClickHouse.FunctionalTests/Query/ArrayQueryFixture.cs new file mode 100644 index 0000000..0d8b4ac --- /dev/null +++ b/EntityFrameworkCore.ClickHouse.FunctionalTests/Query/ArrayQueryFixture.cs @@ -0,0 +1,78 @@ +using EntityFrameworkCore.ClickHouse.FunctionalTests.TestModels.Array; +using EntityFrameworkCore.ClickHouse.FunctionalTests.TestUtilities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.TestUtilities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace EntityFrameworkCore.ClickHouse.FunctionalTests.Query; + +public abstract class ArrayQueryFixture : SharedStoreFixtureBase, IQueryFixtureBase, ITestSqlLoggerFactory +{ + protected override ITestStoreFactory TestStoreFactory + => ClickHouseTestStoreFactory.Instance; + + public TestSqlLoggerFactory TestSqlLoggerFactory + => (TestSqlLoggerFactory)ListLoggerFactory; + + private ArrayQueryData _expectedData; + + public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) + => base.AddOptions(builder).ConfigureWarnings(wcb => wcb.Ignore(CoreEventId.CollectionWithoutComparer)); + + protected override Task SeedAsync(ArrayQueryContext context) + => ArrayQueryContext.SeedAsync(context); + + public Func GetContextCreator() + => CreateContext; + + public ISetSource GetExpectedData() + => _expectedData ??= new ArrayQueryData(); + + public IReadOnlyDictionary EntitySorters + => new Dictionary> + { + { typeof(ArrayEntity), e => ((ArrayEntity)e)?.Id } + }.ToDictionary(e => e.Key, e => (object)e.Value); + + public IReadOnlyDictionary EntityAsserters + => new Dictionary> + { + { + typeof(ArrayEntity), (e, a) => + { + Assert.Equal(e is null, a is null); + if (a is not null) + { + var ee = (ArrayEntity)e; + var aa = (ArrayEntity)a; + + Assert.Equal(ee.Id, aa.Id); + Assert.Equal(ee.IntArray, ee.IntArray); + Assert.Equal(ee.IntList, ee.IntList); + Assert.Equal(ee.NullableIntArray, ee.NullableIntArray); + Assert.Equal(ee.Bytea, ee.Bytea); + Assert.Equal(ee.ByteArray, ee.ByteArray); + Assert.Equal(ee.StringArray, ee.StringArray); + Assert.Equal(ee.StringList, ee.StringList); + Assert.Equal(ee.NullableStringArray, ee.NullableStringArray); + Assert.Equal(ee.NullableStringList, ee.NullableStringList); + Assert.Equal(ee.NullableText, ee.NullableText); + Assert.Equal(ee.NonNullableText, ee.NonNullableText); + Assert.Equal(ee.EnumConvertedToInt, ee.EnumConvertedToInt); + Assert.Equal(ee.ArrayOfStringConvertedToDelimitedString, ee.ArrayOfStringConvertedToDelimitedString); + Assert.Equal(ee.ListOfStringConvertedToDelimitedString, ee.ListOfStringConvertedToDelimitedString); + Assert.Equal(ee.ValueConvertedArrayOfEnum, ee.ValueConvertedArrayOfEnum); + Assert.Equal(ee.ValueConvertedListOfEnum, ee.ValueConvertedListOfEnum); + Assert.Equal(ee.IList, ee.IList); + Assert.Equal(ee.Byte, ee.Byte); + } + } + } + }.ToDictionary(e => e.Key, e => (object)e.Value); +} \ No newline at end of file diff --git a/EntityFrameworkCore.ClickHouse.FunctionalTests/Query/ArrayQueryTest.cs b/EntityFrameworkCore.ClickHouse.FunctionalTests/Query/ArrayQueryTest.cs new file mode 100644 index 0000000..0b6184e --- /dev/null +++ b/EntityFrameworkCore.ClickHouse.FunctionalTests/Query/ArrayQueryTest.cs @@ -0,0 +1,536 @@ +using EntityFrameworkCore.ClickHouse.FunctionalTests.TestModels.Array; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace EntityFrameworkCore.ClickHouse.FunctionalTests.Query; + +public abstract class ArrayQueryTest : QueryTestBase + where TFixture : ArrayQueryFixture, new() +{ + // ReSharper disable once UnusedParameter.Local + public ArrayQueryTest(TFixture fixture, ITestOutputHelper testOutputHelper) + : base(fixture) + { + Fixture.TestSqlLoggerFactory.Clear(); + Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + #region Roundtrip + + [ConditionalFact] + public void Roundtrip() + { + using var ctx = CreateContext(); + var x = ctx.SomeEntities.Single(e => e.Id == 1); + + Assert.Equal(new[] { 3, 4 }, x.IntArray); + Assert.Equal([3, 4], x.IntList); + Assert.Equal([3, 4, null], x.NullableIntArray); + Assert.Equal( + [ + 3, + 4, + null + ], x.NullableIntList); + } + + #endregion + + #region Indexers + + [Theory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Index_with_constant(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(e => e.IntArray[0] == 3)); + + [Theory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Index_with_parameter(bool async) + { + // ReSharper disable once ConvertToConstant.Local + var x = 0; + + return AssertQuery( + async, + ss => ss.Set().Where(e => e.IntArray[x] == 3)); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Nullable_index_with_constant(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(e => e.NullableIntArray[0] == 3)); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Nullable_value_array_index_compare_to_null(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Where(e => e.NullableIntArray[2] == null)); + +#pragma warning disable CS0472 + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Non_nullable_value_array_index_compare_to_null(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(e => e.IntArray[1] == null), + assertEmpty: true); +#pragma warning restore CS0472 + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Nullable_reference_array_index_compare_to_null(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Where(e => e.NullableStringArray[2] == null)); + +#pragma warning disable CS0472 + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Non_nullable_reference_array_index_compare_to_null(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(e => e.StringArray[1] == null), + assertEmpty: true); +#pragma warning restore CS0472 + + #endregion Indexers + + #region SequenceEqual + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task SequenceEqual_with_parameter(bool async) + { + var arr = new[] { 3, 4 }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => e.IntArray.SequenceEqual(arr))); + } + + [ConditionalTheory(Skip = "https://github.com/dotnet/efcore/issues/30786")] + [MemberData(nameof(IsAsyncData))] + public virtual async Task SequenceEqual_with_array_literal(bool async) + => await AssertQuery( + async, + ss => ss.Set().Where(e => e.IntArray.SequenceEqual(new[] { 3, 4 }))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task SequenceEqual_over_nullable_with_parameter(bool async) + { + var arr = new int?[] { 3, 4, null }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => e.NullableIntArray.SequenceEqual(arr))); + } + + #endregion + + #region Containment + + // See also tests in NorthwindMiscellaneousQueryNpgsqlTest + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Array_column_Any_equality_operator(bool async) + => await AssertQuery( + async, + ss => ss.Set().Where(e => e.StringArray.Any(p => p == "3"))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Array_column_Any_Equals(bool async) + => await AssertQuery( + async, + ss => ss.Set().Where(e => e.StringArray.Any(p => "3".Equals(p)))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Array_column_Contains_literal_item(bool async) + => await AssertQuery( + async, + ss => ss.Set().Where(e => e.IntArray.Contains(3))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Array_column_Contains_parameter_item(bool async) + { + var p = 3; + + await AssertQuery( + async, + ss => ss.Set().Where(e => e.IntArray.Contains(p))); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Array_column_Contains_column_item(bool async) + => await AssertQuery( + async, + ss => ss.Set().Where(e => e.IntArray.Contains(e.Id + 2))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Array_column_Contains_null_constant(bool async) + => await AssertQuery( + async, + ss => ss.Set().Where(e => e.NullableStringArray.Contains(null))); + + [ConditionalFact] + public abstract void Array_column_Contains_null_parameter_does_not_work(); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Nullable_array_column_Contains_literal_item(bool async) + => await AssertQuery( + async, + ss => ss.Set().Where(e => e.NullableIntArray.Contains(3))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_constant_Contains_column(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_param_Contains_nullable_column(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_param_Contains_non_nullable_column(bool async); + + [ConditionalFact] + public abstract void Array_param_with_null_Contains_non_nullable_not_found(); + + [ConditionalFact] + public abstract void Array_param_with_null_Contains_non_nullable_not_found_negated(); + + [ConditionalFact] + public abstract void Array_param_with_null_Contains_nullable_not_found(); + + [ConditionalFact] + public abstract void Array_param_with_null_Contains_nullable_not_found_negated(); + + [ConditionalTheory] // #2123 + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_param_Contains_column_with_ToString(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Byte_array_parameter_contains_column(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_param_Contains_value_converted_column_enum_to_int(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_param_Contains_value_converted_column_enum_to_string(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_param_Contains_value_converted_column_nullable_enum_to_string(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_param_Contains_value_converted_column_nullable_enum_to_string_with_non_nullable_lambda(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_column_Contains_value_converted_param(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_column_Contains_value_converted_constant(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_param_Contains_value_converted_array_column(bool async); + + // [ConditionalTheory] + // [MemberData(nameof(IsAsyncData))] + // public virtual async Task Array_column_Contains_in_scalar_subquery(bool async) + // => await AssertQuery( + // async, + // ss => ss.Set().Where(c => c.ArrayEntities.OrderBy(e => e.Id).First().NullableIntArray.Contains(3))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task IList_column_contains_constant(bool async) + => await AssertQuery( + async, + ss => ss.Set().Where(a => a.IList.Contains(10))); + + #endregion + + #region Length/Count + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_Length(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Nullable_array_Length(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_Length_on_EF_Property(bool async); + + #endregion Length/Count + + #region Any/All + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Any_no_predicate(bool async) + => await AssertQuery( + async, + ss => ss.Set().Where(e => e.IntArray.Any())); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Any_like(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Any_ilike(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Any_like_anonymous(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task All_like(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task All_ilike(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Any_Contains_on_constant_array(bool async) + => await AssertQuery( + async, + ss => ss.Set().Where(e => new[] { 2, 3 }.Any(p => e.IntArray.Contains(p)))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Any_Contains_between_column_and_List(bool async) + { + var ints = new List { 2, 3 }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => e.IntArray.Any(i => ints.Contains(i)))); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Any_Contains_between_column_and_array(bool async) + { + var ints = new[] { 2, 3 }; + + await AssertQuery( + async, + ss => ss.Set().Where(e => e.IntArray.Any(i => ints.Contains(i)))); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Any_Contains_between_column_and_other_type(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task All_Contains(bool async) + => await AssertQuery( + async, + ss => ss.Set().Where(e => new[] { 5, 6 }.All(p => e.IntArray.Contains(p)))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Any_like_column(bool async) + => 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")))); + + #endregion Any/All + + #region New + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task New_array_with_columns(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Select(e => new[] { e.NullableText, e.NonNullableText }), + elementAsserter: Assert.Equal, + elementSorter: strings => strings != null ? string.Join(separator: "", strings) : null); + + AssertSql( + """ +SELECT ARRAY[s."NullableText",s."NonNullableText"]::text[] +FROM "SomeEntities" AS s +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task New_array_with_heterogeneous_columns_throws(bool async) + { + // Note that arrays of objects are treated specially by EF Core, so they're fine. + // The below checks Bytea and ByteArray, which are the same CLR type (byte[]) but mapped to different PG types + // (bytea and smallint[]) + await using var context = CreateContext(); + + var exception = async + ? await Assert.ThrowsAsync( + () => context.Set().Select(e => new[] { e.Bytea, e.ByteArray }).ToListAsync()) + : Assert.Throws( + () => context.Set().Select(e => new[] { e.Bytea, e.ByteArray }).ToList()); + + // TODO Assert.Equal(NpgsqlStrings.HeterogeneousTypesInNewArray("bytea", "smallint[]"), exception.Message); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task New_array_with_heterogeneous_columns_but_same_base_type(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Select(e => new[] { e.Varchar10, e.Varchar15 }), + elementAsserter: Assert.Equal, + elementSorter: strings => strings != null ? string.Join(separator: "", strings) : null); + + AssertSql( + """ +SELECT ARRAY[s."Varchar10",s."Varchar15"]::varchar(15)[] +FROM "SomeEntities" AS s +"""); + } + + [Theory] // #2342 + [MemberData(nameof(IsAsyncData))] + public async Task New_array_with_heterogeneous_columns_but_textual(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Select(e => new[] { e.NonNullableText, e.Varchar15 }), + elementAsserter: Assert.Equal, + elementSorter: strings => strings != null ? string.Join(separator: "", strings) : null); + + AssertSql( + """ +SELECT ARRAY[s."NonNullableText",s."Varchar15"]::text[] +FROM "SomeEntities" AS s +"""); + } + + [Theory] // #2342 + [MemberData(nameof(IsAsyncData))] + public async Task New_array_with_heterogeneous_columns_but_textual_after_ToString(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Select(e => new[] { e.Id.ToString(), e.Varchar15 }), + elementAsserter: Assert.Equal, + elementSorter: strings => strings != null ? string.Join(separator: "", strings) : null); + + AssertSql( + """ +SELECT ARRAY[s."Id"::text,s."Varchar15"]::text[] +FROM "SomeEntities" AS s +"""); + } + + [Theory] // #2688 + [MemberData(nameof(IsAsyncData))] + public async Task New_array_VisitChildren(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Select(e => new[] { e.NonNullableText, e.NullableText ?? "" }), + elementAsserter: Assert.Equal, + elementSorter: strings => strings != null ? string.Join(separator: "", strings) : null); + + AssertSql( + """ +SELECT ARRAY[s."NonNullableText",COALESCE(s."NullableText", '')]::text[] +FROM "SomeEntities" AS s +"""); + } + + #endregion + + #region Other translations + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Append(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Where(e => e.IntArray.Append(5).SequenceEqual(new[] { 3, 4, 5 }))); + + [ConditionalTheory(Skip = "https://github.com/dotnet/efcore/issues/30786")] + [MemberData(nameof(IsAsyncData))] + public virtual Task Concat(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Where(e => e.IntArray.Concat(new[] { 5, 6 }).SequenceEqual(new[] { 3, 4, 5, 6 }))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_IndexOf1(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task Array_IndexOf2(bool async); + + // Note: see NorthwindFunctionsQueryNpgsqlTest.String_Join_non_aggregate for regular use without an array column/parameter + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task String_Join_with_array_of_int_column(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Where(e => string.Join(", ", e.IntArray) == "3, 4")); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task String_Join_with_array_of_string_column(bool async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public abstract Task String_Join_disallow_non_array_type_mapped_parameter(bool async); + + #endregion Other translations + + #region Support + + protected ArrayQueryContext CreateContext() + => Fixture.CreateContext(); + + protected void AssertSql(params string[] expected) + => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); + + #endregion +} \ No newline at end of file diff --git a/EntityFrameworkCore.ClickHouse.FunctionalTests/TestModels/Array/ArrayEntity.cs b/EntityFrameworkCore.ClickHouse.FunctionalTests/TestModels/Array/ArrayEntity.cs new file mode 100644 index 0000000..eab686c --- /dev/null +++ b/EntityFrameworkCore.ClickHouse.FunctionalTests/TestModels/Array/ArrayEntity.cs @@ -0,0 +1,34 @@ +#nullable enable + +using System.Collections.Generic; + +namespace EntityFrameworkCore.ClickHouse.FunctionalTests.TestModels.Array; + +public class ArrayEntity +{ + public int Id { get; set; } + public int[] IntArray { get; set; } = null!; + public List IntList { get; set; } = null!; + public int?[] NullableIntArray { get; set; } = null!; + public List NullableIntList { get; set; } = null!; + public byte[] Bytea { get; set; } = null!; + public byte[] ByteArray { get; set; } = null!; + public string[] StringArray { get; set; } = null!; + public List StringList { get; set; } = null!; + public string?[] NullableStringArray { get; set; } = null!; + public List NullableStringList { get; set; } = null!; + public string? NullableText { get; set; } + public string NonNullableText { get; set; } = null!; + public string Varchar10 { get; set; } = null!; + public string Varchar15 { get; set; } = null!; + public SomeEnum EnumConvertedToInt { get; set; } + public SomeEnum EnumConvertedToString { get; set; } + public SomeEnum? NullableEnumConvertedToString { get; set; } + public SomeEnum? NullableEnumConvertedToStringWithNonNullableLambda { get; set; } + public SomeEnum[] ValueConvertedArrayOfEnum { get; set; } = null!; + public List ValueConvertedListOfEnum { get; set; } = null!; + public string[] ArrayOfStringConvertedToDelimitedString { get; set; } = null!; + public List ListOfStringConvertedToDelimitedString { get; set; } = null!; + public IList IList { get; set; } = null!; + public byte Byte { get; set; } +} \ No newline at end of file diff --git a/EntityFrameworkCore.ClickHouse.FunctionalTests/TestModels/Array/ArrayQueryContext.cs b/EntityFrameworkCore.ClickHouse.FunctionalTests/TestModels/Array/ArrayQueryContext.cs new file mode 100644 index 0000000..b329b3e --- /dev/null +++ b/EntityFrameworkCore.ClickHouse.FunctionalTests/TestModels/Array/ArrayQueryContext.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.EntityFrameworkCore.TestUtilities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace EntityFrameworkCore.ClickHouse.FunctionalTests.TestModels.Array; + +public class ArrayQueryContext(DbContextOptions options) : PoolableDbContext(options) +{ + public DbSet SomeEntities { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity( + e => + { + e.Property(ae => ae.ByteArray).HasColumnType("smallint[]"); + + e.Property(ae => ae.Varchar10).HasColumnType("varchar(10)"); + e.Property(ae => ae.Varchar15).HasColumnType("varchar(15)"); + + // We do negative to make sure our value converter is properly used, and not the built-in one + e.Property(ae => ae.EnumConvertedToInt) + .HasConversion(w => -(int)w, v => (SomeEnum)(-v)); + + e.Property(ae => ae.EnumConvertedToString) + .HasConversion(typeof(EnumToStringConverter)); + + e.Property(ae => ae.NullableEnumConvertedToString) + .HasConversion(typeof(EnumToStringConverter)); + + e.Property(ae => ae.NullableEnumConvertedToStringWithNonNullableLambda) + .HasConversion(new ValueConverter(w => w.ToString(), v => Enum.Parse(v))); + + e.Property(ae => ae.ListOfStringConvertedToDelimitedString) + .HasConversion( + v => string.Join(",", v), + v => v.Split(',', StringSplitOptions.None).ToList(), + new ValueComparer>( + (c1, c2) => c1.SequenceEqual(c2), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => c.ToList())); + + e.Property(ae => ae.ArrayOfStringConvertedToDelimitedString) + .HasConversion( + v => string.Join(",", v), + v => v.Split(',', StringSplitOptions.None).ToArray(), + new ValueComparer( + (c1, c2) => c1.SequenceEqual(c2), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => c.ToArray())); + + e.PrimitiveCollection(ae => ae.ValueConvertedArrayOfEnum) + .ElementType(eb => eb.HasConversion(typeof(EnumToStringConverter))); + + e.PrimitiveCollection(ae => ae.ValueConvertedListOfEnum) + .ElementType(eb => eb.HasConversion(typeof(EnumToStringConverter))); + + e.HasIndex(ae => ae.NonNullableText); + }); + + public static async Task SeedAsync(ArrayQueryContext context) + { + var arrayEntities = ArrayQueryData.CreateArrayEntities(); + + context.SomeEntities.AddRange(arrayEntities); + await context.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/EntityFrameworkCore.ClickHouse.FunctionalTests/TestModels/Array/ArrayQueryData.cs b/EntityFrameworkCore.ClickHouse.FunctionalTests/TestModels/Array/ArrayQueryData.cs new file mode 100644 index 0000000..417763e --- /dev/null +++ b/EntityFrameworkCore.ClickHouse.FunctionalTests/TestModels/Array/ArrayQueryData.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore.TestUtilities; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace EntityFrameworkCore.ClickHouse.FunctionalTests.TestModels.Array; + +public class ArrayQueryData : ISetSource +{ + public IReadOnlyList ArrayEntities { get; } = CreateArrayEntities(); + + public IQueryable Set() + where TEntity : class + { + if (typeof(TEntity) == typeof(ArrayEntity)) + { + return (IQueryable)ArrayEntities.AsQueryable(); + } + + throw new InvalidOperationException("Invalid entity type: " + typeof(TEntity)); + } + + public static IReadOnlyList CreateArrayEntities() + => + [ + new() + { + Id = 1, + IntArray = [3, 4], + IntList = [3, 4], + NullableIntArray = [3, 4, null], + NullableIntList = [3, 4, null], + Bytea = [3, 4], + ByteArray = [3, 4], + StringArray = ["3", "4"], + NullableStringArray = ["3", "4", null], + StringList = ["3", "4"], + NullableStringList = ["3", "4", null], + NullableText = "foo", + NonNullableText = "foo", + Varchar10 = "foo", + Varchar15 = "foo", + EnumConvertedToInt = SomeEnum.One, + EnumConvertedToString = SomeEnum.One, + NullableEnumConvertedToString = SomeEnum.One, + NullableEnumConvertedToStringWithNonNullableLambda = SomeEnum.One, + ValueConvertedArrayOfEnum = [SomeEnum.Eight, SomeEnum.Nine], + ValueConvertedListOfEnum = [SomeEnum.Eight, SomeEnum.Nine], + ArrayOfStringConvertedToDelimitedString = ["3", "4"], + ListOfStringConvertedToDelimitedString = ["3", "4"], + IList = [8, 9], + Byte = 10 + }, + new() + { + Id = 2, + IntArray = [5, 6, 7, 8], + IntList = [5, 6, 7, 8], + NullableIntArray = [5, 6, 7, 8], + NullableIntList = [5, 6, 7, 8], + Bytea = [5, 6, 7, 8], + ByteArray = [5, 6, 7, 8], + StringArray = ["5", "6", "7", "8"], + NullableStringArray = ["5", "6", "7", "8"], + StringList = ["5", "6", "7", "8"], + NullableStringList = ["5", "6", "7", "8"], + NullableText = "bar", + NonNullableText = "bar", + Varchar10 = "bar", + Varchar15 = "bar", + EnumConvertedToInt = SomeEnum.Two, + EnumConvertedToString = SomeEnum.Two, + NullableEnumConvertedToString = SomeEnum.Two, + NullableEnumConvertedToStringWithNonNullableLambda = SomeEnum.Two, + ValueConvertedArrayOfEnum = [SomeEnum.Nine, SomeEnum.Ten], + ValueConvertedListOfEnum = [SomeEnum.Nine, SomeEnum.Ten], + ArrayOfStringConvertedToDelimitedString = ["5", "6", "7", "8"], + ListOfStringConvertedToDelimitedString = ["5", "6", "7", "8"], + IList = [9, 10], + Byte = 20 + } + ]; +} \ No newline at end of file diff --git a/EntityFrameworkCore.ClickHouse.FunctionalTests/TestModels/Array/SomeEnum.cs b/EntityFrameworkCore.ClickHouse.FunctionalTests/TestModels/Array/SomeEnum.cs new file mode 100644 index 0000000..8f31979 --- /dev/null +++ b/EntityFrameworkCore.ClickHouse.FunctionalTests/TestModels/Array/SomeEnum.cs @@ -0,0 +1,11 @@ +namespace EntityFrameworkCore.ClickHouse.FunctionalTests.TestModels.Array; + +public enum SomeEnum +{ + One = 1, + Two = 2, + Three = 3, + Eight = 8, + Nine = 9, + Ten = 10 +} diff --git a/EntityFrameworkCore.ClickHouse/EntityFrameworkCore.ClickHouse.csproj b/EntityFrameworkCore.ClickHouse/EntityFrameworkCore.ClickHouse.csproj index 3058712..2717e78 100644 --- a/EntityFrameworkCore.ClickHouse/EntityFrameworkCore.ClickHouse.csproj +++ b/EntityFrameworkCore.ClickHouse/EntityFrameworkCore.ClickHouse.csproj @@ -25,4 +25,8 @@ + + + + diff --git a/EntityFrameworkCore.ClickHouse/Extensions/TypeExtensions.cs b/EntityFrameworkCore.ClickHouse/Extensions/TypeExtensions.cs new file mode 100644 index 0000000..eb48212 --- /dev/null +++ b/EntityFrameworkCore.ClickHouse/Extensions/TypeExtensions.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace ClickHouse.EntityFrameworkCore.Extensions; + +internal static class TypeExtensions +{ + public static bool IsEnumerable(this Type type) + { + return type.IsGenericType && + type.GetInterfaces().Any(e => e.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + } + + public static Type UnwrapNullableType(this Type type) + => Nullable.GetUnderlyingType(type) ?? type; + + public static bool IsNullableValueType(this Type type) + => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); + + public static Type? TryGetElementType(this Type type, Type interfaceOrBaseType) + { + if (type.IsGenericTypeDefinition) + { + return null; + } + + var types = GetGenericTypeImplementations(type, interfaceOrBaseType); + + Type? singleImplementation = null; + foreach (var implementation in types) + { + if (singleImplementation is null) + { + singleImplementation = implementation; + } + else + { + singleImplementation = null; + break; + } + } + + return singleImplementation?.GenericTypeArguments.FirstOrDefault(); + } + + public static IEnumerable GetGenericTypeImplementations(this Type type, Type interfaceOrBaseType) + { + var typeInfo = type.GetTypeInfo(); + if (!typeInfo.IsGenericTypeDefinition) + { + var baseTypes = interfaceOrBaseType.GetTypeInfo().IsInterface + ? typeInfo.ImplementedInterfaces + : type.GetBaseTypes(); + foreach (var baseType in baseTypes) + { + if (baseType.IsGenericType + && baseType.GetGenericTypeDefinition() == interfaceOrBaseType) + { + yield return baseType; + } + } + + if (type.IsGenericType + && type.GetGenericTypeDefinition() == interfaceOrBaseType) + { + yield return type; + } + } + } + + public static IEnumerable GetBaseTypes(this Type type) + { + type = type.BaseType; + + while (type is not null) + { + yield return type; + + type = type.BaseType; + } + } + + public static bool IsNullableType(this Type type) + => !type.IsValueType || type.IsNullableValueType(); + + public static PropertyInfo? FindIndexerProperty(this Type type) + { + var defaultPropertyAttribute = type.GetCustomAttributes().FirstOrDefault(); + + return defaultPropertyAttribute is null + ? null + : type.GetRuntimeProperties() + .FirstOrDefault(pi => pi.Name == defaultPropertyAttribute.MemberName && pi.GetIndexParameters().Length == 1); + } +} \ No newline at end of file diff --git a/EntityFrameworkCore.ClickHouse/Query/ClickHouseSqlExpressionFactory.cs b/EntityFrameworkCore.ClickHouse/Query/ClickHouseSqlExpressionFactory.cs index 5d2e551..e69b275 100644 --- a/EntityFrameworkCore.ClickHouse/Query/ClickHouseSqlExpressionFactory.cs +++ b/EntityFrameworkCore.ClickHouse/Query/ClickHouseSqlExpressionFactory.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; using Microsoft.EntityFrameworkCore.Storage; +using System; using System.Linq.Expressions; namespace ClickHouse.EntityFrameworkCore.Query; @@ -41,6 +42,26 @@ public override SqlExpression MakeBinary( return base.MakeBinary(operatorType, left, right, typeMapping, existingExpression); } + public override SqlExpression MakeUnary( + ExpressionType operatorType, + SqlExpression operand, + Type type, + RelationalTypeMapping typeMapping = null, + SqlExpression existingExpression = null) + { + if (operatorType == ExpressionType.ArrayLength) + { + return Function( + name: "length", + arguments: [operand], + nullable: true, + [true], + returnType: typeof(int)); + } + + return base.MakeUnary(operatorType, operand, type, typeMapping, existingExpression); + } + public virtual ClickHouseTrimExpression Trim( SqlExpression stringExpression, SqlExpression chars, diff --git a/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseArrayTranslator.cs b/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseArrayTranslator.cs index 48143a6..21f40af 100644 --- a/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseArrayTranslator.cs +++ b/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseArrayTranslator.cs @@ -80,6 +80,11 @@ public SqlExpression Translate(SqlExpression instance, MemberInfo member, Type r public SqlExpression Translate(SqlExpression instance, MethodInfo method, IReadOnlyList arguments, IDiagnosticsLogger logger) { + if (method.DeclaringType != typeof(Array)) + { + return null; + } + if (method.Equals(EmptyArrayUInt8)) { return _sqlExpressionFactory.Function( @@ -200,6 +205,17 @@ public SqlExpression Translate(SqlExpression instance, MethodInfo method, IReadO returnType: typeof(string[])); } + if (method.Name == nameof(Array.IndexOf)) + { + return _sqlExpressionFactory.Subtract(_sqlExpressionFactory.Function( + name: "indexOf", + arguments: [arguments[0], arguments[1]], + nullable: true, + argumentsPropagateNullability: [true, true], + returnType: typeof(int)), + _sqlExpressionFactory.Constant(1)); + } + return null; } -} +} \ No newline at end of file diff --git a/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseQuerySqlGenerator.cs b/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseQuerySqlGenerator.cs index 5a86c62..5114934 100644 --- a/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseQuerySqlGenerator.cs +++ b/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseQuerySqlGenerator.cs @@ -1,6 +1,7 @@ using ClickHouse.EntityFrameworkCore.Query.Expressions; using ClickHouse.EntityFrameworkCore.Query.Expressions.Internal; using ClickHouse.EntityFrameworkCore.Storage.Internal; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; using Microsoft.EntityFrameworkCore.Storage; @@ -48,6 +49,16 @@ protected override void GenerateLimitOffset(SelectExpression selectExpression) } } + protected override Expression VisitUnary(UnaryExpression node) + { + if (node.NodeType == ExpressionType.ArrayLength) + { + return node; + } + + return base.VisitUnary(node); + } + protected override Expression VisitSqlParameter(SqlParameterExpression sqlParameterExpression) { var parameterNameInCommand = Dependencies.SqlGenerationHelper.GenerateParameterName(sqlParameterExpression.Name); diff --git a/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseQueryableMethodTranslatingExpressionVisitor.cs b/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseQueryableMethodTranslatingExpressionVisitor.cs index 23b959e..cfad983 100644 --- a/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseQueryableMethodTranslatingExpressionVisitor.cs +++ b/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseQueryableMethodTranslatingExpressionVisitor.cs @@ -38,4 +38,9 @@ protected override ShapedQueryExpression TranslateTake(ShapedQueryExpression sou return null; } + + protected override ShapedQueryExpression TranslateCount(ShapedQueryExpression source, LambdaExpression predicate) + { + return base.TranslateCount(source, predicate); + } } \ No newline at end of file diff --git a/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseSqlTranslatingExpressionVisitor.cs b/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseSqlTranslatingExpressionVisitor.cs index 643e7c7..1f9bd76 100644 --- a/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseSqlTranslatingExpressionVisitor.cs +++ b/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseSqlTranslatingExpressionVisitor.cs @@ -62,6 +62,21 @@ protected override Expression VisitBinary(BinaryExpression binaryExpression) return base.VisitBinary(binaryExpression); } + protected override Expression VisitUnary(UnaryExpression unaryExpression) + { + if (unaryExpression.NodeType == ExpressionType.ArrayLength) + { + return Dependencies.SqlExpressionFactory.Function( + name: "length", + arguments: [Translate(unaryExpression)], + nullable: true, + [true], + returnType: typeof(int)); + } + + return base.VisitUnary(unaryExpression); + } + public override SqlExpression GenerateGreatest(IReadOnlyList expressions, Type resultType) { var resultTypeMapping = ExpressionExtensions.InferTypeMapping(expressions); diff --git a/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseStringTranslator.cs b/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseStringTranslator.cs index 57ce146..ab199c4 100644 --- a/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseStringTranslator.cs +++ b/EntityFrameworkCore.ClickHouse/Query/Internal/ClickHouseStringTranslator.cs @@ -406,37 +406,5 @@ private SqlExpression GetTrimChars(SqlExpression chars) _ => _sqlExpressionFactory.ApplyDefaultTypeMapping(chars) }; - - // if (chars.Type == typeof(char)) - // { - // return chars; - // } - // - // if (chars is NewArrayExpression newArrayExpression) - // { - // return _sqlExpressionFactory.Function( - // "concat", - // newArrayExpression.Expressions, - // true, - // Enumerable.Repeat(true, methodArgs.Count), - // typeof(string), - // Dependencies.TypeMappingSource.FindMapping(typeof(string))); - // } - // else - // { - // trimArg = Dependencies.SqlExpressionFactory.Function( - // "arrayStringConcat", - // [Translate(methodArgs[0])], - // true, - // [true], - // typeof(string), - // Dependencies.TypeMappingSource.FindMapping(typeof(string))); - // } - // - // var trimInstance = Translate(methodCallExpression.Object); - // - // var trimMapping = Dependencies.TypeMappingSource.FindMapping(methodCallExpression.Method.DeclaringType); - // - // return new ClickHouseTrimFunction([trimArg, trimInstance], trimMapping, trimMode); } } \ No newline at end of file diff --git a/EntityFrameworkCore.ClickHouse/Scaffolding/Internal/ClickHouseDatabaseModelFactory.cs b/EntityFrameworkCore.ClickHouse/Scaffolding/Internal/ClickHouseDatabaseModelFactory.cs index a8662ff..31906f8 100644 --- a/EntityFrameworkCore.ClickHouse/Scaffolding/Internal/ClickHouseDatabaseModelFactory.cs +++ b/EntityFrameworkCore.ClickHouse/Scaffolding/Internal/ClickHouseDatabaseModelFactory.cs @@ -290,9 +290,6 @@ void Unwrap() } } - private static Type UnwrapNullableType(Type type) - => Nullable.GetUnderlyingType(type) ?? type; - private static (bool IsNullable, string StoreType) ParseType(string storeType) { return storeType.StartsWith("Nullable(") @@ -302,7 +299,7 @@ private static (bool IsNullable, string StoreType) ParseType(string storeType) private static bool IsInteger(Type type) { - type = UnwrapNullableType(type); + type = type.UnwrapNullableType(); return type == typeof(int) || type == typeof(long) diff --git a/EntityFrameworkCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs b/EntityFrameworkCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs index 29452b5..7d95cc8 100644 --- a/EntityFrameworkCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs +++ b/EntityFrameworkCore.ClickHouse/Storage/Internal/ClickHouseTypeMappingSource.cs @@ -1,9 +1,12 @@ -using ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping; +using ClickHouse.EntityFrameworkCore.Extensions; +using ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping; using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; +using BindingFlags = System.Reflection.BindingFlags; namespace ClickHouse.EntityFrameworkCore.Storage.Internal; @@ -143,7 +146,6 @@ public override RelationalTypeMapping FindMapping(Type type) protected override RelationalTypeMapping FindMapping(in RelationalTypeMappingInfo mappingInfo) => FindExistingMapping(mappingInfo) ?? - FindArrayMapping(mappingInfo) ?? FindTupleMapping(mappingInfo) ?? FindDecimalMapping(mappingInfo) ?? base.FindMapping(in mappingInfo); @@ -170,22 +172,67 @@ private RelationalTypeMapping FindExistingMapping(in RelationalTypeMappingInfo m return null; } - private RelationalTypeMapping FindArrayMapping(in RelationalTypeMappingInfo mappingInfo) + protected override RelationalTypeMapping FindCollectionMapping( + RelationalTypeMappingInfo info, + Type modelType, + Type providerType, + CoreTypeMapping elementMapping) { - if (mappingInfo.StoreTypeName != null && - mappingInfo.StoreTypeName.StartsWith("Array(") && - mappingInfo.StoreTypeName.EndsWith(')')) + if (elementMapping is not null and not RelationalTypeMapping) { - var elementTypeName = mappingInfo.StoreTypeName.Substring(6, mappingInfo.StoreTypeName.Length - 7).Trim(); - var elementTypeMapping = AliasTypeMapping[elementTypeName]; - return new ClickHouseArrayTypeMapping(mappingInfo.StoreTypeName, elementTypeMapping); + return null; + } + + if (info.StoreTypeName != null && + info.StoreTypeName.StartsWith("Array(") && + info.StoreTypeName.EndsWith(')')) + { + var elementTypeName = info.StoreTypeName.Substring(6, info.StoreTypeName.Length - 7).Trim(); + + if (AliasTypeMapping.TryGetValue(elementTypeName, out var elementTypeMapping)) + { + var concreteCollectionType = FindTypeToInstantiate(modelType, elementTypeMapping.ClrType); + + return (RelationalTypeMapping)Activator.CreateInstance( + typeof(ClickHouseArrayTypeMapping<,,>).MakeGenericType(modelType, concreteCollectionType, elementTypeMapping.ClrType), + elementTypeMapping); + } } - if (mappingInfo.ClrType is { IsArray: true }) + modelType = modelType.UnwrapNullableType(); + + if (info.ClrType is { IsArray: true } || modelType.IsEnumerable()) { - var elementType = mappingInfo.ClrType.GetElementType(); - var elementTypeMapping = ClrTypeMappings[elementType!]; - return new ClickHouseArrayTypeMapping($"Array({elementTypeMapping.StoreType})", elementTypeMapping); + var elementType = modelType.IsArray + ? modelType.GetElementType()!/*.UnwrapNullableType()*/ + : modelType.GenericTypeArguments[0]/*.UnwrapNullableType()*/; + + ValueConverter? converter = null; + + if (elementType.IsEnum) + { + var enumType = elementType; + elementType = Enum.GetUnderlyingType(elementType); + converter = (ValueConverter)Activator.CreateInstance(typeof(EnumToNumberConverter<,>).MakeGenericType(enumType, elementType)); + } + + var elementTypeMapping = FindMapping(elementType); + + if (elementTypeMapping == null && !ClrTypeMappings.TryGetValue(elementType, out elementTypeMapping)) + { + return null; + } + + var concreteCollectionType = FindTypeToInstantiate(modelType, elementType); + + if (converter != null) + { + elementTypeMapping = (RelationalTypeMapping)elementTypeMapping.WithComposedConverter(converter); + } + + return (RelationalTypeMapping)Activator.CreateInstance( + typeof(ClickHouseArrayTypeMapping<,,>).MakeGenericType(modelType, concreteCollectionType, elementType), + elementTypeMapping); } return null; @@ -223,4 +270,30 @@ private RelationalTypeMapping FindTupleMapping(in RelationalTypeMappingInfo mapp return null; } + + private static Type FindTypeToInstantiate(Type collectionType, Type elementType) + { + if (collectionType.IsArray) + { + return collectionType; + } + + var listOfT = typeof(List<>).MakeGenericType(elementType); + + if (collectionType.IsAssignableFrom(listOfT)) + { + if (!collectionType.IsAbstract) + { + var constructor = collectionType.GetConstructor(BindingFlags.Public, Type.EmptyTypes); + if (constructor != null) + { + return collectionType; + } + } + + return listOfT; + } + + return collectionType; + } } \ No newline at end of file diff --git a/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseArrayTypeMapping.cs b/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseArrayTypeMapping.cs index 065d2ea..ef144cb 100644 --- a/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseArrayTypeMapping.cs +++ b/EntityFrameworkCore.ClickHouse/Storage/Internal/Mapping/ClickHouseArrayTypeMapping.cs @@ -1,53 +1,99 @@ using ClickHouse.EntityFrameworkCore.Extensions; +using ClickHouse.EntityFrameworkCore.Storage.ValueConversation; using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using System; using System.Data.Common; using System.Diagnostics; -using System.Text; namespace ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping; -public class ClickHouseArrayTypeMapping : RelationalTypeMapping +public class ClickHouseArrayTypeMapping : RelationalTypeMapping { - public RelationalTypeMapping ElementMapping { get; } - - public ClickHouseArrayTypeMapping(string storeType, RelationalTypeMapping elementMapping) - : this(storeType, elementMapping, elementMapping.ClrType.MakeArrayType()) {} - - public ClickHouseArrayTypeMapping(RelationalTypeMapping elementMapping, Type arrayType) - : this(elementMapping.StoreType + "[]", elementMapping, arrayType) {} + public ClickHouseArrayTypeMapping(RelationalTypeMapping elementTypeMapping) + : this($"Array(Nullable({elementTypeMapping.StoreType}))", elementTypeMapping) + { + } - ClickHouseArrayTypeMapping(string storeType, RelationalTypeMapping elementMapping, Type arrayType) - : this(new RelationalTypeMappingParameters( - new CoreTypeMappingParameters(arrayType, null, CreateComparer(elementMapping, arrayType)), storeType - ), elementMapping) {} + public ClickHouseArrayTypeMapping(string storeType, RelationalTypeMapping elementTypeMapping) + : this(CreateParameters(storeType, elementTypeMapping)) + { + } - protected ClickHouseArrayTypeMapping(RelationalTypeMappingParameters parameters, RelationalTypeMapping elementMapping) + protected ClickHouseArrayTypeMapping(RelationalTypeMappingParameters parameters) : base(parameters) - => ElementMapping = elementMapping; - - protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) - => new ClickHouseArrayTypeMapping(parameters, ElementMapping); - - protected override string GenerateNonNullSqlLiteral(object value) { - var arr = (Array)value; + } - if (arr.Rank != 1) - throw new NotSupportedException("Multidimensional array literals aren't supported"); + private static RelationalTypeMappingParameters CreateParameters( + string storeType, + RelationalTypeMapping elementMapping) + { + ValueConverter? converter = null; + var elementType = elementMapping.ClrType; - var sb = new StringBuilder(); - sb.Append('['); - for (var i = 0; i < arr.Length; i++) + if (elementMapping.Converter is { } elementConverter) { - sb.Append(ElementMapping.GenerateSqlLiteral(arr.GetValue(i))); - if (i < arr.Length - 1) - sb.Append(','); + var providerElementType = elementConverter.ProviderClrType; + + converter = (ValueConverter)Activator.CreateInstance( + typeof(ClickHouseArrayConverter<,,>).MakeGenericType( + typeof(TCollection), + typeof(TConcreteCollection), + providerElementType.MakeArrayType()), + elementConverter)!; } + else if (typeof(TCollection) != typeof(TConcreteCollection)) + { + converter = (ValueConverter)Activator.CreateInstance( + typeof(ClickHouseArrayConverter<,,>).MakeGenericType( + typeof(TCollection), + typeof(TConcreteCollection), + elementType.MakeArrayType()), + elementMapping.Converter)!; + } + + var comparer = typeof(TCollection).IsArray && typeof(TCollection).GetArrayRank() > 1 + ? null // TODO: Value comparer for multidimensional arrays + : (ValueComparer?)Activator.CreateInstance( + elementType.IsNullableValueType() + ? typeof(ListOfNullableValueTypesComparer<,>) + .MakeGenericType(typeof(TConcreteCollection), elementType.UnwrapNullableType()) + : elementType.IsValueType + ? typeof(ListOfValueTypesComparer<,>).MakeGenericType(typeof(TConcreteCollection), elementType) + : typeof(ListOfReferenceTypesComparer<,>).MakeGenericType(typeof(TConcreteCollection), elementType), + elementMapping.Comparer.ToNullableComparer(elementType)!); + + return new RelationalTypeMappingParameters( + new CoreTypeMappingParameters( + typeof(TCollection), converter, comparer, elementMapping: elementMapping), + storeType); + } + + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + => new ClickHouseArrayTypeMapping(parameters); - sb.Append(']'); - return sb.ToString(); + protected override string GenerateNonNullSqlLiteral(object value) + { + throw new NotImplementedException(); + // var arr = (Array)value; + // + // if (arr.Rank != 1) + // throw new NotSupportedException("Multidimensional array literals aren't supported"); + // + // var sb = new StringBuilder(); + // sb.Append('['); + // for (var i = 0; i < arr.Length; i++) + // { + // sb.Append(ElementMapping.GenerateSqlLiteral(arr.GetValue(i))); + // if (i < arr.Length - 1) + // sb.Append(','); + // } + // + // sb.Append(']'); + // return sb.ToString(); } protected override void ConfigureParameter(DbParameter parameter) @@ -212,4 +258,4 @@ static TElem[] DoSnapshot(TElem[] source) } #endregion Value Comparison -} +} \ No newline at end of file diff --git a/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseArrayConverter.cs b/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseArrayConverter.cs new file mode 100644 index 0000000..37f654a --- /dev/null +++ b/EntityFrameworkCore.ClickHouse/Storage/ValueConversation/ClickHouseArrayConverter.cs @@ -0,0 +1,315 @@ +using ClickHouse.EntityFrameworkCore.Extensions; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using static System.Linq.Expressions.Expression; + +namespace ClickHouse.EntityFrameworkCore.Storage.ValueConversation; + +public class ClickHouseArrayConverter + : ValueConverter + where TModelCollection: IEnumerable + where TConcreteModelCollection: IEnumerable + where TProviderCollection: IEnumerable +{ + public ClickHouseArrayConverter() + : this(null) + { + } + + public ClickHouseArrayConverter(ValueConverter elementConverter) + : base( + ArrayConversionExpression( + elementConverter?.ConvertToProviderExpression), + ArrayConversionExpression( + elementConverter?.ConvertFromProviderExpression)) + { + var modelElementType = typeof(TModelCollection).TryGetElementType(typeof(IEnumerable<>)); + var providerElementType = typeof(TProviderCollection).TryGetElementType(typeof(IEnumerable<>)); + + if (modelElementType is null || providerElementType is null) + { + throw new ArgumentException("Can only convert between arrays"); + } + + if (elementConverter is not null) + { + if (modelElementType.UnwrapNullableType() != elementConverter.ModelClrType.UnwrapNullableType()) + { + throw new ArgumentException( + $"The element's value converter model type ({elementConverter.ModelClrType}), doesn't match the array's ({modelElementType})"); + } + + if (providerElementType.UnwrapNullableType() != elementConverter.ProviderClrType.UnwrapNullableType()) + { + throw new ArgumentException( + $"The element's value converter provider type ({elementConverter.ProviderClrType}), doesn't match the array's ({providerElementType})"); + } + } + + ElementConverter = elementConverter; + } + + public virtual ValueConverter ElementConverter { get; } + + private static Expression> ArrayConversionExpression( + LambdaExpression elementConversionExpression) + { + var inputElementType = typeof(TInput).IsArray + ? typeof(TInput).GetElementType() + : typeof(TInput).TryGetElementType(typeof(IEnumerable<>)); + + var outputElementType = typeof(TOutput).IsArray + ? typeof(TOutput).GetElementType() + : typeof(TOutput).TryGetElementType(typeof(IEnumerable<>)); + + if (inputElementType is null || outputElementType is null) + { + throw new ArgumentException("Both TInput and TOutput must be arrays or IList"); + } + + // elementConversionExpression is always over non-nullable value types. If the array is over nullable types, + // we need to sanitize via an external null check. + if (elementConversionExpression is not null && inputElementType.IsNullableType() && outputElementType.IsNullableType()) + { + // p => p is null ? null : elementConversionExpression(p) + var p = Parameter(inputElementType, "foo"); + elementConversionExpression = Lambda( + Condition( + Equal(p, Constant(null, inputElementType)), + Constant(null, outputElementType), + Convert( + Invoke( + elementConversionExpression, + // The user-provided conversion lambda typically accepts non-nullable (value) types, with EF Core doing the + // null-sanitization and conversion to non-nullable; do this here unless the user-provided lambda happens to + // accept a nullable value type parameter. + elementConversionExpression.Parameters[0].Type.IsNullableType() + ? p + : Convert(p, inputElementType.UnwrapNullableType())), + outputElementType)), + p); + } + + var input = Parameter(typeof(TInput), "input"); + var convertedInput = input; + var output = Parameter(typeof(TConcreteOutput), "result"); + var lengthVariable = Variable(typeof(int), "length"); + + var expressions = new List(); + var variables = new List { output, lengthVariable }; + + Expression getInputLength; + Func? indexer; + + // The conversion is going to depend on what kind of input we have: array, list, collection, or arbitrary IEnumerable. + // For array/list we can get the length and index inside, so we can do an efficient for loop. + // For other ICollections (e.g. HashSet) we can get the length (and so pre-allocate the output), but we can't index; so we + // get an enumerator and use that. + // For arbitrary IEnumerable, we can't get the length so we can't preallocate output arrays; so we to call ToList() on it and then + // process that (note that we could avoid that when the output is a List rather than an array). + var inputInterfaces = input.Type.GetInterfaces(); + switch (input.Type) + { + // Input is typed as an array - we can get its length and index into it + case { IsArray: true }: + getInputLength = ArrayLength(input); + indexer = i => ArrayAccess(input, i); + break; + + // Input is typed as an IList - we can get its length and index into it + case { IsGenericType: true } when inputInterfaces.Append(input.Type) + .Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IList<>)): + { + getInputLength = Property( + input, + input.Type.GetProperty("Count") + // If TInput is an interface (IList), its Count property needs to be found on ICollection + ?? typeof(ICollection<>).MakeGenericType(input.Type.GetGenericArguments()[0]).GetProperty("Count")!); + indexer = i => Property(input, Extensions.TypeExtensions.FindIndexerProperty(input.Type)!, i); + + break; + } + + // Input is typed as an ICollection - we can get its length, but we can't index into it + case { IsGenericType: true } when inputInterfaces.Append(input.Type) + .Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ICollection<>)): + { + getInputLength = Property( + input, typeof(ICollection<>).MakeGenericType(input.Type.GetGenericArguments()[0]).GetProperty("Count")!); + indexer = null; + break; + } + + // Input is typed as an IEnumerable - we can't get its length, and we can't index into it. + // All we can do is call ToList() on it and then process that. + case { IsGenericType: true } when inputInterfaces.Append(input.Type) + .Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)): + { + // TODO: In theory, we could add runtime checks for array/list/collection, downcast for those cases and include + // the logic from the other switch cases here. + convertedInput = Variable(typeof(List<>).MakeGenericType(inputElementType), "convertedInput"); + variables.Add(convertedInput); + expressions.Add( + Assign( + convertedInput, + Call(typeof(Enumerable).GetMethod(nameof(Enumerable.ToList))!.MakeGenericMethod(inputElementType), input))); + getInputLength = Property(convertedInput, convertedInput.Type.GetProperty("Count")!); + indexer = i => Property(convertedInput, Extensions.TypeExtensions.FindIndexerProperty(convertedInput.Type)!, i); + break; + } + + default: + throw new NotSupportedException($"Array value converter input type must be an IEnumerable, but is {typeof(TInput)}"); + } + + expressions.AddRange( + [ + // Get the length of the input array or list + // var length = input.Length; + Assign(lengthVariable, getInputLength), + + // Allocate an output array or list + // var result = new int[length]; + Assign( + output, typeof(TConcreteOutput).IsArray + ? NewArrayBounds(outputElementType, lengthVariable) + : typeof(TConcreteOutput).GetConstructor([typeof(int)]) is ConstructorInfo ctorWithLength + ? New(ctorWithLength, lengthVariable) + : New(typeof(TConcreteOutput).GetConstructor([])!)) + ]); + + if (indexer is not null) + { + // Good case: the input is an array or list, so we can index into it. Generate code for an efficient for loop, which applies + // the element converter on each element. + // for (var i = 0; i < length; i++) + // { + // result[i] = input[i]; + // } + var counter = Parameter(typeof(int), "i"); + + expressions.Add( + ForLoop( + loopVar: counter, + initValue: Constant(0), + condition: LessThan(counter, lengthVariable), + increment: AddAssign(counter, Constant(1)), + loopContent: + typeof(TConcreteOutput).IsArray + ? Assign( + ArrayAccess(output, counter), + elementConversionExpression is null + ? indexer(counter) + : Invoke(elementConversionExpression, indexer(counter))) + : Call( + output, + typeof(TConcreteOutput).GetMethod("Add", [outputElementType])!, + elementConversionExpression is null + ? indexer(counter) + : Invoke(elementConversionExpression, indexer(counter))))); + } + else + { + // Bad case: the input is not an array or list, but is a collection (e.g. HashSet), so we can't index into it. + // Generate code for a less efficient enumerator-based iteration. + // enumerator = input.GetEnumerator(); + // counter = 0; + // while (enumerator.MoveNext()) + // { + // output[counter] = enumerator.Current; + // counter++; + // } + var enumerableType = typeof(IEnumerable<>).MakeGenericType(inputElementType); + var enumeratorType = typeof(IEnumerator<>).MakeGenericType(inputElementType); + + var enumeratorVariable = Variable(enumeratorType, "enumerator"); + var counterVariable = Variable(typeof(int), "variable"); + variables.AddRange([enumeratorVariable, counterVariable]); + + expressions.AddRange( + [ + // enumerator = input.GetEnumerator(); + Assign(enumeratorVariable, Call(input, enumerableType.GetMethod(nameof(IEnumerable.GetEnumerator))!)), + + // counter = 0; + Assign(counterVariable, Constant(0)) + ]); + + var breakLabel = Label("LoopBreak"); + + var loop = + Loop( + IfThenElse( + Equal(Call(enumeratorVariable, typeof(IEnumerator).GetMethod(nameof(IEnumerator.MoveNext))!), Constant(true)), + Block( + typeof(TConcreteOutput).IsArray + // output[counter] = enumerator.Current; + ? Assign( + ArrayAccess(output, counterVariable), + elementConversionExpression is null + ? Property(enumeratorVariable, "Current") + : Invoke(elementConversionExpression, Property(enumeratorVariable, "Current"))) + // output.Add(enumerator.Current); + : Call( + output, + typeof(TConcreteOutput).GetMethod("Add", [outputElementType])!, + elementConversionExpression is null + ? Property(enumeratorVariable, "Current") + : Invoke(elementConversionExpression, Property(enumeratorVariable, "Current"))), + + // counter++; + AddAssign(counterVariable, Constant(1))), + Break(breakLabel)), + breakLabel); + + expressions.Add( + TryFinally( + loop, + Call(enumeratorVariable, typeof(IDisposable).GetMethod(nameof(IDisposable.Dispose))!))); + } + + // return output; + expressions.Add(output); + + return Lambda>( + // First, check if the given array value is null and return null immediately if so + Condition( + ReferenceEqual(input, Constant(null)), + Constant(null, typeof(TOutput)), + Block(typeof(TOutput), variables, expressions)), + input); + } + + private static Expression ForLoop( + ParameterExpression loopVar, + Expression initValue, + Expression condition, + Expression increment, + Expression loopContent) + { + var initAssign = Assign(loopVar, initValue); + var breakLabel = Label("LoopBreak"); + var loop = Block( + [loopVar], + initAssign, + Loop( + IfThenElse( + condition, + Block( + loopContent, + increment + ), + Break(breakLabel) + ), + breakLabel) + ); + + return loop; + } +} \ No newline at end of file