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..7e65092ac
--- /dev/null
+++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlHstoreTranslator.cs
@@ -0,0 +1,289 @@
+using System.Collections.Immutable;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal;
+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.FindIndexerProperty()!.GetMethod!;
+
+ private static readonly MethodInfo ImmutableDictionary_Item_Getter =
+ ImmutableDictionaryType.FindIndexerProperty()!.GetMethod!;
+
+ private static readonly MethodInfo Enumerable_Any =
+ typeof(Enumerable).GetMethod(
+ nameof(Enumerable.Any), BindingFlags.Public | BindingFlags.Static,
+ [typeof(IEnumerable<>).MakeGenericType(Type.MakeGenericMethodParameter(0))])!
+ .MakeGenericMethod(typeof(KeyValuePair));
+
+ private static readonly MethodInfo Enumerable_Count =
+ typeof(Enumerable).GetMethod(
+ nameof(Enumerable.Count), BindingFlags.Public | BindingFlags.Static,
+ [typeof(IEnumerable<>).MakeGenericType(Type.MakeGenericMethodParameter(0))])!
+ .MakeGenericMethod(typeof(KeyValuePair));
+
+ private static readonly MethodInfo Enumerable_ToList =
+ typeof(Enumerable).GetMethod(
+ nameof(Enumerable.ToList), BindingFlags.Public | BindingFlags.Static,
+ [typeof(IEnumerable<>).MakeGenericType(Type.MakeGenericMethodParameter(0))])!
+ .MakeGenericMethod(typeof(string));
+
+ private static readonly MethodInfo Enumerable_ToDictionary =
+ typeof(Enumerable).GetMethod(
+ nameof(Enumerable.ToDictionary), BindingFlags.Public | BindingFlags.Static,
+ [
+ typeof(IEnumerable<>).MakeGenericType(
+ typeof(KeyValuePair<,>).MakeGenericType(Type.MakeGenericMethodParameter(0), Type.MakeGenericMethodParameter(1)))
+ ])!.MakeGenericMethod(typeof(string), typeof(string));
+
+ private static readonly MethodInfo ImmutableDictionary_ToImmutableDictionary =
+ typeof(ImmutableDictionary).GetMethod(
+ nameof(ImmutableDictionary.ToImmutableDictionary), BindingFlags.Public | BindingFlags.Static,
+ [
+ typeof(IEnumerable<>).MakeGenericType(
+ typeof(KeyValuePair<,>).MakeGenericType(Type.MakeGenericMethodParameter(0), Type.MakeGenericMethodParameter(1)))
+ ])!.MakeGenericMethod(typeof(string), typeof(string));
+
+ private static readonly MethodInfo Enumerable_Concat = typeof(Enumerable).GetMethod(
+ nameof(Enumerable.Concat), BindingFlags.Public | BindingFlags.Static,
+ [
+ typeof(IEnumerable<>).MakeGenericType(Type.MakeGenericMethodParameter(0)),
+ typeof(IEnumerable<>).MakeGenericType(Type.MakeGenericMethodParameter(0))
+ ])!.MakeGenericMethod(typeof(KeyValuePair));
+
+ private static readonly MethodInfo Enumerable_Except = typeof(Enumerable).GetMethod(
+ nameof(Enumerable.Except), BindingFlags.Public | BindingFlags.Static,
+ [
+ typeof(IEnumerable<>).MakeGenericType(Type.MakeGenericMethodParameter(0)),
+ typeof(IEnumerable<>).MakeGenericType(Type.MakeGenericMethodParameter(0))
+ ])!.MakeGenericMethod(typeof(KeyValuePair));
+
+ 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 static readonly PropertyInfo Dictionary_Keys = DictionaryType.GetProperty(nameof(Dictionary.Keys))!;
+
+ private static readonly PropertyInfo ImmutableDictionary_Keys =
+ ImmutableDictionaryType.GetProperty(nameof(ImmutableDictionary.Keys))!;
+
+ private static readonly PropertyInfo Dictionary_Values = DictionaryType.GetProperty(nameof(Dictionary.Values))!;
+
+ private static readonly PropertyInfo ImmutableDictionary_Values =
+ ImmutableDictionaryType.GetProperty(nameof(ImmutableDictionary.Values))!;
+
+ private readonly RelationalTypeMapping _stringListTypeMapping;
+ private readonly RelationalTypeMapping _stringTypeMapping;
+ private readonly RelationalTypeMapping _dictionaryMapping;
+ private readonly RelationalTypeMapping _immutableDictionaryMapping;
+ 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))!;
+ _stringTypeMapping = typeMappingSource.FindMapping(typeof(string))!;
+ _dictionaryMapping = typeMappingSource.FindMapping(DictionaryType)!;
+ _immutableDictionaryMapping = typeMappingSource.FindMapping(ImmutableDictionaryType)!;
+ }
+
+ ///
+ /// 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 is null)
+ {
+ if (arguments.Count is 2)
+ {
+ if (arguments[0].TypeMapping?.StoreType != "hstore" || arguments[1].TypeMapping?.StoreType != "hstore")
+ {
+ return null;
+ }
+
+ // store1.Concat(store2) => store1 || store2
+ if (method == Enumerable_Concat)
+ {
+ return _sqlExpressionFactory.MakePostgresBinary(
+ PgExpressionType.HStoreConcat, arguments[0], arguments[1], arguments[1].TypeMapping);
+ }
+
+ // store1.Except(store2) => store1 - store2
+ if (method == Enumerable_Except)
+ {
+ return _sqlExpressionFactory.MakePostgresBinary(
+ PgExpressionType.HStoreSubtract, arguments[0], arguments[1], arguments[1].TypeMapping);
+ }
+
+ return null;
+ }
+
+ if (arguments.Count is not 1)
+ {
+ return null;
+ }
+
+ if (arguments[0].TypeMapping?.StoreType == "hstore")
+ {
+ // store.Any() => cardinality(akeys(store)) <> 0
+ if (method == Enumerable_Any)
+ {
+ return _sqlExpressionFactory.NotEqual(Count(arguments[0]), _sqlExpressionFactory.Constant(0));
+ }
+
+ // store.Count() => cardinality(akeys(store))
+ if (method == Enumerable_Count)
+ {
+ return Count(arguments[0]);
+ }
+
+ // store.ToDictionary() => store OR CAST(store as hstore) OR store::hstore
+ if (method == Enumerable_ToDictionary)
+ {
+ return arguments[0].Type == ImmutableDictionaryType
+ ? _sqlExpressionFactory.Convert(arguments[0], DictionaryType, _dictionaryMapping)
+ : arguments[0];
+ }
+
+ // store.ToImmutableDictionary() => store OR CAST(store as hstore) OR store::hstore
+ if (method == ImmutableDictionary_ToImmutableDictionary)
+ {
+ return arguments[0].Type == DictionaryType
+ ? _sqlExpressionFactory.Convert(arguments[0], ImmutableDictionaryType, _immutableDictionaryMapping)
+ : arguments[0];
+ }
+
+ return null;
+ }
+
+ // store.Keys.ToList() => akeys(store) OR store.Values.ToList() -> avals(store)
+ if (method == Enumerable_ToList && arguments[0] is SqlFunctionExpression { Arguments: [{ TypeMapping.StoreType: "hstore" }] })
+ {
+ return arguments[0];
+ }
+
+ return null;
+ }
+
+ if (instance.TypeMapping?.StoreType != "hstore")
+ {
+ return null;
+ }
+
+ // store.ContainsKey(key) => store ? key
+ if (method == Dictionary_ContainsKey || method == ImmutableDictionary_ContainsKey)
+ {
+ return _sqlExpressionFactory.MakePostgresBinary(PgExpressionType.HStoreContainsKey, instance, arguments[0]);
+ }
+
+ // store.ContainsValue(value) => value ANY(avals(store))
+ if (method == Dictionary_ContainsValue || method == ImmutableDictionary_ContainsValue)
+ {
+ return _sqlExpressionFactory.Any(arguments[0], Values(instance), PgAnyOperatorType.Equal);
+ }
+
+ // store[key] => store -> key
+ if (method == Dictionary_Item_Getter || method == ImmutableDictionary_Item_Getter)
+ {
+ return _sqlExpressionFactory.MakePostgresBinary(PgExpressionType.HStoreValueForKey, instance, arguments[0], _stringTypeMapping);
+ }
+
+ 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?.StoreType != "hstore")
+ {
+ return null;
+ }
+
+ // store.Count => cardinality(akeys(store))
+ if (member == Dictionary_Count || member == ImmutableDictionary_Count)
+ {
+ return Count(instance, true);
+ }
+
+ // store.Keys => akeys(store)
+ if (member == Dictionary_Keys || member == ImmutableDictionary_Keys)
+ {
+ return Keys(instance);
+ }
+
+ // store.Values => avals(store)
+ if (member == Dictionary_Values || member == ImmutableDictionary_Values)
+ {
+ return Values(instance);
+ }
+
+ // store.IsEmpty => cardinality(akeys(store)) = 0
+ if (member == ImmutableDictionary_IsEmpty)
+ {
+ return _sqlExpressionFactory.Equal(Count(instance), _sqlExpressionFactory.Constant(0));
+ }
+
+ return null;
+ }
+
+ private SqlExpression Keys(SqlExpression instance)
+ => _sqlExpressionFactory.Function(
+ "akeys", [instance], true, TrueArrays[1], typeof(List), _stringListTypeMapping);
+
+ private SqlExpression Values(SqlExpression instance)
+ => _sqlExpressionFactory.Function(
+ "avals", [instance], true, TrueArrays[1], typeof(List), _stringListTypeMapping);
+
+ private SqlExpression Count(SqlExpression instance, bool nullable = false)
+ => _sqlExpressionFactory.Function("cardinality", [Keys(instance)], nullable, TrueArrays[1], typeof(int));
+}
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..34154755e 100644
--- a/src/EFCore.PG/Query/Expressions/Internal/PgBinaryExpression.cs
+++ b/src/EFCore.PG/Query/Expressions/Internal/PgBinaryExpression.cs
@@ -151,6 +151,11 @@ protected override void Print(ExpressionPrinter expressionPrinter)
PgExpressionType.Distance => "<->",
+ PgExpressionType.HStoreContainsKey => "?",
+ PgExpressionType.HStoreValueForKey => "->",
+ PgExpressionType.HStoreConcat => "||",
+ PgExpressionType.HStoreSubtract => "-",
+
_ => 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..1a33c8c82 100644
--- a/src/EFCore.PG/Query/Expressions/PgExpressionType.cs
+++ b/src/EFCore.PG/Query/Expressions/PgExpressionType.cs
@@ -159,4 +159,28 @@ 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, // ->
+
+ ///
+ /// Represents a PostgreSQL operator for concatenating hstores
+ ///
+ HStoreConcat, // ||
+
+ ///
+ /// Represents a PostgreSQL operator for subtracting hstores
+ ///
+ HStoreSubtract, // -
+
+ #endregion HStore
}
diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs
index 3418d5045..49ffbf2f7 100644
--- a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs
+++ b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs
@@ -527,6 +527,11 @@ when binaryExpression.Left.TypeMapping is NpgsqlInetTypeMapping or NpgsqlCidrTyp
PgExpressionType.Distance => "<->",
+ PgExpressionType.HStoreContainsKey => "?",
+ PgExpressionType.HStoreValueForKey => "->",
+ PgExpressionType.HStoreConcat => "||",
+ PgExpressionType.HStoreSubtract => "-",
+
_ => 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..963a227e2 100644
--- a/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs
+++ b/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs
@@ -307,6 +307,7 @@ public virtual SqlExpression MakePostgresBinary(
case PgExpressionType.JsonExists:
case PgExpressionType.JsonExistsAny:
case PgExpressionType.JsonExistsAll:
+ case PgExpressionType.HStoreContainsKey:
returnType = typeof(bool);
break;
@@ -773,6 +774,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 +825,18 @@ when left.Type.FullName is "NodaTime.Instant" or "NodaTime.LocalDateTime" or "No
break;
}
+ case PgExpressionType.HStoreValueForKey:
+ case PgExpressionType.HStoreConcat:
+ case PgExpressionType.HStoreSubtract:
+ {
+ return new PgBinaryExpression(
+ operatorType,
+ ApplyDefaultTypeMapping(left),
+ ApplyDefaultTypeMapping(right),
+ typeMapping!.ClrType,
+ typeMapping);
+ }
+
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..0b8b5f0dc 100644
--- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlHstoreTypeMapping.cs
+++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlHstoreTypeMapping.cs
@@ -5,7 +5,7 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping;
///
/// The type mapping for the PostgreSQL hstore type. Supports both
-/// and over strings.
+/// and where TKey and TValue are both strings.
///
///
/// See: https://www.postgresql.org/docs/current/static/hstore.html
diff --git a/test/EFCore.PG.FunctionalTests/Query/HstoreQueryTest.cs b/test/EFCore.PG.FunctionalTests/Query/HstoreQueryTest.cs
new file mode 100644
index 000000000..414fca792
--- /dev/null
+++ b/test/EFCore.PG.FunctionalTests/Query/HstoreQueryTest.cs
@@ -0,0 +1,541 @@
+using System.Collections.Immutable;
+using Npgsql.EntityFrameworkCore.PostgreSQL.TestUtilities;
+
+namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query;
+
+public class DictionaryEntity
+{
+ public int Id { get; set; }
+
+ public Dictionary Dictionary { get; set; } = null!;
+
+ public ImmutableDictionary ImmutableDictionary { get; set; } = null!;
+
+}
+
+public class DictionaryQueryContext(DbContextOptions options) : PoolableDbContext(options)
+{
+ public DbSet SomeEntities { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity();
+ }
+
+ public static async Task SeedAsync(DictionaryQueryContext context)
+ {
+ var arrayEntities = DictionaryQueryData.CreateDictionaryEntities();
+
+ context.SomeEntities.AddRange(arrayEntities);
+ await context.SaveChangesAsync();
+ }
+}
+
+public class DictionaryQueryData : ISetSource
+{
+ public IReadOnlyList DictionaryEntities { get; } = CreateDictionaryEntities();
+
+ public IQueryable Set()
+ where TEntity : class
+ {
+ if (typeof(TEntity) == typeof(DictionaryEntity))
+ {
+ return (IQueryable)DictionaryEntities.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(),
+ }
+ ];
+}
+
+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 }
+ }.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);
+
+ }
+ }
+ }
+ }.ToDictionary(e => e.Key, e => (object)e.Value);
+}
+
+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_ContainsKey(bool async)
+ {
+ var keyToTest = "key";
+ await AssertQuery(async, ss => ss.Set().Where(s => s.Dictionary.ContainsKey(keyToTest)));
+ AssertSql("""
+@__keyToTest_0='key'
+
+SELECT s."Id", s."Dictionary", s."ImmutableDictionary"
+FROM "SomeEntities" AS s
+WHERE s."Dictionary" ? @__keyToTest_0
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_ContainsKey(bool async)
+ {
+ var keyToTest = "key3";
+ await AssertQuery(async, ss => ss.Set().Where(s => s.ImmutableDictionary.ContainsKey(keyToTest)));
+ AssertSql(
+ """
+@__keyToTest_0='key3'
+
+SELECT s."Id", s."Dictionary", s."ImmutableDictionary"
+FROM "SomeEntities" AS s
+WHERE s."ImmutableDictionary" ? @__keyToTest_0
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task Dictionary_ContainsValue(bool async)
+ {
+ var valueToTest = "value";
+ await AssertQuery(async, ss => ss.Set().Where(s => s.Dictionary.ContainsValue(valueToTest)));
+ AssertSql(
+ """
+@__valueToTest_0='value'
+
+SELECT s."Id", s."Dictionary", s."ImmutableDictionary"
+FROM "SomeEntities" AS s
+WHERE @__valueToTest_0 = ANY (avals(s."Dictionary"))
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_ContainsValue(bool async)
+ {
+ var valueToTest = "value2";
+ await AssertQuery(async, ss => ss.Set().Where(s => s.ImmutableDictionary.ContainsValue(valueToTest)));
+ AssertSql(
+ """
+@__valueToTest_0='value2'
+
+SELECT s."Id", s."Dictionary", s."ImmutableDictionary"
+FROM "SomeEntities" AS s
+WHERE @__valueToTest_0 = ANY (avals(s."ImmutableDictionary"))
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task Dictionary_Keys_ToList(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.Dictionary.Keys.ToList()), elementAsserter: Assert.Equal,
+ assertOrder: true);
+ AssertSql(
+ """
+SELECT akeys(s."Dictionary")
+FROM "SomeEntities" AS s
+""");
+ }
+
+ // Note: There is no "Dictionary_Keys" or "Dictionary_Values" tests as they return a Dictionary.KeyCollection and Dictionary.ValueCollection
+ // which cannot be translated from a `List` which is what the `avals` and `akeys` functions returns. ImmutableDictionary.Keys and ImmutableDictionary.Values
+ // does have tests as they return an `IEnumerable` that `List` is compatible with
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_Keys(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.ImmutableDictionary.Keys), elementAsserter: Assert.Equal,
+ assertOrder: true);
+ AssertSql(
+ """
+SELECT akeys(s."ImmutableDictionary")
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_Keys_ToList(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.ImmutableDictionary.Keys.ToList()), elementAsserter: Assert.Equal,
+ assertOrder: true);
+ AssertSql(
+ """
+SELECT akeys(s."ImmutableDictionary")
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task Dictionary_Values_ToList(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.Dictionary.Values.ToList()), elementAsserter: Assert.Equal,
+ assertOrder: true);
+ AssertSql(
+ """
+SELECT avals(s."Dictionary")
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_Values(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.ImmutableDictionary.Values), elementAsserter: Assert.Equal,
+ assertOrder: true);
+ AssertSql(
+ """
+SELECT avals(s."ImmutableDictionary")
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_Values_ToList(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.ImmutableDictionary.Values.ToList()), elementAsserter: Assert.Equal,
+ assertOrder: true);
+ AssertSql(
+ """
+SELECT avals(s."ImmutableDictionary")
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task Dictionary_Item_equals(bool async)
+ {
+ var keyToTest = "key";
+ var valueToTest = "value";
+ await AssertQuery(async, ss => ss.Set().Where(s => s.Dictionary[keyToTest] == valueToTest));
+ AssertSql(
+ """
+@__valueToTest_0='value'
+
+SELECT s."Id", s."Dictionary", s."ImmutableDictionary"
+FROM "SomeEntities" AS s
+WHERE s."Dictionary" -> 'key' = @__valueToTest_0
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_Item_equals(bool async)
+ {
+ var keyToTest = "key2";
+ var valueToTest = "value2";
+ await AssertQuery(async, ss =>
+ ss.Set().Where(s => s.ImmutableDictionary[keyToTest] == valueToTest),
+ ss => ss.Set().Where(s =>
+ s.ImmutableDictionary.ContainsKey(keyToTest) && s.ImmutableDictionary[keyToTest] == valueToTest));
+ AssertSql(
+ """
+@__keyToTest_0='key2'
+@__valueToTest_1='value2'
+
+SELECT s."Id", s."Dictionary", s."ImmutableDictionary"
+FROM "SomeEntities" AS s
+WHERE s."ImmutableDictionary" -> @__keyToTest_0 = @__valueToTest_1
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task Dictionary_Where_Count(bool async)
+ {
+ await AssertQuery(async, ss => ss.Set().Where(s => s.Dictionary.Count >= 1));
+ AssertSql(
+ """
+SELECT s."Id", s."Dictionary", s."ImmutableDictionary"
+FROM "SomeEntities" AS s
+WHERE cardinality(akeys(s."Dictionary")) >= 1
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task Dictionary_Select_Count(bool async)
+ {
+ await AssertQuery(async, ss => ss.Set().Select(s => s.Dictionary.Count));
+ AssertSql(
+ """
+SELECT cardinality(akeys(s."Dictionary"))
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_Where_Count(bool async)
+ {
+ await AssertQuery(async, ss => ss.Set().Where(s => s.ImmutableDictionary.Count >= 1));
+ AssertSql(
+ """
+SELECT s."Id", s."Dictionary", s."ImmutableDictionary"
+FROM "SomeEntities" AS s
+WHERE cardinality(akeys(s."ImmutableDictionary")) >= 1
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_Select_Count(bool async)
+ {
+ await AssertQuery(async, ss => ss.Set().Select(s => s.ImmutableDictionary.Count));
+ AssertSql(
+ """
+SELECT cardinality(akeys(s."ImmutableDictionary"))
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task Enumerable_KeyValuePair_Count(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.Dictionary.Count()));
+ AssertSql(
+ """
+SELECT cardinality(akeys(s."Dictionary"))
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_Where_IsEmpty(bool async)
+ {
+ await AssertQuery(async, ss => ss.Set().Where(s => !s.ImmutableDictionary.IsEmpty));
+ AssertSql(
+ """
+SELECT s."Id", s."Dictionary", s."ImmutableDictionary"
+FROM "SomeEntities" AS s
+WHERE cardinality(akeys(s."ImmutableDictionary")) <> 0
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task Dictionary_Where_Any(bool async)
+ {
+ await AssertQuery(async, ss => ss.Set().Where(s => s.Dictionary.Any()));
+ AssertSql(
+ """
+SELECT s."Id", s."Dictionary", s."ImmutableDictionary"
+FROM "SomeEntities" AS s
+WHERE cardinality(akeys(s."Dictionary")) <> 0
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_Where_Any(bool async)
+ {
+ await AssertQuery(async, ss => ss.Set().Where(s => s.ImmutableDictionary.Any()));
+ AssertSql(
+ """
+SELECT s."Id", s."Dictionary", s."ImmutableDictionary"
+FROM "SomeEntities" AS s
+WHERE cardinality(akeys(s."ImmutableDictionary")) <> 0
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task Dictionary_ToImmutableDictionary(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.Dictionary.ToImmutableDictionary()),
+ elementAsserter: Assert.Equal, assertOrder: true);
+ AssertSql(
+ """
+SELECT s."Dictionary"::hstore
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_ToDictionary(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.ImmutableDictionary.ToDictionary()),
+ elementAsserter: Assert.Equal, assertOrder: true);
+ AssertSql(
+ """
+SELECT s."ImmutableDictionary"::hstore
+FROM "SomeEntities" AS s
+""");
+
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_Concat_Dictionary(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.ImmutableDictionary.Concat(s.Dictionary)),
+ elementAsserter: AssertEqualsIgnoringOrder, assertOrder: true);
+ AssertSql(
+ """
+SELECT s."ImmutableDictionary" || s."Dictionary"
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task Dictionary_Concat_ImmutableDictionary(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.Dictionary.Concat(s.ImmutableDictionary)),
+ elementAsserter: AssertEqualsIgnoringOrder, assertOrder: true);
+ AssertSql(
+ """
+SELECT s."Dictionary" || s."ImmutableDictionary"
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task Dictionary_Keys_Concat_ImmutableDictionary_Keys(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.Dictionary.Keys.Concat(s.ImmutableDictionary.Keys)),
+ elementAsserter: Assert.Equal, assertOrder: true);
+ AssertSql(
+ """
+SELECT array_cat(akeys(s."Dictionary"), akeys(s."ImmutableDictionary"))
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_Values_Concat_Dictionary_Values(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.ImmutableDictionary.Values.Concat(s.Dictionary.Values)),
+ elementAsserter: Assert.Equal, assertOrder: true);
+ AssertSql(
+ """
+SELECT array_cat(avals(s."ImmutableDictionary"), avals(s."Dictionary"))
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task ImmutableDictionary_Except_Dictionary(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.ImmutableDictionary.Except(s.Dictionary)),
+ elementAsserter: AssertEqualsIgnoringOrder, assertOrder: true);
+ AssertSql(
+ """
+SELECT s."ImmutableDictionary" - s."Dictionary"
+FROM "SomeEntities" AS s
+""");
+ }
+
+ [Theory]
+ [MemberData(nameof(IsAsyncData))]
+ public async Task Dictionary_Except_ImmutableDictionary(bool async)
+ {
+ await AssertQuery(
+ async, ss => ss.Set().Select(s => s.Dictionary.Except(s.ImmutableDictionary)),
+ elementAsserter: AssertEqualsIgnoringOrder, assertOrder: true);
+ AssertSql(
+ """
+SELECT s."Dictionary" - s."ImmutableDictionary"
+FROM "SomeEntities" AS s
+""");
+ }
+
+ // ReSharper disable twice PossibleMultipleEnumeration
+ private static void AssertEqualsIgnoringOrder(IEnumerable left, IEnumerable right)
+ {
+ Assert.Empty(left.Except(right));
+ Assert.Empty(right.Except(left));
+ }
+
+ protected void AssertSql(params string[] expected)
+ => Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
+}