paimon 0.2.0

The rust implementation of Apache Paimon
Documentation
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

use crate::Error;
use serde_json::Value;

/// Lightweight representation of an Avro writer schema, used to build
/// field-index mappings for schema-evolution-aware decoding.
#[derive(Debug)]
pub struct WriterSchema {
    /// The top-level record fields (after unwrapping a union wrapper if present).
    pub fields: Vec<WriterField>,
    /// Whether the original schema was a union (e.g. `["null", record]`).
    pub is_union_wrapped: bool,
}

#[derive(Debug)]
pub struct WriterField {
    pub name: String,
    pub schema: FieldSchema,
    pub nullable: bool,
}

/// Simplified Avro field schema — just enough to know how to skip unknown fields.
#[derive(Debug)]
pub enum FieldSchema {
    Null,
    Boolean,
    Int,
    Long,
    Float,
    Double,
    Bytes,
    String,
    Fixed(usize),
    Enum { symbols_count: usize },
    Union(Vec<FieldSchema>),
    Array(Box<FieldSchema>),
    Map(Box<FieldSchema>),
    Record(WriterSchema),
}

impl WriterSchema {
    pub fn parse(json: &str) -> crate::Result<Self> {
        let value: Value = serde_json::from_str(json).map_err(|e| Error::UnexpectedError {
            message: format!("avro schema: invalid JSON: {e}"),
            source: None,
        })?;
        parse_record_from_value(&value)
    }
}

fn parse_record_from_value(value: &Value) -> crate::Result<WriterSchema> {
    match value {
        Value::Object(obj) if obj.get("type").and_then(|t| t.as_str()) == Some("record") => {
            let mut ws = parse_record_object(obj)?;
            ws.is_union_wrapped = false;
            Ok(ws)
        }
        Value::Array(arr) => {
            for item in arr {
                if let Value::Object(obj) = item {
                    if obj.get("type").and_then(|t| t.as_str()) == Some("record") {
                        let mut ws = parse_record_object(obj)?;
                        ws.is_union_wrapped = true;
                        return Ok(ws);
                    }
                }
            }
            Err(Error::UnexpectedError {
                message: "avro schema: union does not contain a record type".into(),
                source: None,
            })
        }
        _ => Err(Error::UnexpectedError {
            message: "avro schema: expected record or union containing record".into(),
            source: None,
        }),
    }
}

fn parse_record_object(obj: &serde_json::Map<String, Value>) -> crate::Result<WriterSchema> {
    let fields_arr = obj
        .get("fields")
        .and_then(|f| f.as_array())
        .ok_or_else(|| Error::UnexpectedError {
            message: "avro schema: record missing 'fields' array".into(),
            source: None,
        })?;

    let mut fields = Vec::with_capacity(fields_arr.len());

    for field_val in fields_arr.iter() {
        let field_obj = field_val
            .as_object()
            .ok_or_else(|| Error::UnexpectedError {
                message: "avro schema: field is not an object".into(),
                source: None,
            })?;
        let name = field_obj
            .get("name")
            .and_then(|n| n.as_str())
            .ok_or_else(|| Error::UnexpectedError {
                message: "avro schema: field missing 'name'".into(),
                source: None,
            })?
            .to_string();
        let type_val = field_obj
            .get("type")
            .ok_or_else(|| Error::UnexpectedError {
                message: format!("avro schema: field '{name}' missing 'type'"),
                source: None,
            })?;
        let schema = parse_field_schema(type_val)?;
        let (schema, nullable) = unwrap_nullable(schema);
        fields.push(WriterField {
            name,
            schema,
            nullable,
        });
    }

    Ok(WriterSchema {
        fields,
        is_union_wrapped: false,
    })
}

/// For a nullable union `["null", T]`, extract the inner type `T` and return `(T, true)`.
/// For non-nullable schemas, return `(schema, false)`.
fn unwrap_nullable(schema: FieldSchema) -> (FieldSchema, bool) {
    if let FieldSchema::Union(branches) = &schema {
        let has_null = branches.iter().any(|b| matches!(b, FieldSchema::Null));
        if has_null && branches.len() == 2 {
            let FieldSchema::Union(mut branches) = schema else {
                unreachable!()
            };
            let inner = if matches!(branches[0], FieldSchema::Null) {
                branches.swap_remove(1)
            } else {
                branches.swap_remove(0)
            };
            return (inner, true);
        }
    }
    (schema, false)
}

fn parse_field_schema(value: &Value) -> crate::Result<FieldSchema> {
    match value {
        Value::String(s) => match s.as_str() {
            "null" => Ok(FieldSchema::Null),
            "boolean" => Ok(FieldSchema::Boolean),
            "int" => Ok(FieldSchema::Int),
            "long" => Ok(FieldSchema::Long),
            "float" => Ok(FieldSchema::Float),
            "double" => Ok(FieldSchema::Double),
            "bytes" => Ok(FieldSchema::Bytes),
            "string" => Ok(FieldSchema::String),
            _ => Err(Error::UnexpectedError {
                message: format!("avro schema: unsupported named type reference: {s}"),
                source: None,
            }),
        },
        Value::Object(obj) => {
            let type_str = obj.get("type").and_then(|t| t.as_str()).unwrap_or("");
            match type_str {
                "record" => {
                    let record = parse_record_object(obj)?;
                    Ok(FieldSchema::Record(record))
                }
                "array" => {
                    let items = obj.get("items").ok_or_else(|| Error::UnexpectedError {
                        message: "avro schema: array missing 'items'".into(),
                        source: None,
                    })?;
                    let item_schema = parse_field_schema(items)?;
                    Ok(FieldSchema::Array(Box::new(item_schema)))
                }
                "map" => {
                    let values = obj.get("values").ok_or_else(|| Error::UnexpectedError {
                        message: "avro schema: map missing 'values'".into(),
                        source: None,
                    })?;
                    let value_schema = parse_field_schema(values)?;
                    Ok(FieldSchema::Map(Box::new(value_schema)))
                }
                "fixed" => {
                    let size = obj.get("size").and_then(|s| s.as_u64()).ok_or_else(|| {
                        Error::UnexpectedError {
                            message: "avro schema: fixed missing 'size'".into(),
                            source: None,
                        }
                    })? as usize;
                    Ok(FieldSchema::Fixed(size))
                }
                "long" | "int" => {
                    if type_str == "long" {
                        Ok(FieldSchema::Long)
                    } else {
                        Ok(FieldSchema::Int)
                    }
                }
                "enum" => {
                    let symbols =
                        obj.get("symbols")
                            .and_then(|s| s.as_array())
                            .ok_or_else(|| Error::UnexpectedError {
                                message: "avro schema: enum missing 'symbols'".into(),
                                source: None,
                            })?;
                    Ok(FieldSchema::Enum {
                        symbols_count: symbols.len(),
                    })
                }
                _ => Err(Error::UnexpectedError {
                    message: format!("avro schema: unsupported type: {type_str}"),
                    source: None,
                }),
            }
        }
        Value::Array(arr) => {
            let branches: Vec<FieldSchema> = arr
                .iter()
                .map(parse_field_schema)
                .collect::<crate::Result<_>>()?;
            Ok(FieldSchema::Union(branches))
        }
        _ => Err(Error::UnexpectedError {
            message: format!("avro schema: unexpected value: {value}"),
            source: None,
        }),
    }
}

/// Skip a value in the cursor according to its schema.
pub fn skip_field(
    cursor: &mut super::cursor::AvroCursor,
    schema: &FieldSchema,
) -> crate::Result<()> {
    skip_field_inner(cursor, schema, false)
}

/// Skip a field that may have been unwrapped from a nullable union.
/// When `nullable` is true, reads the union index first.
pub fn skip_nullable_field(
    cursor: &mut super::cursor::AvroCursor,
    schema: &FieldSchema,
    nullable: bool,
) -> crate::Result<()> {
    skip_field_inner(cursor, schema, nullable)
}

fn skip_field_inner(
    cursor: &mut super::cursor::AvroCursor,
    schema: &FieldSchema,
    nullable: bool,
) -> crate::Result<()> {
    if nullable {
        let idx = cursor.read_union_index()?;
        if idx == 0 {
            return Ok(());
        }
    }
    match schema {
        FieldSchema::Null => Ok(()),
        FieldSchema::Boolean => cursor.skip_raw(1),
        FieldSchema::Int | FieldSchema::Long | FieldSchema::Enum { .. } => cursor.skip_long(),
        FieldSchema::Float => cursor.skip_raw(4),
        FieldSchema::Double => cursor.skip_raw(8),
        FieldSchema::Bytes | FieldSchema::String => cursor.skip_bytes(),
        FieldSchema::Fixed(size) => cursor.skip_raw(*size),
        FieldSchema::Union(branches) => {
            let idx = cursor.read_union_index()? as usize;
            if idx < branches.len() {
                skip_field(cursor, &branches[idx])
            } else {
                Err(Error::UnexpectedError {
                    message: format!("avro skip: union index {idx} out of range"),
                    source: None,
                })
            }
        }
        FieldSchema::Array(item_schema) => {
            loop {
                let count = cursor.read_long()?;
                if count == 0 {
                    break;
                }
                let count = if count < 0 {
                    cursor.skip_long()?;
                    (-count) as usize
                } else {
                    count as usize
                };
                for _ in 0..count {
                    skip_field(cursor, item_schema)?;
                }
            }
            Ok(())
        }
        FieldSchema::Map(value_schema) => {
            loop {
                let count = cursor.read_long()?;
                if count == 0 {
                    break;
                }
                let count = if count < 0 {
                    cursor.skip_long()?;
                    (-count) as usize
                } else {
                    count as usize
                };
                for _ in 0..count {
                    cursor.skip_bytes()?;
                    skip_field(cursor, value_schema)?;
                }
            }
            Ok(())
        }
        FieldSchema::Record(record_schema) => {
            for field in &record_schema.fields {
                skip_field_inner(cursor, &field.schema, field.nullable)?;
            }
            Ok(())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_manifest_file_meta_schema() {
        let schema_json = r#"["null", {
            "type": "record",
            "name": "record",
            "namespace": "org.apache.paimon.avro.generated",
            "fields": [
                {"name": "_VERSION", "type": "int"},
                {"name": "_FILE_NAME", "type": "string"},
                {"name": "_FILE_SIZE", "type": "long"},
                {"name": "_NUM_ADDED_FILES", "type": "long"},
                {"name": "_NUM_DELETED_FILES", "type": "long"},
                {"name": "_PARTITION_STATS", "type": ["null", {
                    "type": "record",
                    "name": "record__PARTITION_STATS",
                    "fields": [
                        {"name": "_MIN_VALUES", "type": "bytes"},
                        {"name": "_MAX_VALUES", "type": "bytes"},
                        {"name": "_NULL_COUNTS", "type": ["null", {"type": "array", "items": ["null", "long"]}], "default": null}
                    ]
                }], "default": null},
                {"name": "_SCHEMA_ID", "type": "long"}
            ]
        }]"#;

        let ws = WriterSchema::parse(schema_json).unwrap();
        assert_eq!(ws.fields.len(), 7);
        assert!(ws.is_union_wrapped);
        assert_eq!(ws.fields[0].name, "_VERSION");
        assert_eq!(ws.fields[1].name, "_FILE_NAME");
        assert_eq!(ws.fields[6].name, "_SCHEMA_ID");
    }

    #[test]
    fn test_parse_nested_record() {
        let schema_json = r#"{"type": "record", "name": "test", "fields": [
            {"name": "nested", "type": ["null", {"type": "record", "name": "inner", "fields": [
                {"name": "x", "type": "int"}
            ]}]}
        ]}"#;

        let ws = WriterSchema::parse(schema_json).unwrap();
        assert!(!ws.is_union_wrapped);
        assert_eq!(ws.fields.len(), 1);
        assert!(ws.fields[0].nullable);
        match &ws.fields[0].schema {
            FieldSchema::Record(inner) => {
                assert_eq!(inner.fields.len(), 1);
                assert_eq!(inner.fields[0].name, "x");
            }
            _ => panic!("expected record"),
        }
    }
}