From 49431a8e5c116131b8c7a1a436b1663d5f8c14c5 Mon Sep 17 00:00:00 2001 From: Matt Vance Date: Thu, 19 Sep 2024 11:37:53 -0700 Subject: [PATCH] Hstore query support Fixes #212 --- .../Internal/NpgsqlHstoreTranslator.cs | 137 ++++++++++ .../NpgsqlMemberTranslatorProvider.cs | 3 +- .../NpgsqlMethodCallTranslatorProvider.cs | 3 +- .../Internal/PgBinaryExpression.cs | 3 + .../Query/Expressions/PgExpressionType.cs | 14 + .../Query/Internal/NpgsqlQuerySqlGenerator.cs | 3 + .../Query/NpgsqlSqlExpressionFactory.cs | 18 ++ .../Mapping/NpgsqlHstoreTypeMapping.cs | 7 +- .../Query/HstoreQueryFixture.cs | 73 +++++ .../Query/HstoreQueryTest.cs | 255 ++++++++++++++++++ .../Dictionary/DictionaryContainerEntity.cs | 7 + .../TestModels/Dictionary/DictionaryEntity.cs | 17 ++ .../Dictionary/DictionaryQueryContext.cs | 22 ++ .../Dictionary/DictionaryQueryData.cs | 47 ++++ 14 files changed, 606 insertions(+), 3 deletions(-) create mode 100644 src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlHstoreTranslator.cs create mode 100644 test/EFCore.PG.FunctionalTests/Query/HstoreQueryFixture.cs create mode 100644 test/EFCore.PG.FunctionalTests/Query/HstoreQueryTest.cs create mode 100644 test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryContainerEntity.cs create mode 100644 test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryEntity.cs create mode 100644 test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryQueryContext.cs create mode 100644 test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryQueryData.cs diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlHstoreTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlHstoreTranslator.cs new file mode 100644 index 000000000..65379e459 --- /dev/null +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlHstoreTranslator.cs @@ -0,0 +1,137 @@ +using System.Collections.Immutable; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions; +using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping; +using static Npgsql.EntityFrameworkCore.PostgreSQL.Utilities.Statics; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.ExpressionTranslators.Internal; + +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +public class NpgsqlHstoreTranslator : IMethodCallTranslator, IMemberTranslator +{ + private static readonly Type DictionaryType = typeof(Dictionary); + private static readonly Type ImmutableDictionaryType = typeof(ImmutableDictionary); + + private static readonly MethodInfo Dictionary_ContainsKey = + DictionaryType.GetMethod(nameof(Dictionary.ContainsKey))!; + + private static readonly MethodInfo ImmutableDictionary_ContainsKey = + ImmutableDictionaryType.GetMethod(nameof(ImmutableDictionary.ContainsKey))!; + + private static readonly MethodInfo Dictionary_ContainsValue = + DictionaryType.GetMethod(nameof(Dictionary.ContainsValue))!; + + private static readonly MethodInfo ImmutableDictionary_ContainsValue = + ImmutableDictionaryType.GetMethod(nameof(ImmutableDictionary.ContainsValue))!; + + private static readonly MethodInfo Dictionary_Item_Getter = + DictionaryType.GetProperty("Item")!.GetMethod!; + + private static readonly MethodInfo ImmutableDictionary_Item_Getter = + ImmutableDictionaryType.GetProperty("Item")!.GetMethod!; + + private static readonly PropertyInfo Dictionary_Count = DictionaryType.GetProperty(nameof(Dictionary.Count))!; + + private static readonly PropertyInfo ImmutableDictionary_Count = + ImmutableDictionaryType.GetProperty(nameof(ImmutableDictionary.Count))!; + + private static readonly PropertyInfo ImmutableDictionary_IsEmpty = + ImmutableDictionaryType.GetProperty(nameof(ImmutableDictionary.IsEmpty))!; + + private readonly RelationalTypeMapping _stringListTypeMapping; + private readonly NpgsqlSqlExpressionFactory _sqlExpressionFactory; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public NpgsqlHstoreTranslator(IRelationalTypeMappingSource typeMappingSource, NpgsqlSqlExpressionFactory sqlExpressionFactory) + { + _sqlExpressionFactory = sqlExpressionFactory; + _stringListTypeMapping = typeMappingSource.FindMapping(typeof(List))!; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public SqlExpression? Translate( + SqlExpression? instance, + MethodInfo method, + IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + if (instance?.TypeMapping is null || instance.TypeMapping.StoreType != NpgsqlHstoreTypeMapping.HstoreType) + { + return null; + } + + if (method == Dictionary_ContainsKey || method == ImmutableDictionary_ContainsKey) + { + return _sqlExpressionFactory.MakePostgresBinary(PgExpressionType.HStoreContainsKey, instance, arguments[0]); + } + + if (method == Dictionary_ContainsValue || method == ImmutableDictionary_ContainsValue) + { + return _sqlExpressionFactory.Equal( + arguments[0], + _sqlExpressionFactory.Function( + "ANY", new[] + { + _sqlExpressionFactory.Function( + "avals", new[] { instance }, false, FalseArrays[1], typeof(List), _stringListTypeMapping) + }, false, FalseArrays[1], typeof(string))); + } + + if (method == Dictionary_Item_Getter || method == ImmutableDictionary_Item_Getter) + { + return _sqlExpressionFactory.MakePostgresBinary(PgExpressionType.HStoreValueForKey, instance, arguments[0]); + } + return null; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public SqlExpression? Translate( + SqlExpression? instance, + MemberInfo member, + Type returnType, + IDiagnosticsLogger logger) + { + + if (instance?.TypeMapping is null || instance.TypeMapping.StoreType != NpgsqlHstoreTypeMapping.HstoreType) + { + return null; + } + + if (member == Dictionary_Count || member == ImmutableDictionary_Count) + { + return _sqlExpressionFactory.Function("array_length", new [] + { + _sqlExpressionFactory.Function( + "akeys", new[] { instance }, false, FalseArrays[1], typeof(List), _stringListTypeMapping), + _sqlExpressionFactory.Constant(1) + }, false, FalseArrays[2], typeof(int)); + } + + if (member == ImmutableDictionary_IsEmpty) + { + return _sqlExpressionFactory.Equal( + Translate(instance, Dictionary_Count, typeof(int), logger)!, + _sqlExpressionFactory.Constant(0)); + } + return null; + } +} diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMemberTranslatorProvider.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMemberTranslatorProvider.cs index 28ab9785a..62859b1c4 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMemberTranslatorProvider.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMemberTranslatorProvider.cs @@ -43,7 +43,8 @@ public NpgsqlMemberTranslatorProvider( JsonPocoTranslator, new NpgsqlRangeTranslator(typeMappingSource, sqlExpressionFactory, model, supportsMultiranges), new NpgsqlStringMemberTranslator(sqlExpressionFactory), - new NpgsqlTimeSpanMemberTranslator(sqlExpressionFactory) + new NpgsqlTimeSpanMemberTranslator(sqlExpressionFactory), + new NpgsqlHstoreTranslator(typeMappingSource, sqlExpressionFactory) ]); } } diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs index 63843eab3..785f67a57 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs @@ -61,7 +61,8 @@ public NpgsqlMethodCallTranslatorProvider( new NpgsqlRegexIsMatchTranslator(sqlExpressionFactory), new NpgsqlRowValueTranslator(sqlExpressionFactory), new NpgsqlStringMethodTranslator(typeMappingSource, sqlExpressionFactory), - new NpgsqlTrigramsMethodTranslator(typeMappingSource, sqlExpressionFactory, model) + new NpgsqlTrigramsMethodTranslator(typeMappingSource, sqlExpressionFactory, model), + new NpgsqlHstoreTranslator(typeMappingSource, sqlExpressionFactory) ]); } } diff --git a/src/EFCore.PG/Query/Expressions/Internal/PgBinaryExpression.cs b/src/EFCore.PG/Query/Expressions/Internal/PgBinaryExpression.cs index 03387b988..040bee0fd 100644 --- a/src/EFCore.PG/Query/Expressions/Internal/PgBinaryExpression.cs +++ b/src/EFCore.PG/Query/Expressions/Internal/PgBinaryExpression.cs @@ -151,6 +151,9 @@ protected override void Print(ExpressionPrinter expressionPrinter) PgExpressionType.Distance => "<->", + PgExpressionType.HStoreContainsKey => "?", + PgExpressionType.HStoreValueForKey => "->", + _ => throw new ArgumentOutOfRangeException($"Unhandled operator type: {OperatorType}") }) .Append(" "); diff --git a/src/EFCore.PG/Query/Expressions/PgExpressionType.cs b/src/EFCore.PG/Query/Expressions/PgExpressionType.cs index 270a67e01..3a167d59e 100644 --- a/src/EFCore.PG/Query/Expressions/PgExpressionType.cs +++ b/src/EFCore.PG/Query/Expressions/PgExpressionType.cs @@ -159,4 +159,18 @@ public enum PgExpressionType LTreeFirstMatches, // ?~ or ?@ #endregion LTree + + #region HStore + + /// + /// Represents a PostgreSQL operator for checking if a hstore contains the given key + /// + HStoreContainsKey, // ? + + /// + /// Represents a PostgreSQL operator for accessing a hstore value for a given key + /// + HStoreValueForKey, // -> + + #endregion HStore } diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs index 3418d5045..51146b8d2 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs @@ -527,6 +527,9 @@ when binaryExpression.Left.TypeMapping is NpgsqlInetTypeMapping or NpgsqlCidrTyp PgExpressionType.Distance => "<->", + PgExpressionType.HStoreValueForKey => "->", + PgExpressionType.HStoreContainsKey => "?", + _ => throw new ArgumentOutOfRangeException($"Unhandled operator type: {binaryExpression.OperatorType}") }) .Append(" "); diff --git a/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs b/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs index 84fac6794..0cc8c353c 100644 --- a/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs +++ b/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs @@ -16,6 +16,7 @@ public class NpgsqlSqlExpressionFactory : SqlExpressionFactory { private readonly NpgsqlTypeMappingSource _typeMappingSource; private readonly RelationalTypeMapping _boolTypeMapping; + private readonly RelationalTypeMapping _stringTypeMapping; private static Type? _nodaTimeDurationType; private static Type? _nodaTimePeriodType; @@ -29,6 +30,7 @@ public NpgsqlSqlExpressionFactory(SqlExpressionFactoryDependencies dependencies) { _typeMappingSource = (NpgsqlTypeMappingSource)dependencies.TypeMappingSource; _boolTypeMapping = _typeMappingSource.FindMapping(typeof(bool), dependencies.Model)!; + _stringTypeMapping = _typeMappingSource.FindMapping(typeof(string), dependencies.Model)!; } #region Expression factory methods @@ -307,12 +309,17 @@ public virtual SqlExpression MakePostgresBinary( case PgExpressionType.JsonExists: case PgExpressionType.JsonExistsAny: case PgExpressionType.JsonExistsAll: + case PgExpressionType.HStoreContainsKey: returnType = typeof(bool); break; case PgExpressionType.Distance: returnType = typeof(double); break; + + case PgExpressionType.HStoreValueForKey: + returnType = typeof(string); + break; } return (PgBinaryExpression)ApplyTypeMapping( @@ -773,6 +780,7 @@ private SqlExpression ApplyTypeMappingOnPostgresBinary( case PgExpressionType.JsonExists: case PgExpressionType.JsonExistsAny: case PgExpressionType.JsonExistsAll: + case PgExpressionType.HStoreContainsKey: { // TODO: For networking, this probably needs to be cleaned up, i.e. we know where the CIDR and INET are // based on operator type? @@ -823,6 +831,16 @@ when left.Type.FullName is "NodaTime.Instant" or "NodaTime.LocalDateTime" or "No break; } + case PgExpressionType.HStoreValueForKey: + { + return new PgBinaryExpression( + operatorType, + ApplyDefaultTypeMapping(left), + ApplyDefaultTypeMapping(right), + typeof(string), + _stringTypeMapping); + } + default: throw new InvalidOperationException( $"Incorrect {nameof(operatorType)} for {nameof(pgBinaryExpression)}: {operatorType}"); diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlHstoreTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlHstoreTypeMapping.cs index 3d6eef2f0..b6bd178e6 100644 --- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlHstoreTypeMapping.cs +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlHstoreTypeMapping.cs @@ -14,6 +14,11 @@ public class NpgsqlHstoreTypeMapping : NpgsqlTypeMapping { private static readonly HstoreMutableComparer MutableComparerInstance = new(); + /// + /// The database store type of the Hstore type + /// + public const string HstoreType = "hstore"; + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -32,7 +37,7 @@ public NpgsqlHstoreTypeMapping(Type clrType) : base( new RelationalTypeMappingParameters( new CoreTypeMappingParameters(clrType, comparer: GetComparer(clrType)), - "hstore"), + HstoreType), NpgsqlDbType.Hstore) { } diff --git a/test/EFCore.PG.FunctionalTests/Query/HstoreQueryFixture.cs b/test/EFCore.PG.FunctionalTests/Query/HstoreQueryFixture.cs new file mode 100644 index 000000000..af0fbd194 --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/Query/HstoreQueryFixture.cs @@ -0,0 +1,73 @@ +using Npgsql.EntityFrameworkCore.PostgreSQL.TestModels.Dictionary; +using Npgsql.EntityFrameworkCore.PostgreSQL.TestUtilities; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query; + +public class HstoreQueryFixture : SharedStoreFixtureBase, IQueryFixtureBase, ITestSqlLoggerFactory +{ + protected override string StoreName + => "HstoreQueryTest"; + + protected override ITestStoreFactory TestStoreFactory + => NpgsqlTestStoreFactory.Instance; + + public TestSqlLoggerFactory TestSqlLoggerFactory + => (TestSqlLoggerFactory)ListLoggerFactory; + + private DictionaryQueryData _expectedData; + + public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) + => base.AddOptions(builder).ConfigureWarnings(wcb => wcb.Ignore(CoreEventId.CollectionWithoutComparer)); + + protected override Task SeedAsync(DictionaryQueryContext context) + => DictionaryQueryContext.SeedAsync(context); + + public Func GetContextCreator() + => CreateContext; + + public ISetSource GetExpectedData() + => _expectedData ??= new DictionaryQueryData(); + + public IReadOnlyDictionary EntitySorters + => new Dictionary> + { + { typeof(DictionaryEntity), e => ((DictionaryEntity)e)?.Id }, { typeof(DictionaryContainerEntity), e => ((DictionaryContainerEntity)e)?.Id } + }.ToDictionary(e => e.Key, e => (object)e.Value); + + public IReadOnlyDictionary EntityAsserters + => new Dictionary> + { + { + typeof(DictionaryEntity), (e, a) => + { + Assert.Equal(e is null, a is null); + if (a is not null) + { + var ee = (DictionaryEntity)e; + var aa = (DictionaryEntity)a; + + Assert.Equal(ee.Id, aa.Id); + Assert.Equal(ee.Dictionary, ee.Dictionary); + Assert.Equal(ee.ImmutableDictionary, ee.ImmutableDictionary); + Assert.Equal(ee.NullableDictionary, ee.NullableDictionary); + Assert.Equal(ee.NullableImmutableDictionary, ee.NullableImmutableDictionary); + + } + } + }, + { + typeof(DictionaryContainerEntity), (e, a) => + { + Assert.Equal(e is null, a is null); + if (a is not null) + { + var ee = (DictionaryContainerEntity)e; + var aa = (DictionaryContainerEntity)a; + + Assert.Equal(ee.Id, aa.Id); + Assert.Equal(ee.DictionaryEntities, ee.DictionaryEntities); + } + } + } + }.ToDictionary(e => e.Key, e => (object)e.Value); +} diff --git a/test/EFCore.PG.FunctionalTests/Query/HstoreQueryTest.cs b/test/EFCore.PG.FunctionalTests/Query/HstoreQueryTest.cs new file mode 100644 index 000000000..7c75674df --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/Query/HstoreQueryTest.cs @@ -0,0 +1,255 @@ +using Npgsql.EntityFrameworkCore.PostgreSQL.TestModels.Dictionary; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query; + +public class HstoreQueryTest : QueryTestBase +{ + public HstoreQueryTest(HstoreQueryFixture fixture, ITestOutputHelper testOutputHelper) + : base(fixture) + { + Fixture.TestSqlLoggerFactory.Clear(); + Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Dictionary_Contains_key(bool async) + { + var keyToTest = "key"; + await AssertQuery(async, _ => _.Set().Where(s => s.Dictionary.ContainsKey(keyToTest))); + AssertSql(""" +@__keyToTest_0='key' + +SELECT s."Id", s."Dictionary", s."DictionaryContainerEntityId", s."ImmutableDictionary", s."NullableDictionary", s."NullableImmutableDictionary" +FROM "SomeEntities" AS s +WHERE s."Dictionary" ? @__keyToTest_0 +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task NullableDictionary_Contains_key(bool async) + { + var keyToTest = "key2"; + await AssertQuery(async, _ => _.Set().Where(s => s.NullableDictionary != null && s.NullableDictionary.ContainsKey(keyToTest))); + AssertSql(""" +@__keyToTest_0='key2' + +SELECT s."Id", s."Dictionary", s."DictionaryContainerEntityId", s."ImmutableDictionary", s."NullableDictionary", s."NullableImmutableDictionary" +FROM "SomeEntities" AS s +WHERE s."NullableDictionary" IS NOT NULL AND s."NullableDictionary" ? @__keyToTest_0 +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task ImmutableNullableDictionary_Contains_key(bool async) + { + var keyToTest = "key3"; + await AssertQuery(async, _ => _.Set().Where(s => s.NullableImmutableDictionary != null && s.NullableImmutableDictionary.ContainsKey(keyToTest))); + AssertSql(""" +@__keyToTest_0='key3' + +SELECT s."Id", s."Dictionary", s."DictionaryContainerEntityId", s."ImmutableDictionary", s."NullableDictionary", s."NullableImmutableDictionary" +FROM "SomeEntities" AS s +WHERE s."NullableImmutableDictionary" IS NOT NULL AND s."NullableImmutableDictionary" ? @__keyToTest_0 +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task ImmutableDictionary_Contains_key(bool async) + { + var keyToTest = "key3"; + await AssertQuery(async, _ => _.Set().Where(s => s.ImmutableDictionary.ContainsKey(keyToTest))); + AssertSql( + """ +@__keyToTest_0='key3' + +SELECT s."Id", s."Dictionary", s."DictionaryContainerEntityId", s."ImmutableDictionary", s."NullableDictionary", s."NullableImmutableDictionary" +FROM "SomeEntities" AS s +WHERE s."ImmutableDictionary" ? @__keyToTest_0 +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Dictionary_Contains_value(bool async) + { + var valueToTest = "value"; + await AssertQuery(async, _ => _.Set().Where(s => s.Dictionary.ContainsValue(valueToTest))); + AssertSql( + """ +@__valueToTest_0='value' + +SELECT s."Id", s."Dictionary", s."DictionaryContainerEntityId", s."ImmutableDictionary", s."NullableDictionary", s."NullableImmutableDictionary" +FROM "SomeEntities" AS s +WHERE @__valueToTest_0 = ANY(avals(s."Dictionary")) +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Dictionary_Item_equals_found(bool async) + { + var keyToTest = "key"; + var valueToTest = "value"; + await AssertQuery(async, _ => _.Set().Where(s => s.Dictionary[keyToTest] == valueToTest)); + AssertSql( + """ +@__valueToTest_0='value' + +SELECT s."Id", s."Dictionary", s."DictionaryContainerEntityId", s."ImmutableDictionary", s."NullableDictionary", s."NullableImmutableDictionary" +FROM "SomeEntities" AS s +WHERE s."Dictionary" -> 'key' = @__valueToTest_0 +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Dictionary_Item_equals_not_found(bool async) + { + var keyToTest = "key"; + var valueToTest = "value2"; + await AssertQuery(async, _ => _.Set().Where(s => s.Dictionary.ContainsKey(keyToTest) && s.Dictionary[keyToTest] == valueToTest), assertEmpty: true); + AssertSql( + """ +@__keyToTest_0='key' +@__valueToTest_1='value2' + +SELECT s."Id", s."Dictionary", s."DictionaryContainerEntityId", s."ImmutableDictionary", s."NullableDictionary", s."NullableImmutableDictionary" +FROM "SomeEntities" AS s +WHERE s."Dictionary" ? @__keyToTest_0 AND s."Dictionary" -> 'key' = @__valueToTest_1 +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task NullableDictionary_Item_equals(bool async) + { + var keyToTest = "key2"; + var valueToTest = "value"; + await AssertQuery( + async, + _ => _.Set().Where( + s => s.NullableDictionary != null + && s.NullableDictionary.ContainsKey(keyToTest) + && s.NullableDictionary[keyToTest] == valueToTest)); + AssertSql( + """ +@__keyToTest_0='key2' +@__valueToTest_1='value' + +SELECT s."Id", s."Dictionary", s."DictionaryContainerEntityId", s."ImmutableDictionary", s."NullableDictionary", s."NullableImmutableDictionary" +FROM "SomeEntities" AS s +WHERE s."NullableDictionary" IS NOT NULL AND s."NullableDictionary" ? @__keyToTest_0 AND s."NullableDictionary" -> 'key2' = @__valueToTest_1 +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task ImmutableNullableDictionary_Item_equals(bool async) + { + var keyToTest = "key3"; + var valueToTest = "value2"; + await AssertQuery( + async, + _ => _.Set().Where( + s => s.NullableImmutableDictionary != null + && s.NullableImmutableDictionary.ContainsKey(keyToTest) + && s.NullableImmutableDictionary[keyToTest] == valueToTest)); + AssertSql( + """ +@__keyToTest_0='key3' +@__valueToTest_1='value2' + +SELECT s."Id", s."Dictionary", s."DictionaryContainerEntityId", s."ImmutableDictionary", s."NullableDictionary", s."NullableImmutableDictionary" +FROM "SomeEntities" AS s +WHERE s."NullableImmutableDictionary" IS NOT NULL AND s."NullableImmutableDictionary" ? @__keyToTest_0 AND s."NullableImmutableDictionary" -> @__keyToTest_0 = @__valueToTest_1 +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task ImmutableDictionary_Item_equals(bool async) + { + var keyToTest = "key2"; + var valueToTest = "value2"; + await AssertQuery(async, _ => _.Set().Where(s => s.ImmutableDictionary.ContainsKey(keyToTest) && s.ImmutableDictionary[keyToTest] == valueToTest)); + AssertSql( + """ +@__keyToTest_0='key2' +@__valueToTest_1='value2' + +SELECT s."Id", s."Dictionary", s."DictionaryContainerEntityId", s."ImmutableDictionary", s."NullableDictionary", s."NullableImmutableDictionary" +FROM "SomeEntities" AS s +WHERE s."ImmutableDictionary" ? @__keyToTest_0 AND s."ImmutableDictionary" -> @__keyToTest_0 = @__valueToTest_1 +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Dictionary_Where_Count(bool async) + { + await AssertQuery(async, _ => _.Set().Where(s => s.Dictionary.Count >= 1)); + AssertSql( + """ +SELECT s."Id", s."Dictionary", s."DictionaryContainerEntityId", s."ImmutableDictionary", s."NullableDictionary", s."NullableImmutableDictionary" +FROM "SomeEntities" AS s +WHERE array_length(akeys(s."Dictionary"), 1) >= 1 +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Dictionary_Select_Count(bool async) + { + await AssertQuery(async, _ => _.Set().Select(s => s.Dictionary.Count)); + AssertSql( + """ +SELECT array_length(akeys(s."Dictionary"), 1) +FROM "SomeEntities" AS s +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task ImmutableDictionary_Where_Count(bool async) + { + await AssertQuery(async, _ => _.Set().Where(s => s.ImmutableDictionary.Count >= 1)); + AssertSql( + """ +SELECT s."Id", s."Dictionary", s."DictionaryContainerEntityId", s."ImmutableDictionary", s."NullableDictionary", s."NullableImmutableDictionary" +FROM "SomeEntities" AS s +WHERE array_length(akeys(s."ImmutableDictionary"), 1) >= 1 +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task ImmutableDictionary_Select_Count(bool async) + { + await AssertQuery(async, _ => _.Set().Select(s => s.ImmutableDictionary.Count)); + AssertSql( + """ +SELECT array_length(akeys(s."ImmutableDictionary"), 1) +FROM "SomeEntities" AS s +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task ImmutableDictionary_Where_IsEmpty(bool async) + { + await AssertQuery(async, _ => _.Set().Where(s => !s.ImmutableDictionary.IsEmpty)); + AssertSql( + """ +SELECT s."Id", s."Dictionary", s."DictionaryContainerEntityId", s."ImmutableDictionary", s."NullableDictionary", s."NullableImmutableDictionary" +FROM "SomeEntities" AS s +WHERE array_length(akeys(s."ImmutableDictionary"), 1) <> 0 +"""); + } + + protected void AssertSql(params string[] expected) + => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); +} diff --git a/test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryContainerEntity.cs b/test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryContainerEntity.cs new file mode 100644 index 000000000..9db129448 --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryContainerEntity.cs @@ -0,0 +1,7 @@ +namespace Npgsql.EntityFrameworkCore.PostgreSQL.TestModels.Dictionary; + +public class DictionaryContainerEntity +{ + public int Id { get; set; } + public List DictionaryEntities { get; set; } = null!; +} diff --git a/test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryEntity.cs b/test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryEntity.cs new file mode 100644 index 000000000..8603ec98c --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryEntity.cs @@ -0,0 +1,17 @@ +#nullable enable +using System.Collections.Immutable; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.TestModels.Dictionary; + +public class DictionaryEntity +{ + public int Id { get; set; } + + public Dictionary Dictionary { get; set; } = null!; + + public ImmutableDictionary ImmutableDictionary { get; set; } = null!; + + public Dictionary? NullableDictionary { get; set; } = null!; + + public ImmutableDictionary? NullableImmutableDictionary { get; set; } = null!; +} diff --git a/test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryQueryContext.cs b/test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryQueryContext.cs new file mode 100644 index 000000000..33b98a041 --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryQueryContext.cs @@ -0,0 +1,22 @@ +namespace Npgsql.EntityFrameworkCore.PostgreSQL.TestModels.Dictionary; + +public class DictionaryQueryContext(DbContextOptions options) : PoolableDbContext(options) +{ + public DbSet SomeEntities { get; set; } + public DbSet SomeEntityContainers { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity(); + } + + public static async Task SeedAsync(DictionaryQueryContext context) + { + var arrayEntities = DictionaryQueryData.CreateDictionaryEntities(); + + context.SomeEntities.AddRange(arrayEntities); + context.SomeEntityContainers.Add(new DictionaryContainerEntity() { Id = 1, DictionaryEntities = arrayEntities.ToList() }); + await context.SaveChangesAsync(); + } +} diff --git a/test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryQueryData.cs b/test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryQueryData.cs new file mode 100644 index 000000000..5ee965b4a --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/TestModels/Dictionary/DictionaryQueryData.cs @@ -0,0 +1,47 @@ +using System.Collections.Immutable; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.TestModels.Dictionary; + +public class DictionaryQueryData : ISetSource +{ + public IReadOnlyList DictionaryEntities { get; } = CreateDictionaryEntities(); + public IReadOnlyList ContainerEntities { get; } = CreateContainerEntities(); + + public IQueryable Set() + where TEntity : class + { + if (typeof(TEntity) == typeof(DictionaryEntity)) + { + return (IQueryable)DictionaryEntities.AsQueryable(); + } + + if (typeof(TEntity) == typeof(DictionaryContainerEntity)) + { + return (IQueryable)ContainerEntities.AsQueryable(); + } + + throw new InvalidOperationException("Invalid entity type: " + typeof(TEntity)); + } + + public static IReadOnlyList CreateDictionaryEntities() + => + [ + new() + { + Id = 1, + Dictionary = new() { ["key"] = "value" }, + ImmutableDictionary = new Dictionary { ["key2"] = "value2" }.ToImmutableDictionary(), + }, + new() + { + Id = 2, + Dictionary = new() { ["key"] = "value" }, + ImmutableDictionary = new Dictionary { ["key3"] = "value3" }.ToImmutableDictionary(), + NullableDictionary = new() { ["key2"] = "value" }, + NullableImmutableDictionary = new Dictionary { ["key3"] = "value2" }.ToImmutableDictionary(), + } + ]; + + public static IReadOnlyList CreateContainerEntities() + => [new DictionaryContainerEntity { Id = 1, DictionaryEntities = CreateDictionaryEntities().ToList() }]; +}