module IHP.SchemaCompiler
( compile
, compileStatementPreview
, compileStatementPreviewWith
, CompilerOptions(..)
, fullCompileOptions
, previewCompilerOptions
, compileCreateStatement
, compileUpdateStatement
, compileFetchByIdStatement
, compileCreateManyStatement
, compileRowDecoderModule
, Schema(..)
) where
import ClassyPrelude
import Data.Bits (bit)
import Data.Maybe (fromJust)
import Data.String.Conversions (cs)
import "interpolate" Data.String.Interpolate (i)
import IHP.NameSupport (tableNameToModelName, columnNameToFieldName, enumValueToControllerName)
import qualified Data.Text as Text
import qualified System.Directory.OsPath as Directory
import IHP.HaskellSupport
import qualified IHP.SchemaCompiler.Parser as SchemaDesigner
import qualified IHP.Postgres.Parser as PostgresParser
import IHP.Postgres.Types
import qualified Control.Exception as Exception
import qualified System.Environment
import NeatInterpolation
import Text.Countable (pluralize)
import System.OsPath (OsPath, encodeUtf, decodeUtf)
import qualified System.OsPath as OsPath
data CompileException = CompileException ByteString deriving (Show)
instance Exception CompileException where
displayException (CompileException message) = cs message
compile :: IO ()
compile = do
relationSupport <- System.Environment.lookupEnv "IHP_RELATION_SUPPORT"
let options = fullCompileOptions { compileRelationSupport = relationSupport /= Just "0" }
SchemaDesigner.parseSchemaSql >>= \case
Left parserError -> Exception.throwIO (CompileException parserError)
Right statements -> do
-- let validationErrors = validate database
-- unless (null validationErrors) (error $ "Schema.hs contains errors: " <> cs (unsafeHead validationErrors))
Directory.createDirectoryIfMissing True "build/Generated"
Directory.createDirectoryIfMissing True "build/Generated/ActualTypes"
Directory.createDirectoryIfMissing True "build/Generated/Statements"
forEach (compileModules options (Schema statements)) \(path, body) -> do
writeIfDifferent path body
compileModules :: CompilerOptions -> Schema -> [(OsPath, Text)]
compileModules options schema =
let ?compilerOptions = options
in [ ("build/Generated/Enums.hs", compileEnums options schema)
, ("build/Generated/ActualTypes/PrimaryKeys.hs", compilePrimaryKeysModule schema)
]
<> actualTypesTableModules schema
<> [ ("build/Generated/ActualTypes.hs", compileTypes options schema) ]
<> tableModules options schema
<> statementModules schema
<> [ ("build/Generated/Statements.hs", compileStatementsIndex schema)
, ("build/Generated/Types.hs", compileIndex schema)
]
applyTables :: (CreateTable -> (OsPath, Text)) -> Schema -> [(OsPath, Text)]
applyTables applyFunction schema =
let ?schema = schema
in
schema.statements
|> mapMaybe (\case
StatementCreateTable table | tableHasPrimaryKey table -> Just (applyFunction table)
otherwise -> Nothing
)
actualTypesTableModules :: (?compilerOptions :: CompilerOptions) => Schema -> [(OsPath, Text)]
actualTypesTableModules schema =
let ?schema = schema
in applyTables actualTypesTableModule schema
actualTypesTableModule :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> (OsPath, Text)
actualTypesTableModule table =
((OsPath.</>) "build/Generated/ActualTypes" (either (error . show) id (encodeUtf (cs (tableNameToModelName table.name) <> ".hs"))), body)
where
body = Text.unlines
[ prelude
, compileActualTypesForTable table
]
moduleName = "Generated.ActualTypes." <> tableNameToModelName table.name
prelude = [trimming|
-- This file is auto generated and will be overriden regulary. Please edit `Application/Schema.sql` to change the Types\n"
{-# LANGUAGE TypeSynonymInstances, FlexibleInstances, InstanceSigs, MultiParamTypeClasses, TypeFamilies, DataKinds, TypeOperators, UndecidableInstances, ConstraintKinds, StandaloneDeriving #-}
{-# OPTIONS_GHC -Wno-unused-imports -Wno-dodgy-imports -Wno-unused-matches -Wno-ambiguous-fields #-}
module $moduleName where
$defaultImports
import Generated.Enums
import Generated.ActualTypes.PrimaryKeys
|]
tableModules :: (?compilerOptions :: CompilerOptions) => CompilerOptions -> Schema -> [(OsPath, Text)]
tableModules options schema =
let ?schema = schema
in
applyTables (tableModule options) schema
<> if options.compileRelationSupport
then applyTables tableIncludeModule schema
else []
tableModule :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CompilerOptions -> CreateTable -> (OsPath, Text)
tableModule options table =
((OsPath.</>) "build/Generated" (either (error . show) id (encodeUtf (cs (tableNameToModelName table.name) <> ".hs"))), body)
where
body = Text.unlines
[ prelude
, tableModuleBody options table
]
moduleName = "Generated." <> tableNameToModelName table.name
modelName = tableNameToModelName table.name
statementImports = Text.unlines
[ "import qualified Generated.Statements.RowDecoder" <> modelName
, "import qualified Generated.Statements.Create" <> modelName
, "import qualified Generated.Statements.Update" <> modelName
, "import qualified Generated.Statements.CreateMany" <> modelName
]
prelude = [trimming|
-- This file is auto generated and will be overriden regulary. Please edit `Application/Schema.sql` to change the Types\n"
{-# LANGUAGE TypeSynonymInstances, FlexibleInstances, InstanceSigs, MultiParamTypeClasses, TypeFamilies, DataKinds, TypeOperators, UndecidableInstances, ConstraintKinds, StandaloneDeriving #-}
{-# OPTIONS_GHC -Wno-unused-imports -Wno-dodgy-imports -Wno-unused-matches -Wno-ambiguous-fields #-}
module $moduleName where
$defaultImports
import Generated.ActualTypes
$statementImports
|]
tableIncludeModule :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> (OsPath, Text)
tableIncludeModule table =
((OsPath.</>) "build/Generated" (either (error . show) id (encodeUtf (cs (tableNameToModelName table.name) <> "Include.hs"))), prelude <> compileInclude table)
where
moduleName = "Generated." <> tableNameToModelName table.name <> "Include"
prelude = [trimming|
-- This file is auto generated and will be overriden regulary. Please edit `Application/Schema.sql` to change the Types\n"
module $moduleName where
import Generated.ActualTypes
import IHP.ModelSupport (Include, GetModelById)
|] <> "\n\n"
tableModuleBody :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CompilerOptions -> CreateTable -> Text
tableModuleBody options table = Text.unlines $ filter (not . Text.null)
[ compileInputValueInstance table
, compileFromRowInstance table
, compileFromRowHasqlInstance table
, compileGetModelName table
, compileCreate table
, compileUpdate table
, compileBuild table
, compileFilterPrimaryKeyInstance table
, if needsHasFieldId table
then compileHasFieldId table
else ""
, if options.compileGetAndSetFieldInstances
then compileSetFieldInstances table <> compileUpdateFieldInstances table
else ""
, compileFieldBitInstances table
]
newtype Schema = Schema { statements :: [Statement] }
data CompilerOptions = CompilerOptions {
-- | We can toggle the generation of @SetField@ and @GetField@ instances.
-- This is e.g. disabled when showing the code preview in the schema designer
-- as it's very noisy and does not add any values. But of course it's needed
-- when do a compilation for the Types.hs
compileGetAndSetFieldInstances :: Bool,
-- | When disabled, generates simpler types without Include\/fetchRelated type machinery.
-- This removes type parameters from record types, removes has-many QueryBuilder fields,
-- and skips generating Include modules. Set @IHP_RELATION_SUPPORT=0@ to disable.
compileRelationSupport :: Bool
}
fullCompileOptions :: CompilerOptions
fullCompileOptions = CompilerOptions { compileGetAndSetFieldInstances = True, compileRelationSupport = True }
previewCompilerOptions :: CompilerOptions
previewCompilerOptions = CompilerOptions { compileGetAndSetFieldInstances = False, compileRelationSupport = True }
atomicType :: PostgresType -> Text
atomicType = \case
PSmallInt -> "Int"
PInt -> "Int"
PBigInt -> "Integer"
PJSONB -> "Data.Aeson.Value"
PText -> "Text"
PBoolean -> "Bool"
PTimestampWithTimezone -> "UTCTime"
PUUID -> "UUID"
PSerial -> "Int"
PBigserial -> "Integer"
PReal -> "Float"
PDouble -> "Double"
PDate -> "Data.Time.Calendar.Day"
PBinary -> "(Binary ByteString)"
PTime -> "TimeOfDay"
(PInterval _) -> "Interval"
PCustomType theType -> tableNameToModelName theType
PTimestamp -> "LocalTime"
(PNumeric _ _) -> "Scientific"
(PVaryingN _) -> "Text"
(PCharacterN _) -> "Text"
PArray type_ -> "[" <> atomicType type_ <> "]"
PPoint -> "Point"
PPolygon -> "Polygon"
PInet -> "Inet"
PTSVector -> "Tsvector"
PSingleChar -> "Text"
PTrigger -> error "atomicType: PTrigger not supported"
PEventTrigger -> error "atomicType: PEventTrigger not supported"
haskellType :: (?schema :: Schema) => CreateTable -> Column -> Text
haskellType table@CreateTable { name = tableName, primaryKeyConstraint } column@Column { name, columnType, notNull, generator }
| [name] == primaryKeyColumnNames primaryKeyConstraint = "(" <> primaryKeyTypeName tableName <> ")"
| otherwise =
let
actualType =
case findForeignKeyConstraint table column of
Just fk@(ForeignKeyConstraint { referenceTable, referenceColumn })
| isForeignKeyReferencingPK fk -> "(" <> primaryKeyTypeName referenceTable <> ")"
| otherwise ->
-- FK references a non-PK column; use the referenced column's actual type
case referenceColumn >>= \refCol -> findTableByName referenceTable >>= \t -> find (\c -> c.name == refCol) t.columns of
Just refColumn -> atomicType refColumn.columnType
Nothing -> atomicType columnType
_ -> atomicType columnType
in
if not notNull || isJust generator
then "(Maybe " <> actualType <> ")"
else actualType
-- haskellType table (HasMany {name}) = "(QueryBuilder.QueryBuilder " <> tableNameToModelName name <> ")"
writeIfDifferent :: OsPath -> Text -> IO ()
writeIfDifferent path content = do
fp <- decodeUtf path
alreadyExists <- Directory.doesFileExist path
existingContent <- if alreadyExists then readFile fp else pure ""
when (existingContent /= cs content) do
putStrLn $ "Updating " <> cs fp
writeFile fp (cs content)
compileTypes :: (?compilerOptions :: CompilerOptions) => CompilerOptions -> Schema -> Text
compileTypes options schema@(Schema statements) = [trimming|
-- This file is auto generated and will be overriden regulary. Please edit `Application/Schema.sql` to change the Types\n"
{-# OPTIONS_GHC -Wno-unused-imports -Wno-dodgy-imports -Wno-unused-matches #-}
module Generated.ActualTypes ($rexports) where
import Generated.Enums
import Generated.ActualTypes.PrimaryKeys
$perTableImports
|]
where
tableModelNames =
statements
|> mapMaybe (\case
StatementCreateTable table | tableHasPrimaryKey table -> Just (tableNameToModelName table.name)
otherwise -> Nothing
)
perTableModuleNames = map (\n -> "Generated.ActualTypes." <> n) tableModelNames
perTableImports = perTableModuleNames
|> map (\n -> "import " <> n)
|> Text.unlines
rexports = ("module Generated.Enums" : "module Generated.ActualTypes.PrimaryKeys" : map (\n -> "module " <> n) perTableModuleNames)
|> Text.intercalate ", "
compileActualTypesForTable :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileActualTypesForTable table = Text.unlines
[ compileData table
, compileTypeAlias table
, compileHasTableNameInstance table
, compileTableInstance table
]
-- | Like 'compileActualTypesForTable' but includes PrimaryKey and Default Id' instances inline.
-- Used by 'compileStatementPreviewWith' so the IDE preview and tests see a self-contained output.
compileActualTypesForTablePreview :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileActualTypesForTablePreview table = Text.unlines
[ compileData table
, compilePrimaryKeyInstance table
, compileTypeAlias table
, compileHasTableNameInstance table
, compileDefaultIdInstance table
, compileTableInstance table
]
compileIndex :: (?compilerOptions :: CompilerOptions) => Schema -> Text
compileIndex schema = [trimming|
-- This file is auto generated and will be overriden regulary. Please edit `Application/Schema.sql` to change the Types\n"
module Generated.Types ($rexports) where
import Generated.ActualTypes
$tableModuleImports
|]
where
tableModuleNames =
schema.statements
|> map (\case
StatementCreateTable table ->
let modelName = tableNameToModelName table.name
in
[ "Generated." <> modelName ]
<> if ?compilerOptions.compileRelationSupport
then [ "Generated." <> modelName <> "Include" ]
else []
otherwise -> []
)
|> concat
tableModuleImports = tableModuleNames
|> map (\name -> "import " <> name)
|> Text.unlines
rexportedModules = ["Generated.ActualTypes"] <> tableModuleNames
rexports = rexportedModules
|> map (\moduleName -> "module " <> moduleName)
|> Text.intercalate ", "
defaultImports = [trimming|
import IHP.HaskellSupport
import IHP.ModelSupport
import CorePrelude hiding (id)
import Data.Time.Clock
import Data.Time.LocalTime
import qualified Data.Time.Calendar
import qualified Data.List as List
import qualified Data.ByteString as ByteString
import Database.PostgreSQL.Simple
import Database.PostgreSQL.Simple.FromRow
import Database.PostgreSQL.Simple.FromField hiding (Field, name)
import Database.PostgreSQL.Simple.ToField hiding (Field)
import qualified IHP.Controller.Param
import GHC.TypeLits
import Data.UUID (UUID)
import Data.Default
import qualified IHP.QueryBuilder as QueryBuilder
import qualified Data.Proxy
import GHC.Records
import Data.Data
import qualified Data.String.Conversions
import qualified Data.Text.Encoding
import qualified Data.Aeson
import Database.PostgreSQL.Simple.Types (Query (Query), Binary ( .. ))
import qualified Database.PostgreSQL.Simple.Types
import IHP.Job.Types
import IHP.Job.Queue (textToEnumJobStatus)
import qualified Control.DeepSeq as DeepSeq
import qualified Data.Dynamic
import Data.Scientific
import IHP.Hasql.FromRow (FromRowHasql(..))
import qualified Hasql.Decoders as Decoders
import qualified Hasql.Encoders
import qualified Hasql.Implicits.Encoders
import IHP.Hasql.Encoders ()
import qualified Hasql.Mapping.IsScalar as Mapping
import Hasql.PostgresqlTypes ()
import Data.Bits ((.&.), (.|.))
import Control.Monad (unless)
|]
compileEnums :: CompilerOptions -> Schema -> Text
compileEnums options schema@(Schema statements) = Text.unlines
[ prelude
, let ?schema = schema
in intercalate "\n\n" (mapMaybe compileStatement statements)
]
where
compileStatement enum@(CreateEnumType {}) = Just (compileEnumDataDefinitions enum)
compileStatement _ = Nothing
prelude = [trimming|
-- This file is auto generated and will be overriden regulary. Please edit `Application/Schema.sql` to change the Types\n"
module Generated.Enums where
import CorePrelude
import IHP.ModelSupport
import Database.PostgreSQL.Simple
import Database.PostgreSQL.Simple.FromField hiding (Field, name)
import Database.PostgreSQL.Simple.ToField hiding (Field)
import qualified IHP.Controller.Param
import Data.Default
import qualified IHP.QueryBuilder as QueryBuilder
import qualified Data.String.Conversions
import qualified Data.Text.Encoding
import qualified Control.DeepSeq as DeepSeq
import qualified Hasql.Encoders
import qualified Hasql.Decoders
import qualified Hasql.Implicits.Encoders
import qualified Hasql.Mapping.IsScalar as Mapping
import qualified Data.HashMap.Strict as HashMap
|]
compilePrimaryKeysModule :: (?compilerOptions :: CompilerOptions) => Schema -> Text
compilePrimaryKeysModule schema@(Schema statements) =
let ?schema = schema
in Text.unlines
[ prelude
, statements
|> mapMaybe (\case
StatementCreateTable table | tableHasPrimaryKey table ->
Just (compilePrimaryKeyInstance table <> compileDefaultIdInstance table)
_ -> Nothing)
|> Text.intercalate "\n"
]
where
prelude = [trimming|
-- This file is auto generated and will be overriden regulary. Please edit `Application/Schema.sql` to change the Types\n"
{-# LANGUAGE TypeSynonymInstances, FlexibleInstances, InstanceSigs, MultiParamTypeClasses, TypeFamilies, DataKinds, TypeOperators, UndecidableInstances, ConstraintKinds, StandaloneDeriving #-}
{-# OPTIONS_GHC -Wno-unused-imports -Wno-dodgy-imports -Wno-unused-matches #-}
module Generated.ActualTypes.PrimaryKeys where
$defaultImports
import Generated.Enums
|]
compileStatementPreview :: [Statement] -> Statement -> Text
compileStatementPreview = compileStatementPreviewWith previewCompilerOptions
compileStatementPreviewWith :: CompilerOptions -> [Statement] -> Statement -> Text
compileStatementPreviewWith options statements statement =
let ?schema = Schema statements
?compilerOptions = options
in
case statement of
CreateEnumType {} -> compileEnumDataDefinitions statement
StatementCreateTable table -> Text.unlines
[ compileActualTypesForTablePreview table
, tableModuleBody options table
]
_ -> ""
-- | Skip generation of tables with no primary keys
tableHasPrimaryKey :: CreateTable -> Bool
tableHasPrimaryKey table = table.primaryKeyConstraint /= (PrimaryKeyConstraint [])
compileTypeAlias :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileTypeAlias table@(CreateTable { name, columns }) =
"type "
<> modelName
<> " = "
<> modelName
<> "'"
<> spacePrefix (unwords (map (haskellType table) (variableAttributes table)) <> hasManyDefaults)
<> "\n"
where
modelName = tableNameToModelName name
hasManyDefaults
| ?compilerOptions.compileRelationSupport =
columnsReferencingTable name
|> map (\(tableName, columnName, _) -> "(QueryBuilder.QueryBuilder \"" <> tableName <> "\")")
|> unwords
| otherwise = ""
primaryKeyTypeName :: Text -> Text
primaryKeyTypeName name = "Id' " <> tshow name <> ""
compileData :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileData table@(CreateTable { name, columns }) =
"data " <> modelName <> "'" <> spacePrefix typeArguments
<> " = " <> modelName <> " {"
<>
table
|> dataFields
|> map (\(fieldName, fieldType) -> fieldName <> " :: " <> fieldType)
|> commaSep
<> "} deriving (Eq, Show)\n"
where
modelName = tableNameToModelName name
typeArguments :: Text
typeArguments = dataTypeArguments table |> unwords
compileInputValueInstance :: CreateTable -> Text
compileInputValueInstance table =
"instance InputValue " <> modelName <> " where inputValue = IHP.ModelSupport.recordToInputValue\n"
where
modelName = qualifiedConstructorNameFromTableName table.name
-- | Returns all the type arguments of the data structure for an entity
dataTypeArguments :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> [Text]
dataTypeArguments table
| not ?compilerOptions.compileRelationSupport = []
| otherwise = (map columnNameToFieldName belongsToVariables) <> hasManyVariables
where
belongsToVariables = variableAttributes table |> map (.name)
hasManyVariables =
columnsReferencingTable table.name
|> compileQueryBuilderFields
|> map snd
-- | Returns the field names and types for the @data MyRecord = MyRecord { .. }@ for a given table
dataFields :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> [(Text, Text)]
dataFields table@(CreateTable { name }) = columnFields <> queryBuilderFields <> [("meta", "MetaBag")]
where
columns = allColumnsIncludingInherited table
columnFields = columns |> map columnField
columnField column =
let fieldName = columnNameToFieldName column.name
in
( fieldName
, if isVariableAttribute table column
then fieldName
else haskellType table column
)
queryBuilderFields
| ?compilerOptions.compileRelationSupport = columnsReferencingTable name |> compileQueryBuilderFields
| otherwise = []
fieldBitPositions :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> [(Text, Integer)]
fieldBitPositions table =
let columns = allColumnsIncludingInherited table
in zip (map (columnNameToFieldName . (.name)) columns) (map bit [0..])
columnsWithBitIndices :: [Column] -> [Column] -> [(Column, Int)]
columnsWithBitIndices allColumns subset =
let subsetNames = setFromList (map (.name) subset) :: Set Text
in [(col, idx) | (col, idx) <- zip allColumns [0..], col.name `member` subsetNames]
compileQueryBuilderFields :: [(Text, Text, Maybe Text)] -> [(Text, Text)]
compileQueryBuilderFields columns = map compileQueryBuilderField columns
where
compileQueryBuilderField (refTableName, refColumnName, _refColumn) =
let
-- Given a relationship like the following:
--
-- CREATE TABLE referrals (
-- user_id UUID NOT NULL,
-- referred_user_id UUID DEFAULT uuid_generate_v4() NOT NULL
-- );
--
-- We would have two fields on the @User@ record called @referrals@ which are
-- going to be used with fetchRelated (user >>= fetchRelated #referrals).
--
-- Of course having two fields in the same record does not work, so we have to
-- detect these duplicate query builder fields and use a more qualified name.
--
-- In the example this will lead to two fileds called @referralsUsers@ and @referralsReferredUsers@
-- being added to the data structure.
hasDuplicateQueryBuilder =
columns
|> map (\(t, _, _) -> t)
|> map columnNameToFieldName
|> filter (columnNameToFieldName refTableName ==)
|> length
|> (\count -> count > 1)
stripIdSuffix :: Text -> Text
stripIdSuffix name = fromMaybe name (Text.stripSuffix "_id" name)
fieldName = if hasDuplicateQueryBuilder
then
(refTableName <> "_" <> (refColumnName |> stripIdSuffix))
|> columnNameToFieldName
|> pluralize
else columnNameToFieldName refTableName
in
(fieldName, fieldName)
-- | Finds all the columns referencing a specific table via a foreign key constraint
--
-- __Example:__
--
-- Given the schema:
--
-- > CREATE TABLE users (id SERIAL, company_id INT);
-- > CREATE TABLE companies (id SERIAL);
--
-- you can do the following:
--
-- >>> columnsReferencingTable "companies"
-- [ ("users", "company_id") ]
columnsReferencingTable :: (?schema :: Schema) => Text -> [(Text, Text, Maybe Text)]
columnsReferencingTable theTableName =
let
(Schema statements) = ?schema
in
statements
|> mapMaybe \case
AddConstraint { tableName, constraint = fk@ForeignKeyConstraint { columnName, referenceTable, referenceColumn } } | referenceTable == theTableName && isForeignKeyReferencingPK fk -> Just (tableName, columnName, referenceColumn)
_ -> Nothing
variableAttributes :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> [Column]
variableAttributes table = filter (isVariableAttribute table) (allColumnsIncludingInherited table)
isVariableAttribute :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Column -> Bool
isVariableAttribute table column
| not ?compilerOptions.compileRelationSupport = False
| otherwise = isRefCol table column
-- | Returns @True@ when the column references another table's primary key via foreign key constraint.
-- FK constraints that reference a non-PK column (e.g., REFERENCES users(email)) return @False@,
-- because the relation machinery (Include, fetchRelated, QueryBuilder) only works for PK-based FKs.
isRefCol :: (?schema :: Schema) => CreateTable -> Column -> Bool
isRefCol table column = case findForeignKeyConstraint table column of
Just fk -> isForeignKeyReferencingPK fk
Nothing -> False
-- | Returns the foreign key constraint bound on the given column.
-- For inherited columns, recursively checks ancestor table constraints.
findForeignKeyConstraint :: (?schema :: Schema) => CreateTable -> Column -> Maybe Constraint
findForeignKeyConstraint table@CreateTable { name, inherits } column =
case find (isFkConstraint name) statements of
Just (AddConstraint { constraint }) -> Just constraint
_ -> case inherits >>= findTableByName of
Just parentTable -> findForeignKeyConstraint parentTable column
Nothing -> Nothing
where
isFkConstraint tableName (AddConstraint { tableName = tName, constraint = ForeignKeyConstraint { columnName }}) = tName == tableName && columnName == column.name
isFkConstraint _ _ = False
(Schema statements) = ?schema
-- | Finds a table by name in the schema
findTableByName :: (?schema :: Schema) => Text -> Maybe CreateTable
findTableByName tableName =
let (Schema statements) = ?schema
in statements
|> mapMaybe (\case
StatementCreateTable table@CreateTable { name } | name == tableName -> Just table
_ -> Nothing)
|> headMay
-- | Returns @True@ when a FK constraint references the primary key of the target table.
-- FK constraints pointing at non-PK columns (e.g., REFERENCES users(email)) return @False@.
isForeignKeyReferencingPK :: (?schema :: Schema) => Constraint -> Bool
isForeignKeyReferencingPK ForeignKeyConstraint { referenceTable, referenceColumn } =
case referenceColumn of
Just refCol -> refCol `elem` referencedPKColumns
Nothing -> True
where
referencedPKColumns = case findTableByName referenceTable of
Just t -> primaryKeyColumnNames t.primaryKeyConstraint
Nothing -> []
isForeignKeyReferencingPK _ = False
-- | Returns all columns including inherited columns from parent tables.
-- Inherited columns that are not overridden in the child table are appended.
allColumnsIncludingInherited :: (?schema :: Schema) => CreateTable -> [Column]
allColumnsIncludingInherited CreateTable { columns, inherits = Nothing } = columns
allColumnsIncludingInherited CreateTable { columns, inherits = Just parentName } =
case findTableByName parentName of
Nothing -> columns
Just parentTable ->
let parentCols = allColumnsIncludingInherited parentTable
childNames = map (.name) columns
inherited = filter (\pc -> pc.name `notElem` childNames) parentCols
in columns <> inherited
compileEnumDataDefinitions :: (?schema :: Schema) => Statement -> Text
compileEnumDataDefinitions CreateEnumType { values = [] } = "" -- Ignore enums without any values
compileEnumDataDefinitions enum@(CreateEnumType { name, values }) =
"data " <> modelName <> " = " <> (intercalate " | " valueConstructors) <> " deriving (Eq, Show, Read, Enum, Bounded, Ord)\n"
<> "instance FromField " <> modelName <> " where\n"
<> indent (unlines (map compileFromFieldInstanceForValue values))
<> " fromField field (Just value) = returnError ConversionFailed field (\"Unexpected value for enum value. Got: \" <> Data.String.Conversions.cs value)\n"
<> " fromField field Nothing = returnError UnexpectedNull field \"Unexpected null for enum value\"\n"
<> "instance Default " <> modelName <> " where def = " <> enumValueToConstructorName (unsafeHead values) <> "\n"
<> "instance ToField " <> modelName <> " where\n" <> indent (unlines (map compileToFieldInstanceForValue values))
<> "instance InputValue " <> modelName <> " where\n" <> indent (unlines (map compileInputValue values))
<> "instance DeepSeq.NFData " <> modelName <> " where" <> " rnf a = seq a ()" <> "\n"
<> "instance IHP.Controller.Param.ParamReader " <> modelName <> " where readParameter = IHP.Controller.Param.enumParamReader; readParameterJSON = IHP.Controller.Param.enumParamReaderJSON\n"
-- textToEnum function for hasql decoder using HashMap for O(1) lookup
<> "textToEnum" <> modelName <> "Map :: HashMap.HashMap Text " <> modelName <> "\n"
<> "textToEnum" <> modelName <> "Map = HashMap.fromList [" <> intercalate ", " (map compileTextToEnumMapEntry values) <> "]\n"
<> "textToEnum" <> modelName <> " :: Text -> Maybe " <> modelName <> "\n"
<> "textToEnum" <> modelName <> " t = HashMap.lookup t textToEnum" <> modelName <> "Map\n"
-- DefaultParamEncoder for hasql queries
<> "instance Hasql.Implicits.Encoders.DefaultParamEncoder " <> modelName <> " where\n"
<> " defaultParam = Hasql.Encoders.nonNullable (Hasql.Encoders.enum (Just \"public\") " <> tshow (Text.toLower name) <> " inputValue)\n"
<> "instance Hasql.Implicits.Encoders.DefaultParamEncoder (Maybe " <> modelName <> ") where\n"
<> " defaultParam = Hasql.Encoders.nullable (Hasql.Encoders.enum (Just \"public\") " <> tshow (Text.toLower name) <> " inputValue)\n"
<> "instance Hasql.Implicits.Encoders.DefaultParamEncoder [" <> modelName <> "] where\n"
<> " defaultParam = Hasql.Encoders.nonNullable $ Hasql.Encoders.foldableArray $ Hasql.Encoders.nonNullable (Hasql.Encoders.enum (Just \"public\") " <> tshow (Text.toLower name) <> " inputValue)\n"
-- IsScalar instance for hasql-mapping (used by generated statement modules)
<> "instance Mapping.IsScalar " <> modelName <> " where\n"
<> " encoder = Hasql.Encoders.enum (Just \"public\") " <> tshow (Text.toLower name) <> " inputValue\n"
<> " decoder = Hasql.Decoders.enum (Just \"public\") " <> tshow (Text.toLower name) <> " textToEnum" <> modelName <> "\n"
where
modelName = tableNameToModelName name
valueConstructors = map enumValueToConstructorName values
enumValueToConstructorName :: Text -> Text
enumValueToConstructorName enumValue = if isEnumValueUniqueInSchema enumValue
then enumValueToControllerName enumValue
else modelName <> (enumValueToControllerName enumValue)
compileFromFieldInstanceForValue value = "fromField field (Just value) | value == (Data.Text.Encoding.encodeUtf8 " <> tshow value <> ") = pure " <> enumValueToConstructorName value
compileToFieldInstanceForValue value = "toField " <> enumValueToConstructorName value <> " = toField (" <> tshow value <> " :: Text)"
compileInputValue value = "inputValue " <> enumValueToConstructorName value <> " = " <> tshow value <> " :: Text"
compileTextToEnumMapEntry value = "(" <> tshow value <> ", " <> enumValueToConstructorName value <> ")"
-- Let's say we have a schema like this:
--
-- > CREATE TYPE property_type AS ENUM ('APARTMENT', 'HOUSE');
-- > CREATE TYPE apartment_type AS ENUM ('LOFT', 'APARTMENT');
--
-- A naive enum implementation will generate these data constructors:
--
-- > data PropertyType = Apartment | House
-- > data ApartmentType = Loft | Apartment
--
-- Now we have two data constructors with the name 'Apartment'. This fails to compile.
--
-- To avoid this we detect if a name is unique across the schema. When it's not unique
-- we use the following naming schema:
--
-- > data PropertyType = PropertyTypeApartment | House
-- > data ApartmentType = Loft | ApartmentTypeApartment
--
-- This function returns True if the given enumValue (like 'APARTMENT') is unique across the schema.
isEnumValueUniqueInSchema :: Text -> Bool
isEnumValueUniqueInSchema enumValue =
?schema
|> \case Schema statements -> statements
|> filter (\case
CreateEnumType { name, values } | enumValue `elem` values -> True
_ -> False
)
|> length
|> \count -> count == 1
compileEnumDataDefinitions _ = ""
qualifiedConstructorNameFromTableName :: Text -> Text
qualifiedConstructorNameFromTableName unqualifiedName = "Generated.ActualTypes." <> (tableNameToModelName unqualifiedName)
--
-- Trigger types don't have encoders as they're not real data columns.
hasqlSupportsColumnType :: PostgresType -> Bool
hasqlSupportsColumnType = \case
PTrigger -> False
PEventTrigger -> False
(PArray inner) -> hasqlSupportsColumnType inner
_ -> True
compileCreate :: (?schema :: Schema) => CreateTable -> Text
compileCreate table@(CreateTable { name }) =
let
columns = allColumnsIncludingInherited table
writableColumns = onlyWritableColumns columns
modelName = qualifiedConstructorNameFromTableName name
funcName = tableNameToModelName name
-- Hasql bodies
isDynamic = hasAnyDefaults writableColumns
hasqlCreateBody = if isDynamic
then "let pool = ?modelContext.hasqlPool\n"
<> "let touched = model.meta.touchedFields\n"
<> "sqlStatementHasql pool model (Generated.Statements.Create" <> funcName <> ".statement touched)"
else "let pool = ?modelContext.hasqlPool\n"
<> "sqlStatementHasql pool model Generated.Statements.Create" <> funcName <> ".statement"
hasqlCreateManyBody = "let pool = ?modelContext.hasqlPool\n"
<> "sqlStatementHasql pool models (Generated.Statements.CreateMany" <> funcName <> ".statement (List.length models))"
hasqlCreateDiscardBody = if isDynamic
then "let pool = ?modelContext.hasqlPool\n"
<> "let touched = model.meta.touchedFields\n"
<> "sqlStatementHasql pool model (Generated.Statements.Create" <> funcName <> ".discardResultStatement touched)"
else "let pool = ?modelContext.hasqlPool\n"
<> "sqlStatementHasql pool model Generated.Statements.Create" <> funcName <> ".discardResultStatement"
in
-- Instance block: delegate to top-level functions
"instance CanCreate " <> modelName <> " where\n"
<> indent ("create = create" <> funcName <> "\n")
<> indent ("createMany = createMany" <> funcName <> "\n")
<> indent ("createRecordDiscardResult = createRecordDiscardResult" <> funcName <> "\n")
-- create<Model>
<> "\n"
<> "create" <> funcName <> " :: (?modelContext :: ModelContext) => " <> modelName <> " -> IO " <> modelName <> "\n"
<> "create" <> funcName <> " model = do\n"
<> indent (hasqlCreateBody <> "\n")
-- createMany<Model>
<> "\n"
<> "createMany" <> funcName <> " :: (?modelContext :: ModelContext) => [" <> modelName <> "] -> IO [" <> modelName <> "]\n"
<> "createMany" <> funcName <> " [] = pure []\n"
<> "createMany" <> funcName <> " models = do\n"
<> indent (hasqlCreateManyBody <> "\n")
-- createRecordDiscardResult<Model>
<> "\n"
<> "createRecordDiscardResult" <> funcName <> " :: (?modelContext :: ModelContext) => " <> modelName <> " -> IO ()\n"
<> "createRecordDiscardResult" <> funcName <> " model = do\n"
<> indent (hasqlCreateDiscardBody <> "\n")
commaSep :: [Text] -> Text
commaSep = intercalate ", "
-- Avoids trailing spaces when type arguments are empty.
spacePrefix :: Text -> Text
spacePrefix "" = ""
spacePrefix t = " " <> t
toBinding :: Text -> Column -> Text
toBinding modelName Column { name } = "let " <> modelName <> "{" <> columnNameToFieldName name <> "} = model in " <> columnNameToFieldName name
onlyWritableColumns columns = columns |> filter (\Column { generator } -> isNothing generator)
compileUpdate :: (?schema :: Schema) => CreateTable -> Text
compileUpdate table@(CreateTable { name }) =
let
columns = allColumnsIncludingInherited table
modelName = qualifiedConstructorNameFromTableName name
funcName = tableNameToModelName name
stmtModule = "Generated.Statements.Update" <> funcName
in
-- Instance block: delegate to top-level functions
"instance CanUpdate " <> modelName <> " where\n"
<> indent ("updateRecord = updateRecord" <> funcName <> "\n")
<> indent ("updateRecordDiscardResult = updateRecordDiscardResult" <> funcName <> "\n")
-- updateRecord<Model>
<> "\n"
<> "updateRecord" <> funcName <> " :: (?modelContext :: ModelContext) => " <> modelName <> " -> IO " <> modelName <> "\n"
<> "updateRecord" <> funcName <> " model = do\n"
<> " let touched = model.meta.touchedFields\n"
<> " if touched == 0 then pure model else do\n"
<> " let pool = ?modelContext.hasqlPool\n"
<> " sqlStatementHasql pool model (" <> stmtModule <> ".statement touched)\n"
-- updateRecordDiscardResult<Model>
<> "\n"
<> "updateRecordDiscardResult" <> funcName <> " :: (?modelContext :: ModelContext) => " <> modelName <> " -> IO ()\n"
<> "updateRecordDiscardResult" <> funcName <> " model = do\n"
<> " let touched = model.meta.touchedFields\n"
<> " unless (touched == 0) $ do\n"
<> " let pool = ?modelContext.hasqlPool\n"
<> " sqlStatementHasql pool model (" <> stmtModule <> ".discardResultStatement touched)\n"
compileFromRowInstance :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileFromRowInstance table@(CreateTable { name }) = cs [i|instance FromRow #{modelName} where
fromRow = do
#{unsafeInit . indent . indent . unlines $ map columnBinding columnNames}
let theRecord = #{modelName} #{intercalate " " (map compileField (dataFields table))}
pure theRecord
|]
where
modelName = qualifiedConstructorNameFromTableName name
columns = allColumnsIncludingInherited table
columnNames = map (columnNameToFieldName . (.name)) columns
columnBinding columnName = columnName <> " <- field"
referencing = columnsReferencingTable table.name
referencingWithFieldNames = zip (map fst (compileQueryBuilderFields referencing)) referencing
compileField (fieldName, _)
| isColumn fieldName = fieldName
| isOneToManyField fieldName = let (Just (_, ref)) = find (\(n, _) -> n == fieldName) referencingWithFieldNames in compileFromRowQueryBuilder table ref
| fieldName == "meta" = "def { originalDatabaseRecord = Just (Data.Dynamic.toDyn theRecord) }"
| otherwise = "def"
isColumn colName = colName `elem` columnNames
isOneToManyField fieldName = fieldName `elem` (map fst referencingWithFieldNames)
-- | Generates a 'FromRowHasql' instance that delegates to the RowDecoder statement module.
compileFromRowHasqlInstance :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileFromRowHasqlInstance table@(CreateTable { name, columns }) =
let modelName = qualifiedConstructorNameFromTableName name
rowDecoderModule = "Generated.Statements.RowDecoder" <> tableNameToModelName name
in cs [i|instance FromRowHasql #{modelName} where
hasqlRowDecoder = #{rowDecoderModule}.rowDecoder
|]
compileFromRowQueryBuilder :: (?schema :: Schema) => CreateTable -> (Text, Text, Maybe Text) -> Text
compileFromRowQueryBuilder table (refTableName, refFieldName, maybeRefColumn) = "(QueryBuilder.filterWhere (#" <> columnNameToFieldName refFieldName <> ", " <> primaryKeyField <> ") (QueryBuilder.query @" <> tableNameToModelName refTableName <> "))"
where
primaryKeyField :: Text
primaryKeyField = if refColumn.notNull then actualPrimaryKeyField else "Just " <> actualPrimaryKeyField
actualPrimaryKeyField :: Text
actualPrimaryKeyField = case maybeRefColumn of
-- When the FK constraint specifies the referenced column, use it directly.
-- This handles composite PK tables where a FK references a specific column
-- (which must have a standalone UNIQUE constraint).
Just refCol -> columnNameToFieldName refCol
-- Otherwise fall back to the single primary key column
Nothing -> case primaryKeyColumns table of
[] -> error $ "Impossible happened in compileFromRowHasqlInstance. No primary keys found for table " <> cs table.name <> ". At least one primary key is required."
[pk] -> columnNameToFieldName pk.name
pks -> error $ "No support yet for composite foreign keys. Tables cannot have foreign keys to table '" <> cs table.name <> "' which has more than one column as its primary key."
(Just refTable) = let (Schema statements) = ?schema in
statements
|> find \case
StatementCreateTable CreateTable { name } -> name == refTableName
otherwise -> False
refColumn :: Column
refColumn = refTable
|> \case StatementCreateTable CreateTable { columns } -> columns
_ -> error "refColumn: expected StatementCreateTable"
|> find (\col -> col.name == refFieldName)
|> \case
Just refColumn -> refColumn
Nothing -> error (cs $ "Could not find " <> refTable.name <> "." <> refFieldName <> " referenced by a foreign key constraint. Make sure that there is no typo in the foreign key constraint")
-- Note: Generated columns are treated as nullable in the Haskell type (even if notNull=True)
-- because they're not included in INSERT statements and are computed by the database.
-- Primary key and foreign key columns are wrapped with Id.
hasqlColumnDecoder :: (?schema :: Schema) => CreateTable -> Column -> Text
hasqlColumnDecoder table column@Column { name, columnType, notNull, generator } =
"Decoders.column (" <> nullability <> " " <> decoder <> ")"
where
-- Id columns (primary keys and foreign keys) use Mapping.decoder which
-- goes through the IsScalar instance for Id'
isPrimaryKey = [name] == primaryKeyColumnNames table.primaryKeyConstraint
-- Match the logic in haskellType: primary keys are always nonNullable (even without
-- explicit NOT NULL), otherwise nullable if not notNull or has a generator
isNullable = not isPrimaryKey && (not notNull || isJust generator)
nullability = if isNullable then "Decoders.nullable" else "Decoders.nonNullable"
isForeignKey = isJust (findForeignKeyConstraint table column)
needsIdWrapper = isPrimaryKey || isForeignKey
baseDecoder = hasqlValueDecoder columnType
decoder = if needsIdWrapper then "Mapping.decoder" else baseDecoder
hasqlValueDecoder :: PostgresType -> Text
hasqlValueDecoder = \case
PUUID -> "Decoders.uuid"
PText -> "Decoders.text"
PSmallInt -> "(fromIntegral <$> Decoders.int2)"
PInt -> "(fromIntegral <$> Decoders.int4)"
PBigInt -> "(fromIntegral <$> Decoders.int8)"
PSerial -> "(fromIntegral <$> Decoders.int4)"
PBigserial -> "(fromIntegral <$> Decoders.int8)"
PBoolean -> "Decoders.bool"
PReal -> "Decoders.float4"
PDouble -> "Decoders.float8"
PTimestampWithTimezone -> "Decoders.timestamptz"
PTimestamp -> "Decoders.timestamp"
PDate -> "Decoders.date"
PTime -> "Decoders.time"
(PNumeric _ _) -> "Decoders.numeric"
PJSONB -> "Decoders.jsonb"
PBinary -> "(Database.PostgreSQL.Simple.Types.Binary <$> Decoders.bytea)"
(PVaryingN _) -> "Decoders.text"
(PCharacterN _) -> "Decoders.text"
(PInterval _) -> "Mapping.decoder"
PPoint -> "Mapping.decoder"
PPolygon -> "Mapping.decoder"
PInet -> "Mapping.decoder"
PTSVector -> "Mapping.decoder"
PArray innerType -> "(Decoders.listArray (" <> hasqlArrayElementDecoder innerType <> "))"
PCustomType _ -> "Mapping.decoder"
PSingleChar -> "Decoders.char"
PTrigger -> "Decoders.text" -- Trigger types shouldn't appear in table columns
PEventTrigger -> "Decoders.text" -- Event trigger types shouldn't appear in table columns
hasqlArrayElementDecoder :: PostgresType -> Text
hasqlArrayElementDecoder innerType = "Decoders.nonNullable " <> hasqlValueDecoder innerType
compileBuild :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileBuild table@(CreateTable { name }) =
let
columns = allColumnsIncludingInherited table
constructor = qualifiedConstructorNameFromTableName name
qbDefaults = if ?compilerOptions.compileRelationSupport
then columnsReferencingTable name |> map (const "def") |> unwords
else ""
in
"instance Record " <> constructor <> " where\n"
<> " {-# INLINE newRecord #-}\n"
<> " newRecord = " <> constructor <> " " <> unwords (map toDefaultValueExpr columns) <> " " <> qbDefaults <> " def\n"
compileDefaultIdInstance :: CreateTable -> Text
compileDefaultIdInstance table = "instance Default (Id' \"" <> table.name <> "\") where def = Id def"
toDefaultValueExpr :: Column -> Text
toDefaultValueExpr Column { columnType, notNull, defaultValue = Just theDefaultValue } =
let
wrapNull False value = "(Just " <> value <> ")"
wrapNull True value = value
isNullExpr (VarExpression varName) = toUpper varName == "NULL"
isNullExpr _ = False
-- We remove type casts here, as we need the actual value literal for setting our default value
theNormalizedDefaultValue = theDefaultValue |> PostgresParser.removeTypeCasts
in
if isNullExpr theDefaultValue
then "Nothing"
else
case columnType of
PText -> case theNormalizedDefaultValue of
TextExpression value -> wrapNull notNull (tshow value)
otherwise -> error ("toDefaultValueExpr: TEXT column needs to have a TextExpression as default value. Got: " <> show otherwise)
PBoolean -> case theNormalizedDefaultValue of
VarExpression value -> wrapNull notNull (tshow (toLower value == "true"))
otherwise -> error ("toDefaultValueExpr: BOOL column needs to have a VarExpression as default value. Got: " <> show otherwise)
PDouble -> case theNormalizedDefaultValue of
DoubleExpression value -> wrapNull notNull (tshow value)
IntExpression value -> wrapNull notNull (tshow value)
otherwise -> error ("toDefaultValueExpr: DOUBLE column needs to have a DoubleExpression as default value. Got: " <> show otherwise)
_ -> "def"
toDefaultValueExpr _ = "def"
compileHasTableNameInstance :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileHasTableNameInstance table@(CreateTable { name }) =
"type instance GetTableName (" <> tableNameToModelName name <> "'" <> spacePrefix (unwords (map (const "_") (dataTypeArguments table))) <> ") = " <> tshow name <> "\n"
<> "type instance GetModelByTableName " <> tshow name <> " = " <> tableNameToModelName name <> "\n"
compilePrimaryKeyInstance :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compilePrimaryKeyInstance table@(CreateTable { name, columns, constraints }) = [trimming|type instance PrimaryKey $symbol = $idType|] <> "\n"
where
symbol = tshow name
idType :: Text
idType = case primaryKeyColumns table of
[] -> error $ "Impossible happened in compilePrimaryKeyInstance. No primary keys found for table " <> cs name <> ". At least one primary key is required."
[column] -> atomicType column.columnType -- PrimaryKey User = UUID
cs -> "(" <> intercalate ", " (map colType cs) <> ")" -- PrimaryKey PostsTag = (Id' "posts", Id' "tags")
where
colType column = haskellType table column
compileFilterPrimaryKeyInstance :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileFilterPrimaryKeyInstance table@(CreateTable { name, columns, constraints }) = cs [i|
instance QueryBuilder.FilterPrimaryKey "#{name}" where
filterWhereId #{primaryKeyPattern} builder =
builder |> #{intercalate " |> " primaryKeyFilters}
{-# INLINE filterWhereId #-}
|]
where
primaryKeyPattern = case primaryKeyColumns table of
[] -> error $ "Impossible happened in compilePrimaryKeyInstance. No primary keys found for table " <> cs name <> ". At least one primary key is required."
[c] -> columnNameToFieldName c.name
cs -> "(Id (" <> intercalate ", " (map (columnNameToFieldName . (.name)) cs) <> "))"
primaryKeyFilters :: [Text]
primaryKeyFilters = map primaryKeyFilter $ primaryKeyColumns table
primaryKeyFilter :: Column -> Text
primaryKeyFilter Column {name} = "QueryBuilder.filterWhere (#" <> columnNameToFieldName name <> ", " <> columnNameToFieldName name <> ")"
compileTableInstance :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileTableInstance table@(CreateTable { name, constraints }) = cs [i|
instance #{instanceHead} where
tableName = \"#{name}\"
columnNames = #{columnNames}
primaryKeyColumnNames = #{primaryKeyColumnNames}
|]
where
instanceHead :: Text
instanceHead = "IHP.ModelSupport.Table (" <> compileTypePattern table <> ")"
primaryKeyColumnNames :: [Text]
primaryKeyColumnNames = primaryKeyColumns table |> map (.name)
columnNames = allColumnsIncludingInherited table
|> map (.name)
|> tshow
compileGetModelName :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileGetModelName table@(CreateTable { name }) = "type instance GetModelName (" <> tableNameToModelName name <> "'" <> spacePrefix (unwords (map (const "_") (dataTypeArguments table))) <> ") = " <> tshow (tableNameToModelName name) <> "\n"
compileDataTypePattern :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileDataTypePattern table@(CreateTable { name }) = tableNameToModelName name <> " " <> unwords (table |> dataFields |> map fst)
compileTypePattern :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileTypePattern table@(CreateTable { name }) = tableNameToModelName name <> "'" <> spacePrefix (unwords (dataTypeArguments table))
compileInclude :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileInclude table@(CreateTable { name }) = (belongsToIncludes <> hasManyIncludes) |> unlines
where
columns = allColumnsIncludingInherited table
belongsToIncludes = map compileBelongsTo (filter (isRefCol table) columns)
hasManyIncludes = columnsReferencingTable name
|> (\refs -> zip (map (\(t, _, _) -> t) refs) (map fst (compileQueryBuilderFields refs)))
|> map compileHasMany
typeArgs = dataTypeArguments table
modelName = tableNameToModelName name
modelConstructor = modelName <> "'"
includeType :: Text -> Text -> Text
includeType fieldName includedType = "type instance Include " <> tshow fieldName <> " (" <> leftModelType <> ") = " <> rightModelType
where
leftModelType = unwords (modelConstructor:typeArgs)
rightModelType = unwords (modelConstructor:(map compileTypeVariable' typeArgs))
compileTypeVariable' name | name == fieldName = includedType
compileTypeVariable' name = name
compileBelongsTo :: Column -> Text
compileBelongsTo column = includeType (columnNameToFieldName column.name) ("(GetModelById " <> columnNameToFieldName column.name <> ")")
compileHasMany :: (Text, Text) -> Text
compileHasMany (refTableName, refColumnName) = includeType refColumnName ("[" <> tableNameToModelName refTableName <> "]")
compileSetFieldInstances :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileSetFieldInstances table@(CreateTable { name, columns }) = unlines (map compileSetField (dataFields table))
where
fieldBitMap = fieldBitPositions table
compileSetField (fieldName, fieldType) =
"instance SetField " <> tshow fieldName <> " (" <> compileTypePattern table <> ") " <> fieldType <> " where\n" <>
" {-# INLINE setField #-}\n" <>
" setField newValue record = record" <> recordUpdate
where
recordUpdate = case lookup fieldName fieldBitMap of
Just bitVal ->
" { " <> fieldName <> " = newValue" <>
", meta = record.meta { touchedFields = record.meta.touchedFields .|. " <> tshow bitVal <> " }" <>
" }"
Nothing ->
" { " <> fieldName <> " = newValue }"
compileUpdateFieldInstances :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileUpdateFieldInstances table@(CreateTable { name, columns }) = unlines (map compileField (dataFields table))
where
modelName = tableNameToModelName name
typeArgs = dataTypeArguments table
fieldBitMap = fieldBitPositions table
compileField (fieldName, fieldType)
| fieldName `elem` typeArgs = compilePolymorphic fieldName fieldType
| otherwise = compileSimple fieldName fieldType
-- Non-polymorphic: model type doesn't change, use record update
compileSimple fieldName fieldType =
"instance UpdateField " <> tshow fieldName <> " (" <> compileTypePattern table <> ") (" <> compileTypePattern table <> ") " <> fieldType <> " " <> fieldType <> " where\n" <>
" {-# INLINE updateField #-}\n" <>
" updateField newValue record = record" <> recordUpdate
where
recordUpdate = case lookup fieldName fieldBitMap of
Just bitVal ->
" { " <> fieldName <> " = newValue" <>
", meta = record.meta { touchedFields = record.meta.touchedFields .|. " <> tshow bitVal <> " }" <>
" }"
Nothing ->
" { " <> fieldName <> " = newValue }"
-- Polymorphic: type changes, must use full pattern match
compilePolymorphic fieldName fieldType =
"instance UpdateField " <> tshow fieldName <> " (" <> compileTypePattern table <> ") (" <> compileTypePattern' fieldName <> ") " <> fieldName <> " " <> fieldName <> "'" <> " where\n" <>
" {-# INLINE updateField #-}\n" <>
" updateField newValue (" <> compileDataTypePattern table <> ") = " <> modelName <> " " <> (unwords (map compileAttribute (table |> dataFields |> map fst)))
where
compileAttribute name'
| name' == fieldName = "newValue"
| name' == "meta" = case lookup fieldName fieldBitMap of
Just bitVal -> "(meta { touchedFields = touchedFields meta .|. " <> tshow bitVal <> " })"
Nothing -> "meta"
| otherwise = name'
compileTypePattern' :: Text -> Text
compileTypePattern' name = tableNameToModelName table.name <> "'" <> spacePrefix (unwords (map (\f -> if f == name then name <> "'" else f) typeArgs))
compileFieldBitInstances :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileFieldBitInstances table@(CreateTable { name }) = unlines (map compileInstance (fieldBitPositions table))
where
typePattern = compileTypePattern table
compileInstance (fieldName, bitVal) =
"instance FieldBit " <> tshow fieldName <> " (" <> typePattern <> ") where fieldBit = " <> tshow bitVal
compileHasFieldId :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileHasFieldId table@CreateTable { name, primaryKeyConstraint } = cs [i|
instance HasField "id" #{tableNameToModelName name} (Id' "#{name}") where
getField (#{compileDataTypePattern table}) = #{compilePrimaryKeyValue}
{-# INLINE getField #-}
|]
where
compilePrimaryKeyValue = case primaryKeyColumnNames primaryKeyConstraint of
[id] -> columnNameToFieldName id
ids -> "Id (" <> commaSep (map columnNameToFieldName ids) <> ")"
needsHasFieldId :: CreateTable -> Bool
needsHasFieldId CreateTable { columns, primaryKeyConstraint } =
case primaryKeyColumnNames primaryKeyConstraint of
[] -> False
["id"] -> False
pkCols
| any (\col -> col.name == "id") columns -> False
| otherwise -> True
primaryKeyColumns :: CreateTable -> [Column]
primaryKeyColumns CreateTable { name, columns, primaryKeyConstraint } =
map getColumn (primaryKeyColumnNames primaryKeyConstraint)
where
getColumn columnName = case find ((==) columnName . (.name)) columns of
Just c -> c
Nothing -> error ("Missing column " <> cs columnName <> " used in primary key for " <> cs name)
--
-- Empty lines are not indented.
indent :: Text -> Text
indent code = code
|> Text.lines
|> map indentLine
|> Text.unlines
where
indentLine "" = ""
indentLine line = " " <> line
hasExplicitOrImplicitDefault :: Column -> Bool
hasExplicitOrImplicitDefault column = case column of
Column { defaultValue = Just _ } -> True
Column { columnType = PSerial } -> True
Column { columnType = PBigserial } -> True
_ -> False
-- Statement Module Generation
-- ===========================
statementModules :: (?compilerOptions :: CompilerOptions) => Schema -> [(OsPath, Text)]
statementModules schema =
let ?schema = schema
in schema.statements
|> concatMap \case
StatementCreateTable table | tableHasPrimaryKey table ->
statementModulesForTable table
_ -> []
statementModulesForTable :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> [(OsPath, Text)]
statementModulesForTable table =
let modelName = tableNameToModelName table.name
mkPath suffix = (OsPath.</>) "build/Generated/Statements" (either (error . show) id (encodeUtf (cs (suffix <> modelName) <> ".hs")))
in [ (mkPath "RowDecoder", compileRowDecoderModule table)
, (mkPath "Create", compileCreateStatement table)
, (mkPath "Update", compileUpdateStatement table)
, (mkPath "Fetch", compileFetchByIdStatement table)
, (mkPath "CreateMany", compileCreateManyStatement table)
]
compileStatementsIndex :: (?compilerOptions :: CompilerOptions) => Schema -> Text
compileStatementsIndex schema@(Schema statements) =
let tableNames = statements
|> mapMaybe \case
StatementCreateTable table | tableHasPrimaryKey table -> Just (tableNameToModelName table.name)
_ -> Nothing
imports = tableNames >>= \modelName ->
[ "import qualified Generated.Statements.RowDecoder" <> modelName
, "import qualified Generated.Statements.Create" <> modelName
, "import qualified Generated.Statements.Update" <> modelName
, "import qualified Generated.Statements.Fetch" <> modelName
, "import qualified Generated.Statements.CreateMany" <> modelName
]
in Text.unlines
[ "-- This file is auto generated and will be overriden regulary."
, "module Generated.Statements ("
, Text.intercalate ",\n" (map (\imp -> " module " <> Text.drop (length ("import qualified " :: Text)) imp) imports)
, ") where"
, Text.unlines imports
]
statementModuleBaseImports :: Text
statementModuleBaseImports =
Text.unlines
[ "import Prelude (($), (.), (<$>), (<*>), (<>), (+), (*), (-), show, fromIntegral, length, null, zip, mconcat, (++), Maybe(..), (!!), map, Bool(..), Int, Integer, pure)"
, "import Generated.ActualTypes"
, "import Generated.Enums"
, "import IHP.ModelSupport.Types (Id'(..), MetaBag(..))"
, "import qualified Hasql.Statement as Statement"
, "import qualified Hasql.Decoders as Decoders"
, "import qualified Hasql.Encoders as Encoders"
, "import qualified Hasql.Mapping.IsScalar as Mapping"
, "import Hasql.PostgresqlTypes ()"
, "import IHP.Job.Queue ()"
, "import Data.Functor.Contravariant (contramap, (>$<))"
, "import Data.Default (def)"
, "import qualified Data.Dynamic"
, "import Data.UUID (UUID)"
, "import Data.Text (Text)"
, "import Data.Int (Int16, Int32, Int64)"
, "import Data.Time.Clock (UTCTime)"
, "import Data.Time.LocalTime (LocalTime, TimeOfDay)"
, "import qualified Data.Time.Calendar"
, "import Data.Scientific (Scientific)"
, "import qualified Data.Aeson"
, "import qualified Database.PostgreSQL.Simple.Types"
, "import PostgresqlTypes.Point (Point)"
, "import PostgresqlTypes.Polygon (Polygon)"
, "import PostgresqlTypes.Inet (Inet)"
, "import PostgresqlTypes.Tsvector (Tsvector)"
, "import PostgresqlTypes.Interval (Interval)"
]
statementModuleDynamicImports :: Text
statementModuleDynamicImports =
Text.unlines
[ "import Data.Bits (testBit)"
, "import Data.Maybe (catMaybes)"
, "import qualified Data.Text as Text"
]
hasqlValueEncoder :: PostgresType -> Text
hasqlValueEncoder = \case
PUUID -> "Encoders.uuid"
PText -> "Encoders.text"
PSmallInt -> "(fromIntegral >$< Encoders.int2)"
PInt -> "(fromIntegral >$< Encoders.int4)"
PBigInt -> "(fromIntegral >$< Encoders.int8)"
PSerial -> "(fromIntegral >$< Encoders.int4)"
PBigserial -> "(fromIntegral >$< Encoders.int8)"
PBoolean -> "Encoders.bool"
PReal -> "Encoders.float4"
PDouble -> "Encoders.float8"
PTimestampWithTimezone -> "Encoders.timestamptz"
PTimestamp -> "Encoders.timestamp"
PDate -> "Encoders.date"
PTime -> "Encoders.time"
(PNumeric _ _) -> "Encoders.numeric"
PJSONB -> "Encoders.jsonb"
PBinary -> "((\\ (Database.PostgreSQL.Simple.Types.Binary bs) -> bs) >$< Encoders.bytea)"
(PVaryingN _) -> "Encoders.text"
(PCharacterN _) -> "Encoders.text"
(PInterval _) -> "Mapping.encoder"
PPoint -> "Mapping.encoder"
PPolygon -> "Mapping.encoder"
PInet -> "Mapping.encoder"
PTSVector -> "Mapping.encoder"
PArray innerType -> "(Encoders.foldableArray (Encoders.nonNullable " <> hasqlValueEncoder innerType <> "))"
PCustomType _ -> "Mapping.encoder"
PSingleChar -> "Encoders.char"
PTrigger -> error "hasqlValueEncoder: PTrigger not supported"
PEventTrigger -> error "hasqlValueEncoder: PEventTrigger not supported"
formatEncoderBlock :: [Text] -> Text
formatEncoderBlock [] = " mconcat []"
formatEncoderBlock encoderLines =
" mconcat\n [ " <> intercalate "\n , " encoderLines <> "\n ]"
hasqlColumnEncoder :: (?schema :: Schema) => CreateTable -> Column -> Text
hasqlColumnEncoder table column@Column { name, columnType, notNull, generator }
| isJust generator = error $ "hasqlColumnEncoder: cannot encode generated column " <> cs name
| otherwise = "(." <> fieldName <> ") >$< Encoders.param (" <> nullability <> " " <> encoder <> ")"
where
fieldName = columnNameToFieldName name
-- For encoders, columns that are nullable in Haskell (not notNull) use nullable encoder
isNullable = not notNull
nullability = if isNullable then "Encoders.nullable" else "Encoders.nonNullable"
isPrimaryKey = [name] == primaryKeyColumnNames table.primaryKeyConstraint
isForeignKey = isJust (findForeignKeyConstraint table column)
needsIdWrapper = isPrimaryKey || isForeignKey
baseEncoder = hasqlValueEncoder columnType
encoder = if needsIdWrapper then "Mapping.encoder" else baseEncoder
compileSqlEntry :: Bool -> Int -> Column -> Text
compileSqlEntry alwaysConditional bitIndex col
| alwaysConditional || hasExplicitOrImplicitDefault col =
"if testBit touchedFields " <> tshow bitIndex <> " then Just " <> tshow col.name <> " else Nothing"
| otherwise = "Just " <> tshow col.name
compileEncoderEntry :: (?schema :: Schema) => Bool -> Int -> CreateTable -> Column -> Text
compileEncoderEntry alwaysConditional bitIndex table col
| alwaysConditional || hasExplicitOrImplicitDefault col =
"if testBit touchedFields " <> tshow bitIndex <> " then Just (" <> hasqlColumnEncoder table col <> ") else Nothing"
| otherwise = "Just (" <> hasqlColumnEncoder table col <> ")"
hasAnyDefaults :: [Column] -> Bool
hasAnyDefaults = any hasExplicitOrImplicitDefault
compileRowDecoderModule :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileRowDecoderModule table@(CreateTable { name }) =
let columns = allColumnsIncludingInherited table
modelName = tableNameToModelName name
moduleName = "Generated.Statements.RowDecoder" <> modelName
qualifiedModelName = qualifiedConstructorNameFromTableName name
extraImports = Text.unlines
[ "import qualified IHP.QueryBuilder as QueryBuilder"
, "import GHC.Records"
]
columnNames = map (columnNameToFieldName . (.name)) columns
referencing = columnsReferencingTable table.name
referencingWithFieldNames = zip (map fst (compileQueryBuilderFields referencing)) referencing
compileField (fieldName, _)
| isColumn fieldName = fieldName
| isOneToManyField fieldName = let (Just (_, ref)) = find (\(n, _) -> n == fieldName) referencingWithFieldNames in compileFromRowQueryBuilder table ref
| fieldName == "meta" = "def { originalDatabaseRecord = Just (Data.Dynamic.toDyn theRecord) }"
| otherwise = "def"
isColumn colName = colName `elem` columnNames
isOneToManyField fieldName = fieldName `elem` (map fst referencingWithFieldNames)
columnBindings = map (\col -> " " <> columnNameToFieldName col.name <> " <- " <> hasqlColumnDecoder table col) columns
constructorArgs = intercalate " " (map compileField (dataFields table))
-- The recursive let (theRecord references itself via toDyn) must be inside
-- the pure expression, not as a do-block let statement, because GHC's
-- ApplicativeDo cannot desugar recursive do-block lets and Decoders.Row
-- has no Monad instance. A let-in inside pure(...) is just a pure Haskell
-- expression that ApplicativeDo doesn't need to analyze.
pureExpr = " pure (let theRecord = " <> qualifiedConstructorNameFromTableName name <> " " <> constructorArgs <> " in theRecord)"
in "{-# LANGUAGE ApplicativeDo, OverloadedLabels, TypeApplications, ScopedTypeVariables #-}\n"
<> statementModuleHeader moduleName ["rowDecoder"] extraImports
<> Text.unlines
([ "rowDecoder :: Decoders.Row " <> qualifiedModelName
, "rowDecoder = do"
] <> columnBindings <>
[ pureExpr
])
compileCreateStatement :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileCreateStatement table@(CreateTable { name }) =
let columns = allColumnsIncludingInherited table
modelName = tableNameToModelName name
moduleName = "Generated.Statements.Create" <> modelName
qualifiedModelName = qualifiedConstructorNameFromTableName name
writableColumns = onlyWritableColumns columns
allColumnNames = commaSep (map (.name) columns)
isDynamic = hasAnyDefaults writableColumns
rowDecoderImport = "import qualified Generated.Statements.RowDecoder" <> modelName <> " as RowDecoder\n"
in if isDynamic
then compileDynamicCreateStatement moduleName qualifiedModelName name writableColumns allColumnNames table columns rowDecoderImport
else compileStaticCreateStatement moduleName qualifiedModelName name writableColumns allColumnNames table columns rowDecoderImport
compileStaticCreateStatement :: (?schema :: Schema) => Text -> Text -> Text -> [Column] -> Text -> CreateTable -> [Column] -> Text -> Text
compileStaticCreateStatement moduleName qualifiedModelName tableName writableColumns allColumnNames table columns rowDecoderImport =
let writableColumnNames = commaSep (map (.name) writableColumns)
placeholders = commaSep ["$" <> tshow i | i <- [1 .. length writableColumns]]
encoderLines = map (hasqlColumnEncoder table) writableColumns
in statementModuleHeader moduleName ["statement", "discardResultStatement"] rowDecoderImport
<> Text.unlines
[ "statement :: Statement.Statement " <> qualifiedModelName <> " " <> qualifiedModelName
, "statement = Statement.preparable sqlReturningResult encoder decoder"
, ""
, "discardResultStatement :: Statement.Statement " <> qualifiedModelName <> " ()"
, "discardResultStatement = Statement.preparable sqlDiscardResult encoder Decoders.noResult"
, ""
, "sql :: Bool -> Text"
, "sql returning = \"INSERT INTO " <> tableName <> " (" <> writableColumnNames <> ") VALUES (" <> placeholders <> ")\""
, " <> if returning then \" RETURNING " <> allColumnNames <> "\" else \"\""
, ""
, "sqlReturningResult :: Text"
, "sqlReturningResult = sql True"
, ""
, "sqlDiscardResult :: Text"
, "sqlDiscardResult = sql False"
, ""
, "encoder :: Encoders.Params " <> qualifiedModelName
, "encoder ="
, formatEncoderBlock encoderLines
, ""
, "decoder :: Decoders.Result " <> qualifiedModelName
, "decoder = Decoders.singleRow RowDecoder.rowDecoder"
]
compileDynamicCreateStatement :: (?schema :: Schema) => Text -> Text -> Text -> [Column] -> Text -> CreateTable -> [Column] -> Text -> Text
compileDynamicCreateStatement moduleName qualifiedModelName tableName writableColumns allColumnNames table columns rowDecoderImport =
let columnBitIndices = columnsWithBitIndices columns writableColumns
sqlEntries = map (\(col, bitIdx) -> compileSqlEntry False bitIdx col) columnBitIndices
sqlBody = Text.unlines
[ "sql :: Integer -> Bool -> Text"
, "sql touchedFields returning ="
, " let entries = catMaybes"
, " [ " <> Text.intercalate "\n , " sqlEntries
, " ]"
, " columns = Text.intercalate \", \" entries"
, " placeholders = Text.intercalate \", \" [\"$\" <> Text.pack (show i) | i <- [1 .. length entries]]"
, " returningClause = if returning then \" RETURNING " <> allColumnNames <> "\" else \"\""
, " in if null entries"
, " then \"INSERT INTO " <> tableName <> " DEFAULT VALUES\" <> returningClause"
, " else \"INSERT INTO " <> tableName <> " (\" <> columns <> \") VALUES (\" <> placeholders <> \")\" <> returningClause"
]
encoderEntries = map (\(col, bitIdx) -> compileEncoderEntry False bitIdx table col) columnBitIndices
encoderBody = Text.unlines
[ "encoder :: Integer -> Encoders.Params " <> qualifiedModelName
, "encoder touchedFields = mconcat $ catMaybes"
, " [ " <> Text.intercalate "\n , " encoderEntries
, " ]"
]
in dynamicStatementModule moduleName qualifiedModelName sqlBody encoderBody rowDecoderImport
compileUpdateStatement :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileUpdateStatement table@(CreateTable { name }) =
let columns = allColumnsIncludingInherited table
modelName = tableNameToModelName name
qualifiedModelName = qualifiedConstructorNameFromTableName name
allWritableColumns = onlyWritableColumns columns
pkColumns = primaryKeyColumns table
pkColumnNames = map (.name) pkColumns
writableColumns = filter (\col -> col.name `notElem` pkColumnNames) allWritableColumns
allColumnNames = commaSep (map (.name) columns)
columnBitIndices = columnsWithBitIndices columns writableColumns
sqlEntries = map (\(col, bitIdx) -> compileSqlEntry True bitIdx col) columnBitIndices
sqlBody = Text.unlines
[ "sql :: Integer -> Bool -> Text"
, "sql touchedFields returning ="
, " let setEntries = catMaybes"
, " [ " <> Text.intercalate "\n , " sqlEntries
, " ]"
, " setClauses = [col <> \" = $\" <> Text.pack (show i) | (i, col) <- zip [1..] setEntries]"
, " pkIdx = length setEntries + 1"
, " whereClause = " <> compileUpdateWhereClause pkColumns
, " returningClause = if returning then \" RETURNING " <> allColumnNames <> "\" else \"\""
, " in \"UPDATE " <> name <> " SET \" <> Text.intercalate \", \" setClauses <> \" WHERE \" <> whereClause pkIdx <> returningClause"
]
encoderEntries = map (\(col, bitIdx) -> compileEncoderEntry True bitIdx table col) columnBitIndices
pkEncoders = map (hasqlColumnEncoder table) pkColumns
encoderBody = Text.unlines
[ "encoder :: Integer -> Encoders.Params " <> qualifiedModelName
, "encoder touchedFields = mconcat (catMaybes"
, " [ " <> Text.intercalate "\n , " encoderEntries
, " ])"
, " <> " <> case pkEncoders of
[e] -> "(" <> e <> ")"
es -> "mconcat [" <> Text.intercalate ", " es <> "]"
]
moduleName = "Generated.Statements.Update" <> modelName
rowDecoderImport = "import qualified Generated.Statements.RowDecoder" <> modelName <> " as RowDecoder\n"
in dynamicStatementModule moduleName qualifiedModelName sqlBody encoderBody rowDecoderImport
where
compileUpdateWhereClause :: [Column] -> Text
compileUpdateWhereClause [col] = "\\startIdx -> " <> tshow col.name <> " <> \" = $\" <> Text.pack (show startIdx)"
compileUpdateWhereClause cols =
let parts = zipWith (\col i ->
tshow col.name <> " <> \" = $\" <> Text.pack (show (startIdx + " <> tshow i <> "))"
) cols [(0 :: Int)..]
in "\\startIdx -> Text.intercalate \" AND \" [" <> Text.intercalate ", " parts <> "]"
dynamicStatementModule :: Text -> Text -> Text -> Text -> Text -> Text
dynamicStatementModule moduleName qualifiedModelName sqlBody encoderBody rowDecoderImport =
statementModuleHeader moduleName ["statement", "discardResultStatement"] (rowDecoderImport <> statementModuleDynamicImports)
<> Text.unlines
[ "statement :: Integer -> Statement.Statement " <> qualifiedModelName <> " " <> qualifiedModelName
, "statement touchedFields = Statement.preparable (sql touchedFields True) (encoder touchedFields) decoder"
, ""
, "discardResultStatement :: Integer -> Statement.Statement " <> qualifiedModelName <> " ()"
, "discardResultStatement touchedFields = Statement.preparable (sql touchedFields False) (encoder touchedFields) Decoders.noResult"
, ""
, sqlBody
, ""
, encoderBody
, ""
, "decoder :: Decoders.Result " <> qualifiedModelName
, "decoder = Decoders.singleRow RowDecoder.rowDecoder"
]
statementModuleHeader :: Text -> [Text] -> Text -> Text
statementModuleHeader moduleName exports extraImports =
Text.unlines
[ "-- This file is auto generated and will be overriden regulary."
, "{-# OPTIONS_GHC -Wno-unused-imports -Wno-dodgy-imports -Wno-unused-matches #-}"
, "module " <> moduleName <> " (" <> intercalate ", " exports <> ") where"
, ""
, statementModuleBaseImports
]
<> extraImports
compileFetchByIdStatement :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileFetchByIdStatement table@(CreateTable { name }) =
let columns = allColumnsIncludingInherited table
modelName = tableNameToModelName name
moduleName = "Generated.Statements.Fetch" <> modelName
qualifiedModelName = qualifiedConstructorNameFromTableName name
pkColumns = primaryKeyColumns table
allColumnNames = commaSep (map (.name) columns)
-- WHERE clause
(whereClauses, _) = foldl' (\(acc, idx) col -> (col.name <> " = $" <> tshow idx : acc, idx + 1)) ([], 1 :: Int) pkColumns
whereClauseSql = intercalate " AND " (reverse whereClauses)
sql = "SELECT " <> allColumnNames <> " FROM " <> name <> " WHERE " <> whereClauseSql <> " LIMIT 1"
rowDecoderImport = "import qualified Generated.Statements.RowDecoder" <> modelName <> " as RowDecoder\n"
in statementModuleHeader moduleName ["statement"] rowDecoderImport
<> Text.unlines
[ "statement :: Statement.Statement (Id' " <> tshow name <> ") (Maybe " <> qualifiedModelName <> ")"
, "statement = Statement.preparable sql encoder decoder"
, ""
, "sql :: Text"
, "sql = " <> tshow sql
, ""
, "encoder :: Encoders.Params (Id' " <> tshow name <> ")"
, "encoder = " <> fetchByIdEncoder table
, ""
, "decoder :: Decoders.Result (Maybe " <> qualifiedModelName <> ")"
, "decoder = Decoders.rowMaybe RowDecoder.rowDecoder"
]
fetchByIdEncoder :: (?schema :: Schema) => CreateTable -> Text
fetchByIdEncoder table = case primaryKeyColumns table of
[col] ->
"Encoders.param (Encoders.nonNullable Mapping.encoder)"
cols ->
let encoders = zipWith (\col idx ->
let baseEncoder = hasqlValueEncoder col.columnType
accessor = "(\\(Id (" <> commaSep (map (\i -> if i == idx then "v" else "_") [0..length cols - 1]) <> ")) -> v)"
in accessor <> " >$< Encoders.param (Encoders.nonNullable " <> baseEncoder <> ")"
) cols [0..]
in "mconcat [" <> intercalate ", " encoders <> "]"
compileCreateManyStatement :: (?schema :: Schema, ?compilerOptions :: CompilerOptions) => CreateTable -> Text
compileCreateManyStatement table@(CreateTable { name }) =
let columns = allColumnsIncludingInherited table
modelName = tableNameToModelName name
moduleName = "Generated.Statements.CreateMany" <> modelName
qualifiedModelName = qualifiedConstructorNameFromTableName name
writableColumns = onlyWritableColumns columns
writableColumnNames = commaSep (map (.name) writableColumns)
allColumnNames = commaSep (map (.name) columns)
numCols = length writableColumns
encoderLines = map (hasqlColumnEncoder table) writableColumns
singleEncoderBlock = formatEncoderBlock encoderLines
rowDecoderImport = "import qualified Generated.Statements.RowDecoder" <> modelName <> " as RowDecoder\n"
in statementModuleHeader moduleName ["statement"] (rowDecoderImport <> "import qualified Data.Text as Text\n")
<> Text.unlines
[ "statement :: Int -> Statement.Statement [" <> qualifiedModelName <> "] [" <> qualifiedModelName <> "]"
, "statement count = Statement.unpreparable (sql count) (encoder count) decoder"
, ""
, "sql :: Int -> Text"
, "sql count = \"INSERT INTO " <> name <> " (" <> writableColumnNames <> ") VALUES \""
, " <> Text.intercalate \", \" [valueGroup (i * " <> tshow numCols <> ") | i <- [0..count - 1]]"
, " <> \" RETURNING " <> allColumnNames <> "\""
, " where"
, " valueGroup offset = \"(\" <> Text.intercalate \", \" [\"$\" <> Text.pack (show (offset + j)) | j <- [1.." <> tshow numCols <> "]] <> \")\""
, ""
, "encoder :: Int -> Encoders.Params [" <> qualifiedModelName <> "]"
, "encoder count = mconcat [contramap (!! i) singleEncoder | i <- [0..count - 1]]"
, ""
, "singleEncoder :: Encoders.Params " <> qualifiedModelName
, "singleEncoder ="
, singleEncoderBlock
, ""
, "decoder :: Decoders.Result [" <> qualifiedModelName <> "]"
, "decoder = Decoders.rowList RowDecoder.rowDecoder"
]