Skip to main content

faucet_core/file_format/
avro.rs

1//! Apache Avro Object Container Files (#719).
2//!
3//! # Reading
4//!
5//! An OCF embeds its writer schema, so a file decodes on its own. When a
6//! *reader* schema is supplied (`avro.schema` on a source) every file is
7//! resolved against it with Avro's schema-resolution rules — the way to
8//! project a subset of fields, rename through aliases, or read files written
9//! across a schema's evolution into one shape. [`ContainerDecoder`](super::container::ContainerDecoder)
10//! adds the multi-file rule: without an explicit reader schema the first
11//! file's writer schema becomes the reader schema for the rest, so a prefix
12//! whose later files cannot be resolved against the first fails with an error
13//! naming both files instead of producing rows of two shapes.
14//!
15//! # Logical types
16//!
17//! Mapped explicitly, never through a lossy numeric fallback:
18//!
19//! | Avro | JSON record | Arrow |
20//! |---|---|---|
21//! | `decimal(p, s)` | exact decimal string (`"12.30"`) | `Decimal128(p, s)` (`Decimal256` above 38 digits) |
22//! | `big-decimal` | decimal string | `Utf8` |
23//! | `date` | `"YYYY-MM-DD"` | `Date32` |
24//! | `time-millis` / `time-micros` | `"HH:MM:SS.fff"` / `"HH:MM:SS.ffffff"` | `Time32(ms)` / `Time64(µs)` |
25//! | `timestamp-{millis,micros,nanos}` | RFC 3339 UTC (`"…Z"`) at that precision | `Timestamp(unit, "UTC")` |
26//! | `local-timestamp-*` | naive ISO 8601 | `Timestamp(unit, None)` |
27//! | `uuid` | canonical string | `Utf8` |
28//! | `duration` | `{months, days, millis}` | `Struct` |
29//!
30//! `bytes` and `fixed` are standard base64 — the canonical binary rendering of
31//! [`crate::fidelity`], the same one Arrow binary columns get, so an Avro file
32//! and a Parquet file carrying the same bytes produce the same records. `NaN`
33//! and the infinities, which JSON cannot hold, become a counted `null`.
34//!
35//! A union of `null` and one type is that type, nullable. A union of several
36//! non-null types decodes to whichever branch the value holds; on the Arrow
37//! path — where a column needs one type — it is a `Utf8` column holding the
38//! value's JSON. A recursive record is likewise `Utf8` JSON on the Arrow path
39//! (Arrow has no recursive types) and a nested object on the record path.
40//!
41//! # Writing
42//!
43//! With `avro.schema` set the records are encoded against it; logical types
44//! are accepted in the shapes above (plus epoch integers). Without one the
45//! schema is inferred from the records being written: `integer` → `long`,
46//! `number` → `double`, objects → nested records, a field that is absent or
47//! null anywhere → `["null", T]` with a `null` default, and a field whose
48//! values mix types → `string`. Field names that are not valid Avro names are
49//! sanitized (`first-name` → `first_name`, a leading digit gains `_`), and the
50//! original name is kept as the field's `faucet.name` attribute.
51
52use super::{AvroCodec, AvroOptions};
53use crate::error::FaucetError;
54use apache_avro::schema::{DecimalSchema, RecordField, UnionSchema};
55use apache_avro::types::Value as Av;
56use apache_avro::{Codec, Schema};
57use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, SecondsFormat, Timelike, Utc};
58use serde_json::{Map, Value, json};
59use std::collections::{HashMap, HashSet};
60
61/// Attribute holding a field's original name when it had to be sanitized.
62pub const ORIGINAL_NAME_ATTR: &str = "faucet.name";
63
64/// Name of the top-level record an inferred schema declares.
65pub const INFERRED_RECORD_NAME: &str = "faucet_record";
66
67/// The field a non-record top-level datum is wrapped in, so every record is an
68/// object whatever the file's root type.
69pub const VALUE_FIELD: &str = "value";
70
71fn codec(c: AvroCodec) -> Codec {
72    match c {
73        AvroCodec::Null => Codec::Null,
74        AvroCodec::Deflate => Codec::Deflate(Default::default()),
75        AvroCodec::Snappy => Codec::Snappy,
76        AvroCodec::Zstd => Codec::Zstandard(Default::default()),
77    }
78}
79
80impl AvroOptions {
81    /// The configured schema, parsed, if any.
82    pub fn parsed_schema(&self) -> Result<Option<Schema>, FaucetError> {
83        self.schema.as_ref().map(parse_schema).transpose()
84    }
85}
86
87/// Parse an Avro schema from its JSON form.
88pub fn parse_schema(v: &Value) -> Result<Schema, FaucetError> {
89    Schema::parse(v)
90        .map_err(|e| FaucetError::Config(format!("avro.schema is not a valid Avro schema: {e}")))
91}
92
93/// Decode a whole OCF into records, resolved against `opts.schema` when set.
94pub fn decode(bytes: &[u8], opts: &AvroOptions) -> Result<Vec<Value>, FaucetError> {
95    let reader_schema = opts.parsed_schema()?;
96    let mut out = Vec::new();
97    read_records(bytes, reader_schema.as_ref(), usize::MAX, &mut |chunk| {
98        out.extend(chunk);
99        Ok(())
100    })
101    .map_err(|e| FaucetError::Source(format!("avro: {e}")))?;
102    Ok(out)
103}
104
105/// The writer schema an OCF declares, read from its header only.
106pub fn writer_schema<R: std::io::Read>(reader: R) -> Result<Schema, FaucetError> {
107    let r = apache_avro::Reader::new(reader)
108        .map_err(|e| FaucetError::Source(format!("avro header: {e}")))?;
109    Ok(r.writer_schema().clone())
110}
111
112/// Stream an OCF's records in chunks of `chunk` (at least 1), resolved against
113/// `reader_schema` when given. Returns the schema the records are shaped by.
114pub fn read_records<R: std::io::Read>(
115    reader: R,
116    reader_schema: Option<&Schema>,
117    chunk: usize,
118    f: &mut dyn FnMut(Vec<Value>) -> Result<(), FaucetError>,
119) -> Result<Schema, FaucetError> {
120    read_records_checked(reader, reader_schema, None, chunk, f)
121}
122
123/// Checks a file's writer schema before any of its rows are decoded.
124pub(crate) type WriterCheck<'a> = dyn Fn(&Schema) -> Result<(), FaucetError> + 'a;
125
126/// [`read_records`] with a writer-schema check run before the first row.
127pub(crate) fn read_records_checked<R: std::io::Read>(
128    reader: R,
129    reader_schema: Option<&Schema>,
130    check: Option<&WriterCheck<'_>>,
131    chunk: usize,
132    f: &mut dyn FnMut(Vec<Value>) -> Result<(), FaucetError>,
133) -> Result<Schema, FaucetError> {
134    read_with(
135        reader,
136        reader_schema,
137        check,
138        chunk,
139        Mode::Record,
140        &mut |chunk, _| f(chunk),
141    )
142}
143
144/// How a datum is rendered: plain JSON, or JSON the Arrow decoder can type
145/// against [`arrow_schema`] (multi-branch unions and recursion as JSON text).
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub(crate) enum Mode {
148    Record,
149    Arrow,
150}
151
152/// Receives each decoded chunk with the schema it is shaped by.
153pub(crate) type ChunkSink<'a> = dyn FnMut(Vec<Value>, &Schema) -> Result<(), FaucetError> + 'a;
154
155pub(crate) fn read_with<R: std::io::Read>(
156    reader: R,
157    reader_schema: Option<&Schema>,
158    check: Option<&WriterCheck<'_>>,
159    chunk: usize,
160    mode: Mode,
161    f: &mut ChunkSink<'_>,
162) -> Result<Schema, FaucetError> {
163    let builder = apache_avro::Reader::builder(reader);
164    let r = match reader_schema {
165        Some(s) => builder.reader_schema(s).build(),
166        None => builder.build(),
167    }
168    .map_err(|e| FaucetError::Source(format!("avro header: {e}")))?;
169    if let Some(check) = check {
170        check(r.writer_schema())?;
171    }
172    let schema = reader_schema
173        .cloned()
174        .unwrap_or_else(|| r.writer_schema().clone());
175    let names = named(&schema);
176    let chunk = chunk.max(1);
177    let mut buf = Vec::with_capacity(chunk.min(4096));
178    for datum in r {
179        let datum = datum.map_err(|e| FaucetError::Source(format!("avro datum: {e}")))?;
180        buf.push(root_to_json(&datum, &schema, &names, mode)?);
181        if buf.len() >= chunk {
182            f(std::mem::take(&mut buf), &schema)?;
183        }
184    }
185    if !buf.is_empty() {
186        f(buf, &schema)?;
187    }
188    Ok(schema)
189}
190
191/// Every named type of a schema by full and short name, owned so a prepared
192/// [`AvroDatumCodec`] can keep it next to its schema.
193type Names = HashMap<String, Schema>;
194
195fn named(schema: &Schema) -> Names {
196    let mut out = HashMap::new();
197    collect_named(schema, &mut out);
198    out
199}
200
201fn collect_named(s: &Schema, out: &mut Names) {
202    match s {
203        Schema::Record(r) => {
204            out.insert(r.name.fullname(None), s.clone());
205            out.entry(r.name.name().to_string())
206                .or_insert_with(|| s.clone());
207            for f in &r.fields {
208                collect_named(&f.schema, out);
209            }
210        }
211        Schema::Enum(e) => {
212            out.insert(e.name.fullname(None), s.clone());
213            out.entry(e.name.name().to_string())
214                .or_insert_with(|| s.clone());
215        }
216        Schema::Fixed(x) | Schema::Duration(x) => {
217            out.insert(x.name.fullname(None), s.clone());
218            out.entry(x.name.name().to_string())
219                .or_insert_with(|| s.clone());
220        }
221        Schema::Array(a) => collect_named(&a.items, out),
222        Schema::Map(m) => collect_named(&m.types, out),
223        Schema::Union(u) => u.variants().iter().for_each(|v| collect_named(v, out)),
224        _ => {}
225    }
226}
227
228fn resolve<'a>(s: &'a Schema, names: &'a Names) -> Result<&'a Schema, FaucetError> {
229    match s {
230        Schema::Ref { name } => names
231            .get(&name.fullname(None))
232            .or_else(|| names.get(name.name()))
233            .ok_or_else(|| {
234                FaucetError::Source(format!(
235                    "avro: unresolved schema reference `{}`",
236                    name.fullname(None)
237                ))
238            }),
239        other => Ok(other),
240    }
241}
242
243fn ref_name(s: &Schema) -> Option<String> {
244    match s {
245        Schema::Ref { name } => Some(name.fullname(None)),
246        Schema::Record(r) => Some(r.name.fullname(None)),
247        _ => None,
248    }
249}
250
251/// A union needing one Arrow column for several value types.
252pub(crate) fn is_complex_union(u: &UnionSchema) -> bool {
253    u.variants()
254        .iter()
255        .filter(|v| !matches!(v, Schema::Null))
256        .count()
257        > 1
258}
259
260/// [`datum_to_json`] / [`datum_from_json_with`] against one schema whose
261/// named types are indexed once, for a codec that converts many datums (a
262/// Kafka topic's messages) — the free functions re-index the schema per call.
263#[derive(Debug, Clone)]
264pub struct AvroDatumCodec {
265    schema: Schema,
266    names: Names,
267}
268
269impl AvroDatumCodec {
270    /// Index `schema`'s named types.
271    pub fn new(schema: Schema) -> Self {
272        let names = named(&schema);
273        Self { schema, names }
274    }
275
276    /// The schema this codec converts against.
277    pub fn schema(&self) -> &Schema {
278        &self.schema
279    }
280
281    /// [`datum_to_json`] without re-indexing the schema.
282    pub fn to_json(&self, datum: &Av) -> Result<Value, FaucetError> {
283        to_json(
284            datum,
285            &self.schema,
286            &self.names,
287            Mode::Record,
288            &mut Vec::new(),
289        )
290    }
291
292    /// [`datum_from_json_with`] without re-indexing the schema.
293    pub fn from_json(&self, value: &Value, text: BytesText) -> Result<Av, String> {
294        from_json(value, &self.schema, &self.names, text, "")
295    }
296}
297
298/// Convert one decoded Avro datum to JSON with the logical-type mapping in the
299/// module docs (exact decimal strings, base64 bytes, ISO temporals). Unlike the
300/// file reader, a non-record datum is returned as-is rather than wrapped in
301/// [`VALUE_FIELD`] — the shape a single-message codec (a Kafka value) wants.
302pub fn datum_to_json(datum: &Av, schema: &Schema) -> Result<Value, FaucetError> {
303    let names = named(schema);
304    to_json(datum, schema, &names, Mode::Record, &mut Vec::new())
305}
306
307/// Build the Avro datum for `value` under `schema`, accepting the JSON shapes
308/// [`datum_to_json`] produces (the inverse mapping). The error names the path
309/// that did not fit.
310pub fn datum_from_json(value: &Value, schema: &Schema) -> Result<Av, FaucetError> {
311    datum_from_json_with(value, schema, BytesText::Base64)
312}
313
314/// How a JSON string is read for an Avro `bytes` / `fixed` value on encode.
315#[derive(
316    Debug,
317    Clone,
318    Copy,
319    Default,
320    PartialEq,
321    Eq,
322    serde::Serialize,
323    serde::Deserialize,
324    schemars::JsonSchema,
325)]
326#[serde(rename_all = "snake_case")]
327#[non_exhaustive]
328pub enum BytesText {
329    /// The string is standard base64, the shape [`datum_to_json`] produces.
330    #[default]
331    Base64,
332    /// The string is lowercase hex (the rendering before #885).
333    Hex,
334    /// The string's UTF-8 bytes are the value.
335    Utf8,
336}
337
338/// [`datum_from_json`] with an explicit reading of JSON strings for
339/// `bytes` / `fixed` values. A JSON array of byte numbers is accepted either way.
340pub fn datum_from_json_with(
341    value: &Value,
342    schema: &Schema,
343    text: BytesText,
344) -> Result<Av, FaucetError> {
345    let names = named(schema);
346    from_json(value, schema, &names, text, "")
347        .map_err(|e| FaucetError::Sink(format!("json->avro: {e}")))
348}
349
350fn root_to_json(v: &Av, schema: &Schema, names: &Names, mode: Mode) -> Result<Value, FaucetError> {
351    let mut stack = Vec::new();
352    let resolved = resolve(schema, names)?;
353    let j = to_json(v, schema, names, mode, &mut stack)?;
354    Ok(match (resolved, j) {
355        (Schema::Record(_), j @ Value::Object(_)) => j,
356        (_, j) => {
357            let mut m = Map::new();
358            m.insert(VALUE_FIELD.into(), j);
359            Value::Object(m)
360        }
361    })
362}
363
364fn to_json(
365    v: &Av,
366    schema: &Schema,
367    names: &Names,
368    mode: Mode,
369    stack: &mut Vec<String>,
370) -> Result<Value, FaucetError> {
371    let s = resolve(schema, names)?;
372    if mode == Mode::Arrow
373        && let Some(n) = ref_name(schema)
374        && stack.contains(&n)
375    {
376        let inner = to_json(v, s, names, Mode::Record, &mut Vec::new())?;
377        return Ok(Value::String(crate::util::canonical_json(&inner)));
378    }
379    Ok(match (v, s) {
380        (Av::Union(i, inner), Schema::Union(u)) => {
381            let branch = u.variants().get(*i as usize).ok_or_else(|| {
382                FaucetError::Source(format!("avro: union branch {i} out of range"))
383            })?;
384            if mode == Mode::Arrow && is_complex_union(u) {
385                if matches!(**inner, Av::Null) {
386                    return Ok(Value::Null);
387                }
388                let j = to_json(inner, branch, names, Mode::Record, &mut Vec::new())?;
389                return Ok(Value::String(crate::util::canonical_json(&j)));
390            }
391            to_json(inner, branch, names, mode, stack)?
392        }
393        (Av::Union(_, inner), other) => to_json(inner, other, names, mode, stack)?,
394        (Av::Null, _) => Value::Null,
395        (Av::Boolean(b), _) => Value::Bool(*b),
396        (Av::Int(i), _) => json!(i),
397        (Av::Long(i), _) => json!(i),
398        (Av::Float(f), _) if mode == Mode::Arrow && !f.is_finite() => {
399            non_finite_text(f64::from(*f))
400        }
401        (Av::Double(f), _) if mode == Mode::Arrow && !f.is_finite() => non_finite_text(*f),
402        (Av::Float(f), _) => crate::fidelity::float32(*f),
403        (Av::Double(f), _) => crate::fidelity::float64(*f),
404        (Av::Bytes(b), _) | (Av::Fixed(_, b), _) => crate::fidelity::binary(b),
405        (Av::String(t), _) => Value::String(t.clone()),
406        (Av::Enum(_, sym), _) => Value::String(sym.clone()),
407        (Av::Array(items), s) => {
408            let item_schema = match s {
409                Schema::Array(a) => &*a.items,
410                _ => s,
411            };
412            Value::Array(
413                items
414                    .iter()
415                    .map(|i| to_json(i, item_schema, names, mode, stack))
416                    .collect::<Result<_, _>>()?,
417            )
418        }
419        (Av::Map(m), s) => {
420            let value_schema = match s {
421                Schema::Map(m) => &*m.types,
422                _ => s,
423            };
424            let mut keys: Vec<&String> = m.keys().collect();
425            keys.sort();
426            let mut out = Map::new();
427            for k in keys {
428                out.insert(k.clone(), to_json(&m[k], value_schema, names, mode, stack)?);
429            }
430            Value::Object(out)
431        }
432        (Av::Record(fields), s) => {
433            let rec = match s {
434                Schema::Record(r) => Some(r),
435                _ => None,
436            };
437            let pushed = rec.map(|r| r.name.fullname(None));
438            if let Some(n) = &pushed {
439                stack.push(n.clone());
440            }
441            let mut out = Map::new();
442            for (name, fv) in fields {
443                let fs = rec
444                    .and_then(|r| r.lookup.get(name).map(|i| &r.fields[*i].schema))
445                    .unwrap_or(&Schema::Null);
446                out.insert(name.clone(), to_json(fv, fs, names, mode, stack)?);
447            }
448            if pushed.is_some() {
449                stack.pop();
450            }
451            Value::Object(out)
452        }
453        (Av::Date(d), _) => Value::String(date_string(*d)?),
454        (Av::Decimal(d), s) => {
455            let scale = match s {
456                Schema::Decimal(DecimalSchema { scale, .. }) => *scale,
457                _ => 0,
458            };
459            let bytes = <Vec<u8>>::try_from(d)
460                .map_err(|e| FaucetError::Source(format!("avro decimal: {e}")))?;
461            Value::String(decimal_to_string(&bytes, scale))
462        }
463        (Av::BigDecimal(d), _) => Value::String(d.to_string()),
464        (Av::TimeMillis(ms), _) => Value::String(time_string(i64::from(*ms) * 1_000_000, 3)?),
465        (Av::TimeMicros(us), _) => Value::String(time_string(us.saturating_mul(1_000), 6)?),
466        (Av::TimestampMillis(t), _) => {
467            Value::String(utc_string(*t, 1_000_000, SecondsFormat::Millis)?)
468        }
469        (Av::TimestampMicros(t), _) => Value::String(utc_string(*t, 1_000, SecondsFormat::Micros)?),
470        (Av::TimestampNanos(t), _) => Value::String(utc_string(*t, 1, SecondsFormat::Nanos)?),
471        (Av::LocalTimestampMillis(t), _) => Value::String(local_string(*t, 1_000_000, 3)?),
472        (Av::LocalTimestampMicros(t), _) => Value::String(local_string(*t, 1_000, 6)?),
473        (Av::LocalTimestampNanos(t), _) => Value::String(local_string(*t, 1, 9)?),
474        (Av::Duration(d), _) => json!({
475            "months": u32::from(d.months()),
476            "days": u32::from(d.days()),
477            "millis": u32::from(d.millis()),
478        }),
479        (Av::Uuid(u), _) => Value::String(u.to_string()),
480    })
481}
482
483/// On the Arrow path a non-finite float stays one: the JSON hop reads these
484/// words back into the `Float` column.
485fn non_finite_text(f: f64) -> Value {
486    Value::String(
487        if f.is_nan() {
488            "NaN"
489        } else if f > 0.0 {
490            "Infinity"
491        } else {
492            "-Infinity"
493        }
494        .into(),
495    )
496}
497
498fn unhex(s: &str) -> Result<Vec<u8>, String> {
499    if !s.len().is_multiple_of(2) {
500        return Err(format!("{s:?} is not hex (odd length)"));
501    }
502    (0..s.len())
503        .step_by(2)
504        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| format!("{s:?} is not hex")))
505        .collect()
506}
507
508const EPOCH: NaiveDate = NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch");
509
510fn date_string(days: i32) -> Result<String, FaucetError> {
511    EPOCH
512        .checked_add_signed(chrono::Duration::days(i64::from(days)))
513        .map(|d| d.format("%Y-%m-%d").to_string())
514        .ok_or_else(|| FaucetError::Source(format!("avro date {days} is out of range")))
515}
516
517fn time_string(nanos: i64, digits: usize) -> Result<String, FaucetError> {
518    let secs = u32::try_from(nanos.div_euclid(1_000_000_000)).ok();
519    let frac = nanos.rem_euclid(1_000_000_000) as u32;
520    let t = secs
521        .and_then(|s| NaiveTime::from_num_seconds_from_midnight_opt(s, frac))
522        .ok_or_else(|| FaucetError::Source(format!("avro time {nanos}ns is out of range")))?;
523    Ok(format!(
524        "{}.{:0w$}",
525        t.format("%H:%M:%S"),
526        t.nanosecond() / 10u32.pow(9 - digits as u32),
527        w = digits
528    ))
529}
530
531fn instant(t: i64, nanos_per_unit: i64) -> Option<DateTime<Utc>> {
532    let n = i128::from(t) * i128::from(nanos_per_unit);
533    let secs = i64::try_from(n.div_euclid(1_000_000_000)).ok()?;
534    DateTime::from_timestamp(secs, n.rem_euclid(1_000_000_000) as u32)
535}
536
537fn utc_string(t: i64, nanos_per_unit: i64, fmt: SecondsFormat) -> Result<String, FaucetError> {
538    instant(t, nanos_per_unit)
539        .map(|d| d.to_rfc3339_opts(fmt, true))
540        .ok_or_else(|| FaucetError::Source(format!("avro timestamp {t} is out of range")))
541}
542
543fn local_string(t: i64, nanos_per_unit: i64, digits: usize) -> Result<String, FaucetError> {
544    let d = instant(t, nanos_per_unit)
545        .ok_or_else(|| FaucetError::Source(format!("avro local timestamp {t} is out of range")))?
546        .naive_utc();
547    Ok(format!(
548        "{}.{:0w$}",
549        d.format("%Y-%m-%dT%H:%M:%S"),
550        d.nanosecond() / 10u32.pow(9 - digits as u32),
551        w = digits
552    ))
553}
554
555/// Render a big-endian two's-complement unscaled integer at `scale`.
556pub(crate) fn decimal_to_string(bytes: &[u8], scale: usize) -> String {
557    let negative = bytes.first().is_some_and(|b| b & 0x80 != 0);
558    let mut mag: Vec<u8> = if negative {
559        negate(bytes)
560    } else {
561        bytes.to_vec()
562    };
563    let mut digits = Vec::new();
564    while mag.iter().any(|b| *b != 0) {
565        let mut rem: u32 = 0;
566        for b in mag.iter_mut() {
567            let cur = (rem << 8) | u32::from(*b);
568            *b = (cur / 10) as u8;
569            rem = cur % 10;
570        }
571        digits.push(b'0' + rem as u8);
572    }
573    while digits.len() <= scale {
574        digits.push(b'0');
575    }
576    digits.reverse();
577    let (int, frac) = digits.split_at(digits.len() - scale);
578    let mut s = String::new();
579    if negative {
580        s.push('-');
581    }
582    s.push_str(std::str::from_utf8(int).expect("ascii digits"));
583    if scale > 0 {
584        s.push('.');
585        s.push_str(std::str::from_utf8(frac).expect("ascii digits"));
586    }
587    s
588}
589
590fn negate(bytes: &[u8]) -> Vec<u8> {
591    let mut out: Vec<u8> = bytes.iter().map(|b| !b).collect();
592    for b in out.iter_mut().rev() {
593        let (v, carry) = b.overflowing_add(1);
594        *b = v;
595        if !carry {
596            break;
597        }
598    }
599    out
600}
601
602/// Parse a decimal literal (optionally in exponent form) into the minimal
603/// big-endian two's-complement unscaled integer at `scale`.
604///
605/// Refuses digits beyond `scale` that are not zero — rounding a money column
606/// silently is not a formatting choice — and more than `precision` digits.
607pub(crate) fn decimal_from_str(
608    text: &str,
609    scale: usize,
610    precision: usize,
611) -> Result<Vec<u8>, String> {
612    let t = text.trim();
613    let (negative, body) = match t.strip_prefix('-') {
614        Some(rest) => (true, rest),
615        None => (false, t.strip_prefix('+').unwrap_or(t)),
616    };
617    let (mantissa, exp) = match body.find(['e', 'E']) {
618        Some(i) => (
619            &body[..i],
620            body[i + 1..]
621                .parse::<i64>()
622                .map_err(|_| format!("{text:?} is not a decimal"))?,
623        ),
624        None => (body, 0),
625    };
626    let (int, frac) = mantissa.split_once('.').unwrap_or((mantissa, ""));
627    if int.is_empty() && frac.is_empty()
628        || !int.bytes().chain(frac.bytes()).all(|b| b.is_ascii_digit())
629    {
630        return Err(format!("{text:?} is not a decimal"));
631    }
632    let mut digits: Vec<u8> = int.bytes().chain(frac.bytes()).map(|b| b - b'0').collect();
633    let out_of_range = || format!("{text:?} is out of range for precision {precision}");
634    let wanted = (int.len() as i64)
635        .checked_add(exp)
636        .and_then(|p| p.checked_add(scale as i64))
637        .ok_or_else(out_of_range)?;
638    // Check the significant digits the scaled value would need before padding,
639    // so an exponent such as `1e18` cannot drive an enormous allocation.
640    match digits.iter().position(|d| *d != 0) {
641        None => digits.clear(),
642        Some(first)
643            if wanted > first as i64 && (wanted - first as i64) as u128 > precision as u128 =>
644        {
645            return Err(format!(
646                "{text:?} needs {} digits, more than the precision {precision}",
647                wanted - first as i64
648            ));
649        }
650        Some(_) => {}
651    }
652    if digits.is_empty() {
653        // Zero at any exponent: nothing to scale.
654    } else if wanted < 0 {
655        return Err(format!("{text:?} has more than {scale} fractional digits"));
656    } else {
657        let wanted = wanted as usize;
658        if digits.len() > wanted {
659            if digits[wanted..].iter().any(|d| *d != 0) {
660                return Err(format!("{text:?} has more than {scale} fractional digits"));
661            }
662            digits.truncate(wanted);
663        } else {
664            digits.resize(wanted, 0);
665        }
666    }
667    let first = digits.iter().position(|d| *d != 0).unwrap_or(digits.len());
668    let digits = &digits[first..];
669    if digits.len() > precision {
670        return Err(format!(
671            "{text:?} needs {} digits, more than the precision {precision}",
672            digits.len()
673        ));
674    }
675    let mut mag: Vec<u8> = vec![0];
676    for d in digits {
677        let mut carry = u32::from(*d);
678        for b in mag.iter_mut().rev() {
679            let cur = u32::from(*b) * 10 + carry;
680            *b = (cur & 0xff) as u8;
681            carry = cur >> 8;
682        }
683        while carry > 0 {
684            mag.insert(0, (carry & 0xff) as u8);
685            carry >>= 8;
686        }
687    }
688    if mag[0] & 0x80 != 0 {
689        mag.insert(0, 0);
690    }
691    while mag.len() > 1 && mag[0] == 0 && mag[1] & 0x80 == 0 {
692        mag.remove(0);
693    }
694    Ok(if negative && mag.iter().any(|b| *b != 0) {
695        negate(&mag)
696    } else {
697        mag
698    })
699}
700
701fn sign_extend(bytes: Vec<u8>, size: usize) -> Result<Vec<u8>, String> {
702    if bytes.len() > size {
703        return Err(format!(
704            "decimal needs {} bytes, the fixed holds {size}",
705            bytes.len()
706        ));
707    }
708    let fill = if bytes.first().is_some_and(|b| b & 0x80 != 0) {
709        0xff
710    } else {
711        0
712    };
713    let mut out = vec![fill; size - bytes.len()];
714    out.extend(bytes);
715    Ok(out)
716}
717
718// ── Writing ──────────────────────────────────────────────────────────────────
719
720/// Encode records into one OCF, against `opts.schema` or an inferred schema.
721pub fn encode(records: &[Value], opts: &AvroOptions) -> Result<Vec<u8>, FaucetError> {
722    let schema = match opts.parsed_schema()? {
723        Some(s) => s,
724        None => infer_schema(records)?,
725    };
726    let names = named(&schema);
727    let mut w = apache_avro::Writer::with_codec(&schema, Vec::new(), codec(opts.codec))
728        .map_err(|e| FaucetError::Sink(format!("avro writer: {e}")))?;
729    for (i, r) in records.iter().enumerate() {
730        let datum = root_from_json(r, &schema, &names)
731            .map_err(|e| FaucetError::Sink(format!("avro: record {i}: {e}")))?;
732        w.append_value_ref(&datum)
733            .map_err(|e| FaucetError::Sink(format!("avro: record {i}: {e}")))?;
734    }
735    w.into_inner()
736        .map_err(|e| FaucetError::Sink(format!("avro writer: {e}")))
737}
738
739fn root_from_json(v: &Value, schema: &Schema, names: &Names) -> Result<Av, String> {
740    let resolved = resolve(schema, names).map_err(|e| e.to_string())?;
741    match resolved {
742        Schema::Record(_) => from_json(v, schema, names, BytesText::Base64, ""),
743        _ => {
744            let inner = v.get(VALUE_FIELD).unwrap_or(v);
745            from_json(inner, schema, names, BytesText::Base64, "")
746        }
747    }
748}
749
750fn at(path: &str) -> String {
751    if path.is_empty() {
752        "the record".into()
753    } else {
754        format!("`{path}`")
755    }
756}
757
758fn from_json(
759    v: &Value,
760    schema: &Schema,
761    names: &Names,
762    text: BytesText,
763    path: &str,
764) -> Result<Av, String> {
765    let s = resolve(schema, names).map_err(|e| e.to_string())?;
766    let want = |what: &str| format!("{}: expected {what}, got {}", at(path), short(v));
767    Ok(match s {
768        Schema::Null => match v {
769            Value::Null => Av::Null,
770            _ => return Err(want("null")),
771        },
772        Schema::Boolean => Av::Boolean(v.as_bool().ok_or_else(|| want("a boolean"))?),
773        Schema::Int => {
774            let i = v.as_i64().ok_or_else(|| want("an integer"))?;
775            Av::Int(
776                i32::try_from(i)
777                    .map_err(|_| format!("{}: {i} does not fit an Avro int", at(path)))?,
778            )
779        }
780        Schema::Long => Av::Long(v.as_i64().ok_or_else(|| want("an integer"))?),
781        Schema::Float => Av::Float(float_from(v).ok_or_else(|| want("a number"))? as f32),
782        Schema::Double => Av::Double(float_from(v).ok_or_else(|| want("a number"))?),
783        Schema::Bytes => Av::Bytes(bytes_from(v, text).map_err(|e| format!("{}: {e}", at(path)))?),
784        Schema::String => Av::String(match v {
785            Value::String(t) => t.clone(),
786            Value::Null => return Err(want("a string")),
787            other => super::cell_text(other),
788        }),
789        Schema::Array(a) => {
790            let items = v.as_array().ok_or_else(|| want("an array"))?;
791            Av::Array(
792                items
793                    .iter()
794                    .enumerate()
795                    .map(|(i, x)| from_json(x, &a.items, names, text, &format!("{path}[{i}]")))
796                    .collect::<Result<_, _>>()?,
797            )
798        }
799        Schema::Map(m) => {
800            let obj = v.as_object().ok_or_else(|| want("an object"))?;
801            Av::Map(
802                obj.iter()
803                    .map(|(k, x)| {
804                        Ok((
805                            k.clone(),
806                            from_json(x, &m.types, names, text, &join(path, k))?,
807                        ))
808                    })
809                    .collect::<Result<_, String>>()?,
810            )
811        }
812        Schema::Record(r) => {
813            let obj = v.as_object().ok_or_else(|| want("an object"))?;
814            let mut fields = Vec::with_capacity(r.fields.len());
815            for f in &r.fields {
816                let key = source_name(f);
817                let fv = obj.get(key).unwrap_or(&Value::Null);
818                let datum = match (fv, accepts_null(&f.schema, names), &f.default) {
819                    (Value::Null, false, Some(default)) => {
820                        from_json(default, &f.schema, names, text, &join(path, key))?
821                    }
822                    (Value::Null, false, None) => {
823                        return Err(format!(
824                            "{}: required field is missing or null",
825                            at(&join(path, key))
826                        ));
827                    }
828                    _ => from_json(fv, &f.schema, names, text, &join(path, key))?,
829                };
830                fields.push((f.name.clone(), datum));
831            }
832            Av::Record(fields)
833        }
834        Schema::Union(u) => {
835            let variants = u.variants();
836            if v.is_null()
837                && let Some(i) = variants.iter().position(|b| matches!(b, Schema::Null))
838            {
839                return Ok(Av::Union(i as u32, Box::new(Av::Null)));
840            }
841            let mut last = None;
842            for (i, b) in variants.iter().enumerate() {
843                if matches!(b, Schema::Null) {
844                    continue;
845                }
846                match from_json(v, b, names, text, path) {
847                    Ok(d) => return Ok(Av::Union(i as u32, Box::new(d))),
848                    Err(e) => last = Some(e),
849                }
850            }
851            return Err(last.unwrap_or_else(|| want("a value matching the union")));
852        }
853        Schema::Enum(e) => {
854            let sym = v.as_str().ok_or_else(|| want("an enum symbol"))?;
855            let i =
856                e.symbols.iter().position(|x| x == sym).ok_or_else(|| {
857                    format!("{}: {sym:?} is not one of {:?}", at(path), e.symbols)
858                })?;
859            Av::Enum(i as u32, sym.to_string())
860        }
861        Schema::Fixed(x) => {
862            let b = bytes_from(v, text).map_err(|e| format!("{}: {e}", at(path)))?;
863            if b.len() != x.size {
864                return Err(format!(
865                    "{}: fixed({}) got {} bytes",
866                    at(path),
867                    x.size,
868                    b.len()
869                ));
870            }
871            Av::Fixed(x.size, b)
872        }
873        Schema::Decimal(d) => {
874            let text = match v {
875                Value::String(t) => t.clone(),
876                Value::Number(n) => n.to_string(),
877                _ => return Err(want("a decimal string or number")),
878            };
879            let bytes = decimal_from_str(&text, d.scale, d.precision)
880                .map_err(|e| format!("{}: {e}", at(path)))?;
881            let bytes = match &d.inner {
882                apache_avro::schema::InnerDecimalSchema::Fixed(f) => {
883                    sign_extend(bytes, f.size).map_err(|e| format!("{}: {e}", at(path)))?
884                }
885                _ => bytes,
886            };
887            Av::Decimal(apache_avro::Decimal::from(bytes))
888        }
889        Schema::BigDecimal => {
890            let text = match v {
891                Value::String(t) => t.clone(),
892                Value::Number(n) => n.to_string(),
893                _ => return Err(want("a decimal string or number")),
894            };
895            Av::BigDecimal(
896                text.parse::<apache_avro::BigDecimal>()
897                    .map_err(|_| format!("{}: {text:?} is not a decimal", at(path)))?,
898            )
899        }
900        Schema::Uuid(_) => {
901            let t = v.as_str().ok_or_else(|| want("a UUID string"))?;
902            Av::Uuid(
903                apache_avro::Uuid::parse_str(t)
904                    .map_err(|_| format!("{}: {t:?} is not a UUID", at(path)))?,
905            )
906        }
907        Schema::Date => Av::Date(match v {
908            Value::String(t) => {
909                let d = NaiveDate::parse_from_str(t, "%Y-%m-%d")
910                    .map_err(|_| format!("{}: {t:?} is not YYYY-MM-DD", at(path)))?;
911                i32::try_from((d - EPOCH).num_days())
912                    .map_err(|_| format!("{}: {t:?} is out of range", at(path)))?
913            }
914            _ => i32::try_from(v.as_i64().ok_or_else(|| want("a date"))?)
915                .map_err(|_| format!("{}: date out of range", at(path)))?,
916        }),
917        Schema::TimeMillis => Av::TimeMillis(
918            i32::try_from(time_from(v, 1_000_000).map_err(|e| format!("{}: {e}", at(path)))?)
919                .map_err(|_| format!("{}: time out of range", at(path)))?,
920        ),
921        Schema::TimeMicros => {
922            Av::TimeMicros(time_from(v, 1_000).map_err(|e| format!("{}: {e}", at(path)))?)
923        }
924        Schema::TimestampMillis => Av::TimestampMillis(
925            ts_from(v, 1_000_000, true).map_err(|e| format!("{}: {e}", at(path)))?,
926        ),
927        Schema::TimestampMicros => {
928            Av::TimestampMicros(ts_from(v, 1_000, true).map_err(|e| format!("{}: {e}", at(path)))?)
929        }
930        Schema::TimestampNanos => {
931            Av::TimestampNanos(ts_from(v, 1, true).map_err(|e| format!("{}: {e}", at(path)))?)
932        }
933        Schema::LocalTimestampMillis => Av::LocalTimestampMillis(
934            ts_from(v, 1_000_000, false).map_err(|e| format!("{}: {e}", at(path)))?,
935        ),
936        Schema::LocalTimestampMicros => Av::LocalTimestampMicros(
937            ts_from(v, 1_000, false).map_err(|e| format!("{}: {e}", at(path)))?,
938        ),
939        Schema::LocalTimestampNanos => {
940            Av::LocalTimestampNanos(ts_from(v, 1, false).map_err(|e| format!("{}: {e}", at(path)))?)
941        }
942        Schema::Duration(_) => {
943            let part = |k: &str| -> Result<u32, String> {
944                v.get(k)
945                    .and_then(Value::as_u64)
946                    .and_then(|n| u32::try_from(n).ok())
947                    .ok_or_else(|| format!("{}: duration needs an unsigned `{k}`", at(path)))
948            };
949            Av::Duration(apache_avro::Duration::new(
950                apache_avro::Months::new(part("months")?),
951                apache_avro::Days::new(part("days")?),
952                apache_avro::Millis::new(part("millis")?),
953            ))
954        }
955        Schema::Ref { .. } => unreachable!("resolved above"),
956    })
957}
958
959fn source_name(f: &RecordField) -> &str {
960    f.custom_attributes
961        .get(ORIGINAL_NAME_ATTR)
962        .and_then(Value::as_str)
963        .unwrap_or(&f.name)
964}
965
966fn accepts_null(s: &Schema, names: &Names) -> bool {
967    match resolve(s, names) {
968        Ok(Schema::Null) => true,
969        Ok(Schema::Union(u)) => u.is_nullable(),
970        _ => false,
971    }
972}
973
974fn join(path: &str, k: &str) -> String {
975    if path.is_empty() {
976        k.to_string()
977    } else {
978        format!("{path}.{k}")
979    }
980}
981
982fn short(v: &Value) -> String {
983    let s = v.to_string();
984    if s.len() > 60 {
985        format!("{}…", &s[..s.floor_char_boundary(60)])
986    } else {
987        s
988    }
989}
990
991fn float_from(v: &Value) -> Option<f64> {
992    match v {
993        Value::Number(n) => n.as_f64(),
994        Value::String(s) => match s.as_str() {
995            "NaN" => Some(f64::NAN),
996            "Infinity" => Some(f64::INFINITY),
997            "-Infinity" => Some(f64::NEG_INFINITY),
998            _ => None,
999        },
1000        _ => None,
1001    }
1002}
1003
1004fn bytes_from(v: &Value, text: BytesText) -> Result<Vec<u8>, String> {
1005    match v {
1006        Value::String(s) if text == BytesText::Utf8 => Ok(s.as_bytes().to_vec()),
1007        Value::String(s) if text == BytesText::Hex => unhex(s),
1008        Value::String(s) => {
1009            use base64::Engine as _;
1010            base64::engine::general_purpose::STANDARD
1011                .decode(s)
1012                .map_err(|_| format!("{s:?} is not base64"))
1013        }
1014        Value::Array(items) => items
1015            .iter()
1016            .map(|i| {
1017                i.as_u64()
1018                    .and_then(|n| u8::try_from(n).ok())
1019                    .ok_or_else(|| "byte array holds a non-byte".to_string())
1020            })
1021            .collect(),
1022        other => Err(format!("expected base64 bytes, got {}", short(other))),
1023    }
1024}
1025
1026fn time_from(v: &Value, nanos_per_unit: i64) -> Result<i64, String> {
1027    match v {
1028        Value::String(t) => {
1029            let parsed = NaiveTime::parse_from_str(t, "%H:%M:%S%.f")
1030                .map_err(|_| format!("{t:?} is not HH:MM:SS[.fff]"))?;
1031            let nanos = i64::from(parsed.num_seconds_from_midnight()) * 1_000_000_000
1032                + i64::from(parsed.nanosecond());
1033            Ok(nanos / nanos_per_unit)
1034        }
1035        other => other
1036            .as_i64()
1037            .ok_or_else(|| format!("expected a time, got {}", short(other))),
1038    }
1039}
1040
1041fn ts_from(v: &Value, nanos_per_unit: i64, utc: bool) -> Result<i64, String> {
1042    let t = match v {
1043        Value::String(t) => t,
1044        other => {
1045            return other
1046                .as_i64()
1047                .ok_or_else(|| format!("expected a timestamp, got {}", short(other)));
1048        }
1049    };
1050    let dt: NaiveDateTime = match DateTime::parse_from_rfc3339(t) {
1051        Ok(d) if utc => d.with_timezone(&Utc).naive_utc(),
1052        Ok(d) => d.naive_local(),
1053        Err(_) => [
1054            "%Y-%m-%dT%H:%M:%S%.f",
1055            "%Y-%m-%d %H:%M:%S%.f",
1056            "%Y-%m-%dT%H:%M:%S",
1057            "%Y-%m-%d %H:%M:%S",
1058        ]
1059        .iter()
1060        .find_map(|f| NaiveDateTime::parse_from_str(t, f).ok())
1061        .ok_or_else(|| format!("{t:?} is not an RFC 3339 / ISO 8601 timestamp"))?,
1062    };
1063    let nanos = dt
1064        .and_utc()
1065        .timestamp_nanos_opt()
1066        .map(i128::from)
1067        .unwrap_or_else(|| {
1068            i128::from(dt.and_utc().timestamp()) * 1_000_000_000
1069                + i128::from(dt.and_utc().timestamp_subsec_nanos())
1070        });
1071    i64::try_from(nanos / i128::from(nanos_per_unit)).map_err(|_| format!("{t:?} is out of range"))
1072}
1073
1074// ── Schema inference ─────────────────────────────────────────────────────────
1075
1076/// Infer an Avro record schema from records (see the module docs for rules).
1077pub fn infer_schema(records: &[Value]) -> Result<Schema, FaucetError> {
1078    let json_schema = crate::schema::infer_schema(records);
1079    let order = super::header_union(records);
1080    let avro =
1081        record_json(INFERRED_RECORD_NAME, &json_schema, Some(&order)).map_err(FaucetError::Sink)?;
1082    Schema::parse(&avro)
1083        .map_err(|e| FaucetError::Sink(format!("avro: inferred schema is invalid: {e}")))
1084}
1085
1086fn record_json(name: &str, js: &Value, order: Option<&[String]>) -> Result<Value, String> {
1087    let props = js
1088        .get("properties")
1089        .and_then(Value::as_object)
1090        .cloned()
1091        .unwrap_or_default();
1092    let keys: Vec<String> = match order {
1093        Some(o) => o
1094            .iter()
1095            .filter(|k| props.contains_key(*k))
1096            .cloned()
1097            .collect(),
1098        None => {
1099            let mut k: Vec<String> = props.keys().cloned().collect();
1100            k.sort();
1101            k
1102        }
1103    };
1104    let mut seen: HashMap<String, String> = HashMap::new();
1105    let mut fields = Vec::with_capacity(keys.len());
1106    for key in keys {
1107        let clean = avro_name(&key);
1108        if let Some(prev) = seen.insert(clean.clone(), key.clone()) {
1109            return Err(format!(
1110                "avro: fields {prev:?} and {key:?} both sanitize to the Avro name {clean:?}"
1111            ));
1112        }
1113        let (ty, nullable) = type_json(&props[&key], &format!("{name}_{clean}"))?;
1114        let mut f = Map::new();
1115        f.insert("name".into(), Value::String(clean.clone()));
1116        if nullable {
1117            f.insert("type".into(), json!(["null", ty]));
1118            f.insert("default".into(), Value::Null);
1119        } else {
1120            f.insert("type".into(), ty);
1121        }
1122        if clean != key {
1123            f.insert(ORIGINAL_NAME_ATTR.into(), Value::String(key));
1124        }
1125        fields.push(Value::Object(f));
1126    }
1127    Ok(json!({"type": "record", "name": name, "fields": fields}))
1128}
1129
1130fn type_json(js: &Value, name: &str) -> Result<(Value, bool), String> {
1131    let types: HashSet<&str> = match js.get("type") {
1132        Some(Value::String(t)) => std::iter::once(t.as_str()).collect(),
1133        Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(),
1134        _ => HashSet::new(),
1135    };
1136    let nullable = types.contains("null");
1137    let non_null: Vec<&str> = types.iter().copied().filter(|t| *t != "null").collect();
1138    let ty = match non_null.as_slice() {
1139        [] => return Ok((json!("null"), false)),
1140        ["boolean"] => json!("boolean"),
1141        ["integer"] => json!("long"),
1142        ["number"] => json!("double"),
1143        ["string"] => json!("string"),
1144        ["array"] => {
1145            let items = match js.get("items") {
1146                Some(i) => {
1147                    let (t, n) = type_json(i, &format!("{name}_item"))?;
1148                    if n { json!(["null", t]) } else { t }
1149                }
1150                None => json!(["null", "string"]),
1151            };
1152            json!({"type": "array", "items": items})
1153        }
1154        ["object"] => record_json(name, js, None)?,
1155        _ => json!("string"),
1156    };
1157    Ok((ty, nullable))
1158}
1159
1160/// A valid Avro name for `s`: `[A-Za-z_][A-Za-z0-9_]*`.
1161pub fn avro_name(s: &str) -> String {
1162    let mut out: String = s
1163        .chars()
1164        .map(|c| {
1165            if c.is_ascii_alphanumeric() || c == '_' {
1166                c
1167            } else {
1168                '_'
1169            }
1170        })
1171        .collect();
1172    if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) {
1173        out.insert(0, '_');
1174    }
1175    out
1176}
1177
1178// ── Arrow ────────────────────────────────────────────────────────────────────
1179
1180/// The Arrow schema the records of `schema` decode into (see the module docs).
1181#[cfg(feature = "arrow")]
1182pub fn arrow_schema(schema: &Schema) -> Result<arrow::datatypes::SchemaRef, FaucetError> {
1183    use arrow::datatypes::{Field, Schema as ArrowSchema};
1184    let names = named(schema);
1185    let mut stack = Vec::new();
1186    let root = resolve(schema, &names)?;
1187    let fields: Vec<Field> = match root {
1188        Schema::Record(r) => {
1189            stack.push(r.name.fullname(None));
1190            r.fields
1191                .iter()
1192                .map(|f| {
1193                    let (dt, nullable) = arrow_type(&f.schema, &names, &mut stack)?;
1194                    Ok(Field::new(&f.name, dt, nullable))
1195                })
1196                .collect::<Result<_, FaucetError>>()?
1197        }
1198        other => {
1199            let (dt, nullable) = arrow_type(other, &names, &mut stack)?;
1200            vec![Field::new(VALUE_FIELD, dt, nullable)]
1201        }
1202    };
1203    Ok(std::sync::Arc::new(ArrowSchema::new(fields)))
1204}
1205
1206#[cfg(feature = "arrow")]
1207fn arrow_type(
1208    schema: &Schema,
1209    names: &Names,
1210    stack: &mut Vec<String>,
1211) -> Result<(arrow::datatypes::DataType, bool), FaucetError> {
1212    use arrow::datatypes::{DataType as D, Field, Fields, TimeUnit};
1213    use std::sync::Arc;
1214    if let Some(n) = ref_name(schema)
1215        && stack.contains(&n)
1216    {
1217        return Ok((D::Utf8, true));
1218    }
1219    let s = resolve(schema, names)?;
1220    let utc: Option<Arc<str>> = Some("UTC".into());
1221    Ok(match s {
1222        Schema::Null => (D::Null, true),
1223        Schema::Boolean => (D::Boolean, false),
1224        Schema::Int => (D::Int32, false),
1225        Schema::Long => (D::Int64, false),
1226        Schema::Float => (D::Float32, false),
1227        Schema::Double => (D::Float64, false),
1228        Schema::Bytes => (D::Binary, false),
1229        Schema::String | Schema::Enum(_) | Schema::Uuid(_) | Schema::BigDecimal => (D::Utf8, false),
1230        Schema::Fixed(f) => (
1231            D::FixedSizeBinary(i32::try_from(f.size).unwrap_or(i32::MAX)),
1232            false,
1233        ),
1234        Schema::Decimal(d) => match (u8::try_from(d.precision), i8::try_from(d.scale)) {
1235            (Ok(p), Ok(sc)) if p <= 38 => (D::Decimal128(p, sc), false),
1236            (Ok(p), Ok(sc)) if p <= 76 => (D::Decimal256(p, sc), false),
1237            _ => (D::Utf8, false),
1238        },
1239        Schema::Date => (D::Date32, false),
1240        Schema::TimeMillis => (D::Time32(TimeUnit::Millisecond), false),
1241        Schema::TimeMicros => (D::Time64(TimeUnit::Microsecond), false),
1242        Schema::TimestampMillis => (D::Timestamp(TimeUnit::Millisecond, utc), false),
1243        Schema::TimestampMicros => (D::Timestamp(TimeUnit::Microsecond, utc), false),
1244        Schema::TimestampNanos => (D::Timestamp(TimeUnit::Nanosecond, utc), false),
1245        Schema::LocalTimestampMillis => (D::Timestamp(TimeUnit::Millisecond, None), false),
1246        Schema::LocalTimestampMicros => (D::Timestamp(TimeUnit::Microsecond, None), false),
1247        Schema::LocalTimestampNanos => (D::Timestamp(TimeUnit::Nanosecond, None), false),
1248        Schema::Duration(_) => (
1249            D::Struct(Fields::from(vec![
1250                Field::new("months", D::Int64, false),
1251                Field::new("days", D::Int64, false),
1252                Field::new("millis", D::Int64, false),
1253            ])),
1254            false,
1255        ),
1256        Schema::Array(a) => {
1257            let (dt, n) = arrow_type(&a.items, names, stack)?;
1258            (D::List(Arc::new(Field::new("item", dt, n))), false)
1259        }
1260        Schema::Map(m) => {
1261            let (dt, n) = arrow_type(&m.types, names, stack)?;
1262            let entries = Field::new(
1263                "entries",
1264                D::Struct(Fields::from(vec![
1265                    Field::new("key", D::Utf8, false),
1266                    Field::new("value", dt, n),
1267                ])),
1268                false,
1269            );
1270            (D::Map(Arc::new(entries), false), false)
1271        }
1272        Schema::Record(r) => {
1273            stack.push(r.name.fullname(None));
1274            let fields = r
1275                .fields
1276                .iter()
1277                .map(|f| {
1278                    let (dt, n) = arrow_type(&f.schema, names, stack)?;
1279                    Ok(Field::new(&f.name, dt, n))
1280                })
1281                .collect::<Result<Vec<_>, FaucetError>>();
1282            stack.pop();
1283            (D::Struct(Fields::from(fields?)), false)
1284        }
1285        Schema::Union(u) => {
1286            let non_null: Vec<&Schema> = u
1287                .variants()
1288                .iter()
1289                .filter(|v| !matches!(v, Schema::Null))
1290                .collect();
1291            match non_null.as_slice() {
1292                [] => (D::Null, true),
1293                [one] => {
1294                    let (dt, _) = arrow_type(one, names, stack)?;
1295                    (dt, u.is_nullable())
1296                }
1297                _ => (D::Utf8, u.is_nullable()),
1298            }
1299        }
1300        Schema::Ref { .. } => unreachable!("resolved above"),
1301    })
1302}
1303
1304/// Stream an OCF as Arrow batches of at most `batch_size` rows (0 = one batch).
1305#[cfg(feature = "arrow")]
1306pub fn read_batches<R: std::io::Read>(
1307    reader: R,
1308    reader_schema: Option<&Schema>,
1309    batch_size: usize,
1310    f: &mut dyn FnMut(arrow::array::RecordBatch) -> Result<(), FaucetError>,
1311) -> Result<(Schema, arrow::datatypes::SchemaRef), FaucetError> {
1312    read_batches_checked(reader, reader_schema, None, batch_size, f)
1313}
1314
1315/// [`read_batches`] with a writer-schema check run before the first row.
1316#[cfg(feature = "arrow")]
1317pub(crate) fn read_batches_checked<R: std::io::Read>(
1318    reader: R,
1319    reader_schema: Option<&Schema>,
1320    check: Option<&WriterCheck<'_>>,
1321    batch_size: usize,
1322    f: &mut dyn FnMut(arrow::array::RecordBatch) -> Result<(), FaucetError>,
1323) -> Result<(Schema, arrow::datatypes::SchemaRef), FaucetError> {
1324    let chunk = if batch_size == 0 {
1325        usize::MAX
1326    } else {
1327        batch_size
1328    };
1329    let mut arrow: Option<arrow::datatypes::SchemaRef> = None;
1330    let schema = read_with(
1331        reader,
1332        reader_schema,
1333        check,
1334        chunk,
1335        Mode::Arrow,
1336        &mut |rows, schema| {
1337            let target = match &arrow {
1338                Some(a) => a.clone(),
1339                None => {
1340                    let a = arrow_schema(schema)?;
1341                    arrow = Some(a.clone());
1342                    a
1343                }
1344            };
1345            f(crate::columnar::values_to_record_batch(&rows, target)?)
1346        },
1347    )?;
1348    let arrow = match arrow {
1349        Some(a) => a,
1350        None => arrow_schema(&schema)?,
1351    };
1352    Ok((schema, arrow))
1353}
1354
1355#[cfg(test)]
1356mod tests {
1357    use super::*;
1358
1359    /// #862 L-034: a complex-union value stringified for an Arrow column is
1360    /// canonical JSON (sorted keys), whatever the record's field order.
1361    #[test]
1362    fn complex_union_text_is_canonical_json() {
1363        let schema = Schema::parse_str(
1364            r#"["null", "string", {"type":"record","name":"r","fields":[
1365                {"name":"z","type":"long"},{"name":"a","type":"long"}]}]"#,
1366        )
1367        .unwrap();
1368        let datum = Av::Union(
1369            2,
1370            Box::new(Av::Record(vec![
1371                ("z".into(), Av::Long(1)),
1372                ("a".into(), Av::Long(2)),
1373            ])),
1374        );
1375        let names = named(&schema);
1376        let v = to_json(&datum, &schema, &names, Mode::Arrow, &mut Vec::new()).unwrap();
1377        assert_eq!(v, json!(r#"{"a":2,"z":1}"#));
1378    }
1379
1380    #[test]
1381    fn a_prepared_codec_matches_the_free_functions() {
1382        let schema = Schema::parse_str(
1383            r#"{"type":"record","name":"R","fields":[
1384                {"name":"a","type":"long"},
1385                {"name":"n","type":["null",{"type":"record","name":"In","fields":[{"name":"x","type":"string"}]}]},
1386                {"name":"again","type":["null","In"]}]}"#,
1387        )
1388        .unwrap();
1389        let codec = AvroDatumCodec::new(schema.clone());
1390        assert_eq!(codec.schema(), &schema);
1391        let json = serde_json::json!({"a": 1, "n": {"x": "y"}, "again": {"x": "z"}});
1392        let datum = codec.from_json(&json, BytesText::Hex).unwrap();
1393        assert_eq!(
1394            datum,
1395            datum_from_json_with(&json, &schema, BytesText::Hex).unwrap()
1396        );
1397        assert_eq!(
1398            codec.to_json(&datum).unwrap(),
1399            datum_to_json(&datum, &schema).unwrap()
1400        );
1401        assert_eq!(codec.to_json(&datum).unwrap(), json);
1402    }
1403
1404    #[test]
1405    fn datum_conversion_keeps_logical_types_exact_and_round_trips() {
1406        let schema = Schema::parse_str(
1407            r#"{"type":"record","name":"r","fields":[
1408                {"name":"amount","type":{"type":"bytes","logicalType":"decimal","precision":10,"scale":2}},
1409                {"name":"raw","type":"bytes"},
1410                {"name":"day","type":{"type":"int","logicalType":"date"}}
1411            ]}"#,
1412        )
1413        .unwrap();
1414        let v = json!({"amount": "12.34", "raw": "0aff", "day": "2026-10-07"});
1415        let datum = datum_from_json(&v, &schema).unwrap();
1416        assert_eq!(datum_to_json(&datum, &schema).unwrap(), v);
1417        assert_eq!(
1418            datum_to_json(&Av::Long(7), &Schema::Long).unwrap(),
1419            json!(7),
1420            "a non-record datum is not wrapped"
1421        );
1422        let err = datum_from_json(&json!({"amount": 1}), &schema).unwrap_err();
1423        assert!(
1424            matches!(&err, FaucetError::Sink(m) if m.starts_with("json->avro: ")),
1425            "{err}"
1426        );
1427    }
1428
1429    fn logical_schema() -> Value {
1430        json!({
1431            "type": "record",
1432            "name": "payment",
1433            "namespace": "test",
1434            "fields": [
1435                {"name": "id", "type": "long"},
1436                {"name": "amount", "type": {"type": "bytes", "logicalType": "decimal", "precision": 10, "scale": 2}},
1437                {"name": "fee", "type": {"type": "fixed", "name": "fee_t", "size": 8, "logicalType": "decimal", "precision": 12, "scale": 3}},
1438                {"name": "day", "type": {"type": "int", "logicalType": "date"}},
1439                {"name": "at", "type": {"type": "long", "logicalType": "timestamp-micros"}},
1440                {"name": "at_ms", "type": {"type": "long", "logicalType": "timestamp-millis"}},
1441                {"name": "at_ns", "type": {"type": "long", "logicalType": "timestamp-nanos"}},
1442                {"name": "local", "type": {"type": "long", "logicalType": "local-timestamp-micros"}},
1443                {"name": "local_ms", "type": {"type": "long", "logicalType": "local-timestamp-millis"}},
1444                {"name": "local_ns", "type": {"type": "long", "logicalType": "local-timestamp-nanos"}},
1445                {"name": "t_ms", "type": {"type": "int", "logicalType": "time-millis"}},
1446                {"name": "t_us", "type": {"type": "long", "logicalType": "time-micros"}},
1447                {"name": "uid", "type": {"type": "string", "logicalType": "uuid"}},
1448                {"name": "span", "type": {"type": "fixed", "name": "dur", "size": 12, "logicalType": "duration"}},
1449                {"name": "note", "type": ["null", "string"], "default": null},
1450                {"name": "either", "type": ["null", "long", "string"], "default": null},
1451                {"name": "kind", "type": {"type": "enum", "name": "kind_t", "symbols": ["A", "B"]}},
1452                {"name": "raw", "type": "bytes"},
1453                {"name": "tags", "type": {"type": "array", "items": "string"}},
1454                {"name": "attrs", "type": {"type": "map", "values": "int"}},
1455                {"name": "ratio", "type": "float"},
1456                {"name": "score", "type": "double"},
1457                {"name": "ok", "type": "boolean"},
1458                {"name": "small", "type": "int"},
1459                {"name": "big", "type": {"type": "bytes", "logicalType": "big-decimal"}}
1460            ]
1461        })
1462    }
1463
1464    fn logical_record() -> Value {
1465        json!({
1466            "id": 7,
1467            "amount": "-1234.50",
1468            "fee": "0.125",
1469            "day": "2024-02-29",
1470            "at": "2024-02-29T12:34:56.123456Z",
1471            "at_ms": "2024-02-29T12:34:56.123Z",
1472            "at_ns": "2024-02-29T12:34:56.123456789Z",
1473            "local": "2024-02-29T12:34:56.123456",
1474            "local_ms": "2024-02-29T12:34:56.123",
1475            "local_ns": "2024-02-29T12:34:56.123456789",
1476            "t_ms": "01:02:03.004",
1477            "t_us": "01:02:03.000004",
1478            "uid": "6f1c2b1e-4b8a-4b8e-9a38-3c3f3f0f9e11",
1479            "span": {"months": 1, "days": 2, "millis": 3},
1480            "note": null,
1481            "either": "text",
1482            "kind": "B",
1483            "raw": "AP8Q",
1484            "tags": ["x", "y"],
1485            "attrs": {"a": 1, "b": 2},
1486            "ratio": 0.5,
1487            "score": "NaN",
1488            "ok": true,
1489            "small": 3,
1490            "big": "123456789012345678901234567890.5"
1491        })
1492    }
1493
1494    #[test]
1495    fn logical_types_round_trip_through_an_ocf() {
1496        for codec in [
1497            AvroCodec::Null,
1498            AvroCodec::Deflate,
1499            AvroCodec::Snappy,
1500            AvroCodec::Zstd,
1501        ] {
1502            let opts = AvroOptions {
1503                schema: Some(logical_schema()),
1504                codec,
1505            };
1506            let bytes = encode(&[logical_record()], &opts).expect("encode");
1507            let back = decode(&bytes, &AvroOptions::default()).expect("decode");
1508            let mut expected = logical_record();
1509            expected["score"] = Value::Null;
1510            assert_eq!(back, vec![expected], "{codec:?}");
1511        }
1512    }
1513
1514    #[test]
1515    fn epoch_integers_are_accepted_for_temporal_types() {
1516        let schema = json!({"type": "record", "name": "r", "fields": [
1517            {"name": "d", "type": {"type": "int", "logicalType": "date"}},
1518            {"name": "t", "type": {"type": "long", "logicalType": "timestamp-millis"}},
1519            {"name": "tm", "type": {"type": "int", "logicalType": "time-millis"}}
1520        ]});
1521        let opts = AvroOptions {
1522            schema: Some(schema),
1523            ..Default::default()
1524        };
1525        let bytes = encode(&[json!({"d": 1, "t": 1000, "tm": 1500})], &opts).expect("encode");
1526        let back = decode(&bytes, &AvroOptions::default()).expect("decode");
1527        assert_eq!(
1528            back[0],
1529            json!({"d": "1970-01-02", "t": "1970-01-01T00:00:01.000Z", "tm": "00:00:01.500"})
1530        );
1531    }
1532
1533    #[test]
1534    fn inferred_schema_widens_nullable_nested_and_mixed_fields() {
1535        let recs = vec![
1536            json!({"id": 1, "name": "a", "first-name": "x", "nested": {"k": 1.5}, "mixed": 1, "list": [1, 2], "empty": []}),
1537            json!({"id": 2, "name": null, "first-name": "y", "nested": {"k": 2}, "mixed": "two", "list": [], "empty": []}),
1538        ];
1539        let bytes = encode(&recs, &AvroOptions::default()).expect("encode");
1540        let schema = writer_schema(&bytes[..]).expect("schema");
1541        let text = serde_json::to_string(&schema).expect("json");
1542        assert!(text.contains("\"first_name\""), "{text}");
1543        assert!(text.contains(ORIGINAL_NAME_ATTR), "{text}");
1544        let back = decode(&bytes, &AvroOptions::default()).expect("decode");
1545        assert_eq!(back[0]["id"], json!(1));
1546        assert_eq!(back[1]["name"], Value::Null);
1547        assert_eq!(back[0]["first_name"], json!("x"));
1548        assert_eq!(back[1]["nested"], json!({"k": 2.0}));
1549        assert_eq!(back[0]["mixed"], json!("1"));
1550        assert_eq!(back[1]["mixed"], json!("two"));
1551        assert_eq!(back[0]["list"], json!([1, 2]));
1552        assert_eq!(back[0]["empty"], json!([]));
1553    }
1554
1555    #[test]
1556    fn inference_refuses_colliding_sanitized_names() {
1557        let err =
1558            encode(&[json!({"a-b": 1, "a_b": 2})], &AvroOptions::default()).expect_err("collision");
1559        assert!(err.to_string().contains("sanitize"), "{err}");
1560    }
1561
1562    #[test]
1563    fn avro_names_are_sanitized() {
1564        assert_eq!(avro_name("ok_1"), "ok_1");
1565        assert_eq!(avro_name("first name"), "first_name");
1566        assert_eq!(avro_name("1st"), "_1st");
1567        assert_eq!(avro_name(""), "_");
1568    }
1569
1570    #[test]
1571    fn reader_schema_projects_and_defaults_added_fields() {
1572        let bytes = encode(&[json!({"a": 1, "b": "x"})], &AvroOptions::default()).expect("encode");
1573        let reader = json!({"type": "record", "name": INFERRED_RECORD_NAME, "fields": [
1574            {"name": "b", "type": "string"},
1575            {"name": "c", "type": "long", "default": 9}
1576        ]});
1577        let back = decode(
1578            &bytes,
1579            &AvroOptions {
1580                schema: Some(reader),
1581                ..Default::default()
1582            },
1583        )
1584        .expect("decode");
1585        assert_eq!(back, vec![json!({"b": "x", "c": 9})]);
1586    }
1587
1588    #[test]
1589    fn a_non_record_root_is_wrapped_in_value() {
1590        let opts = AvroOptions {
1591            schema: Some(json!("long")),
1592            ..Default::default()
1593        };
1594        let bytes = encode(&[json!({"value": 5}), json!(6)], &opts).expect("encode");
1595        let back = decode(&bytes, &AvroOptions::default()).expect("decode");
1596        assert_eq!(back, vec![json!({"value": 5}), json!({"value": 6})]);
1597    }
1598
1599    #[test]
1600    fn recursive_records_decode_as_nested_objects() {
1601        let schema = json!({"type": "record", "name": "node", "fields": [
1602            {"name": "v", "type": "long"},
1603            {"name": "next", "type": ["null", "node"], "default": null}
1604        ]});
1605        let rec = json!({"v": 1, "next": {"v": 2, "next": null}});
1606        let opts = AvroOptions {
1607            schema: Some(schema),
1608            ..Default::default()
1609        };
1610        let bytes = encode(std::slice::from_ref(&rec), &opts).expect("encode");
1611        assert_eq!(
1612            decode(&bytes, &AvroOptions::default()).expect("decode"),
1613            vec![rec]
1614        );
1615    }
1616
1617    #[test]
1618    fn encoding_errors_name_the_field() {
1619        let schema = json!({"type": "record", "name": "r", "fields": [
1620            {"name": "n", "type": "int"},
1621            {"name": "req", "type": "string"}
1622        ]});
1623        let opts = AvroOptions {
1624            schema: Some(schema),
1625            ..Default::default()
1626        };
1627        let err = encode(&[json!({"n": "x", "req": "a"})], &opts).expect_err("type");
1628        assert!(err.to_string().contains("`n`"), "{err}");
1629        let err = encode(&[json!({"n": 1})], &opts).expect_err("missing");
1630        assert!(err.to_string().contains("required"), "{err}");
1631        let err = encode(&[json!({"n": 1i64 << 40, "req": "a"})], &opts).expect_err("range");
1632        assert!(err.to_string().contains("does not fit"), "{err}");
1633    }
1634
1635    #[test]
1636    fn invalid_schemas_and_bodies_are_typed_errors() {
1637        let opts = AvroOptions {
1638            schema: Some(json!({"type": "nope"})),
1639            ..Default::default()
1640        };
1641        assert!(matches!(opts.parsed_schema(), Err(FaucetError::Config(_))));
1642        let err = decode(b"not avro", &AvroOptions::default()).expect_err("garbage");
1643        assert!(err.to_string().contains("avro"), "{err}");
1644    }
1645
1646    #[test]
1647    fn decimals_render_and_parse_exactly() {
1648        let cases = [
1649            ("0", 0),
1650            ("-1", 0),
1651            ("12.30", 2),
1652            ("-0.05", 2),
1653            ("170141183460469231731687303715884105727", 0),
1654        ];
1655        for (text, scale) in cases {
1656            let bytes = decimal_from_str(text, scale, 60).expect(text);
1657            assert_eq!(decimal_to_string(&bytes, scale), text, "{text}");
1658        }
1659        assert_eq!(
1660            decimal_to_string(&decimal_from_str("1.5e1", 1, 5).expect("exp"), 1),
1661            "15.0"
1662        );
1663        assert_eq!(
1664            decimal_to_string(&decimal_from_str("1.230", 2, 5).expect("trailing zero"), 2),
1665            "1.23"
1666        );
1667        assert!(decimal_from_str("1.234", 2, 5).is_err());
1668        // A huge exponent is refused before any padding (CORE-35).
1669        for huge in ["1e18", "1e9223372036854775807", "-5e400000000000"] {
1670            let err = decimal_from_str(huge, 2, 10).unwrap_err();
1671            assert!(err.contains("precision"), "{huge}: {err}");
1672        }
1673        assert_eq!(
1674            decimal_to_string(&decimal_from_str("0e400000000000", 2, 5).expect("zero"), 2),
1675            "0.00"
1676        );
1677        assert!(decimal_from_str("1e-9223372036854775807", 2, 5).is_err());
1678        assert!(decimal_from_str("123456", 0, 5).is_err());
1679        assert!(decimal_from_str("abc", 0, 5).is_err());
1680        assert!(decimal_from_str("1e-9", 2, 5).is_err());
1681        assert_eq!(decimal_from_str("0e-9", 2, 5).expect("zero"), vec![0]);
1682        assert_eq!(
1683            sign_extend(vec![0xff], 3).expect("neg"),
1684            vec![0xff, 0xff, 0xff]
1685        );
1686        assert!(sign_extend(vec![1, 2, 3], 2).is_err());
1687    }
1688
1689    #[test]
1690    fn value_coercions_and_their_refusals() {
1691        let names = HashMap::new();
1692        assert!(
1693            matches!(from_json(&json!(1), &Schema::String, &names, BytesText::Hex, "s"), Ok(Av::String(s)) if s == "1")
1694        );
1695        assert!(from_json(&Value::Null, &Schema::String, &names, BytesText::Hex, "s").is_err());
1696        assert!(from_json(&json!(1), &Schema::Null, &names, BytesText::Hex, "s").is_err());
1697        assert!(
1698            matches!(from_json(&json!([1, 2]), &Schema::Bytes, &names, BytesText::Hex, "b"), Ok(Av::Bytes(b)) if b == vec![1, 2])
1699        );
1700        assert!(from_json(&json!("abc"), &Schema::Bytes, &names, BytesText::Hex, "b").is_err());
1701        assert!(from_json(&json!("zz"), &Schema::Bytes, &names, BytesText::Hex, "b").is_err());
1702        assert!(from_json(&json!([300]), &Schema::Bytes, &names, BytesText::Hex, "b").is_err());
1703        assert!(from_json(&json!(true), &Schema::Bytes, &names, BytesText::Hex, "b").is_err());
1704        assert!(
1705            from_json(
1706                &json!("Infinity"),
1707                &Schema::Double,
1708                &names,
1709                BytesText::Hex,
1710                "d"
1711            )
1712            .is_ok()
1713        );
1714        assert!(
1715            from_json(
1716                &json!("-Infinity"),
1717                &Schema::Double,
1718                &names,
1719                BytesText::Hex,
1720                "d"
1721            )
1722            .is_ok()
1723        );
1724        assert!(from_json(&json!("x"), &Schema::Double, &names, BytesText::Hex, "d").is_err());
1725        assert!(from_json(&json!("x"), &Schema::Date, &names, BytesText::Hex, "d").is_err());
1726        assert!(
1727            from_json(
1728                &json!("x"),
1729                &Schema::TimestampMicros,
1730                &names,
1731                BytesText::Hex,
1732                "d"
1733            )
1734            .is_err()
1735        );
1736        assert!(
1737            from_json(
1738                &json!("2024-01-01 00:00:00"),
1739                &Schema::TimestampMicros,
1740                &names,
1741                BytesText::Hex,
1742                "d"
1743            )
1744            .is_ok()
1745        );
1746        assert!(
1747            from_json(
1748                &json!("2024-01-01T00:00:00+02:00"),
1749                &Schema::LocalTimestampMicros,
1750                &names,
1751                BytesText::Hex,
1752                "d"
1753            )
1754            .is_ok()
1755        );
1756        assert!(
1757            from_json(
1758                &json!(true),
1759                &Schema::TimeMicros,
1760                &names,
1761                BytesText::Hex,
1762                "d"
1763            )
1764            .is_err()
1765        );
1766        assert!(
1767            from_json(
1768                &json!("bad"),
1769                &Schema::TimeMicros,
1770                &names,
1771                BytesText::Hex,
1772                "d"
1773            )
1774            .is_err()
1775        );
1776        assert!(from_json(&json!(5), &Schema::TimeMicros, &names, BytesText::Hex, "d").is_ok());
1777        assert!(
1778            from_json(
1779                &json!(true),
1780                &Schema::TimestampMicros,
1781                &names,
1782                BytesText::Hex,
1783                "d"
1784            )
1785            .is_err()
1786        );
1787        assert!(
1788            from_json(
1789                &json!({"months": 1}),
1790                &parse_schema(
1791                    &json!({"type": "fixed", "name": "d", "size": 12, "logicalType": "duration"})
1792                )
1793                .unwrap(),
1794                &names,
1795                BytesText::Hex,
1796                "d"
1797            )
1798            .is_err()
1799        );
1800        let e = parse_schema(&json!({"type": "enum", "name": "e", "symbols": ["A"]})).unwrap();
1801        assert!(from_json(&json!("Z"), &e, &named(&e), BytesText::Hex, "e").is_err());
1802        let f = parse_schema(&json!({"type": "fixed", "name": "f", "size": 2})).unwrap();
1803        assert!(from_json(&json!("00"), &f, &named(&f), BytesText::Hex, "f").is_err());
1804        let u = parse_schema(&json!({"type": "string", "logicalType": "uuid"})).unwrap();
1805        assert!(from_json(&json!("nope"), &u, &names, BytesText::Hex, "u").is_err());
1806        let bd = parse_schema(&json!({"type": "bytes", "logicalType": "big-decimal"})).unwrap();
1807        assert!(from_json(&json!(1.5), &bd, &names, BytesText::Hex, "b").is_ok());
1808        assert!(from_json(&json!("x"), &bd, &names, BytesText::Hex, "b").is_err());
1809        assert!(from_json(&json!(true), &bd, &names, BytesText::Hex, "b").is_err());
1810        let dec = parse_schema(
1811            &json!({"type": "bytes", "logicalType": "decimal", "precision": 4, "scale": 1}),
1812        )
1813        .unwrap();
1814        assert!(from_json(&json!(1.5), &dec, &names, BytesText::Hex, "d").is_ok());
1815        assert!(from_json(&json!(true), &dec, &names, BytesText::Hex, "d").is_err());
1816        let un = parse_schema(&json!(["long", "boolean"])).unwrap();
1817        assert!(from_json(&json!("x"), &un, &names, BytesText::Hex, "u").is_err());
1818        assert!(from_json(&Value::Null, &un, &names, BytesText::Hex, "u").is_err());
1819        assert!(short(&json!("x".repeat(100))).ends_with('…'));
1820    }
1821
1822    #[test]
1823    fn utf8_bytes_text_reads_strings_as_their_bytes() {
1824        let schema = parse_schema(&json!({"type": "record", "name": "r", "fields": [
1825            {"name": "raw", "type": "bytes"},
1826            {"name": "tag", "type": {"type": "fixed", "name": "t", "size": 4}}
1827        ]}))
1828        .unwrap();
1829        let v = json!({"raw": "cafe", "tag": "abcd"});
1830        assert_eq!(
1831            datum_from_json_with(&v, &schema, BytesText::Utf8).unwrap(),
1832            Av::Record(vec![
1833                ("raw".into(), Av::Bytes(b"cafe".to_vec())),
1834                ("tag".into(), Av::Fixed(4, b"abcd".to_vec())),
1835            ])
1836        );
1837        assert!(datum_from_json(&json!({"raw": "hello", "tag": "abcd"}), &schema).is_err());
1838        assert!(
1839            datum_from_json_with(
1840                &json!({"raw": [1, 2], "tag": "abcd"}),
1841                &schema,
1842                BytesText::Utf8
1843            )
1844            .is_ok()
1845        );
1846        assert_eq!(BytesText::default(), BytesText::Base64);
1847        assert_eq!(
1848            datum_from_json_with(
1849                &json!({"raw": "cafe", "tag": "61626364"}),
1850                &schema,
1851                BytesText::Hex
1852            )
1853            .unwrap(),
1854            Av::Record(vec![
1855                ("raw".into(), Av::Bytes(vec![0xca, 0xfe])),
1856                ("tag".into(), Av::Fixed(4, b"abcd".to_vec())),
1857            ])
1858        );
1859    }
1860
1861    #[test]
1862    fn out_of_range_temporal_values_are_errors() {
1863        assert!(date_string(i32::MAX).is_err());
1864        assert!(time_string(-1, 3).is_err());
1865        assert!(utc_string(i64::MAX, 1_000_000, SecondsFormat::Millis).is_err());
1866        assert!(local_string(i64::MAX, 1_000_000, 3).is_err());
1867        assert_eq!(crate::fidelity::float64(f64::NEG_INFINITY), Value::Null);
1868    }
1869
1870    #[cfg(feature = "arrow")]
1871    #[test]
1872    fn arrow_batches_carry_logical_types() {
1873        use arrow::datatypes::{DataType as D, TimeUnit};
1874        let opts = AvroOptions {
1875            schema: Some(logical_schema()),
1876            ..Default::default()
1877        };
1878        let rec = logical_record();
1879        let bytes = encode(&[rec.clone(), rec], &opts).expect("encode");
1880        let mut batches = Vec::new();
1881        let (_, schema) = read_batches(&bytes[..], None, 1, &mut |b| {
1882            batches.push(b);
1883            Ok(())
1884        })
1885        .expect("batches");
1886        assert_eq!(batches.len(), 2);
1887        let ty = |n: &str| schema.field_with_name(n).expect(n).data_type().clone();
1888        assert_eq!(ty("amount"), D::Decimal128(10, 2));
1889        assert_eq!(ty("day"), D::Date32);
1890        assert_eq!(
1891            ty("at"),
1892            D::Timestamp(TimeUnit::Microsecond, Some("UTC".into()))
1893        );
1894        assert_eq!(ty("local"), D::Timestamp(TimeUnit::Microsecond, None));
1895        assert_eq!(ty("t_ms"), D::Time32(TimeUnit::Millisecond));
1896        assert_eq!(ty("either"), D::Utf8);
1897        assert!(schema.field_with_name("note").unwrap().is_nullable());
1898        let back = crate::columnar::record_batch_to_values(&batches[0]).expect("values");
1899        let amount = batches[0]
1900            .column_by_name("amount")
1901            .and_then(|c| c.as_any().downcast_ref::<arrow::array::Decimal128Array>())
1902            .expect("decimal column");
1903        assert_eq!(amount.value(0), -123_450);
1904        assert_eq!(back[0]["day"], json!("2024-02-29"));
1905        assert_eq!(back[0]["either"], json!("\"text\""));
1906    }
1907
1908    #[cfg(feature = "arrow")]
1909    #[test]
1910    fn arrow_schema_handles_roots_recursion_and_wide_decimals() {
1911        use arrow::datatypes::DataType as D;
1912        let s = parse_schema(&json!("long")).unwrap();
1913        assert_eq!(arrow_schema(&s).unwrap().field(0).name(), VALUE_FIELD);
1914        let rec = parse_schema(&json!({"type": "record", "name": "node", "fields": [
1915            {"name": "next", "type": ["null", "node"], "default": null},
1916            {"name": "wide", "type": {"type": "bytes", "logicalType": "decimal", "precision": 50, "scale": 2}},
1917            {"name": "huge", "type": {"type": "bytes", "logicalType": "decimal", "precision": 90, "scale": 2}},
1918            {"name": "only_null", "type": ["null"]},
1919            {"name": "n", "type": "null"}
1920        ]}))
1921        .unwrap();
1922        let a = arrow_schema(&rec).unwrap();
1923        assert_eq!(a.field(0).data_type(), &D::Utf8);
1924        assert_eq!(a.field(1).data_type(), &D::Decimal256(50, 2));
1925        assert_eq!(a.field(2).data_type(), &D::Utf8);
1926        assert_eq!(a.field(3).data_type(), &D::Null);
1927        let opts = AvroOptions {
1928            schema: Some(json!({"type": "record", "name": "node", "fields": [
1929                {"name": "v", "type": "long"},
1930                {"name": "next", "type": ["null", "node"], "default": null}
1931            ]})),
1932            ..Default::default()
1933        };
1934        let bytes = encode(&[json!({"v": 1, "next": {"v": 2, "next": null}})], &opts).unwrap();
1935        let mut rows = Vec::new();
1936        read_batches(&bytes[..], None, 0, &mut |b| {
1937            rows.extend(crate::columnar::record_batch_to_values(&b)?);
1938            Ok(())
1939        })
1940        .unwrap();
1941        let next: Value = serde_json::from_str(rows[0]["next"].as_str().unwrap()).unwrap();
1942        assert_eq!(next, json!({"v": 2, "next": null}));
1943    }
1944}