apache / apache/arrow-rs

[arrow-avro] Refactor `schema.rs` and add `ArrowToAvroSchemaBuilder` for explicit Arrow to Avro schema construction

Open
#9,233 2 comments 0 reactions 0 assignees View on GitHub
enhancement
Dominant language
Rust
Stars
3.6k
Forks
1.3k
Avg merge
2d 14h
Merged PRs (30d)
167

Description

**Is your feature request related to a problem or challenge? Please describe what you are trying to do.**

`arrow-avro` currently contains Arrow -> Avro schema logic in `schema.rs` that was originally built as a *writer convenience* for when an Avro schema is not provided. As such, it was developed to make a best effort attempt at synthesizing an `AvroSchema` from an Arrow `Schema` (plus some Arrow schema metadata like `avro.name` / `avro.namespace`).

As `arrow-avro` adoption grows (OCF files, SOE frames, Confluent/Apicurio framing), we increasingly need schema behavior that is:
- **Explicit** about whether we are using a real Avro schema vs inferring one,
- **Modular** so it’s more maintainable (today `schema.rs` is a large multi-purpose module),
- **Correct-by-construction** so downstream consumers don’t need to patch up inferred schemas or reimplement Avro schema editing.

Real-world pain points motivating this include:
- Schema inference from Arrow metadata alone can produce incorrect Avro schemas for nested named types (e.g. #8928: confusion between nested record *type name* and *field name*).
- Downstream consumers (e.g. DataFusion) want to apply column projection at the Avro schema level without reimplementing Avro-aware projection and metadata handling (see #8923).
- Tests/integration code often treat `{ Arrow schema + avro.name/avro.namespace }` as sufficient, but this is not reliable for all schemas, and brittle inference can break reader schema workflows.
- The Arrow -> Avro path needs clearer configuration points (null-union ordering, naming strategy for generated nested types, metadata passthrough policy, etc.), but these knobs are currently either implicit, crate-private, or spread across helpers.

**Describe the solution you'd like**

Refactor / enhance `schema.rs` so Arrow -> Avro schema behavior is **explicit, modular, and correct-by-construction**, with APIs that clearly distinguish between these three fundamental schema conversion functions:
1. **"Using the real schema"** (preferred for readers): consume the Avro writer schema (OCF header / schema registry / user-provided) and optionally transform it into a reader schema as needed (projection, evolution).
2. **"Inferring a schema (defaults)"** (writer convenience): synthesize Avro JSON from an Arrow `Schema` when no Avro schema JSON is provided / embedded.
3. **"Building an explicitly correct `AvroSchema` from an Arrow `Schema` (configured builder for users)"**: add an `ArrowToAvroSchemaBuilder` for constructing an `AvroSchema` from an Arrow `Schema` with explicit configuration knobs.

Below are additional details for the proposed solution:

**A) Introduce `ArrowToAvroSchemaBuilder`**

Add a public builder style API along these lines:

```rust
use arrow_schema::Schema as ArrowSchema;
use arrow_avro::schema::AvroSchema;

// minimal defaults (equivalent to today's best effort inference)
let avro: AvroSchema = ArrowToAvroSchemaBuilder::new(&arrow_schema).build()?;

// use a writer schema to inform configuration
let avro: AvroSchema = ArrowToAvroSchemaBuilder::from_writer_schema(&arrow_schema, &avro_writer_schema).build()?;

// explicit configuration
let avro: AvroSchema = ArrowToAvroSchemaBuilder::new(&arrow_schema)
.with_root_name("User")
.with_namespace("com.example")
.with_doc("Schema inferred from Arrow")
.with_nullability_order(Nullability::NullFirst)
.with_strip_internal_arrow_metadata(true)
.with_type_naming_strategy(TypeNamingStrategy::PathBased)
.with_passthrough_metadata_policy(PassthroughMetadataPolicy::Default)
.build()?;
````

Initial builder "with_" knobs that would help correctness and downstream use-cases:

* Root record identity:

* `with_root_name(...)` (default: `AVRO_ROOT_RECORD_DEFAULT_NAME` or Arrow `avro.name`)
* `with_namespace(...)` (default: Arrow `avro.namespace` if present)
* `with_doc(...)` (default: Arrow `avro.doc` if present)
* Nullability + unions:

* `with_nullability_order(Nullability::NullFirst|NullSecond)` (default `NullFirst`, aligning with Avro union-default constraints)
* Metadata behavior:

* `with_strip_internal_arrow_metadata(bool)` (defaults to current behavior)
* `with_passthrough_metadata_policy(...)` controlling how non-reserved Arrow metadata becomes Avro attributes (today there is logic for "passthrough metadata" that could become configurable)
* Naming strategy for generated nested named types (records/enums/fixed):

* `with_type_naming_strategy(...)` to guarantee deterministic and collision-free nested type names
* (optional) `with_type_name_overrides(...)` for explicit mapping by Arrow field-path
* Logical/extension type policy:

* Define how Arrow logical/extension types map to Avro logical types, and what happens when unsupported (error vs fallback encoding)

This builder should be positioned as the explicit advanced inference entry point, while keeping a simpler defaults path for writer convenience.

**B) Make "use embedded schema" vs "infer schema" explicit**

Today, the Arrow schema metadata key `SCHEMA_METADATA_KEY = "avro.schema"` can contain the full Avro schema JSON. When present, it is often preferable to use it verbatim to preserve exact schema identity across OCF/SOE/registry contexts.

We should make this explicit and stable:

* A clear helper for "use embedded Avro schema if present, else error" (reader-like behavior)
* A clear helper for "use embedded schema if present, else infer" (writer convenience)

(Exact API design TBD, but could be builder flags or separate helpers.)

**C) Split `schema.rs` by responsibility (internal refactor)**

`schema.rs` currently mixes multiple concerns. Refactor into a module layout that preserves the public API but improves maintainability and testability, for instance:

* `schema::mod`: schema representation + serde + builder (Avro JSON)
* `schema::store`: schema store, canonical form + Rabin/MD5/SHA256 fingerprints
* `schema::metadata`: Arrow schema metadata keys + embed/extract helpers (`avro.schema`, `avro.name`, `avro.namespace`, `avro.doc`, defaults/enums)
* `schema::infer`: Arrow -> Avro inference logic (used by the builder)
* `schema::project`: Avro-aware projection/pruning utilities (ties into #8923)
* `schema::evolve`: Avro-aware evolution/extension utilities (also used by the builder)
* (optional) `schema::compat` / `schema::resolve`: compatibility checks + clearer error reporting (path + failure reason)

**D) Provide Avro-aware schema projection/evolution primitives**

Centralize Avro schema pruning/projection in `arrow-avro` (rather than downstream).
This is related to #8923 and would ideally live alongside the refactor so both "use real schema", "inference", and "builder" paths can share projection and evolution logic.

**E) Deprecate `AvroSchema::try_from()`**

Deprecate the existing `AvroSchema::try_from()` method and use `ArrowToAvroSchemaBuilder::new().build()?` in it's place. This shouldn't create any downstream behavior so long as `ArrowToAvroSchemaBuilder` matches `AvroSchema::try_from()` when no knobs are used.

**Describe alternatives you've considered**

1. **Continue fixing inference bugs incrementally without refactoring**

* Risks continued complexity growth in `schema.rs` and makes it harder to reason about correctness across reader/writer/projection paths.

2. **Require callers to always provide Avro schema JSON**

* This removes the writer convenience path and doesn’t address projection/evolution needs or tests where schemas are partially specified via Arrow metadata.

3. **Downstream projects implement Avro schema editing themselves**

* This duplicates Avro-specific logic and encourages subtle divergences from `arrow-avro` behavior, especially around naming, metadata, and resolution.

4. **Expose a single `InferOptions` struct instead of a builder**

* Works initially, but becomes less ergonomic as options grow, and makes it harder to evolve without breaking call-sites. A builder provides a more extensible surface.

**Additional context**

* Related issues:

* #8928 (nested named type: type name vs field name mismatch when generating schemas from Arrow-only metadata)
* #8923 (need Avro-aware projection API in `ReaderBuilder` / centralize schema editing)
* Relevant constants/metadata (current behavior to preserve where possible):

* `SCHEMA_METADATA_KEY = "avro.schema"`
* `AVRO_NAME_METADATA_KEY = "avro.name"`
* `AVRO_NAMESPACE_METADATA_KEY = "avro.namespace"`
* `AVRO_DOC_METADATA_KEY = "avro.doc"`
* `AVRO_FIELD_DEFAULT_METADATA_KEY = "avro.field.default"`
* `AVRO_ENUM_SYMBOLS_METADATA_KEY = "avro.enum.symbols"`
* Avro spec considerations that influence inference defaults:

* Union default values must match the first union branch, which is why `["null", T]` is typically preferred for optional fields:
[https://avro.apache.org/docs/1.11.1/specification/#unions](https://avro.apache.org/docs/1.11.1/specification/#unions)
* This issue is intentionally large: the goal is to land a design that solves the schema limitations in `arrow-avro` in the long-run. This will need to be implemented either via sub-issues or smaller partial PRs.

Contributor guide

Open the contributing guide

Research direction

Start by reading arrow-avro's schema.rs and the related issues #8928 and #8923 to map the current inference, metadata, projection, and evolution responsibilities. The work is done when the design is split into implementable sub-issues with explicit APIs, preserved behavior where required, and focused validation for the builder and schema-handling paths.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
data-engineering
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.