packages feed

dataframe-persistent-0.3.0.0: src/DataFrame/IO/Persistent/Schema/Introspect.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : DataFrame.IO.Persistent.Schema.Introspect
License     : MIT

Compile-time-safe SQLite schema introspection. Everything here is plain 'IO'
(callable from a Template Haskell @runIO@ in "DataFrame.IO.Persistent.Schema")
plus a pure SQLite-affinity → 'HaskellType' mapping. No Template Haskell and no
DataFrame dependencies live here, so the logic can be unit-tested directly.
-}
module DataFrame.IO.Persistent.Schema.Introspect (
    -- * Column metadata
    ColumnInfo (..),
    HaskellType (..),

    -- * Introspection (IO)
    introspectTable,
    introspectTableConn,
    introspectTableNames,
    introspectTableNamesConn,

    -- * Type inference
    inferType,
    haskellTypeName,

    -- * Raw-query helper (shared with the runtime reader)
    runRawRows,
    quoteIdent,
) where

import Control.Monad.Trans.Reader (ReaderT)
import Control.Monad.Trans.Resource (MonadResource)
import Data.Conduit (runConduit, (.|))
import qualified Data.Conduit.List as CL
import Data.List (sortOn)
import Data.Text (Text)
import qualified Data.Text as T
import Database.Persist (PersistValue (..))
import Database.Persist.Sql (SqlBackend, rawQuery)
import Database.Persist.SqlBackend (getRDBMS)
import Database.Persist.Sqlite (runSqlite)

-- | A single column as reported by @PRAGMA table_info@.
data ColumnInfo = ColumnInfo
    { ciName :: Text
    -- ^ Raw column name as stored in SQLite (e.g. @"ArtistId"@).
    , ciDeclType :: Text
    -- ^ Declared type string (e.g. @"NVARCHAR(120)"@); may be empty.
    , ciNotNull :: Bool
    -- ^ Whether the column carries a @NOT NULL@ constraint.
    , ciPk :: Bool
    -- ^ Whether the column participates in the primary key.
    }
    deriving (Eq, Show)

{- | The closed set of Haskell element types we map SQLite columns onto. Every
constructor corresponds to a type that is both 'Database.Persist.PersistField'
and 'DataFrame.Internal.Column.Columnable'.
-}
data HaskellType
    = HTInt
    | HTDouble
    | HTText
    | HTBool
    | HTByteString
    | HTDay
    | HTUTCTime
    | HTTimeOfDay
    deriving (Eq, Show)

{- | Map a declared SQLite type to a 'HaskellType' using SQLite's own
(case-insensitive, substring) type-affinity rules, with a few semantic
refinements for dates/booleans. Unknown or empty declarations fall back the
same way SQLite does (BLOB affinity for empty, numeric otherwise).
-}
inferType :: Text -> HaskellType
inferType declared
    | "INT" `inUpper` declared = HTInt
    | anyIn ["CHAR", "CLOB", "TEXT"] declared = HTText
    | anyIn ["BLOB", "BYTEA"] declared || T.null (T.strip declared) = HTByteString
    | anyIn ["REAL", "FLOA", "DOUB"] declared = HTDouble
    | "BOOL" `inUpper` declared = HTBool
    | anyIn ["DATETIME", "TIMESTAMP"] declared = HTUTCTime
    | "DATE" `inUpper` declared = HTDay
    | "TIME" `inUpper` declared = HTTimeOfDay
    | otherwise = HTDouble

-- | A display name for a 'HaskellType' (the element type, without nullability).
haskellTypeName :: HaskellType -> Text
haskellTypeName HTInt = "Int"
haskellTypeName HTDouble = "Double"
haskellTypeName HTText = "Text"
haskellTypeName HTBool = "Bool"
haskellTypeName HTByteString = "ByteString"
haskellTypeName HTDay = "Day"
haskellTypeName HTUTCTime = "UTCTime"
haskellTypeName HTTimeOfDay = "TimeOfDay"

inUpper :: Text -> Text -> Bool
inUpper needle hay = needle `T.isInfixOf` T.toUpper hay

anyIn :: [Text] -> Text -> Bool
anyIn needles hay = any (`inUpper` hay) needles

{- | Read the columns of @table@ from the SQLite database at @path@, ordered by
their @PRAGMA table_info@ @cid@ (i.e. declaration order).
-}
introspectTable :: FilePath -> String -> IO [ColumnInfo]
introspectTable path table =
    runSqlite (T.pack path) (introspectTableConn (T.pack table))

{- | 'introspectTable' over an existing connection. Backend-agnostic: SQLite uses
@PRAGMA table_info@; every other backend (PostgreSQL, MySQL, etc.) uses the
standard @information_schema.columns@.
-}
introspectTableConn ::
    (MonadResource m) => Text -> ReaderT SqlBackend m [ColumnInfo]
introspectTableConn table = do
    rdbms <- getRDBMS
    if rdbms == "sqlite" then sqlitePragmaColumns table else infoSchemaColumns table

sqlitePragmaColumns ::
    (MonadResource m) => Text -> ReaderT SqlBackend m [ColumnInfo]
sqlitePragmaColumns table = do
    rows <- runRawRows ("PRAGMA table_info(" <> quoteIdent table <> ")") []
    pure (map snd (sortOn fst (map parsePragmaRow rows)))

infoSchemaColumns ::
    (MonadResource m) => Text -> ReaderT SqlBackend m [ColumnInfo]
infoSchemaColumns table = do
    rows <- runRawRows infoSchemaColumnQuery [PersistText table]
    pks <- runRawRows infoSchemaPkQuery [PersistText table]
    let pkNames = [pvText n | (n : _) <- pks]
    pure (map (parseInfoSchemaRow pkNames) rows)

infoSchemaColumnQuery :: Text
infoSchemaColumnQuery =
    "SELECT column_name, data_type, is_nullable FROM information_schema.columns \
    \WHERE table_name = ? AND table_schema NOT IN ('pg_catalog', 'information_schema') \
    \ORDER BY ordinal_position"

infoSchemaPkQuery :: Text
infoSchemaPkQuery =
    "SELECT kcu.column_name FROM information_schema.table_constraints tc \
    \JOIN information_schema.key_column_usage kcu ON kcu.constraint_name = tc.constraint_name \
    \WHERE tc.table_name = ? AND tc.constraint_type = 'PRIMARY KEY'"

-- | List the user tables (excluding SQLite internal tables) in @path@.
introspectTableNames :: FilePath -> IO [Text]
introspectTableNames path = runSqlite (T.pack path) introspectTableNamesConn

-- | 'introspectTableNames' over an existing connection (backend-agnostic).
introspectTableNamesConn :: (MonadResource m) => ReaderT SqlBackend m [Text]
introspectTableNamesConn = do
    rdbms <- getRDBMS
    rows <-
        runRawRows
            (if rdbms == "sqlite" then sqliteTableQuery else infoSchemaTableQuery)
            []
    pure [pvText n | (n : _) <- rows]

sqliteTableQuery :: Text
sqliteTableQuery =
    "SELECT name FROM sqlite_master WHERE type = 'table' \
    \AND name NOT LIKE 'sqlite_%' ORDER BY name"

infoSchemaTableQuery :: Text
infoSchemaTableQuery =
    "SELECT table_name FROM information_schema.tables \
    \WHERE table_schema NOT IN ('pg_catalog', 'information_schema') \
    \AND table_type = 'BASE TABLE' ORDER BY table_name"

-- | Run a raw SQL query and collect every row's column values.
runRawRows ::
    (MonadResource m) =>
    Text -> [PersistValue] -> ReaderT SqlBackend m [[PersistValue]]
runRawRows sql params = runConduit (rawQuery sql params .| CL.consume)

parsePragmaRow :: [PersistValue] -> (Int, ColumnInfo)
parsePragmaRow [cid, name, ctype, notnull, _dflt, pk] =
    ( pvInt cid
    , ColumnInfo (pvText name) (pvText ctype) (pvInt notnull == 1) (pvInt pk > 0)
    )
parsePragmaRow other =
    error ("introspectTable: unexpected PRAGMA table_info row: " <> show other)

{- | Parse an @information_schema.columns@ row @(name, data_type, is_nullable)@,
marking 'ciPk' from the table's primary-key column names.
-}
parseInfoSchemaRow :: [Text] -> [PersistValue] -> ColumnInfo
parseInfoSchemaRow pkNames [name, dtype, nullable] =
    ColumnInfo nm (pvText dtype) (pvText nullable == "NO") (nm `elem` pkNames)
  where
    nm = pvText name
parseInfoSchemaRow _ other =
    error ("introspectTable: unexpected information_schema row: " <> show other)

pvText :: PersistValue -> Text
pvText (PersistText t) = t
pvText PersistNull = ""
pvText other = T.pack (show other)

pvInt :: PersistValue -> Int
pvInt (PersistInt64 i) = fromIntegral i
pvInt _ = 0

-- | Double-quote a SQLite identifier, escaping embedded quotes.
quoteIdent :: Text -> Text
quoteIdent t = "\"" <> T.replace "\"" "\"\"" t <> "\""