hydra-0.15.0: src/main/haskell/Hydra/Tools/AvroWorkflows.hs
-- | A utility for transforming Avro-compliant JSON into other models
module Hydra.Tools.AvroWorkflows (
TransformWorkflow(..),
JsonPayloadFormat(..),
TermEncoder(..),
LastMile(..),
defaultTinkerpopAnnotations,
encodeStringValue,
examplePgSchema,
executeAvroTransformWorkflow,
propertyGraphGraphsonLastMile,
rdfDescriptionsToNtriples,
shaclRdfLastMile,
typeApplicationTermToShaclRdf,
transformAvroJsonDirectory,
) where
import Hydra.Kernel
import Hydra.Workflow
import qualified Hydra.Show.Errors as ShowError
import Hydra.Dsl.Annotations
import qualified Hydra.Avro.Schema as Avro
import qualified Hydra.Json.Model as Json
import Hydra.Extract.Json
import Hydra.Parsing (ParseResult(..), ParseSuccess(..), ParseError(..))
import qualified Hydra.Json.Parser as JsonParser
import Hydra.Avro.Coder
import Hydra.Avro.Environment
import Hydra.Avro.SchemaJson hiding (Result)
import Hydra.Pg.Graphson.Utils
import qualified Hydra.Shacl.Coder as Shacl
import qualified Hydra.Rdf.Syntax as Rdf
import qualified Hydra.Rdf.Utils as RdfUt
import qualified Hydra.Pg.Model as PG
import qualified Hydra.Pg.Mapping as PGM
import Hydra.Rdf.Serde
import Hydra.Sources.Kernel.Types.Core
import Hydra.Pg.Graphson.Coder
import Hydra.Pg.Graphson.Syntax as G
import Hydra.Pg.Utils (defaultTinkerpopAnnotations, examplePgSchema, typeApplicationTermToPropertyGraph)
import qualified Hydra.Json.Writer as JsonWriter
import qualified Control.Monad as CM
import qualified Data.Either as E
import qualified Data.List as L
import qualified Data.Map as M
import qualified Data.Set as S
import qualified Data.Maybe as Y
import System.IO
import System.FilePath
import System.FilePath.Posix
import System.Directory
type Result a = Either Error a
-- | The last mile of a transformation, which encodes and serializes terms to a file
data LastMile a =
LastMile {
-- | An encoder for terms to a list of output objects
lastMileEncoder :: (Type -> Context -> Graph -> Result (Term -> Graph -> Context -> Result [a])),
-- | A function which serializes a list of output objects to a string representation
lastMileSerializer :: ([a] -> Result String),
-- | A file extension for the generated file(s)
lastMileFileExtension :: String}
-- | Parse a JSON string, returning Either for compatibility
parseJsonEither :: String -> Either String Json.Value
parseJsonEither s = case JsonParser.parseJson s of
ParseResultSuccess success -> Right (parseSuccessValue success)
ParseResultFailure err -> Left (parseErrorMessage err)
eitherToIo :: Result a -> IO a
eitherToIo (Left ic) = fail (ShowError.error ic)
eitherToIo (Right v) = return v
data JsonPayloadFormat = Json | Jsonl
type TermEncoder x = Term -> Graph -> Context -> Result [x]
-- | A convenience for transformAvroJsonDirectory, bundling all of the input parameters together as a workflow
executeAvroTransformWorkflow :: LastMile x -> TransformWorkflow -> IO ()
executeAvroTransformWorkflow lastMile (TransformWorkflow name schemaSpec srcDir destDir) = do
schemaPath <- case schemaSpec of
SchemaSpecFile p -> pure p
_ -> fail "unsupported schema spec"
putStrLn $ "Executing workflow " ++ show name ++ ":"
transformAvroJsonDirectory lastMile schemaPath srcDir destDir
-- Replace all lists with sets, for better query performance.
-- This is a last-mile step which breaks type/term conformance
-- (a more robust solution would modify the target language in the SHACL coder, so that list types are also transformed to set types).
listsToSets :: Term -> Term
listsToSets = rewriteTerm mapExpr
where
mapExpr recurse = recurse . replaceLists
replaceLists term = case term of
TermList els -> TermSet $ S.fromList els
_ -> term
rdfDescriptionsToNtriples :: [Rdf.Description] -> String
rdfDescriptionsToNtriples = rdfGraphToNtriples . RdfUt.descriptionsToGraph
-- | A LastMile which converts Hydra terms to GraphSON property graph JSON (JSONL format).
-- Uses the default Tinkerpop annotation schema for vertex/edge labels and IDs.
propertyGraphGraphsonLastMile :: LastMile Json.Value
propertyGraphGraphsonLastMile = LastMile encoder serializer "jsonl"
where
encoder typ cx graf = do
encodeTerm <- typeApplicationTermToPropertyGraph examplePgSchema typ () () cx graf
return $ \term _graf cx' -> do
elements <- encodeTerm term cx'
pgElementsToGraphson encodeStringValue elements
serializer jsonValues = Right $ L.unlines $ L.map JsonWriter.printJson jsonValues
shaclRdfLastMile :: LastMile Rdf.Description
shaclRdfLastMile = LastMile typeApplicationTermToShaclRdf (Right . rdfDescriptionsToNtriples) "nt"
typeApplicationTermToShaclRdf :: Type -> Context -> Graph -> Result (Term -> Graph -> Context -> Result [Rdf.Description])
typeApplicationTermToShaclRdf _ _cx _g = Right encode
where
encode term graf cx = do
elDescs <- CM.mapM (encodeElement cx) $ graphToBindings graf
termDescs <- encodeBlankTerm cx
return $ L.concat (termDescs:elDescs)
where
encodeElement cx' el = do
let subject = Rdf.ResourceIri $ RdfUt.nameToIri $ bindingName el
fst <$> Shacl.encodeTerm subject (listsToSets $ bindingTerm el) cx' graf
encodeBlankTerm cx' = if notInGraph
then do
let (subject, cx'') = RdfUt.nextBlankNode cx'
fst <$> Shacl.encodeTerm subject (listsToSets term) cx'' graf
else pure []
notInGraph = L.null $ L.filter (\e -> bindingTerm e == term) $ graphToBindings graf
transformAvroJson :: JsonPayloadFormat -> Adapter Avro.Schema Type Json.Value Term -> LastMile x -> FilePath -> FilePath -> IO ()
transformAvroJson format adapter lastMile inFile outFile = do
putStr $ "\t" ++ inFile ++ " --> "
contents <- readFile inFile
let cx = emptyContext
let entities = case format of
Json -> [contents]
Jsonl -> L.filter (not . L.null) $ lines contents
lmEncoder <- eitherToIo $ lastMileEncoder lastMile (adapterTarget adapter) cx hydraCoreGraph
descs <- L.concat <$> CM.mapM (jsonToTarget inFile adapter lmEncoder cx) (L.zip [1..] entities)
result <- eitherToIo $ lastMileSerializer lastMile descs
writeFile outFile result
putStrLn $ outFile ++ " (" ++ descEntities entities ++ ")"
where
descEntities entities = if L.length entities == 1 then "1 entity" else show (L.length entities) ++ " entities"
jsonToTarget inFile' adapter' lmEncoder cx (index, payload) = case parseJsonEither payload of
Left msg -> fail $ "Failed to read JSON payload #" ++ show index ++ " in file " ++ inFile' ++ ": " ++ msg
Right json -> do
term <- eitherToIo $ coderEncode (adapterCoder adapter') cx json
let bindings = extractElements (adapterTarget adapter') term
let graph = bindingsToGraph hydraCoreGraph bindings
eitherToIo $ lmEncoder term graph cx
-- | Given a payload format (one JSON object per file, or one per line),
-- a path to an Avro *.avsc schema, a path to a source directory containing JSON files conforming to the schema,
-- and a path to a destination directory, map each input file to a corresponding output file in the
-- destination directory. This transformation is sensitive to Hydra-specific annotations (primaryKey/foreignKey)
-- in the Avro schema, which tell Hydra which objects to treat as elements and which fields are references to elements.
transformAvroJsonDirectory :: LastMile x -> FilePath -> FilePath -> FilePath -> IO ()
transformAvroJsonDirectory lastMile schemaPath srcDir destDir = do
createDirectoryIfMissing True destDir
schemaStr <- readFile schemaPath
let cx = emptyContext
adapter <- eitherToIo $ loadAdapter cx schemaStr
paths <- getDirectoryContents srcDir
conf <- CM.mapM (transformFile adapter) paths
return ()
where
loadAdapter cx schemaStr = do
avroSchema <- coderDecode (avroSchemaStringCoder cx) cx schemaStr
fst <$> avroHydraAdapter cx avroSchema emptyAvroEnvironment
transformFile adapter srcFile = do
case jsonPayloadFormat srcFile of
Nothing -> return False
Just format -> do
let destFile = replaceExtension srcFile (lastMileFileExtension lastMile)
transformAvroJson format adapter lastMile (combine srcDir srcFile) (combine destDir destFile)
return True
jsonPayloadFormat fileName = if ext == ".json"
then Just Json
else if ext == ".jsonl"
then Just Jsonl
else Nothing
where
ext = takeExtension fileName
-- | Extract elements from a term based on @primaryKey annotations in the type.
-- Walks the type and term together; for each record with a @primaryKey field,
-- creates a Binding using the primary key value as the element name.
extractElements :: Type -> Term -> [Binding]
extractElements typ term = case (stripType typ, stripTerm term) of
(TypeRecord fieldTypes, TermRecord (Record _ fields)) ->
let fieldMap = M.fromList [(fieldName f, fieldTerm f) | f <- fields]
-- Check if this record has a @primaryKey annotation
selfElements = case findPrimaryKeyInType fieldTypes of
Nothing -> []
Just (pkFieldName, pkConstructor) ->
case M.lookup pkFieldName fieldMap of
Nothing -> []
Just pkTerm -> case extractStringFromTerm pkTerm of
Nothing -> []
Just pkValue ->
let elementName = pkConstructor pkValue
in [Binding elementName term Nothing]
-- Recurse into nested record fields
childElements = L.concatMap (\ft ->
case M.lookup (fieldTypeName ft) fieldMap of
Nothing -> []
Just fTerm -> extractElements (fieldTypeType ft) fTerm) fieldTypes
in selfElements ++ childElements
(TypeList innerType, TermList terms) ->
L.concatMap (extractElements innerType) terms
(TypeMaybe innerType, TermMaybe (Just t)) ->
extractElements innerType t
_ -> []
where
stripType t = case t of
TypeAnnotated (AnnotatedType inner _) -> stripType inner
TypeWrap inner -> stripType inner
_ -> t
stripTerm t = case t of
TermAnnotated (AnnotatedTerm inner _) -> stripTerm inner
_ -> t
-- | Find a @primaryKey annotation among the record's field types.
-- Returns the field name and constructor function if found.
findPrimaryKeyInType :: [FieldType] -> Maybe (Name, String -> Name)
findPrimaryKeyInType fieldTypes = Y.listToMaybe $ Y.mapMaybe checkField fieldTypes
where
checkField (FieldType fname ftyp) = case ftyp of
TypeAnnotated (AnnotatedType _ anns) ->
case M.lookup (Name "@primaryKey") anns of
Just (TermLiteral (LiteralString pattern_)) -> Just (fname, patternToName pattern_)
_ -> Nothing
_ -> Nothing
patternToName pattern_ value = Name $ L.intercalate value $ splitOn "${}" pattern_
splitOn :: String -> String -> [String]
splitOn sep s = case L.break (== head sep) s of
(before, []) -> [before]
(before, rest) ->
if L.take (length sep) rest == sep
then before : splitOn sep (L.drop (length sep) rest)
else [s]
-- | Extract a string value from a term (handling wraps and literals)
extractStringFromTerm :: Term -> Maybe String
extractStringFromTerm term = case term of
TermLiteral (LiteralString s) -> Just s
TermAnnotated (AnnotatedTerm inner _) -> extractStringFromTerm inner
TermVariable (Name s) -> Just s -- Foreign key references are stored as variables
_ -> Nothing
-- | Build a Graph from a base graph and a list of bindings (elements)
bindingsToGraph :: Graph -> [Binding] -> Graph
bindingsToGraph base bindings = base {
graphBoundTerms = M.union (M.fromList [(bindingName b, bindingTerm b) | b <- bindings]) (graphBoundTerms base)}