Skip to content

Map byte[] to json/jsonb as a raw JSON document again - #3935

Open
Aykuttonpc wants to merge 2 commits into
npgsql:mainfrom
Aykuttonpc:scout/efcore.pg-3698-byte-array-jsonb
Open

Aykuttonpc wants to merge 2 commits into
npgsql:mainfrom
Aykuttonpc:scout/efcore.pg-3698-byte-array-jsonb

Conversation

@Aykuttonpc

Copy link
Copy Markdown

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

  • NpgsqlTypeMappingSourceTest.Json_byte_array (json, jsonb): byte[] with a json/jsonb store type resolves to NpgsqlJsonTypeMapping with ClrType byte[] and no element mapping (fails before this change).
  • NpgsqlTypeMappingSourceTest.Json_byte_array_configured_as_primitive_collection: PrimitiveCollection() on byte[] with jsonb still produces a primitive collection with a smallint element mapping.
  • AdHocJsonQueryNpgsqlTest.Byte_array_mapped_to_json_column (json, jsonb): writes a document through EF and checks the stored value with ->>, reads a document inserted with raw SQL, and updates it (fails before this change).

Notes

  • Data written by 10.0 under this mapping is stored as a JSON array of byte values. With this change it is read back as the raw bytes of that array text. Users who depend on the 10.0 representation can keep it by configuring the property with PrimitiveCollection(); otherwise the stored data needs converting.
  • Only byte[] is restored. char[] (and ArraySegment), which Npgsql also writes to json/jsonb as raw text, went through the same change in 10.0; I left it out since the issue is about byte[], but the same check could cover it.
  • JsonAgg/JsonbAgg (which return byte[]) also go back to their 9.x behavior: the aggregated JSON array is read as its raw bytes.

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 roji left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

byte[] can't be used anymore for jsonb columns

2 participants