Json decoder factory - #10670
Conversation
63e65ba to
d38a0dd
Compare
|
Thanks @hareshkh -- this looks pretty good, though it is still marked as draft I filed a ticket to track this work |
|
Thanks @alamb! Can I please have a review now? |
# Which issue does this PR close? N/A -- documentation only. # Rationale for this change The ability to customize JSON encoding via [`EncoderFactory`] was added in #7015, but neither the crate-level docs nor the writer module docs mention it, so users only find the hook by stumbling on `WriterBuilder::with_encoder_factory`. [`EncoderFactory`]: https://docs.rs/arrow-json/latest/arrow_json/trait.EncoderFactory.html # What changes are included in this PR? Documentation only, no code changes: - Crate-level docs: list a custom `EncoderFactory` as an alternative for binary data encoding - Misc other wording fixeers It would be nice to add a matching pointer for customizing *decoding* as part of #10670 # Are these changes tested? Covered by existing doc tests and CI rustdoc link checking. # Are there any user-facing changes? Documentation only.
…10741) # Which issue does this PR close? N/A -- documentation only. # Rationale for this change The ability to customize JSON encoding via [`EncoderFactory`] was added in apache#7015, but neither the crate-level docs nor the writer module docs mention it, so users only find the hook by stumbling on `WriterBuilder::with_encoder_factory`. [`EncoderFactory`]: https://docs.rs/arrow-json/latest/arrow_json/trait.EncoderFactory.html # What changes are included in this PR? Documentation only, no code changes: - Crate-level docs: list a custom `EncoderFactory` as an alternative for binary data encoding - Misc other wording fixeers It would be nice to add a matching pointer for customizing *decoding* as part of apache#10670 # Are these changes tested? Covered by existing doc tests and CI rustdoc link checking. # Are there any user-facing changes? Documentation only.
| fn make_custom_decoder( | ||
| &self, | ||
| _ctx: &DecoderContext, | ||
| _data_type: &DataType, |
There was a problem hiding this comment.
Rather than pass in the fields of a DataType, how about just pass in the &Field reference directly:
That would also allow the decoder to key off Metadata (where the extension type information is stored)
@@ -861,17 +864,24 @@ pub trait ArrayDecoder: Send {
///
/// [`EncoderFactory`]: crate::EncoderFactory
pub trait DecoderFactory: std::fmt::Debug + Send + Sync {
- /// Make a decoder for `data_type`, or `Ok(None)` to use the reader's default.
+ /// Make a decoder for `field`, or `Ok(None)` to use the reader's default.
+ ///
+ /// Receives the [`FieldRef`] rather than just its [`DataType`] so decoder
+ /// selection can consider the field's metadata, e.g. to identify [extension
+ /// types]. The root of a [`ReaderBuilder::new`] schema is presented as a
+ /// synthesized nameless `Struct` field.
///
/// Use [`DecoderContext::make_decoder`] on `ctx` to build child decoders. Calling
- /// it for the `data_type` this was invoked with recurses back here and loops.
+ /// it with the field this was invoked with recurses back here and loops.
///
- /// `is_nullable` folds in ancestor nullability, so it may be `true` even where the
- /// corresponding field is not.
+ /// `is_nullable` folds in ancestor nullability, so it may be `true` even when
+ /// `field.is_nullable()` is not.
+ ///
+ /// [extension types]: https://arrow.apache.org/docs/format/Columnar.html#extension-types
fn make_custom_decoder(
&self,
_ctx: &DecoderContext,
- _data_type: &DataType,
+ _field: &FieldRef,
_is_nullable: bool,
) -> Result<Option<Box<dyn ArrayDecoder>>, ArrowError> {
Ok(None)There was a problem hiding this comment.
Done, however is_nullable can be different from field.is_nullable so keeping that around. But this will still unlock metadata based dispatch which can be useful.
| fn make_decoder( | ||
| /// The standard way to create child decoders from within a decoder, and how a | ||
| /// [`DecoderFactory`] delegates to the decoder the reader would otherwise use. | ||
| /// The factory is consulted first, so calling this for the same data type the |
There was a problem hiding this comment.
this seems like a non trivial foot gun and would be nice to have a way to get the standard decoder from within the factor (e.g. to delegate to the default decoders)
Could we add some method like make_default_decoder that bypasses the Decoder factor?
There was a problem hiding this comment.
Added make_builtin_decoder, please let me know if naming looks off
| /// | ||
| /// `is_nullable` folds in ancestor nullability, so it may be `true` even where the | ||
| /// corresponding field is not. | ||
| fn make_custom_decoder( |
There was a problem hiding this comment.
It would be nice to make this name symmetric with the writer side -
arrow-rs/arrow-json/src/writer/encoder.rs
Line 251 in 4cc296b
So something like
fn make_default_decoder(|
I think this is pretty close |
…10741) # Which issue does this PR close? N/A -- documentation only. # Rationale for this change The ability to customize JSON encoding via [`EncoderFactory`] was added in apache#7015, but neither the crate-level docs nor the writer module docs mention it, so users only find the hook by stumbling on `WriterBuilder::with_encoder_factory`. [`EncoderFactory`]: https://docs.rs/arrow-json/latest/arrow_json/trait.EncoderFactory.html # What changes are included in this PR? Documentation only, no code changes: - Crate-level docs: list a custom `EncoderFactory` as an alternative for binary data encoding - Misc other wording fixeers It would be nice to add a matching pointer for customizing *decoding* as part of apache#10670 # Are these changes tested? Covered by existing doc tests and CI rustdoc link checking. # Are there any user-facing changes? Documentation only.
Preparatory work for allowing custom decoders in the arrow-json reader. A custom `ArrayDecoder` necessarily reads the tape, so `Tape` and `TapeElement` have to be public for such a hook to be usable. Rather than making `mod tape` public, this re-exports just the two types that a decoder needs. `TapeDecoder` stays private, so downstream code can read a `Tape` it is handed but cannot construct one, keeping the tape's production an implementation detail. `TapeElement` is marked `#[non_exhaustive]`. Its representation is an implementation detail — 64-bit values are split across two consecutive elements, and the type's own docs note that offsets may become a custom `u56` type — so downstream matches need a wildcard arm for that to remain a non-breaking change. Also documents two aspects of the tape that become public contract: string data is copied into the tape with escapes resolved, and numbers appear either as `Number` (parsing JSON text) or as the native `I32`/`I64`/`F32`/`F64` variants (serializing Rust values), so decoders must handle both.
The JSON writer has supported overriding how a type is encoded since apache#7015 via `EncoderFactory`. The reader has had no equivalent, so anything the built-in decoders don't do — a different binary encoding, an extension type — requires forking the crate. This adds the reader-side counterpart: * `ArrayDecoder` is now public, so callers can implement a decoder. * `DecoderFactory` is consulted for every data type before the reader's own dispatch, and returns `Ok(None)` to accept the default. * `DecoderContext::make_decoder` is now public, so a factory can delegate to the decoder the reader would otherwise have used. Without this, overriding anything nested would mean reimplementing all of its children. * `ReaderBuilder::with_decoder_factory` registers a factory. Prior art: apache#9021 and apache#9272, both of which went stale.
6b326e5 to
0c5989d
Compare
| struct CheckedDecoder { | ||
| inner: Box<dyn ArrayDecoder>, | ||
| data_type: DataType, | ||
| } |
There was a problem hiding this comment.
@alamb: This is net new from the previous pass and adds validation to custom decoders - what are your thoughts on this?
There was a problem hiding this comment.
I think it is a good idea to check that the array type returned is what was declared
|
Thanks for the review @alamb! This should be ready for another look. |
alamb
left a comment
There was a problem hiding this comment.
Thanks @hareshkh
I went over this again and I think the code parts look good to me.
I have some suggestions on the examples and the documentation that I think are worth considering, but we could also handle them after merging
Note I will be away for the next week so I may be slow to respond
| /// Decodes `Binary` from a JSON array of integers rather than the default hex string. | ||
| /// | ||
| /// ``` | ||
| /// use std::sync::Arc; |
There was a problem hiding this comment.
I recommend we prefix the use statements with # so that they are hidden in the documentation rendering
There was a problem hiding this comment.
E.g instead of
/// use arrow_array::{Array, ArrayRef, BinaryArray};
like
/// # use arrow_array::{Array, ArrayRef, BinaryArray};
| /// | ||
| /// # Examples | ||
| /// | ||
| /// Decodes `Binary` from a JSON array of integers rather than the default hex string. |
There was a problem hiding this comment.
I think this example does a bunch of other stuff too
To keep the example simpler, I reccomend splitting it up -- one example that shows how to decode Binary and then one example that shows how to implement the SHOUT extension type
That will be more lines overall as the setup mut be shared, but splitting the concepts I think will make it easier to understand as those are two separate usecases
| pub trait DecoderFactory: std::fmt::Debug + Send + Sync { | ||
| /// Make a decoder for `field`, or `Ok(None)` to use the reader's default. | ||
| /// | ||
| /// Receives the [`FieldRef`] rather than just its [`DataType`] so decoder |
There was a problem hiding this comment.
I think the fact that Field ref is passed in is obvious from the code -- maybe we can here just focus on "Note: the extension metadata can be found from the field argument
| /// Use [`DecoderContext::make_decoder`] on `ctx` to build child decoders, and | ||
| /// [`DecoderContext::make_builtin_decoder`] to build on the reader's own decoder | ||
| /// for this field. Calling `make_decoder` with the field this was invoked with | ||
| /// recurses back here and loops. |
There was a problem hiding this comment.
maybe mention "causes infinite recursion and stack overflow"?
Maybe as a follow on PR we could find some simple way to detect this case and error (rather than stack overflow)
| struct CheckedDecoder { | ||
| inner: Box<dyn ArrayDecoder>, | ||
| data_type: DataType, | ||
| } |
There was a problem hiding this comment.
I think it is a good idea to check that the array type returned is what was declared
| } | ||
| } | ||
|
|
||
| /// Declining everything must be indistinguishable from no factory at all. |
There was a problem hiding this comment.
what is the value of this test (I wonder what type of error it would catch)?
Maybe it is worth just removing
| /// iteration may increase this to a custom `u56` type. | ||
| /// | ||
| /// Numbers take more than one form, and matches must handle all of them. Parsing JSON | ||
| /// text always yields [`Self::Number`], holding the value textually (read via |
There was a problem hiding this comment.
what does "holding the value "textually" mean?
There was a problem hiding this comment.
The detail here about calling Tape::get_string and Decoder::serialize seems too much and not in the right place
I think the point of this comment is supposed to be "Numbers take more than one form, so your decoder must handle all of them. For example, JSON text yields Self::Number, but deserializing Rust values yields Self::I64, ...."
The other details just makes this harder for me to understand.
If you want to document that Tape::ge_string should be used to get the value from Self::number , it should be documented on Self::number I would think.
| /// This approach to decoding JSON is inspired by [simdjson] | ||
| /// | ||
| /// String data is copied into the tape with escapes resolved, so [`Tape::get_string`] | ||
| /// borrows from the tape, not the input. A `Tape` is read, never constructed. |
There was a problem hiding this comment.
maybe point out that a Tape is only constructed internally to the crate, and is exposed to consumers to read, but that other crates can not construct this
|
run benchmark json_reader |
|
run benchmark json_reader |
|
🤖 Arrow criterion benchmark running (GKE) | trigger CPU Details (lscpu)Comparing json-decoder-factory (09cb374) to 27f68b1 (merge-base) diff Run configurationrun benchmark json_readerBENCH_COMMAND=cargo bench --features=arrow,async,test_common,experimental,object_store --bench json_reader File an issue against this benchmark runner |
|
🤖 Arrow criterion benchmark running (GKE) | trigger CPU Details (lscpu)Comparing json-decoder-factory (09cb374) to 27f68b1 (merge-base) diff Run configurationrun benchmark json_readerBENCH_COMMAND=cargo bench --features=arrow,async,test_common,experimental,object_store --bench json_reader File an issue against this benchmark runner |
|
🤖 Arrow criterion benchmark completed (GKE) | trigger Instance: Comparing json-decoder-factory (09cb374) to 27f68b1 (merge-base) diff Run configurationrun benchmark json_readerCPU Details (lscpu)Details
Resource Usagebase (merge-base)
branch
File an issue against this benchmark runner |
|
🤖 Arrow criterion benchmark completed (GKE) | trigger Instance: Comparing json-decoder-factory (09cb374) to 27f68b1 (merge-base) diff Run configurationrun benchmark json_readerCPU Details (lscpu)Details
Resource Usagebase (merge-base)
branch
File an issue against this benchmark runner |
…10741) # Which issue does this PR close? N/A -- documentation only. # Rationale for this change The ability to customize JSON encoding via [`EncoderFactory`] was added in apache#7015, but neither the crate-level docs nor the writer module docs mention it, so users only find the hook by stumbling on `WriterBuilder::with_encoder_factory`. [`EncoderFactory`]: https://docs.rs/arrow-json/latest/arrow_json/trait.EncoderFactory.html # What changes are included in this PR? Documentation only, no code changes: - Crate-level docs: list a custom `EncoderFactory` as an alternative for binary data encoding - Misc other wording fixeers It would be nice to add a matching pointer for customizing *decoding* as part of apache#10670 # Are these changes tested? Covered by existing doc tests and CI rustdoc link checking. # Are there any user-facing changes? Documentation only.
|
Thanks again @hareshkh |
|
Thanks @alamb! |
Adds crate-level and reader-module doc pointers to the `DecoderFactory` extension point added in apache#10670, mirroring the encoder documentation added in apache#10741.
Adds crate-level and reader-module doc pointers to the `DecoderFactory` extension point added in apache#10670, mirroring the encoder documentation added in apache#10741.
Which issue does this PR close?
Rationale for this change
EncoderFactory, plus a publicmake_encoderto delegate to defaults). The reader has no equivalent.DecoderContextalready sits onmain.What changes are included in this PR?
TapeElement#[non_exhaustive], documenting numbers asNumber(JSON text) or nativei32/i64/f32/f64(serde path), 64-bit spanning two elements.ArrayDecoderpublic;posholds one tape index per output row.DecoderFactory, consulted before the reader's dispatch;Ok(None)accepts the default.DecoderContext::make_decoderpublic, so a factory can delegate to the decoder the reader would otherwise use - without it, overriding a nested type means reimplementing its children (@scovich's point on Allow extensions to arrow-json decoder and include an extension for variant #9021).ReaderBuilder::with_decoder_factory.Are these changes tested?
Yes. Unit tests and a doctest decoding
Binaryfrom a JSON int array - the inverse of the existingEncoderFactorydoctest, so the two round-trip, no new dependency.Are there any user-facing changes?
New:
arrow_json::{Tape, TapeElement, ArrayDecoder, DecoderFactory},DecoderContext::{make_decoder, decoder_factory},ReaderBuilder::with_decoder_factory.