feat: transcode BigQuery-shaped Arrow columns (string ints, maps from lists, packed bytes) - #5
Draft
Baoyuan-Xing wants to merge 3 commits into
Draft
Conversation
proto3's canonical JSON encoding renders 64-bit integers as strings, so a system that decodes proto into a structured view surfaces them as string columns. BigQuery is the common case: a decoded `uint64` arrives as STRING and today cannot reach an integer proto field at all -- there is no `Utf8 -> int64` path, with or without coercion -- so every such column needs a CAST in the source query, at every level of nesting. Add the coercion for all ten integer kinds and both string widths, carried as `ScalarKind::Utf8AsInt(IntTarget)` rather than 20 flat variants. Semantics follow the existing coercions rather than inventing new ones: - unsigned targets parse `u64`, falling back to `i64` reinterpreted as two's complement, matching the signed<->unsigned crossover already present for producers with only a signed integer type; - narrowing to 32 bits is range-checked and fails the batch, matching `Int64 -> int32` instead of silently truncating. Parsing is strict: no whitespace trimming and no empty-string-as-zero. Both would quietly turn malformed input into a plausible value; a caller who wants "absent" should map it to NULL in the source, which is unambiguous. The `nested_string_ints` fixture covers the shape that actually occurs: repeated messages inside repeated messages, with string-encoded integers at every level, rather than only flat top-level columns.
A proto map field required an Arrow `Map`. Engines with no MAP type can
only ever express one as `ARRAY<STRUCT<key, value>>`, which arrives as
`List<Struct<..>>` -- so proto map fields were unreachable from BigQuery
entirely, failing at transcoder build with a shape mismatch.
Accept `List`/`LargeList` of two-field entry structs alongside `Map`, in
both `resolve_map` and `encode_map`. This is sound rather than a
convenience: proto3 encodes `map<K,V>` as `repeated MapEntry{key=1,
value=2}`, so the two Arrow shapes describe the same wire format. A test
asserts the outputs are byte-identical rather than merely both decoding.
A null entry in the list form is skipped: it has no key, and proto map
keys cannot be absent, so encoding it would silently add a ""-keyed
entry.
Baoyuan-Xing
force-pushed
the
bigquery-column-shapes
branch
from
August 7, 2026 13:55
8a32855 to
5727426
Compare
An envelope that carries a serialized payload in a `bytes` field is a
common storage shape: a timestamp, a tombstone flag, and the value as
opaque bytes. The envelope proto deliberately does not reference the
payload type, so there is no way to express "serialize this struct
column as message M and put the result here" -- the field is `bytes`,
and a Struct column simply does not bind to it.
apb already performs exactly this double serialization for
`google.protobuf.Any` via `any_pack`, but it is rejected anywhere else:
return Err(MappingError::AnyPackOnNonAnyField { .. });
Generalize it. `InferOptions::pack` (and an `(apb).pack` annotation) name
a payload message for a `bytes` field, keyed by fully qualified field
name. The caller-side option means this works for envelopes whose proto
cannot be modified, which is the usual case.
No new encoder is needed, and that is the point: a length-delimited
embedded message and a `bytes` field are identical on the wire -- tag,
length, payload -- so the existing message encoder already emits the
right bytes. Only the resolution rule differs, taking the payload's
shape from the pack target rather than from the field's own kind. A test
asserts the output is byte-identical to serializing the payload
separately and assigning the result, rather than merely that it decodes.
Declaring pack on a non-`bytes` field is an error that points at
any_pack for the Any case. Repeated and map-valued bytes fields are not
covered; they fall through to the normal path unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Three gaps that together make it impossible to transcode a realistic message from BigQuery without first rewriting the data in SQL — or, in two cases, at all.
CASTevery column, at every nesting levelUtf8 -> integercoercionmap<K,V>fieldList<Struct<key,value>>bytesfieldpacka struct column into itEach is a resolution-rule change. No new encoding machinery: every one of them reuses an encoder apb already has.
Why these three
String-encoded integers. proto3's canonical JSON encoding renders 64-bit integers as strings, so any system that decodes proto into a structured view surfaces them that way — at every level of nesting, not just on flat top-level columns.
Maps. Engines with no MAP type can only express
map<K,V>asARRAY<STRUCT<key,value>>, which arrives asList<Struct<..>>. apb requiredDataType::Map, so proto map fields were simply unreachable from those sources.Packed bytes. An envelope that carries a serialized payload in a
bytesfield is a common storage shape — a timestamp, a tombstone flag, and the value as opaque bytes. The envelope proto deliberately does not reference the payload type, so there was no way to express "serialize this struct column as message M and put the result here".The first has a workaround (CAST), but for nested and repeated fields it means rebuilding arrays element by element with
UNNEST ... WITH OFFSET ... ORDER BY; on a real workload that reshaping cost far more than the encode it existed to serve. The other two have no workaround short of declaring a mirror message per payload type.How
Utf8 -> integerfor all ten integer kinds and both string widths, asScalarKind::Utf8AsInt(IntTarget)rather than 20 flat variants. Semantics follow the existing coercions:u64, falling back toi64reinterpreted as two's complement, matching the signed↔unsigned crossover already present for producers with only a signed integer type;Int64 -> int32rather than truncating.Parsing is strict — no whitespace trimming, no empty-string-as-zero. Both would quietly turn malformed input into a plausible value; a caller who wants "absent" should map it to NULL in the source.
Maps from lists.
resolve_mapandencode_mapacceptList/LargeListof two-field entry structs alongsideMap. Sound rather than convenient: proto3 encodesmap<K,V>asrepeated MapEntry{key=1,value=2}, so both Arrow shapes describe one wire format. Null entries are skipped, since a proto map key cannot be absent.pack. apb already performs exactly this double serialization forgoogle.protobuf.Anyviaany_pack, but it was fenced off everywhere else:Generalized to
InferOptions::pack/(apb).pack/--pack FIELD=MESSAGE, keyed by fully qualified field name. The caller-side option is the important one: envelope protos usually cannot be modified.No new encoder, and that is the point — a length-delimited embedded message and a
bytesfield are identical on the wire (tag | length | payload), so the existing message encoder already emits the right bytes. Only the payload's shape comes from a different place.Two claims proved by construction
The wire-equivalence arguments above are load-bearing, so both are tests rather than comments:
list_backed_map_is_wire_identical_to_map_array— the same data as aMapArrayand as a list of entry structs produce byte-identical output.packed_bytes_is_byte_identical_to_separate_serialization— packing equals serializing the payload separately and assigning the result, byte for byte.Testing
16 new tests.
cargo test --workspaceis green at each commit individually, so bisect stays clean.nested_string_intsfixture covering repeated-inside-repeated rather than only flat columnsbytesfield rejected (pointing atany_pack), unknown target rejected, and abytesfield without a declaration still refuses a Struct — no implicit packingValidation beyond the suite
Ran against a real BigQuery table: an envelope message whose
bytesfield is packed with a domain containing amap<string, Message>of repeated sub-messages several levels deep, with 64-bit integers arriving as STRING throughout. Output parsed straight off the wire — envelope fields intact, and the packed bytes decoding standalone under the unmodified domain proto. Every leaf cross-checked against the source row, includinguint64/int64from STRING columns and agoogle.protobuf.Timestampmatching to the millisecond.Scope and compatibility
--coerceor an annotation,Mapis still accepted exactly as before, and abytesfield without a pack declaration behaves as it always did.IntTarget, twoScalarKindvariants,InferOptions::pack,(apb).pack(field 4 onApbFieldOptions), and twoMappingErrorvariants.bytesfields.any_packsupports those positions;packcurrently falls through to the normal path. Easy to add if you want the symmetry — say the word.Draft: opening for direction before polish. Happy to split into three PRs if you would rather take them separately.