packages feed

wireform-parquet-0.1.0.0: src/Parquet/Variant/Schema.hs

{-# LANGUAGE OverloadedStrings #-}

{- | Canonical @arrow.parquet.variant@ storage schemas. The binary Variant
codec accepts a wider scale range than a typed decimal column: typed storage
must use the narrowest precision band and @0 <= scale <= precision@.

See https://arrow.apache.org/docs/format/CanonicalExtensions.html#parquet-variant.
This module checks storage shape and type mappings; value reconstruction is
owned by the Variant semantic reader, not by this schema validator.
-}
module Parquet.Variant.Schema (
  VariantSchemaLimits (..),
  VariantStorageSchema,
  compileVariantStorageSchema,
  compileVariantStorageSchemaWithLimits,
  defaultVariantSchemaLimits,
  variantExtensionMetadata,
  isVariantField,
  variantField,
  validateVariantField,
  validateVariantFieldWithLimits,
  variantTypedLeaf,
) where

import Arrow.Types qualified as A
import Control.Monad (unless)
import Data.Int (Int32)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Read qualified as TR
import Data.Vector qualified as V
import Parquet.Nested.Types (VariantStorageSchema)
import Parquet.Nested.Types qualified as N
import Parquet.Types qualified as P
import Parquet.Variant qualified as B


data VariantSchemaLimits = VariantSchemaLimits
  { variantSchemaMaxDepth :: {-# UNPACK #-} !Int
  , variantSchemaMaxFields :: {-# UNPACK #-} !Int
  }
  deriving (Show, Eq)


defaultVariantSchemaLimits :: VariantSchemaLimits
defaultVariantSchemaLimits = VariantSchemaLimits 64 1048576


variantExtensionMetadata :: V.Vector (Text, Text)
variantExtensionMetadata = V.fromList [("ARROW:extension:name", "arrow.parquet.variant"), ("ARROW:extension:metadata", "")]


isVariantField :: A.Field -> Bool
isVariantField = V.elem ("ARROW:extension:name", "arrow.parquet.variant") . A.fieldMetadata


{- | Standard metadata/value storage, optionally with a typed column. Its
supplied name/nullability are replaced with the canonical optional field.
-}
variantField :: Text -> Bool -> Maybe A.Field -> Either String A.Field
variantField name nullable typed = do
  let fields = V.fromList ([A.defaultLeafField "metadata" False A.ABinary, A.defaultLeafField "value" True A.ABinary] ++ maybe [] (\field -> [field {A.fieldName = "typed_value", A.fieldNullable = True}]) typed)
      result = (A.defaultField name nullable A.AStruct fields) {A.fieldMetadata = variantExtensionMetadata}
  validateVariantField result
  pure result


values :: Text -> A.Field -> V.Vector Text
values key = V.map snd . V.filter ((== key) . fst) . A.fieldMetadata


bad :: String -> Either String a
bad = Left . ("Parquet.Variant.Schema: " ++)


need :: Bool -> String -> Either String ()
need condition message = unless condition (bad message)


emptyExtension :: Text -> A.Field -> Either String ()
emptyExtension name field = do
  need (values "ARROW:extension:name" field == V.singleton name) "extension name must be unique and exact"
  let parameters = values "ARROW:extension:metadata" field
  need (V.length parameters <= 1 && V.all T.null parameters) "extension parameters must be absent or empty"


plain :: A.Field -> Bool
plain field = A.fieldDictionary field == Nothing && V.null (A.fieldChildren field)


binary :: A.ArrowType -> Bool
binary typ = typ `elem` [A.ABinary, A.ALargeBinary, A.ABinaryView]


validateVariantField :: A.Field -> Either String ()
validateVariantField = validateVariantFieldWithLimits defaultVariantSchemaLimits


validateVariantFieldWithLimits :: VariantSchemaLimits -> A.Field -> Either String ()
validateVariantFieldWithLimits limits field = do
  need (variantSchemaMaxDepth limits >= 0 && variantSchemaMaxFields limits > 0) "invalid schema resource limits"
  emptyExtension "arrow.parquet.variant" field
  _ <- node 0 True (variantSchemaMaxFields limits) field
  pure ()
  where
    charge depth remaining = do
      need (depth <= variantSchemaMaxDepth limits) "schema depth limit exceeded"
      need (remaining > 0) "schema field limit exceeded"
      pure (remaining - 1)
    node depth root remaining current = do
      budget <- charge depth remaining
      need (A.fieldType current == A.AStruct && A.fieldDictionary current == Nothing) "Variant node must use struct storage"
      let fields = A.fieldChildren current
          names = A.fieldName <$> fields
          allowed = if root then ["metadata", "value", "typed_value"] else ["value", "typed_value"]
      need (V.length fields <= length allowed && Set.size (Set.fromList (V.toList names)) == V.length fields && V.all (`elem` allowed) names) "node fields must be unique canonical names"
      need (V.elem "value" names || V.elem "typed_value" names) "node requires value or typed_value"
      need (not root || V.elem "metadata" names) "Variant root requires metadata"
      V.foldM'
        ( \left child -> case A.fieldName child of
            "metadata" -> metadata (depth + 1) left child
            "value" -> do
              next <- charge (depth + 1) left
              need (plain child && binary (A.fieldType child)) "value must use binary storage"
              pure next
            "typed_value" -> typed (depth + 1) left child
            _ -> bad "unexpected node field"
        )
        budget
        fields
    metadata depth remaining current = do
      budget <- charge depth remaining
      need (not (A.fieldNullable current)) "metadata must be non-nullable"
      case A.fieldType current of
        A.ARunEndEncoded -> do
          need (A.fieldDictionary current == Nothing) "metadata cannot combine dictionary and run-end encoding"
          case V.toList (A.fieldChildren current) of
            [ends, entries] -> do
              b1 <- charge (depth + 1) budget
              b2 <- charge (depth + 1) b1
              need (plain ends && not (A.fieldNullable ends) && A.fieldType ends `elem` [A.AInt 16 True, A.AInt 32 True, A.AInt 64 True]) "metadata run ends require non-null signed16/32/64 storage"
              need (plain entries && binary (A.fieldType entries)) "metadata run values must be binary"
              pure b2
            _ -> bad "metadata run-end encoding requires two children"
        typ -> do
          need (binary typ && V.null (A.fieldChildren current)) "metadata must use binary storage"
          case A.fieldDictionary current of
            Nothing -> pure budget
            Just dictionary -> do
              case A.deIndexType dictionary of
                A.AInt width _ -> need (width `elem` [8, 16, 32, 64]) "metadata dictionary index width must be8/16/32/64"
                _ -> bad "metadata dictionary indices must be integers"
              pure budget
    typed depth remaining current = case A.fieldType current of
      A.AStruct -> do
        need (V.null (values "ARROW:extension:name" current)) "typed object must not carry another extension"
        budget <- charge depth remaining
        need (A.fieldDictionary current == Nothing) "typed objects cannot be dictionary encoded"
        let fields = A.fieldChildren current
        need (V.length fields <= budget) "schema field limit exceeded"
        need (Set.size (Set.fromList (V.toList (A.fieldName <$> fields))) == V.length fields) "object field names must be unique"
        V.foldM'
          ( \left child -> do
              need (not (A.fieldNullable child)) "shredded object field wrappers must be non-nullable"
              node (depth + 1) False left child
          )
          budget
          fields
      typ | typ `elem` [A.AList, A.ALargeList, A.AListView] -> do
        need (V.null (values "ARROW:extension:name" current)) "typed list must not carry another extension"
        budget <- charge depth remaining
        need (A.fieldDictionary current == Nothing) "typed lists cannot be dictionary encoded"
        case V.toList (A.fieldChildren current) of
          [child] -> do
            need (not (A.fieldNullable child)) "shredded list elements must be non-nullable"
            node (depth + 1) False budget child
          _ -> bad "typed list requires one struct element field"
      _ -> do
        budget <- charge depth remaining
        _ <- variantTypedLeaf current
        pure budget


{- | Exact Arrow-to-Parquet primitive mapping for typed Variant storage.
Generic Arrow decimals with negative scales, wider carriers or different
units remain valid Arrow types, but cannot be used as typed Variants.
-}
variantTypedLeaf :: A.Field -> Either String N.LeafType
variantTypedLeaf field = do
  need (plain field) "typed primitives cannot have children or dictionary encoding"
  let extension = values "ARROW:extension:name" field
  if extension == V.singleton "arrow.uuid"
    then do
      emptyExtension "arrow.uuid" field
      need (A.fieldType field == A.AFixedSizeBinary 16) "UUID storage must be fixed binary16"
      pure N.LtUuid
    else do
      need (V.null extension) "unrecognized typed primitive extension"
      case A.fieldType field of
        A.ABool -> pure N.LtBool
        A.AInt 8 True -> pure (N.LtInteger 8 True)
        A.AInt 16 True -> pure (N.LtInteger 16 True)
        A.AInt 32 True -> pure N.LtInt32
        A.AInt 64 True -> pure N.LtInt64
        A.AFloatingPoint A.Single -> pure N.LtFloat
        A.AFloatingPoint A.DoublePrecision -> pure N.LtDouble
        A.ADecimal32 precision scale -> decimal 1 9 precision scale
        A.ADecimal64 precision scale -> decimal 10 18 precision scale
        A.ADecimal precision scale -> decimal 19 38 precision scale
        A.ADate A.DateDay -> pure N.LtDate
        A.ATime A.Microsecond 64 -> pure (N.LtTime False P.LtMicros)
        A.ATimestamp A.Microsecond Nothing -> pure (N.LtTimestamp False P.LtMicros)
        A.ATimestamp A.Nanosecond Nothing -> pure (N.LtTimestamp False P.LtNanos)
        A.ATimestamp A.Microsecond (Just "UTC") -> pure (N.LtTimestamp True P.LtMicros)
        A.ATimestamp A.Nanosecond (Just "UTC") -> pure (N.LtTimestamp True P.LtNanos)
        typ | typ `elem` [A.ABinary, A.ALargeBinary, A.ABinaryView] -> pure N.LtBinary
        typ | typ `elem` [A.AUtf8, A.ALargeUtf8, A.AUtf8View] -> pure N.LtString
        _ -> bad "Arrow type has no canonical typed Variant mapping"
  where
    decimal low high precision scale = do
      need (precision >= low && precision <= high && scale >= 0 && scale <= precision) "typed decimal requires its exact precision band and scale0..precision"
      pure (N.LtDecimal (fromIntegral precision) (fromIntegral scale))


{- | Compile canonical Arrow Variant storage to a native typed schema.
Primitive tags/scales are never inferred from row values. Metadata storage
encodings normalize to binary while the original payload dictionary stays
byte-for-byte intact. Use 'N.NSVariantTyped' with this opaque schema.
-}
compileVariantStorageSchema :: A.Field -> Either String VariantStorageSchema
compileVariantStorageSchema = compileVariantStorageSchemaWithLimits B.defaultVariantLimits defaultVariantSchemaLimits


compileVariantStorageSchemaWithLimits :: B.VariantLimits -> VariantSchemaLimits -> A.Field -> Either String VariantStorageSchema
compileVariantStorageSchemaWithLimits limits schemaLimits field = do
  validateVariantFieldWithLimits schemaLimits field
  need (B.variantMaxDepth limits >= 0 && B.variantMaxValues limits > 0 && B.variantMaxDictionaryEntries limits >= 0) "invalid Variant resource limits"
  layout <- nodeLayout True field
  plan <- nodePlan field
  identifiers <- nodeIds field
  let metadataIndex = maybe (-1) id (V.findIndex ((== "metadata") . A.fieldName) (A.fieldChildren field))
  pure (N.VariantStorageSchema limits field layout metadataIndex identifiers plan)
  where
    optional nullable value = if nullable then N.NSOptional value else N.NSRequired value
    nodeLayout root current = do
      let fields = A.fieldChildren current
          shredded = V.any ((== "typed_value") . A.fieldName) fields
      children <-
        V.mapM
          ( \child -> do
              layout <- case A.fieldName child of
                "metadata" -> pure (N.NSRequired (N.NSPrimitive N.LtBinary))
                "value" -> pure (optional (not root || shredded) (N.NSPrimitive N.LtBinary))
                "typed_value" -> N.NSOptional <$> typedLayout child
                _ -> bad "unexpected storage field"
              pure (A.fieldName child, layout)
          )
          fields
      let complete = if V.any ((== "value") . A.fieldName) fields then children else V.snoc children ("value", N.NSOptional (N.NSPrimitive N.LtBinary))
      pure (N.NSStruct complete)
    typedLayout current = case A.fieldType current of
      A.AStruct ->
        N.NSStruct
          <$> V.mapM
            ( \child -> do
                layout <- nodeLayout False child
                pure (A.fieldName child, N.NSRequired layout)
            )
            (A.fieldChildren current)
      typ | typ `elem` [A.AList, A.ALargeList, A.AListView] -> do
        layout <- nodeLayout False (V.head (A.fieldChildren current))
        pure (N.NSList (N.NSRequired layout))
      _ -> N.NSPrimitive <$> variantTypedLeaf current
    nodePlan current = do
      let fields = A.fieldChildren current
          index name = maybe (-1) id (V.findIndex ((== name) . A.fieldName) fields)
          required position = maybe False (not . A.fieldNullable) (fields V.!? position)
          value = index "value"
          typed = index "typed_value"
      plan <- case fields V.!? typed of
        Nothing -> pure Nothing
        Just child -> Just <$> typedPlan child
      pure (N.VariantStorageNode (V.length fields) value typed (required value) (required typed) plan (maybe False below plan))
    typedPlan current = case A.fieldType current of
      A.AStruct -> do
        children <- V.imapM (\index child -> N.VariantStorageChild (A.fieldName child) index <$> nodePlan child) (A.fieldChildren current)
        pure (N.StorageObject children (Set.fromList (V.toList (N.storageChildName <$> children))))
      typ | typ `elem` [A.AList, A.ALargeList, A.AListView] -> N.StorageArray <$> nodePlan (V.head (A.fieldChildren current))
      _ -> pure N.StoragePrimitive

    fieldId current = case V.toList (values "PARQUET:field_id" current) of
      [] -> pure Nothing
      [text] -> case TR.signed TR.decimal text :: Either String (Integer, Text) of
        Right (number, rest) | T.null rest && number >= toInteger (minBound :: Int32) && number <= toInteger (maxBound :: Int32) -> pure (Just (fromInteger number))
        _ -> bad "field ID must fit a signed INT32"
      _ -> bad "field ID must be unique"
    nodeIds current = do
      ident <- fieldId current
      children <- V.mapM (\child -> if A.fieldName child == "typed_value" then typedIds child else V.singleton <$> fieldId child) (A.fieldChildren current)
      let body = V.concat (V.toList children)
          complete = if V.any ((== "value") . A.fieldName) (A.fieldChildren current) then body else V.snoc body Nothing
      pure (V.cons ident complete)
    typedIds current = do
      ident <- fieldId current
      case A.fieldType current of
        A.AStruct -> V.cons ident . V.concat . V.toList <$> V.mapM nodeIds (A.fieldChildren current)
        typ | typ `elem` [A.AList, A.ALargeList, A.AListView] -> V.cons ident . V.cons Nothing <$> nodeIds (V.head (A.fieldChildren current))
        _ -> pure (V.singleton ident)

    needs required plan = N.storageValueIndex plan < 0 || N.storageNormalizationBelow plan || (required && not (N.storageValueRequired plan) && not (N.storageTypedRequired plan))
    below current = case current of
      N.StoragePrimitive -> False
      N.StorageObject children _ -> V.any (needs False . N.storageChildNode) children
      N.StorageArray child -> needs True child