From 2fe1177b0b3f76f3a00a636eb43c379be88fcfc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aykut=20C=C4=B1nc=C4=B1k?= <130911953+Aykuttonpc@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:59:20 +0000 Subject: [PATCH 1/4] Map byte[] to json/jsonb as a raw JSON document again byte[] mapped to a json/jsonb column was picked up as a primitive collection of bytes, so it was written as a JSON array of numbers and failed to read existing JSON documents. Unless an element mapping is explicitly configured, fall back to NpgsqlJsonTypeMapping as before. --- .../Internal/NpgsqlTypeMappingSource.cs | 7 +++ .../Query/AdHocJsonQueryNpgsqlTest.cs | 53 +++++++++++++++++++ .../Storage/NpgsqlTypeMappingSourceTest.cs | 36 +++++++++++++ 3 files changed, 96 insertions(+) diff --git a/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs b/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs index 2d9bf1df8..88c9aecd6 100644 --- a/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs +++ b/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs @@ -745,6 +745,13 @@ public NpgsqlTypeMappingSource( // However, when scalar collections are nested inside a JSON document, they obviously have to be JSON-mapped. // In addition, at least in theory users may decide to map to JSON instead of array even outside JSON documents. case "jsonb" or "json" when modelClrType is not null: + // byte[] mapped to json/jsonb represents the raw UTF-8 JSON document (as supported by Npgsql), rather than a primitive + // collection of bytes; return null to let the calling code in FindBaseMapping return an NpgsqlJsonTypeMapping. + if (modelClrType == typeof(byte[]) && elementMapping is null) + { + return null; + } + var mapping = base.FindCollectionMapping( new RelationalTypeMappingInfo( modelClrType, (RelationalTypeMapping?)elementMapping, storeTypeName: storeType, storeTypeNameBase: storeType), diff --git a/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs index 735ce4789..264b0b5fc 100644 --- a/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + #pragma warning disable EF8001 // ToJson on owned entities is obsolete namespace Microsoft.EntityFrameworkCore.Query; @@ -76,6 +78,57 @@ LIMIT 2 } } + [ConditionalTheory] + [InlineData("jsonb")] + [InlineData("json")] + public virtual async Task Byte_array_mapped_to_json_column(string storeType) + { + var contextFactory = await InitializeNonSharedTest( + onModelCreating: mb => mb.Entity().Property(e => e.Json).HasColumnType(storeType), + seed: async context => + { + context.Entities.Add(new ByteArrayJsonEntity { Id = 1, Json = """{"Foo":"Bar"}"""u8.ToArray() }); + await context.SaveChangesAsync(); + + // Simulate a JSON document which wasn't written via EF + await context.Database.ExecuteSqlRawAsync("""INSERT INTO "Entities" ("Id", "Json") VALUES (2, '{{"Foo":"Baz"}}')"""); + }); + + using var context = contextFactory.CreateDbContext(); + + // The byte array must be written as the raw UTF-8 JSON document, and not as a JSON array of byte values + Assert.Equal( + "Bar", + await context.Database.SqlQueryRaw("""SELECT "Json" ->> 'Foo' AS "Value" FROM "Entities" WHERE "Id" = 1""") + .SingleAsync()); + + var entities = await context.Entities.OrderBy(e => e.Id).ToListAsync(); + + Assert.Collection( + entities, + e => Assert.Equal("Bar", JsonDocument.Parse(e.Json).RootElement.GetProperty("Foo").GetString()), + e => Assert.Equal("Baz", JsonDocument.Parse(e.Json).RootElement.GetProperty("Foo").GetString())); + + entities[1].Json = """{"Foo":"Qux"}"""u8.ToArray(); + await context.SaveChangesAsync(); + + Assert.Equal( + "Qux", + await context.Database.SqlQueryRaw("""SELECT "Json" ->> 'Foo' AS "Value" FROM "Entities" WHERE "Id" = 2""") + .SingleAsync()); + } + + protected class ByteArrayJsonDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Entities { get; set; } + } + + public class ByteArrayJsonEntity + { + public int Id { get; set; } + public byte[] Json { get; set; } + } + protected class TypesDbContext(DbContextOptions options) : DbContext(options) { public DbSet Entities { get; set; } diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs index b1ec3c98b..fe586871f 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingSourceTest.cs @@ -313,6 +313,36 @@ public void Json_primitive_collection() Assert.Same(typeof(int), elementMapping.ClrType); } + [Theory] + [InlineData("jsonb")] + [InlineData("json")] + public void Json_byte_array(string storeType) + { + // byte[] mapped to json/jsonb is the raw UTF-8 JSON document, not a primitive collection of bytes + var mapping = CreateTypeMappingSource().FindMapping(typeof(byte[]), storeType); + Assert.IsType(mapping); + Assert.Equal(storeType, mapping.StoreType); + Assert.Same(typeof(byte[]), mapping.ClrType); + Assert.Null(mapping.ElementTypeMapping); + } + + [Fact] + public void Json_byte_array_configured_as_primitive_collection() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Entity().PrimitiveCollection(e => e.Bytes).HasColumnType("jsonb"); + var model = modelBuilder.FinalizeModel(); + + var mapping = model.FindEntityType(typeof(ByteArrayEntity))!.FindProperty(nameof(ByteArrayEntity.Bytes))! + .GetRelationalTypeMapping(); + Assert.Equal("jsonb", mapping.StoreType); + + var elementMapping = (RelationalTypeMapping)mapping.ElementTypeMapping; + Assert.NotNull(elementMapping); + Assert.Equal("smallint", elementMapping.StoreType); + Assert.Same(typeof(byte), elementMapping.ClrType); + } + [Theory] [InlineData(typeof(JsonDocument[]))] [InlineData(typeof(List))] @@ -464,6 +494,12 @@ private class DummyType; private class UnknownType; + private class ByteArrayEntity + { + public int Id { get; set; } + public byte[] Bytes { get; set; } + } + protected IModel CreateEmptyModel() => CreateModelBuilder().Model.FinalizeModel(); From ee4a6b643c8d8f3bf243264be981c22e024c0fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aykut=20C=C4=B1nc=C4=B1k?= <130911953+Aykuttonpc@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:17:00 +0000 Subject: [PATCH 2/4] Preserve collection semantics for JsonAgg/JsonbAgg over bytes json_agg/jsonb_agg return a JSON array, but their result mapping was looked up without an element mapping, so byte[] (from JsonAgg and JsonbAgg) got the raw JSON document mapping and returned the UTF-8 bytes of the array text. Pass the element mapping explicitly to request a JSON collection mapping. --- .../NpgsqlMiscAggregateMethodTranslator.cs | 16 ++++++- .../Query/AdHocJsonQueryNpgsqlTest.cs | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMiscAggregateMethodTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMiscAggregateMethodTranslator.cs index 920455001..09d3b2436 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMiscAggregateMethodTranslator.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMiscAggregateMethodTranslator.cs @@ -1,4 +1,5 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal; using static Npgsql.EntityFrameworkCore.PostgreSQL.Utilities.Statics; namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.ExpressionTranslators.Internal; @@ -104,7 +105,7 @@ public class NpgsqlMiscAggregateMethodTranslator( nullable: true, argumentsPropagateNullability: FalseArrays[1], returnType: method.ReturnType, - _typeMappingSource.FindMapping(method.ReturnType, "json")); + FindJsonArrayMapping(method.ReturnType, "json")); case nameof(NpgsqlAggregateDbFunctionsExtensions.JsonbAgg): return _sqlExpressionFactory.AggregateFunction( @@ -114,7 +115,7 @@ public class NpgsqlMiscAggregateMethodTranslator( nullable: true, argumentsPropagateNullability: FalseArrays[1], returnType: method.ReturnType, - _typeMappingSource.FindMapping(method.ReturnType, "jsonb")); + FindJsonArrayMapping(method.ReturnType, "jsonb")); case nameof(NpgsqlAggregateDbFunctionsExtensions.Sum): return _sqlExpressionFactory.AggregateFunction( @@ -191,4 +192,15 @@ public class NpgsqlMiscAggregateMethodTranslator( return null; } + + // json_agg/jsonb_agg return a JSON array of the aggregated values, so we explicitly request a JSON collection mapping by passing + // the element mapping. Without it, byte[] (from JsonAgg/JsonbAgg) would get the opaque mapping used for byte[] + // properties mapped to json/jsonb, which reads the raw UTF-8 bytes of the JSON array instead of its elements. + // If there's no collection mapping for the element type, we fall back to the regular mapping lookup. + private RelationalTypeMapping? FindJsonArrayMapping(Type arrayType, string storeType) + => _typeMappingSource.FindMapping(arrayType.GetElementType()!) is { } elementMapping + && ((NpgsqlTypeMappingSource)_typeMappingSource).FindCollectionMapping( + storeType, arrayType, providerClrType: null, elementMapping) is { } collectionMapping + ? collectionMapping + : _typeMappingSource.FindMapping(arrayType, storeType); } diff --git a/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs index 264b0b5fc..757ea2cd3 100644 --- a/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs @@ -118,6 +118,40 @@ await context.Database.SqlQueryRaw("""SELECT "Json" ->> 'Foo' AS "Value" .SingleAsync()); } + [ConditionalTheory] + [InlineData("jsonb")] + [InlineData("json")] + public virtual async Task Json_aggregate_over_byte_values(string storeType) + { + var contextFactory = await InitializeNonSharedTest( + seed: async context => + { + context.Entities.AddRange( + new ByteAggregateEntity { Id = 1, GroupId = 1, Value = 2 }, + new ByteAggregateEntity { Id = 2, GroupId = 1, Value = 1 }); + await context.SaveChangesAsync(); + }); + + using var context = contextFactory.CreateDbContext(); + + // json_agg/jsonb_agg return a JSON array, so the byte[] result must contain the aggregated values, and not the raw UTF-8 bytes + // of the JSON array text (as with a byte[] property mapped to json/jsonb above) + var groups = context.Entities.GroupBy(e => e.GroupId); + var values = storeType == "jsonb" + ? await groups.Select(g => EF.Functions.JsonbAgg(g.Select(e => e.Value).OrderBy(v => v))).SingleAsync() + : await groups.Select(g => EF.Functions.JsonAgg(g.Select(e => e.Value).OrderBy(v => v))).SingleAsync(); + + Assert.Equal(new byte[] { 1, 2 }, values); + + AssertSql( + $""" +SELECT {storeType}_agg(e."Value" ORDER BY e."Value" NULLS FIRST) +FROM "Entities" AS e +GROUP BY e."GroupId" +LIMIT 2 +"""); + } + protected class ByteArrayJsonDbContext(DbContextOptions options) : DbContext(options) { public DbSet Entities { get; set; } @@ -129,6 +163,18 @@ public class ByteArrayJsonEntity public byte[] Json { get; set; } } + protected class ByteAggregateDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Entities { get; set; } + } + + public class ByteAggregateEntity + { + public int Id { get; set; } + public int GroupId { get; set; } + public byte Value { get; set; } + } + protected class TypesDbContext(DbContextOptions options) : DbContext(options) { public DbSet Entities { get; set; } From a8e7c1c0315a728f3dd94045ffb2c3bf52114e39 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Mon, 19 Oct 2026 11:19:19 +0200 Subject: [PATCH 3/4] Generate raw JSON literals for byte arrays Decode byte[] JSON values as UTF-8 when generating SQL literals instead of serializing them as base64 strings. Cover json/jsonb literals, escaping, and parameter versus constant query results. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0dab5d4d-9313-4544-9ba3-e349c9925c89 --- .../Internal/Mapping/NpgsqlJsonTypeMapping.cs | 2 ++ .../Query/AdHocJsonQueryNpgsqlTest.cs | 35 +++++++++++++++++++ .../Storage/NpgsqlTypeMappingTest.cs | 16 +++++++++ 3 files changed, 53 insertions(+) diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlJsonTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlJsonTypeMapping.cs index 9e32d5575..8a43f13bf 100644 --- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlJsonTypeMapping.cs +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlJsonTypeMapping.cs @@ -108,6 +108,8 @@ protected override string GenerateNonNullSqlLiteral(object value) } case string s: return $"'{EscapeSqlLiteral(s)}'"; + case byte[] bytes: + return $"'{EscapeSqlLiteral(Encoding.UTF8.GetString(bytes))}'"; default: // User POCO return $"'{EscapeSqlLiteral(JsonSerializer.Serialize(value))}'"; } diff --git a/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs index 757ea2cd3..c901dd5ed 100644 --- a/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs @@ -118,6 +118,41 @@ await context.Database.SqlQueryRaw("""SELECT "Json" ->> 'Foo' AS "Value" .SingleAsync()); } + [ConditionalTheory, MemberData(nameof(IsAsyncData))] + public virtual async Task Byte_array_mapped_to_json_column_in_predicate(bool async) + { + var json = """{"Foo":"O'Reilly"}"""u8.ToArray(); + var contextFactory = await InitializeNonSharedTest( + onModelCreating: mb => mb.Entity().Property(e => e.Json).HasColumnType("jsonb"), + seed: async context => + { + context.Entities.AddRange( + new ByteArrayJsonEntity { Id = 1, Json = json }, + new ByteArrayJsonEntity { Id = 2, Json = """{"Foo":"Bar"}"""u8.ToArray() }); + await context.SaveChangesAsync(); + }); + + using var context = contextFactory.CreateDbContext(); + + var parameterQuery = context.Entities.Where(e => e.Json == json); + var parameterResult = async ? await parameterQuery.SingleAsync() : parameterQuery.Single(); + Assert.Equal(1, parameterResult.Id); + + ((TestSqlLoggerFactory)ListLoggerFactory).Clear(); + + var constantQuery = context.Entities.Where(e => e.Json == EF.Constant(json)); + var constantResult = async ? await constantQuery.SingleAsync() : constantQuery.Single(); + Assert.Equal(1, constantResult.Id); + + AssertSql( + """ +SELECT e."Id", e."Json" +FROM "Entities" AS e +WHERE e."Json" = '{"Foo":"O''Reilly"}' +LIMIT 2 +"""); + } + [ConditionalTheory] [InlineData("jsonb")] [InlineData("json")] diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs index bfefb835a..0eaf4d0ff 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs @@ -3,6 +3,7 @@ using System.Net; using System.Net.NetworkInformation; using System.Numerics; +using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore.Design.Internal; using Microsoft.EntityFrameworkCore.Storage.Json; @@ -871,6 +872,21 @@ public void GenerateSqlLiteral_returns_jsonb_string_literal() public void GenerateSqlLiteral_returns_json_string_literal() => Assert.Equal("""'{"a":1}'""", GetMapping("json").GenerateSqlLiteral("""{"a":1}""")); + [Theory] + [InlineData("json")] + [InlineData("jsonb")] + public void GenerateSqlLiteral_returns_json_byte_array_literal(string storeType) + { + var mapping = GetMapping(typeof(byte[]), storeType); + + Assert.Equal("""'{"a":1}'""", mapping.GenerateSqlLiteral("""{"a":1}"""u8.ToArray())); + Assert.Equal( + "'{\"Name\":\"O''Reilly \u00e9\"}'", + mapping.GenerateSqlLiteral(Encoding.UTF8.GetBytes("{\"Name\":\"O'Reilly \u00e9\"}"))); + Assert.Equal("'[1,2]'", mapping.GenerateSqlLiteral("[1,2]"u8.ToArray())); + Assert.Equal("'null'", mapping.GenerateSqlLiteral("null"u8.ToArray())); + } + [Fact] public void GenerateSqlLiteral_returns_jsonb_object_literal() { From 960db544640c88bc71ca1f7968f34f11d2ccfe4f Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Mon, 19 Oct 2026 11:25:26 +0200 Subject: [PATCH 4/4] Move byte JSON coverage into existing query suites Use the shared JSON translation fixture for raw byte document and predicate coverage. Exercise byte JSON aggregates with the existing Northwind fixture instead of ad hoc contexts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0dab5d4d-9313-4544-9ba3-e349c9925c89 --- .../Query/AdHocJsonQueryNpgsqlTest.cs | 134 ------------------ .../NorthwindFunctionsQueryNpgsqlTest.cs | 24 ++++ .../JsonTranslationsNpgsqlTest.cs | 104 ++++++++++++++ 3 files changed, 128 insertions(+), 134 deletions(-) diff --git a/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs index c901dd5ed..735ce4789 100644 --- a/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs @@ -1,5 +1,3 @@ -using System.Text.Json; - #pragma warning disable EF8001 // ToJson on owned entities is obsolete namespace Microsoft.EntityFrameworkCore.Query; @@ -78,138 +76,6 @@ LIMIT 2 } } - [ConditionalTheory] - [InlineData("jsonb")] - [InlineData("json")] - public virtual async Task Byte_array_mapped_to_json_column(string storeType) - { - var contextFactory = await InitializeNonSharedTest( - onModelCreating: mb => mb.Entity().Property(e => e.Json).HasColumnType(storeType), - seed: async context => - { - context.Entities.Add(new ByteArrayJsonEntity { Id = 1, Json = """{"Foo":"Bar"}"""u8.ToArray() }); - await context.SaveChangesAsync(); - - // Simulate a JSON document which wasn't written via EF - await context.Database.ExecuteSqlRawAsync("""INSERT INTO "Entities" ("Id", "Json") VALUES (2, '{{"Foo":"Baz"}}')"""); - }); - - using var context = contextFactory.CreateDbContext(); - - // The byte array must be written as the raw UTF-8 JSON document, and not as a JSON array of byte values - Assert.Equal( - "Bar", - await context.Database.SqlQueryRaw("""SELECT "Json" ->> 'Foo' AS "Value" FROM "Entities" WHERE "Id" = 1""") - .SingleAsync()); - - var entities = await context.Entities.OrderBy(e => e.Id).ToListAsync(); - - Assert.Collection( - entities, - e => Assert.Equal("Bar", JsonDocument.Parse(e.Json).RootElement.GetProperty("Foo").GetString()), - e => Assert.Equal("Baz", JsonDocument.Parse(e.Json).RootElement.GetProperty("Foo").GetString())); - - entities[1].Json = """{"Foo":"Qux"}"""u8.ToArray(); - await context.SaveChangesAsync(); - - Assert.Equal( - "Qux", - await context.Database.SqlQueryRaw("""SELECT "Json" ->> 'Foo' AS "Value" FROM "Entities" WHERE "Id" = 2""") - .SingleAsync()); - } - - [ConditionalTheory, MemberData(nameof(IsAsyncData))] - public virtual async Task Byte_array_mapped_to_json_column_in_predicate(bool async) - { - var json = """{"Foo":"O'Reilly"}"""u8.ToArray(); - var contextFactory = await InitializeNonSharedTest( - onModelCreating: mb => mb.Entity().Property(e => e.Json).HasColumnType("jsonb"), - seed: async context => - { - context.Entities.AddRange( - new ByteArrayJsonEntity { Id = 1, Json = json }, - new ByteArrayJsonEntity { Id = 2, Json = """{"Foo":"Bar"}"""u8.ToArray() }); - await context.SaveChangesAsync(); - }); - - using var context = contextFactory.CreateDbContext(); - - var parameterQuery = context.Entities.Where(e => e.Json == json); - var parameterResult = async ? await parameterQuery.SingleAsync() : parameterQuery.Single(); - Assert.Equal(1, parameterResult.Id); - - ((TestSqlLoggerFactory)ListLoggerFactory).Clear(); - - var constantQuery = context.Entities.Where(e => e.Json == EF.Constant(json)); - var constantResult = async ? await constantQuery.SingleAsync() : constantQuery.Single(); - Assert.Equal(1, constantResult.Id); - - AssertSql( - """ -SELECT e."Id", e."Json" -FROM "Entities" AS e -WHERE e."Json" = '{"Foo":"O''Reilly"}' -LIMIT 2 -"""); - } - - [ConditionalTheory] - [InlineData("jsonb")] - [InlineData("json")] - public virtual async Task Json_aggregate_over_byte_values(string storeType) - { - var contextFactory = await InitializeNonSharedTest( - seed: async context => - { - context.Entities.AddRange( - new ByteAggregateEntity { Id = 1, GroupId = 1, Value = 2 }, - new ByteAggregateEntity { Id = 2, GroupId = 1, Value = 1 }); - await context.SaveChangesAsync(); - }); - - using var context = contextFactory.CreateDbContext(); - - // json_agg/jsonb_agg return a JSON array, so the byte[] result must contain the aggregated values, and not the raw UTF-8 bytes - // of the JSON array text (as with a byte[] property mapped to json/jsonb above) - var groups = context.Entities.GroupBy(e => e.GroupId); - var values = storeType == "jsonb" - ? await groups.Select(g => EF.Functions.JsonbAgg(g.Select(e => e.Value).OrderBy(v => v))).SingleAsync() - : await groups.Select(g => EF.Functions.JsonAgg(g.Select(e => e.Value).OrderBy(v => v))).SingleAsync(); - - Assert.Equal(new byte[] { 1, 2 }, values); - - AssertSql( - $""" -SELECT {storeType}_agg(e."Value" ORDER BY e."Value" NULLS FIRST) -FROM "Entities" AS e -GROUP BY e."GroupId" -LIMIT 2 -"""); - } - - protected class ByteArrayJsonDbContext(DbContextOptions options) : DbContext(options) - { - public DbSet Entities { get; set; } - } - - public class ByteArrayJsonEntity - { - public int Id { get; set; } - public byte[] Json { get; set; } - } - - protected class ByteAggregateDbContext(DbContextOptions options) : DbContext(options) - { - public DbSet Entities { get; set; } - } - - public class ByteAggregateEntity - { - public int Id { get; set; } - public int GroupId { get; set; } - public byte Value { get; set; } - } - protected class TypesDbContext(DbContextOptions options) : DbContext(options) { public DbSet Entities { get; set; } diff --git a/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs index fe360fda2..d308bd07c 100644 --- a/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs @@ -701,6 +701,30 @@ GROUP BY c."City" """); } + [ConditionalTheory] + [InlineData("jsonb")] + [InlineData("json")] + public virtual async Task GroupBy_Json_aggregate_over_byte_values(string storeType) + { + await using var context = CreateContext(); + + var groups = context.Set().Where(e => e.EmployeeID <= 2).GroupBy(e => e.Country); + var values = storeType == "jsonb" + ? await groups.Select(g => EF.Functions.JsonbAgg(g.Select(e => (byte)e.EmployeeID).OrderBy(v => v))).SingleAsync() + : await groups.Select(g => EF.Functions.JsonAgg(g.Select(e => (byte)e.EmployeeID).OrderBy(v => v))).SingleAsync(); + + Assert.Equal(new byte[] { 1, 2 }, values); + + AssertSql( + $""" +SELECT {storeType}_agg(e."EmployeeID"::smallint ORDER BY e."EmployeeID"::smallint NULLS FIRST) +FROM "Employees" AS e +WHERE e."EmployeeID" <= 2 +GROUP BY e."Country" +LIMIT 2 +"""); + } + #endregion Aggregate functions #region JsonObjectAgg diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/JsonTranslationsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/JsonTranslationsNpgsqlTest.cs index edc1ef570..bbb2fb7e5 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/JsonTranslationsNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/JsonTranslationsNpgsqlTest.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + namespace Microsoft.EntityFrameworkCore.Query.Translations; public class JsonTranslationsNpgsqlTest : JsonTranslationsRelationalTestBase @@ -48,6 +50,100 @@ WHERE JSON_EXISTS(j."JsonOwnedType", '$.OptionalInt') """); } + [ConditionalTheory] + [InlineData("jsonb")] + [InlineData("json")] + public virtual async Task Byte_array_mapped_to_json_column(string storeType) + { + await using var context = CreateContext(); + await context.Database.CreateExecutionStrategy().ExecuteAsync( + async () => + { + await using var transaction = await context.Database.BeginTransactionAsync(); + + context.Add( + new ByteArrayJsonEntity + { + Id = 1, + Json = """{"Foo":"Bar"}"""u8.ToArray(), + Jsonb = """{"Foo":"Bar"}"""u8.ToArray() + }); + await context.SaveChangesAsync(); + + // Simulate a JSON document which wasn't written via EF. + await context.Database.ExecuteSqlRawAsync( + """ +INSERT INTO "ByteArrayJsonEntities" ("Id", "Json", "Jsonb") VALUES (2, '{{"Foo":"Baz"}}', '{{"Foo":"Baz"}}') +"""); + + var column = storeType == "jsonb" ? nameof(ByteArrayJsonEntity.Jsonb) : nameof(ByteArrayJsonEntity.Json); + var sql = $$"""SELECT "{{column}}" ->> 'Foo' AS "Value" FROM "ByteArrayJsonEntities" WHERE "Id" = {0}"""; + + Assert.Equal("Bar", await context.Database.SqlQueryRaw(sql, 1).SingleAsync()); + + var entities = await context.Set().AsNoTracking().OrderBy(e => e.Id).ToListAsync(); + Assert.Equal(2, entities.Count); + using var efDocument = JsonDocument.Parse(storeType == "jsonb" ? entities[0].Jsonb : entities[0].Json); + using var externalDocument = JsonDocument.Parse(storeType == "jsonb" ? entities[1].Jsonb : entities[1].Json); + Assert.Equal("Bar", efDocument.RootElement.GetProperty("Foo").GetString()); + Assert.Equal("Baz", externalDocument.RootElement.GetProperty("Foo").GetString()); + + context.Attach(entities[1]); + entities[1].Json = """{"Foo":"Qux"}"""u8.ToArray(); + entities[1].Jsonb = """{"Foo":"Qux"}"""u8.ToArray(); + await context.SaveChangesAsync(); + + Assert.Equal("Qux", await context.Database.SqlQueryRaw(sql, 2).SingleAsync()); + }); + } + + [ConditionalTheory, MemberData(nameof(IsAsyncData))] + public virtual async Task Byte_array_mapped_to_json_column_in_predicate(bool async) + { + await using var context = CreateContext(); + await context.Database.CreateExecutionStrategy().ExecuteAsync( + async () => + { + await using var transaction = await context.Database.BeginTransactionAsync(); + + var json = """{"Foo":"O'Reilly"}"""u8.ToArray(); + context.AddRange( + new ByteArrayJsonEntity { Id = 1, Json = json, Jsonb = json }, + new ByteArrayJsonEntity + { + Id = 2, + Json = """{"Foo":"Bar"}"""u8.ToArray(), + Jsonb = """{"Foo":"Bar"}"""u8.ToArray() + }); + await context.SaveChangesAsync(); + + var parameterQuery = context.Set().Where(e => e.Jsonb == json); + var parameterResult = async ? await parameterQuery.SingleAsync() : parameterQuery.Single(); + Assert.Equal(1, parameterResult.Id); + + Fixture.TestSqlLoggerFactory.Clear(); + + var constantQuery = context.Set().Where(e => e.Jsonb == EF.Constant(json)); + var constantResult = async ? await constantQuery.SingleAsync() : constantQuery.Single(); + Assert.Equal(1, constantResult.Id); + + AssertSql( + """ +SELECT b."Id", b."Json", b."Jsonb" +FROM "ByteArrayJsonEntities" AS b +WHERE b."Jsonb" = '{"Foo":"O''Reilly"}' +LIMIT 2 +"""); + }); + } + + public class ByteArrayJsonEntity + { + public int Id { get; set; } + public byte[] Json { get; set; } = null!; + public byte[] Jsonb { get; set; } = null!; + } + public class JsonTranslationsQueryNpgsqlFixture : JsonTranslationsQueryFixtureBase, ITestSqlLoggerFactory { protected override ITestStoreFactory TestStoreFactory @@ -58,6 +154,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con base.OnModelCreating(modelBuilder, context); modelBuilder.Entity().Property(e => e.JsonString).HasColumnType("jsonb"); + + modelBuilder.Entity(b => + { + b.ToTable("ByteArrayJsonEntities"); + b.Property(e => e.Id).ValueGeneratedNever(); + b.Property(e => e.Json).HasColumnType("json"); + b.Property(e => e.Jsonb).HasColumnType("jsonb"); + }); } protected override string RemoveJsonProperty(string column, string property)