Repository navigation
Map byte[] to json/jsonb as a raw JSON document again - #3935
Aykuttonpc wants to merge 2 commits into
Conversation
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.
roji
left a comment
There was a problem hiding this comment.
The regression reported in #3698 is real, and the core fix is appropriately small. However, this change also silently breaks typed JSON aggregates that currently work; please preserve their collection semantics before merging.
Problem and expected approach
For a byte[] property configured with HasColumnType("jsonb"), assigning """{"Foo":"Bar"}"""u8.ToArray() stored the raw JSON document in 9.x. Starting with 10.x, the provider identifies the bytes as a primitive collection and stores [123, 34, 70, ...] instead. Reading existing object-shaped documents then throws Invalid token type: 'StartObject'. This is a provider storage-format regression, not a .NET runtime incompatibility. Raw bytes are a legitimate representation for applications forwarding JSON without deserializing it.
The expected fix is a narrow mapping change restoring raw-document semantics while retaining numeric-array semantics wherever collection intent is explicit. No new infrastructure or plugin changes are needed.
Assessment
The seven-line production change follows that approach for properties: it lets unconfigured byte[] fall back to NpgsqlJsonTypeMapping, while preserving an explicitly supplied element mapping. The new functional tests meaningfully check what PostgreSQL stores, reading externally written JSON, and updating it, rather than merely checking a self-consistent EF round-trip.
I confirmed that explicit PrimitiveCollection() mappings still round-trip correctly for both json and jsonb, and that default bytea and explicit smallint[] mappings remain unaffected.
Required change: preserve typed JSON aggregate results
As detailed in the inline comment, JsonAgg<byte> and JsonbAgg<byte> request a JSON mapping for byte[] without supplying an element mapping, so the new guard also changes their results. For input values 1, 2, the PR parent and released 10.0.0 return [1, 2]; this PR returns [91, 49, 44, 32, 50, 93], the UTF-8 bytes of the JSON text [1, 2]. The query succeeds but returns the wrong values for APIs whose return type is T[].
The PR description acknowledges this side effect, but restoring the broken 9.x aggregate behavior is not necessary to restore raw-document properties. Please make these aggregate mappings explicitly request collection semantics and add regression coverage for both aggregates.
Non-blocking limitation: SQL literals
The restored legacy mapping also mishandles SQL literals. Parameterized equality works, but a query such as context.Documents.Where(e => e.Payload == EF.Constant(payload)), where payload contains raw JSON bytes, produces a base64 JSON string literal and fails to match the same document. For example, {"Foo":"Qux"} becomes the SQL literal '"eyJGb28iOiJRdXgifQ=="'.
I verified that this also happens in 9.0.4, so I would not block this regression fix on that pre-existing limitation or require expanding this PR to fix it.
Release-note guidance
Please explain that numeric byte arrays already stored by 10.x will now be read as JSON text bytes. Users who want to retain numeric-array semantics must explicitly configure PrimitiveCollection(); users who want raw-document semantics for data written under the 10.x mapping need to convert that stored data.
Validation
I ran standalone EF applications against PostgreSQL 18 using released 9.0.4 and 10.0.0, plus the exact PR head and its unchanged parent. These reproduced the original storage-format change and materialization failure, confirmed the raw-document fix, and demonstrated the aggregate regression and pre-existing literal limitation. The 125 type-mapping tests and six relevant functional tests also passed.
Posted by copilot
| 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) |
There was a problem hiding this comment.
P2: Preserve typed JSON aggregate results. This condition also catches JsonAgg<byte> and JsonbAgg<byte>, whose translators call FindMapping(method.ReturnType, "json"/"jsonb") without supplying an element mapping. They therefore fall back to the opaque raw-document mapping even though these calls explicitly represent collections.
I reproduced this against PostgreSQL with both the PR parent and this head:
var values = await context.Items
.GroupBy(x => x.GroupId)
.Select(g => EF.Functions.JsonbAgg(
g.Select(x => x.ByteValue).OrderBy(x => x)))
.SingleAsync();For input bytes 1, 2, the parent and released 10.0.0 return [1, 2], but this PR returns [91, 49, 44, 32, 50, 93] (UTF-8 for [1, 2]). JsonAgg<byte> has the same problem. This silently returns the wrong values for the T[] API. Although 9.x also had this aggregate bug, restoring it is not necessary to fix raw JSON properties.
Please make the aggregate result mappings explicitly request collection semantics, preserving this property fix, and add functional regression tests for both aggregate methods.
Posted by copilot
json_agg/jsonb_agg return a JSON array, but their result mapping was looked up without an element mapping, so byte[] (from JsonAgg<byte> and JsonbAgg<byte>) 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.
Fixes #3698
Since 10.0, a byte[] property mapped to a json/jsonb column is picked up by NpgsqlTypeMappingSource.FindCollectionMapping as a JSON primitive collection of bytes. It is written as a JSON array of numbers, and rows holding real JSON documents (e.g. written by 9.x) fail to materialize with "Invalid token type: 'StartObject'".
This restores the 9.x behavior, using the same approach as the multidimensional array fix (#3663): unless an element mapping is explicitly configured (e.g. via PrimitiveCollection()), FindCollectionMapping returns null for byte[] on json/jsonb, so FindBaseMapping falls back to NpgsqlJsonTypeMapping, which sends the bytes as the raw UTF-8 document as supported by Npgsql. The default bytea mapping and the byte[] -> smallint[] array mapping are unaffected.
Tests
Notes