packages feed

poppy-codegen-1.0.0: src/Poppy/Codegen/Emit/Include.hs

{-# LANGUAGE OverloadedStrings #-}

module Poppy.Codegen.Emit.Include
  ( emitIncludeModule,
  )
where

import Data.List (nubBy)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Poppy.Codegen.EmitCommon
  ( fieldBinder,
    pickedTypeName,
    primaryKeyField,
    rowTypeName,
    selectTypeName,
    tableTypeName,
    toPickedName,
  )
import Poppy.Codegen.IR
import Poppy.Codegen.Lookup (lookupField, lookupModel)
import Poppy.Codegen.TextUtil (lowerFirst, upperFirst)

emitIncludeModule :: Text -> Schema -> Model -> Text
emitIncludeModule moduleName schema model
  | modelName model /= modelName (representative schema model) =
      emitReexport moduleName schema model
  | otherwise =
      emitComponent moduleName schema (componentModels schema model)

emitReexport :: Text -> Schema -> Model -> Text
emitReexport moduleName schema model =
  T.unlines $
    [ "module " <> moduleName
    ]
      ++ exportLines (modelExportNames model)
      ++ [ "where",
           "",
           "import " <> repModule <> " (" <> T.intercalate ", " (modelExportNames model) <> ")"
         ]
  where
    rep = representative schema model
    repModule = includeModuleName (modulePrefix moduleName model) rep

emitComponent :: Text -> Schema -> [Model] -> Text
emitComponent moduleName schema models =
  T.unlines $
    [ "{-# LANGUAGE DataKinds #-}",
      "{-# LANGUAGE DuplicateRecordFields #-}",
      "{-# LANGUAGE FlexibleContexts #-}",
      "{-# LANGUAGE FlexibleInstances #-}",
      "{-# LANGUAGE MultiParamTypeClasses #-}",
      "{-# LANGUAGE NoFieldSelectors #-}",
      "{-# LANGUAGE OverloadedRecordDot #-}",
      "{-# LANGUAGE StandaloneDeriving #-}",
      "{-# LANGUAGE TypeApplications #-}",
      "{-# LANGUAGE TypeFamilies #-}",
      "{-# LANGUAGE UndecidableInstances #-}",
      "{-# OPTIONS_GHC -Wno-redundant-constraints #-}",
      "",
      "module " <> moduleName
    ]
      ++ exportLines (concatMap modelExportNames models)
      ++ ["where", ""]
      ++ emitImports moduleName schema models
      ++ [""]
      ++ concatMap (\model -> emitModelTypes schema model ++ [""]) models
      ++ concatMap (\model -> emitModelLoad schema model ++ [""]) models

emitImports :: Text -> Schema -> [Model] -> [Text]
emitImports moduleName schema models =
  [generatedImport schema models]
    ++ concatMap (emitSchemaImport prefix models childNames) (importModels schema models)
    ++ map (emitIncludeImport prefix) (externalChildren schema models)
  where
    prefix = modulePrefix moduleName (head models)
    childNames =
      [ modelName (childModel schema rel)
        | model <- models,
          rel <- modelRelations model
      ]

generatedImport :: Schema -> [Model] -> Text
generatedImport schema models =
  "import Poppy.Internal.Generated\n  ( "
    <> T.intercalate ",\n    " names
    <> "\n  )"
  where
    rels = concatMap modelRelations models
    requireRelatedNeeded =
      any (any (isRequiredBelongsTo schema) . modelRelations) models
    names =
      [ "Db",
        "IncludeFor",
        "Load (..)",
        "Skip (..)",
        "Skipped",
        "ValidEdge",
        "skipped",
        "OmitSelect (..)"
      ]
        ++ ["requireRelated" | requireRelatedNeeded]
        ++ ["findByIn" | not (null rels)]
        ++ ["indexByPk" | any (\rel -> relKind rel == RelBelongsTo) rels]
        ++ ["indexHasMany" | any (isPlainHasMany schema) rels]
        ++ ["indexHasManyMaybe" | any (isNullableHasMany schema) rels]
        ++ ["lookupByPk" | any (\rel -> relKind rel == RelBelongsTo) rels]
        ++ ["lookupGroups" | any (\rel -> relKind rel == RelHasMany) rels]

emitSchemaImport :: Text -> [Model] -> [Text] -> Model -> [Text]
emitSchemaImport prefix component childNames model =
  [ "import qualified " <> modName <> " as " <> modelName model
    | isChild
  ]
    ++ [ "import " <> modName <> " (" <> T.intercalate ", " names <> ")"
         | not (null names)
       ]
  where
    modName = prefix <> modelName model
    inComponent = modelName model `elem` map modelName component
    isChild = modelName model `elem` childNames
    names =
      concat
        [ [pickedTypeName model | inComponent],
          [rowTypeName model <> " (..)"],
          [selectTypeName model | inComponent],
          [tableTypeName model | isChild],
          [toPickedName model | inComponent]
        ]

emitIncludeImport :: Text -> Model -> Text
emitIncludeImport prefix child =
  "import "
    <> includeModuleName prefix child
    <> " ("
    <> T.intercalate ", " names
    <> ")"
  where
    names =
      [ modelName child <> "Include (..)",
        modelName child <> "Result",
        modelName child <> "With (..)",
        "Load" <> modelName child <> " (..)"
      ]

emitModelTypes :: Schema -> Model -> [Text]
emitModelTypes schema model =
  concat
    [ emitIncludeData model,
      [""],
      emitWithData model False,
      [""],
      emitWithData model True,
      [""],
      emitToPicked model,
      [""],
      concatMap (\rel -> emitEdgeFamily schema model rel ++ [""]) (modelRelations model),
      emitResultFamily model,
      [""],
      emitReadFamily model
    ]

emitIncludeData :: Model -> [Text]
emitIncludeData model =
  [ "data " <> includeType model <> " " <> params model <> " = " <> includeType model,
    "  { " <> T.intercalate ",\n    " [relName rel <> " :: " <> relName rel | rel <- modelRelations model],
    "  }",
    "  deriving (Show, Eq)"
  ]

emitWithData :: Model -> Bool -> [Text]
emitWithData model picked =
  [ "data " <> withName <> " " <> params model <> " = " <> withName,
    "  { " <> T.intercalate ",\n    " fields,
    "  }",
    "",
    "deriving instance (" <> ctx "Eq" <> ") => Eq (" <> applied <> ")",
    "deriving instance (" <> ctx "Show" <> ") => Show (" <> applied <> ")"
  ]
  where
    withName = if picked then withPickedType model else withType model
    rootTy = if picked then pickedTypeName model else rowTypeName model
    applied = withName <> " " <> params model
    fields =
      (rootVar model <> " :: " <> rootTy)
        : [ relName rel <> " :: " <> edgeFamily model rel <> " " <> relName rel
            | rel <- modelRelations model
          ]
    ctx cls =
      T.intercalate
        ", "
        ( (cls <> " " <> rootTy)
            : [ cls <> " (" <> edgeFamily model rel <> " " <> relName rel <> ")"
                | rel <- modelRelations model
              ]
        )

emitToPicked :: Model -> [Text]
emitToPicked model =
  [ toWithPickedName model
      <> " :: "
      <> selectTypeName model
      <> " -> "
      <> withType model
      <> " "
      <> params model
      <> " -> "
      <> withPickedType model
      <> " "
      <> params model,
    toWithPickedName model <> " select_ nested =",
    "  " <> withPickedType model,
    "    { " <> T.intercalate ",\n      " fields,
    "    }"
  ]
  where
    fields =
      (rootVar model <> " = " <> toPickedName model <> " select_ nested." <> rootVar model)
        : [relName rel <> " = nested." <> relName rel | rel <- modelRelations model]

emitEdgeFamily :: Schema -> Model -> RelationSpec -> [Text]
emitEdgeFamily schema model rel =
  [ "type family " <> fam <> " edge where",
    "  " <> fam <> " Skip = Skipped \"" <> relName rel <> "\" " <> skipTy,
    "  " <> fam <> " " <> loadPat <> " = " <> loadedTy
  ]
  where
    fam = edgeFamily model rel
    child = childModel schema rel
    skipTy = wrapSkip (plainLoaded schema rel)
    (loadPat, loadedTy)
      | hasInclude child =
          ("(Load " <> tableTypeName child <> " include)", loadedRhs schema rel "include")
      | otherwise =
          ("(Load " <> tableTypeName child <> " ())", loadedRhs schema rel "")

emitResultFamily :: Model -> [Text]
emitResultFamily model =
  [ "type family " <> modelName model <> "Result include where",
    "  " <> modelName model <> "Result () = " <> rowTypeName model,
    "  "
      <> modelName model
      <> "Result ("
      <> includeType model
      <> " "
      <> params model
      <> ") = "
      <> withType model
      <> " "
      <> params model
  ]

emitReadFamily :: Model -> [Text]
emitReadFamily model =
  [ "type family " <> modelName model <> "Read include select where",
    "  " <> modelName model <> "Read () OmitSelect = " <> rowTypeName model,
    "  " <> modelName model <> "Read () " <> selectTypeName model <> " = " <> pickedTypeName model,
    "  "
      <> modelName model
      <> "Read ("
      <> includeType model
      <> " "
      <> params model
      <> ") OmitSelect = "
      <> withType model
      <> " "
      <> params model,
    "  "
      <> modelName model
      <> "Read ("
      <> includeType model
      <> " "
      <> params model
      <> ") "
      <> selectTypeName model
      <> " = "
      <> withPickedType model
      <> " "
      <> params model
  ]

emitModelLoad :: Schema -> Model -> [Text]
emitModelLoad schema model =
  concatMap (\rel -> emitEdgeLoad schema model rel ++ [""]) (modelRelations model)
    ++ emitModelLoadClass model
    ++ [""]
    ++ emitIncludeFor model

emitEdgeLoad :: Schema -> Model -> RelationSpec -> [Text]
emitEdgeLoad schema model rel =
  emitEdgeClass model rel
    ++ [""]
    ++ emitSkipInstance model rel
    ++ [""]
    ++ emitUnitInstance schema model rel
    ++ nested
    ++ [""]
    ++ emitRejectedInstance model rel
  where
    child = childModel schema rel
    nested
      | hasInclude child = "" : emitNestedInstance schema model rel child
      | otherwise = []

emitEdgeClass :: Model -> RelationSpec -> [Text]
emitEdgeClass model rel =
  [ "class " <> edgeClass model rel <> " edge where",
    "  "
      <> edgeMethod model rel
      <> " :: edge -> ["
      <> rowTypeName model
      <> "] -> Db ["
      <> edgeFamily model rel
      <> " edge]"
  ]

emitSkipInstance :: Model -> RelationSpec -> [Text]
emitSkipInstance model rel =
  [ "instance " <> edgeClass model rel <> " Skip where",
    "  " <> edgeMethod model rel <> " Skip roots = pure (map (const skipped) roots)"
  ]

emitUnitInstance :: Schema -> Model -> RelationSpec -> [Text]
emitUnitInstance schema model rel =
  [ "instance " <> edgeClass model rel <> " (Load " <> tableTypeName child <> " ()) where",
    "  " <> edgeMethod model rel <> " edge roots = do"
  ]
    ++ emitFetch schema model rel
    ++ emitGroup schema model rel False
  where
    child = childModel schema rel

emitNestedInstance :: Schema -> Model -> RelationSpec -> Model -> [Text]
emitNestedInstance schema model rel child =
  [ "instance (" <> loadClass child <> " " <> params child <> ") => " <> edgeClass model rel <> " (Load " <> tableTypeName child <> " (" <> includeType child <> " " <> params child <> ")) where",
    "  " <> edgeMethod model rel <> " edge roots = do"
  ]
    ++ emitFetch schema model rel
    ++ ["    loaded <- " <> loadMethod child <> " edge.include_ rows"]
    ++ emitGroup schema model rel True

emitRejectedInstance :: Model -> RelationSpec -> [Text]
emitRejectedInstance model rel =
  [ "instance {-# OVERLAPPABLE #-} (ValidEdge \"" <> relToModel rel <> "\" edge) => " <> edgeClass model rel <> " edge where",
    "  " <> edgeMethod model rel <> " _ roots = pure (map (const skipped) roots)"
  ]

emitFetch :: Schema -> Model -> RelationSpec -> [Text]
emitFetch schema model rel =
  case relKind rel of
    RelHasMany ->
      [ "    rows <- findByIn @" <> tableTypeName child <> " @" <> rowTypeName child <> " " <> fkBinder <> " (map (." <> parentPk <> ") roots) edge.where_ edge.orderBy_ edge.take_"
      ]
    RelBelongsTo
      | isNullableBelongsTo schema rel ->
          [ "    let keys = [key | root <- roots, Just key <- [root." <> fkName <> "]]",
            "    rows <- findByIn @" <> tableTypeName child <> " @" <> rowTypeName child <> " " <> pkBinder <> " keys edge.where_ edge.orderBy_ edge.take_"
          ]
      | otherwise ->
          [ "    rows <- findByIn @" <> tableTypeName child <> " @" <> rowTypeName child <> " " <> pkBinder <> " (map (." <> fkName <> ") roots) edge.where_ edge.orderBy_ edge.take_"
          ]
  where
    child = childModel schema rel
    parentPk = fieldName (primaryKeyField model)
    fkName = relForeignField rel
    fkField = lookupField child (relForeignField rel)
    fkBinder = modelName child <> "." <> fieldBinder child fkField
    pkField = lookupField child (relLocalField rel)
    pkBinder = modelName child <> "." <> fieldBinder child pkField

emitGroup :: Schema -> Model -> RelationSpec -> Bool -> [Text]
emitGroup schema model rel nested =
  case relKind rel of
    RelHasMany ->
      [ "    let grouped = " <> indexFn <> " " <> keyExpr <> " " <> source,
        "    pure [lookupGroups root." <> parentPk <> " grouped | root <- roots]"
      ]
    RelBelongsTo
      | isNullableBelongsTo schema rel ->
          [ "    let indexed = indexByPk " <> keyExpr <> " " <> source,
            "    pure [root." <> fkName <> " >>= \\key -> lookupByPk key indexed | root <- roots]"
          ]
      | otherwise ->
          [ "    let indexed = indexByPk " <> keyExpr <> " " <> source,
            "    pure [requireRelated \"" <> relName rel <> "\" (lookupByPk root." <> fkName <> " indexed) | root <- roots]"
          ]
  where
    child = childModel schema rel
    parentPk = fieldName (primaryKeyField model)
    fkName = relForeignField rel
    source = if nested then "loaded" else "rows"
    indexFn
      | isNullableHasMany schema rel = "indexHasManyMaybe"
      | otherwise = "indexHasMany"
    keyExpr
      | nested && relKind rel == RelHasMany =
          keyOn (childFkName schema rel) (lowerFirst (modelName child))
      | nested =
          keyOn (fieldName (primaryKeyField child)) (lowerFirst (modelName child))
      | relKind rel == RelHasMany =
          "(." <> childFkName schema rel <> ")"
      | otherwise =
          "(." <> fieldName (primaryKeyField child) <> ")"

emitModelLoadClass :: Model -> [Text]
emitModelLoadClass model =
  [ "class " <> loadClass model <> " " <> params model <> " where",
    "  "
      <> loadMethod model
      <> " :: "
      <> includeType model
      <> " "
      <> params model
      <> " -> ["
      <> rowTypeName model
      <> "] -> Db ["
      <> withType model
      <> " "
      <> params model
      <> "]",
    "",
    "instance (" <> T.intercalate ", " constraints <> ") => " <> loadClass model <> " " <> params model <> " where",
    "  " <> loadMethod model <> " include roots = do"
  ]
    ++ concatMap edgeBind (modelRelations model)
    ++ [ "    pure",
         "      [ " <> withType model,
         "          { " <> T.intercalate ",\n            " fields,
         "          }",
         "      | (n, root) <- zip [0 :: Int ..] roots",
         "      ]"
       ]
  where
    constraints =
      [edgeClass model rel <> " " <> relName rel | rel <- modelRelations model]
        ++ [validEdge model rel | rel <- modelRelations model]
    edgeBind rel =
      [ "    "
          <> relName rel
          <> "Loaded <- "
          <> edgeMethod model rel
          <> " include."
          <> relName rel
          <> " roots"
      ]
    fields =
      (rootVar model <> " = root")
        : [relName rel <> " = " <> relName rel <> "Loaded !! n" | rel <- modelRelations model]

emitIncludeFor :: Model -> [Text]
emitIncludeFor model =
  [ "instance (" <> T.intercalate ", " [validEdge model rel | rel <- modelRelations model] <> ") => IncludeFor \"" <> modelName model <> "\" (" <> includeType model <> " " <> params model <> ")"
  ]

validEdge :: Model -> RelationSpec -> Text
validEdge _model rel =
  "ValidEdge \"" <> relToModel rel <> "\" " <> relName rel

childModel :: Schema -> RelationSpec -> Model
childModel schema rel = lookupModel schema (relToModel rel)

hasInclude :: Model -> Bool
hasInclude model = not (null (modelRelations model))

isRequiredBelongsTo :: Schema -> RelationSpec -> Bool
isRequiredBelongsTo schema rel =
  relKind rel == RelBelongsTo && not (fieldNullable (lookupField (lookupModel schema (relFromModel rel)) (relForeignField rel)))

isNullableBelongsTo :: Schema -> RelationSpec -> Bool
isNullableBelongsTo schema rel =
  relKind rel == RelBelongsTo && not (isRequiredBelongsTo schema rel)

isNullableHasMany :: Schema -> RelationSpec -> Bool
isNullableHasMany schema rel =
  relKind rel == RelHasMany && fieldNullable (lookupField (childModel schema rel) (relForeignField rel))

isPlainHasMany :: Schema -> RelationSpec -> Bool
isPlainHasMany schema rel =
  relKind rel == RelHasMany && not (isNullableHasMany schema rel)

plainLoaded :: Schema -> RelationSpec -> Text
plainLoaded schema rel =
  case relKind rel of
    RelHasMany -> "[" <> rowTypeName child <> "]"
    RelBelongsTo
      | isNullableBelongsTo schema rel -> "Maybe " <> rowTypeName child
      | otherwise -> rowTypeName child
  where
    child = childModel schema rel

wrapSkip :: Text -> Text
wrapSkip ty
  | " " `T.isInfixOf` ty = "(" <> ty <> ")"
  | otherwise = ty

loadedRhs :: Schema -> RelationSpec -> Text -> Text
loadedRhs schema rel includeVar =
  case relKind rel of
    RelHasMany -> "[" <> childTy <> "]"
    RelBelongsTo
      | isNullableBelongsTo schema rel -> "Maybe (" <> childTy <> ")"
      | otherwise -> childTy
  where
    child = childModel schema rel
    childTy
      | hasInclude child = modelName child <> "Result" <> includeArg
      | otherwise = rowTypeName child
    includeArg
      | T.null includeVar = ""
      | otherwise = " " <> includeVar

childFkName :: Schema -> RelationSpec -> Text
childFkName schema rel =
  fieldName (lookupField (childModel schema rel) (relForeignField rel))

keyOn :: Text -> Text -> Text
keyOn keyField accessor = "((." <> keyField <> ") . (." <> accessor <> "))"

params :: Model -> Text
params model = T.unwords (map relName (modelRelations model))

includeType :: Model -> Text
includeType model = modelName model <> "Include"

withType :: Model -> Text
withType model = modelName model <> "With"

withPickedType :: Model -> Text
withPickedType model = modelName model <> "WithPicked"

edgeFamily :: Model -> RelationSpec -> Text
edgeFamily model rel = modelName model <> upperFirst (relName rel)

edgeClass :: Model -> RelationSpec -> Text
edgeClass model rel = "Load" <> edgeFamily model rel

edgeMethod :: Model -> RelationSpec -> Text
edgeMethod model rel = "load" <> edgeFamily model rel

loadClass :: Model -> Text
loadClass model = "Load" <> modelName model

loadMethod :: Model -> Text
loadMethod model = "load" <> modelName model

toWithPickedName :: Model -> Text
toWithPickedName model = "to" <> withPickedType model

rootVar :: Model -> Text
rootVar model = lowerFirst (modelName model)

modelExportNames :: Model -> [Text]
modelExportNames model =
  [ includeType model <> " (..)",
    withType model <> " (..)",
    withPickedType model <> " (..)"
  ]
    ++ [edgeFamily model rel | rel <- modelRelations model]
    ++ [ modelName model <> "Result",
         modelName model <> "Read",
         loadClass model <> " (..)",
         toWithPickedName model
       ]

exportLines :: [Text] -> [Text]
exportLines [] = ["  ()"]
exportLines (first : rest) =
  ("  ( " <> first) : map ("  , " <>) rest ++ ["  )"]

includeModels :: Schema -> [Model]
includeModels schema = filter hasInclude (schemaModels schema)

componentModels :: Schema -> Model -> [Model]
componentModels schema model =
  [m | m <- includeModels schema, modelName m `elem` componentNames]
  where
    componentNames =
      head
        [ names
          | names <- stronglyConnected (map modelName (includeModels schema)) (neighborNames schema),
            modelName model `elem` names
        ]

representative :: Schema -> Model -> Model
representative schema model = head (componentModels schema model)

neighborNames :: Schema -> Text -> [Text]
neighborNames schema name =
  map modelName (deps schema (lookupModel schema name))

deps :: Schema -> Model -> [Model]
deps schema model =
  nubBy (\a b -> modelName a == modelName b) $
    [ child
      | rel <- modelRelations model,
        let child = childModel schema rel,
        modelName child /= modelName model,
        hasInclude child
    ]

importModels :: Schema -> [Model] -> [Model]
importModels schema models =
  nubBy (\a b -> modelName a == modelName b) $
    models ++ concatMap (map (childModel schema) . modelRelations) models

externalChildren :: Schema -> [Model] -> [Model]
externalChildren schema models =
  nubBy (\a b -> modelName a == modelName b) $
    [ child
      | model <- models,
        rel <- modelRelations model,
        let child = childModel schema rel,
        hasInclude child,
        modelName child `notElem` map modelName models
    ]

modulePrefix :: Text -> Model -> Text
modulePrefix moduleName model =
  fromMaybe
    "Schema."
    (T.stripSuffix ("Include." <> modelName model) moduleName)

includeModuleName :: Text -> Model -> Text
includeModuleName prefix model = prefix <> "Include." <> modelName model

stronglyConnected :: [Text] -> (Text -> [Text]) -> [[Text]]
stronglyConnected nodes neighbors =
  let (_, finish) = dfs neighbors [] [] nodes
   in walk (transpose nodes neighbors) [] [] finish

dfs :: (Text -> [Text]) -> [Text] -> [Text] -> [Text] -> ([Text], [Text])
dfs _ seen order [] = (seen, order)
dfs neighbors seen order (name : rest)
  | name `elem` seen = dfs neighbors seen order rest
  | otherwise =
      let (seen1, order1) = dfs neighbors (name : seen) order (neighbors name)
       in dfs neighbors seen1 (name : order1) rest

walk :: (Text -> [Text]) -> [Text] -> [[Text]] -> [Text] -> [[Text]]
walk _ _ comps [] = comps
walk incoming seen comps (name : rest)
  | name `elem` seen = walk incoming seen comps rest
  | otherwise =
      let (members, seen1) = collect incoming [] (name : seen) [name]
       in walk incoming seen1 (members : comps) rest

collect :: (Text -> [Text]) -> [Text] -> [Text] -> [Text] -> ([Text], [Text])
collect _ acc seen [] = (acc, seen)
collect incoming acc seen (name : stack) =
  let more = [next | next <- incoming name, next `notElem` seen]
   in collect incoming (name : acc) (more ++ seen) (more ++ stack)

transpose :: [Text] -> (Text -> [Text]) -> Text -> [Text]
transpose nodes neighbors name =
  [other | other <- nodes, name `elem` neighbors other]