packages feed

pg-schema-0.5.0.0: src/PgSchema/Generation.hs

{-# LANGUAGE CPP #-}
-- |
-- Module: PgSchema.Generation
-- Copyright: (c) Dmitry Olshansky
-- License: BSD-3-Clause
-- Maintainer: olshanskydr@gmail.com, dima@typeable.io
-- Stability: experimental
--
-- === Generation of type-level database schema definitions
--
-- Typically you build an executable that imports this module
-- and run it to emit the schema definition.
--
module PgSchema.Generation
  (updateSchemaFile, GenNames(..), AddRelation(..)
  , NameNS'(..), NameNS, (->>)
  ) where

import Control.Monad
import Control.Monad.Catch
import Data.Bifunctor
import Data.ByteString as BS hiding (readFile, writeFile)
import Data.Coerce
import Data.Functor
import Data.List qualified as L
import Data.List.NonEmpty as NE
import Data.Map as M
import Data.Maybe as Mb
import Data.Set as S
import Data.String
import Data.Text as T
import Data.Text.IO as T
import Data.Traversable
import Database.PostgreSQL.Simple
import GHC.Int
import GHC.Records
import GHC.TypeLits ( Symbol )
import PgSchema.Ann
import PgSchema.DML.Select
import PgSchema.DML.Select.Types
import PgSchema.Schema
import PgSchema.Schema.Catalog
import PgSchema.Schema.Info
import PgSchema.Types
import PgSchema.Utils.ShowType
import Prelude as P
import System.Directory
import System.Environment


data ExceptionSch
  = ConnectException ByteString SomeException
  | GetDataException (Text, [SomeToField]) SomeException
  deriving Show

instance Exception ExceptionSch

data AddRelation = AddRelation
  { name  :: Text
  -- ^ name of an additional (non-existing in the database) relation.
  -- All additional relations will be added with the namespace "_add".
  , from  :: NameNS
  , to    :: NameNS
  , cols  :: [(Text, Text)] }

data GenNames = GenNames
  { schemas :: [Text]   -- ^ generate data for all tables in these schemas
  , tables  :: [NameNS] -- ^ generate data for these tables
  , addRelations :: [AddRelation] -- ^ additional relations. Be careful!
  }

type AnnCat tn = 'Ann RenamerId PgCatalog 3 (PGC tn)

selCat :: forall (tn :: Symbol) -> forall r. (Selectable (AnnCat tn) r)
  => Connection -> QueryParam PgCatalog (PGC tn) -> IO ([r], (Text,[SomeToField]))
selCat tn = selectSch (AnnCat tn)

selTxt :: forall (tn :: Symbol) -> forall r. (Selectable (AnnCat tn) r)
  => QueryParam PgCatalog (PGC tn) -> (Text,[SomeToField])
selTxt tn @r = selectText (AnnCat tn) @r

getSchema
  :: Connection -- ^ connection to PostgreSQL database
  -> GenNames   -- ^ names of schemas and tables to generate from the database
  -> IO ([PgType], [PgClass], [PgRelation])
getSchema conn GenNames {..} = do
  types <- selCat "pg_type" conn qpTyp `catch`
    (throwM . GetDataException (selTxt "pg_type" @PgType qpTyp))
  classes <- L.filter checkClass . fst <$> selCat "pg_class" conn qpClass `catch`
    (throwM . GetDataException (selTxt "pg_class" @PgClass qpClass))
  relations <- L.filter checkRels . (Mb.mapMaybe (mkRel classes) addRelations <>)
    . fst <$> selCat "pg_constraint" conn qpRel `catch`
      (throwM . GetDataException (selTxt "pg_constraint" @PgRelation qpRel))
  pure (fst types, classes, relations)
  where
    mkRel classes ar = do
      conkey <- mkNums ar.from $ fst <$> ar.cols
      confkey <- mkNums ar.to $ snd <$> ar.cols
      pure PgRelation
        { constraint__namespace = "nspname" =: "_add"
        , conname               = ar.name
        , constraint__class     = toPgClassShort ar.from
        , constraint__fclass    = toPgClassShort ar.to
        , .. }
      where
        toPgClassShort nns = PgClassShort
          { class__namespace = "nspname" =: nns.nnsNamespace
          , relname          = nns.nnsName }
        mkNums nns fields = do
          pgcl <- L.find (\c -> c.class__namespace == "nspname" =: nns.nnsNamespace
            && c.relname == nns.nnsName) classes
          inds <- for fields \fld ->
            L.findIndex ((==fld) . (.attname)) pgcl.attribute__class
          pure $ pgArr' $ fromIntegral . (+1) <$> inds

-- all data are ordered to provide stable `hashSchema`
    qpTyp = qRoot @PgCatalog @(PGC "pg_type") do
      qOrderBy [ascf "typname", ordNS "typnamespace"]
      qPath "enum__type" do
        qOrderBy [ascf "enumsortorder"]
    qpClass = qRoot @PgCatalog @(PGC "pg_class") do
      qWhere $ condClass &&& pin "relkind" (PgChar <$> 'v' :| "r") -- views & tables
      qOrderBy [ascf "relname", ordNS "relnamespace"]
      qPath "attribute__class" do
        qWhere $ "attnum" >? (0::Int16)
        qOrderBy [ascf "attnum"]
      qPath "constraint__class" do
        qOrderBy [ascf "conname"]
    qpRel = qRoot @PgCatalog @(PGC "pg_constraint") do
      qWhere
        $   pparent (PGC "constraint__class") condClass
        ||| pparent (PGC "constraint__fclass") condClass
      qOrderBy [ascf "conname", ordNS "connamespace"]
    ordNS fld = UnsafeOrd do
      o <- tabPref
      pure ("(select nspname from pg_catalog.pg_namespace p where p.oid = "
        <> o <> "." <> fld <> ")", Asc)
    condClass = condSchemas ||| condTabs
      where
        condSchemas = pparent (PGC "class__namespace")
          $ foldMap (pin "nspname") $ nonEmpty schemas
        condTabs
          = pparent (PGC "class__namespace")
            (foldMap (pin "nspname" . fmap nnsNamespace) (nonEmpty tables))
          &&& foldMap (pin "relname" . fmap nnsName) (nonEmpty tables)
    checkClass PgClass {..}
      = (coerce class__namespace `L.elem` schemas)
      || (coerce class__namespace ->> relname `L.elem` tables)
    checkRels PgRelation {..} =
      check constraint__class || check constraint__fclass
      where
        check PgClassShort {..}
          = (coerce class__namespace `L.elem` schemas)
          || (coerce class__namespace ->> relname `L.elem` tables)

getDefs
  :: ([PgType], [PgClass], [PgRelation])
  -> (Map NameNS TypDef
    , Map (NameNS,Text) FldDef
    , Map NameNS (TabDef, [NameNS], [NameNS])
    , Map NameNS RelDef)
getDefs (types,classes,relations) =
  ( M.fromList $ ptypDef <$> ntypes
  , M.fromList $ pfldDef <$> attrs
  , M.fromList $ ptabDef <$> classes
  , M.fromList relDefs )
  where
    classAttrs = ((,) <$> tabKey <*> attribute__class) <$> classes
    mClassAttrs =
      M.fromList [((c, attnum a), attname a)| (c,as) <- classAttrs, a <- as]
    attrs :: [(NameNS, PgAttribute)] =
      L.concatMap (\(a,xs) -> (a,) <$> xs) classAttrs
    typKey = NameNS <$> (coerce . type__namespace) <*> typname
    ntypes = ntype <$> L.filter ((`S.member` attrsTypes) . typKey) types
      where
        ntype t = (t, typKey <$> M.lookup (fromPgOid $ typelem t) mtypes)
        attrsTypes = S.fromList $ (typKey . attribute__type . snd <$> attrs)
          <> [pgc "int8", pgc "float8"] -- added for Aggr
        mtypes = M.fromList $ (\x -> (fromPgOid $ oid x , x)) <$> types
    ptypDef (x@PgType{..}, typElem) = (typKey x, TypDef {..})
      where
        typCategory = T.singleton $ coerce typcategory
        typEnum = enumlabel <$> coerce enum__type
    pfldDef (cname::NameNS, PgAttribute{..}) = ((cname,attname), FldDef{..})
      where
        fdType = typKey attribute__type
        fdNullable = not attnotnull
        fdHasDefault = atthasdef
    tabKey
      :: forall r .
        ( HasField "class__namespace" r ("nspname" := Text)
        , HasField "relname" r Text )
      => r -> NameNS
    tabKey r = NameNS (coerce r.class__namespace) r.relname
    ptabDef c@PgClass{..} = (tabName, (TabDef{..}, froms, tos))
      where
        tabName = tabKey c
        tdFlds = attname <$> coerce attribute__class
        tdKey = L.concat $ keysBy (=='p')
        tdUniq = keysBy (=='u')
        keysBy f
          = Mb.mapMaybe -- if something is wrong exclude such constraint
          (traverse numToName . unPgArr' . (.conkey))
          $ L.filter (f . coerce . contype) (coerce constraint__class)
          where
            numToName a =
              attname <$> L.find ((==a) . attnum) attribute__class
        (froms, tos) = bimap getNames getNames (rdFrom, rdTo)
          where
            getNames f = fst <$> L.filter ((==tabName) . f . snd) relDefs
    relDefs = Mb.mapMaybe mbRelDef relations
    mbRelDef PgRelation {..} = sequenceA
      ( rdName
      , zipWithM getName2 (unPgArr' conkey) (unPgArr' confkey)
        <&> \rdCols -> RelDef{..})
      where
        rdName = NameNS (coerce constraint__namespace) conname
        rdFrom = tabKey constraint__class
        rdTo = tabKey constraint__fclass
        getName t n = M.lookup (t,n) mClassAttrs
        getName2 n1 n2 = (,) <$> getName rdFrom n1 <*> getName rdTo n2

-- | Update (or create) the Haskell file containing the schema definition
updateSchemaFile
  :: Bool       -- ^ verbose mode
  -> String     -- ^ file name
  -> Either String ByteString
    -- ^ name of environment variable with connection string, or
    -- the connection string itself.
    -- When this environment variable is not set or the connection string is empty,
    -- we do nothing.
  -> Text     -- ^ haskell module name to generate
  -> Text     -- ^ name of generated haskell type for schema
  -> GenNames -- ^ names of schemas in database or tables to generate
  -> IO Bool
updateSchemaFile verbose fileName ecs moduleName schName genNames = do
  connStr <- either getConnStr pure ecs
  if BS.null connStr
    then pure False
    else do
      fe <- doesFileExist fileName
      conn <- connectPostgreSQL connStr
      P.putStrLn "Trying to get schema"
      schema <- getSchema conn genNames
      P.putStrLn "Generation"
      let newTxt = moduleText schema
      needGen <- if fe
        then (/= newTxt) <$> T.readFile fileName
        else pure True
      P.putStrLn $ "Need to generate file: " <> P.show needGen
      when needGen do
        when fe $ copyFile fileName (fileName <> ".bak")
        T.writeFile fileName newTxt
      when verbose $ print schema
      pure needGen
  where
    getConnStr env =
      handle (const @_ @SomeException $ pure "") (fromString <$> getEnv env)
    moduleText = genModuleText moduleName schName . getDefs

mkInst :: ShowType a => Text -> [Text] -> a -> Text
mkInst name pars a
  =  "instance C" <> sgn <> " where\n"
  <> "  type T" <> sgn <> " = \n"
  <> "    " <> showSplit 6 70 a <> "\n"
  where
    sgn = T.intercalate " " (name : pars)

textTypDef :: Text -> NameNS -> TypDef -> Text
textTypDef sch typ td@TypDef {..} = mkInst "TypDef" ss td <> pgEnum
  where
    ss = [sch, showType typ]
    st = T.intercalate " " ss
    pgEnum
      | L.null typEnum = ""
      | otherwise
        = "data instance PGEnum " <> st <> "\n  = "
        <> showSplit' "|" 2 70
          ( T.intercalate " | "
            $ ((T.toTitle (nnsName typ) <> "_") <>) <$> typEnum )
        <> "  deriving (Show, Read, Ord, Eq, Generic, Bounded, Enum)\n\n"
#ifdef MK_HASHABLE
        <> "instance Hashable (PGEnum " <> st <> ")\n\n"
#endif
        <> "instance NFData (PGEnum " <> st <> ")\n\n"

textTabDef :: Text -> NameNS -> TabDef -> Text
textTabDef sch tab = mkInst "TabDef" [sch, showType tab]

textRelDef :: Text -> NameNS -> RelDef -> Text
textRelDef sch relName rel =
  "instance CRelDef " <> sch <> " " <> showType relName <> " where\n" <>
  "  type TRelDef " <> sch <> " " <> showType relName <> " = " <> showType rel <> "\n\n"

textTabRel :: Text -> NameNS -> [NameNS] -> [NameNS] -> Text
textTabRel sch tab froms tos
  =  "instance CTabRels " <> pars <> " where\n"
  <> "  type TFrom " <> pars <> " = \n"
  <> "    " <> showSplit 6 70 froms <> "\n"
  <> "  type TTo " <> pars <> " = \n"
  <> "    " <> showSplit 6 70 tos <> "\n"
  where
    pars = T.intercalate " " [sch, showType tab]

-- Generate Ref in type-level format (using 'FldDef directly)
textRef :: FldDef -> FldDef -> Text -> Text -> Text
textRef fromDef toDef fromName toName =
  "'Ref " <> showType fromName <> " (" <> showType fromDef <> ") "
  <> showType toName <> " (" <> showType toDef <> ")"

-- RHS only (for closed type family equations)
rhsPlain :: FldDef -> Text
rhsPlain fd = "'RFPlain (" <> showType fd <> ")"

rhsToHere :: NameNS -> NameNS -> RelDef -> M.Map (NameNS, Text) FldDef -> Text
rhsToHere tab fromTab rel mfld =
  let refsText = T.intercalate "\n      , " $
        [ textRef (mfld M.! (fromTab, fromName)) (mfld M.! (tab, toName)) fromName toName
        | (fromName, toName) <- rdCols rel
        ]
  in "'RFToHere " <> showType fromTab <> "\n      '[ " <> refsText <> " ]"

rhsFromHere :: NameNS -> NameNS -> RelDef -> M.Map (NameNS, Text) FldDef -> Text
rhsFromHere tab toTab rel mfld =
  let refsText = T.intercalate "\n      , " $
        [ textRef (mfld M.! (tab, fromName)) (mfld M.! (toTab, toName)) fromName toName
        | (fromName, toName) <- rdCols rel
        ]
  in "'RFFromHere " <> showType toTab <> "\n      '[ " <> refsText <> " ]"

rhsSelfRef :: NameNS -> RelDef -> M.Map (NameNS, Text) FldDef -> Text
rhsSelfRef tab rel mfld =
  let refsText = T.intercalate "\n      , " $
        [ textRef (mfld M.! (tab, fromName)) (mfld M.! (tab, toName)) fromName toName
        | (fromName, toName) <- rdCols rel
        ]
  in "'RFSelfRef " <> showType tab <> "\n      '[ " <> refsText <> " ]"

-- Closed type family TDBFieldInfo<Sch> and single CDBFieldInfo instance
typeFamilyName :: Text -> Text
typeFamilyName sch = "TDBFieldInfo" <> sch

typeErrorMsg
  :: Text -> Text -> [Text] -> [Text] -> Text
typeErrorMsg sch tabStr fields rels =
  "TE.TypeError (TE.Text \"In schema \" TE.:<>: TE.ShowType " <> sch
  <> "\n    TE.:$$: TE.Text \"for table \" TE.:<>: TE.ShowType " <> tabStr
  <> "\n    TE.:$$: TE.Text \"name \" TE.:<>: TE.ShowType f TE.:<>: TE.Text \" is not defined.\""
  <> "\n    TE.:$$: TE.Text \"\""
  <> "\n    TE.:$$: TE.Text \"Valid values are:\""
  <> "\n    TE.:$$: TE.Text \"  Fields: " <> T.intercalate ", " fields <> ".\""
  <> "\n    TE.:$$: TE.Text \"  Foreign key constraints: " <> T.intercalate ", " rels <> ".\""
  <> "\n    TE.:$$: TE.Text \"\""
  <> "\n    TE.:$$: TE.Text \"Your source or target type or renaimer is probably invalid.\""
  <> "\n    TE.:$$: TE.Text \"\""
  <> ")"

textClosedFieldInfoTF
  :: Text
  -> (M.Map (NameNS, Text) FldDef
    , M.Map NameNS (TabDef, [NameNS]
    , [NameNS]), M.Map NameNS RelDef)
  -> Text
textClosedFieldInfoTF schName (mfld, mtab, mrel) =
  "type family " <> tfName
  <> " (t :: NameNSK) (f :: TL.Symbol) :: RecFieldK NameNSK where\n" <> equations <> "\n"
  <> "instance (ToStar (TDBFieldInfo " <> schName <> " t f), ToStar t, ToStar f) => CDBFieldInfo " <> schName <> " t f where\n"
  <> "  type TDBFieldInfo " <> schName <> " t f = " <> tfName <> " t f\n\n"
  where
    tfName = typeFamilyName schName
      -- All (tab, fldName, rhs) in deterministic order: by table, then plain fields,
      -- then toHere, then fromHere, then selfRef (for self-FK).
    plainEntries =
      [ (tab, fldName, rhsPlain fd) | ((tab, fldName), fd) <- M.toList mfld ]
    toHereEntries =
      [ (tab, nnsName relName, rhsToHere tab (rdFrom rel) rel mfld)
      | (tab, (_, _froms, tos)) <- M.toList mtab, relName <- tos
      , let rel = mrel M.! relName
      , not (rdFrom rel == tab && rdTo rel == tab)
      ]
    fromHereEntries =
      [ (tab, nnsName relName, rhsFromHere tab (rdTo rel) rel mfld)
      | (tab, (_, froms, _tos)) <- M.toList mtab, relName <- froms
      , let rel = mrel M.! relName
      , not (rdFrom rel == tab && rdTo rel == tab)
      ]
    selfEntries =
      [ (tab, nnsName relName, rhsSelfRef tab rel mfld)
      | (tab, (_, froms, tos)) <- M.toList mtab
      , relName <- L.nub (tos <> froms)
      , let rel = mrel M.! relName
      , rdFrom rel == tab && rdTo rel == tab
      ]
    allEntries = plainEntries <> toHereEntries <> fromHereEntries <> selfEntries
    eqnLine tab fldName rhs =
      "  " <> tfName <> " " <> showType tab <> " \"" <> fldName <> "\" = " <> rhs <> "\n"
    perTableDefault _ tabStr fieldNames relNames =
      "  " <> tfName <> " " <> tabStr <> " f = " <> typeErrorMsg schName tabStr fieldNames relNames <> "\n"
    tableBlocks =
      [ mconcat [ eqnLine tab fld rhs | (t, fld, rhs) <- allEntries, t == tab ]
        <> perTableDefault tab (showType tab) (tdFlds td) ((nnsName <$> froms) <> (nnsName <$> tos))
      | (tab, (td, froms, tos)) <- M.toList mtab ]
    equations = mconcat tableBlocks <> "  " <> tfName <> " t f = "
      <> typeErrorMsgFinal <> "\n"
    typeErrorMsgFinal =
      "TE.TypeError (TE.Text \"In schema \" TE.:<>: TE.ShowType " <> schName
      <> " TE.:<>: TE.Text \" the table \" TE.:<>: TE.ShowType t TE.:<>: TE.Text \" is not defined.\""
      <> "\n    TE.:$$: TE.Text \"\""
      <> ")"


genModuleText
  :: Text -- ^ module name
  -> Text -- ^ schema name
  -> (Map NameNS TypDef
    , Map (NameNS,Text) FldDef
    , Map NameNS (TabDef, [NameNS], [NameNS])
    , Map NameNS RelDef)
  -> Text
genModuleText moduleName schName (mtyp, mfld, mtab, mrel)
  =  "{- HLINT ignore -}\n"
  <> "{-# LANGUAGE FlexibleContexts #-}\n"
  <> "{-# LANGUAGE TypeFamilies #-}\n"
  <> "{-# LANGUAGE UndecidableInstances #-}\n"
  <> "{-# OPTIONS_GHC -fno-warn-unused-top-binds #-}\n"
  <> "{-# OPTIONS_GHC -fno-warn-unused-imports #-}\n"
  <> "{-# OPTIONS_GHC -freduction-depth=300 #-}\n"
  <> "module " <> moduleName <> " where\n\n"
  <> "-- This file is generated and can't be edited.\n\n"
  <> "import Control.DeepSeq\n" -- for PGEnum if exist
#ifdef MK_HASHABLE
  <> "import Data.Hashable\n" -- for PGEnum if exist
#endif
  <> "import GHC.Generics\n" -- for PGEnum if exists
  <> "import GHC.TypeError qualified as TE\n"
  <> "import GHC.TypeLits qualified as TL\n"
  <> "import PgSchema.Import\n"
  <> "data " <> schName <> "\n\n"
  <> mconcat (uncurry (textTypDef schName) <$> M.toList mtyp)
  <> mconcat ((\(tab,(td,_,_)) -> textTabDef schName tab td) <$> M.toList mtab)
  <> mconcat ([ textRelDef schName relName rel | (relName, rel) <- M.toList mrel ])
  <> mconcat ((\(tab,(_,froms,tos)) -> textTabRel schName tab froms tos)
    <$> M.toList mtab)
  <> textClosedFieldInfoTF schName (mfld, mtab, mrel)
  <> "instance CSchema " <> schName <> " where\n"
  <> "  type TTabs " <> schName <> " = " <> showSplit 4 70 (keys mtab) <> "\n"
  <> "  type TTypes " <> schName <> " = " <> showSplit 4 70 (keys mtyp) <> "\n"

showSplit :: ShowType a => Int -> Int -> a -> Text
showSplit shift width = showSplit' "," shift width . showType

showSplit' :: Text -> Int -> Int -> Text -> Text
showSplit' delim shift width
  = T.unlines . mapTail ((T.replicate shift " " <>) . (delim <>))
  . L.map (T.intercalate delim) . fst . mkLines
  where
    mapTail _ []     = []
    mapTail f (x:xs) = x : L.map f xs
    mkLines = L.foldr step ([],0). T.splitOn delim
      where
        step t (xs,len)
          | tlen + len > width = ([t] : xs, tlen)
          | otherwise = case xs of
            []     -> ([[t]], tlen)
            z : zs -> ((t:z):zs, tlen + len)
          where
            tlen = T.length t