postgresql-orm-0.5.1: src/Database/PostgreSQL/ORM/Association.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeOperators #-}
module Database.PostgreSQL.ORM.Association (
Association(..), assocProject, assocWhere, findAssoc
-- * Associations based on parent-child relationships
, GDBRefInfo(..), DBRefInfo, defaultDBRefInfo, dbrefAssocs, has, belongsTo
-- * Join table Associations
, JoinTable(..), defaultJoinTable, jtAssocs, joinTable
-- ** Operations on join tables
, jtAdd, jtRemove, jtRemoveByRef
-- ** Semi-internal join table functions
, jtAddStatement, jtRemoveStatement, jtParam
, jtFlip, jtAssoc
-- * Nested and chained associations
, nestAssoc, chainAssoc
) where
#if __GLASGOW_HASKELL__ < 710
import Control.Applicative
#endif
import qualified Data.ByteString as S
import qualified Data.ByteString.Char8 as S8
import Data.List
import Data.Monoid
import Database.PostgreSQL.Simple
import Database.PostgreSQL.Simple.ToField
import Database.PostgreSQL.Simple.Types
import Data.GetField
import Database.PostgreSQL.Escape
import Database.PostgreSQL.ORM.DBSelect
import Database.PostgreSQL.ORM.Model
-- | A data structure representing a relationship between a model @a@
-- and a model @b@. At a high level, an @Association a b@ tells you
-- how to find rows of type @b@ given rows of type @a@. More
-- concretely, this boils down to being able to make two types of
-- query.
--
-- * You want to look up a bunch of @(a ':.' b)@s, filtering using
-- predicates on both @a@ and @b@ (e.g., get a list of recent posts
-- and their authors). For this purpose, you can use 'assocSelect',
-- which allows you to 'addWhere' predicates mentioning columns in
-- both @a@ and @b@.
--
-- * You already have an instance of type @a@, and want to find all
-- the @b@s associated with it. For that you use either 'assocWhere'
-- or 'findAssoc' (which internally access fields 'assocSelectOnlyB',
-- 'assocWhereQuery', and 'assocWhereParam'). This type of query is
-- strictly less general than the first one, but can be formulated in
-- a more efficient way by extracting values directly from a concrete
-- instance of @a@ without needing to touch table @a@ in the
-- database.
--
-- Note that an @Association@ is asymmetric. It tells you how to get
-- @b@s from @a@s, but not vice versa. In practice, there will almost
-- always be an association in the other direction, too. Functions
-- such as 'dbrefAssocs' and 'jtAssocs' therefore create an
-- @Association@ and its inverse simultaneously, returning them as a
-- pair.
data Association a b = Association {
assocSelect :: !(DBSelect (a :. b))
-- ^ General select returning all instances of @a@ and @b@ that
-- match according to the association.
, assocSelectOnlyB :: !(DBSelect b)
-- ^ The right-hand side of the 'assocSelect' query. This query
-- makes no mention of type @a@ (but can be combined with the next
-- two fields to form an optimized query). You probably never
-- want to use this directly, and should instead use either
-- 'findAssoc' or 'assocWhere'. Also note this is not useful for
-- selecting all the @b@s in the relation; for that you should use
-- 'assocProject'.
, assocWhereQuery :: !Query
-- ^ A @WHERE@ clause to find all the 'b's associated with a
-- particular @a@. This can often be done more efficiently than
-- through 'assocSelect'. The clause contains @\'?\'@ characters
-- which should be filled in by 'assocWhereParam'.
, assocWhereParam :: !(a -> [Action])
-- ^ The query parameters for the query returned by
-- 'assocWhereQuery'.
}
instance Show (Association a b) where
show assoc =
"Association { assocSelect = " ++ show (assocSelect assoc) ++
", assocSelectOnlyB = " ++ show (assocSelectOnlyB assoc) ++
", assocWhereQuery = " ++ S8.unpack (fromQuery $ assocWhereQuery assoc) ++
" }"
-- | A projection of 'assocSelect', extracting only the fields of
-- model @b@. Note that this query touches table @a@ even if it does
-- not return results from @a@. Hence, you can use 'addWhere' to add
-- predicates on both @a@ and @b@. (Note the contrast to
-- 'assocSelectOnlyB', which does not touch table @a@ at all, and
-- hence in the case of an @INNER JOIN@ might return rows of @b@ that
-- should not be part of the association. 'assocSelectOnlyB' is
-- intended for use only in conjunction with 'assocWhereQuery'.)
assocProject :: (Model b) => Association a b -> DBSelect b
assocProject = dbProject . assocSelect
-- | Returns a 'DBSelect' for all @b@s associated with a particular
-- @a@.
assocWhere :: (Model b) => Association a b -> a -> DBSelect b
assocWhere ab a = addWhere (assocWhereQuery ab) (assocWhereParam ab a)
(assocSelectOnlyB ab)
-- | Follow an association to return all of the @b@s associated
-- with a particular @a@. The behavior is similar to:
--
-- > findAssoc' ab c a = dbSelect c $ assocWhere ab a
--
-- But if the first argument is a static association, this function
-- may be marginally faster because it pre-renders most of the query.
findAssoc :: (Model b) => Association a b -> Connection -> a -> IO [b]
{-# INLINE findAssoc #-}
findAssoc assoc = \c a ->
map lookupRow <$> query c q (assocWhereParam assoc a)
where {-# NOINLINE q #-}
q = renderDBSelect $
addWhere_ (assocWhereQuery assoc) $ assocSelectOnlyB assoc
-- | Combine two associations into one.
nestAssoc :: (Model a, Model b) =>
Association a b -> Association b c -> Association a (b :. c)
nestAssoc ab bc = ab { assocSelect = dbNest (assocSelect ab) (assocSelect bc)
, assocSelectOnlyB = assocSelect bc }
-- | Combine two associations into one, and project away the middle
-- type. (The middle type can still be mentioned in @WHERE@ clauses.)
--
-- An example:
--
-- > data Author = Author {
-- > authorId :: DBKey
-- > } deriving (Show, Generic)
-- > instance Model Author where modelInfo = underscoreModelInfo "author"
-- >
-- > data Post = Post {
-- > postId :: DBKey
-- > , postAuthorId :: DBRef Author
-- > } deriving (Show, Generic)
-- > instance Model Post where modelInfo = underscoreModelInfo "post"
-- >
-- > data Comment = Comment {
-- > commentId :: DBKey
-- > , commentPostId :: DBRef Post
-- > } deriving (Show, Generic)
-- > instance Model Comment where modelInfo = underscoreModelInfo "comment"
-- >
-- > author_posts :: Association Author Post
-- > post_author :: Association Post Author
-- > (post_author, author_posts) = dbrefAssocs defaultDBRefInfo
-- >
-- > -- Could equally well use dbrefAssocs as above
-- > post_comments :: Association Post Comment
-- > post_comments = has
-- >
-- > comment_post :: Association Comment Post
-- > comment_post = belongsTo
-- >
-- > comment_author :: Association Comment Author
-- > comment_author = chainAssoc comment_post post_author
-- >
-- > author_comments :: Association Author Comment
-- > author_comments = chainAssoc author_posts post_comments
chainAssoc :: (Model a, Model b, Model c) =>
Association a b -> Association b c -> Association a c
chainAssoc ab bc = ab { assocSelect = dbChain (assocSelect ab) (assocSelect bc)
, assocSelectOnlyB = dbProject $ assocSelect bc }
-- | A common type of association is when one model contains a 'DBRef'
-- or 'DBRefUnique' pointing to another model. In this case, the
-- model containing the 'DBRef' is known as the /child/, and the
-- referenced model is known as the /parent/.
--
-- Two pieces of information are required to describe a parent-child
-- relationship: First, the field selector that extracts the Haskell
-- 'DBRef' from the haskell type @child@, and second the name of the
-- database column that stores this 'DBRef' field.
--
-- For example, consider the following:
--
-- > data Author = Author {
-- > authorId :: DBKey
-- > } deriving (Show, Generic)
-- > instance Model Author
-- >
-- > data Post = Post {
-- > postId :: DBKey
-- > , postAuthorId :: DBRef Author
-- > } deriving (Show, Generic)
-- > instance Model Post
-- >
-- > post_author_refinfo :: DBRefInfo Post Author
-- > post_author_refinfo = DBRefInfo {
-- > dbrefSelector = postAuthorId
-- > , dbrefQColumn = "\"post\".\"postAuthorId\""
-- > }
--
-- Note that the parent-child relationship described by a @GDBRefInfo@
-- is asymmetric, but bidirectional. When a @'DBRefInfo' child
-- parent@ exists, the schema should generally /not/ permit the
-- existence of a valid @'DBRefInfo' parent child@ structure.
-- However, the 'dbrefAssocs' function generates 'Association's in
-- both directions from a single 'DBRefInfo'.
--
-- Constructing such parent-child 'Association's requires knowing how
-- to extract primary keys from the @parent@ type as well as the name
-- of the column storing primary keys in @parent@. Fortunately, this
-- information is already available from the 'Model' class, and thus
-- does not need to be in the @GDBRefInfo@. (Most functions on
-- @GDBRefInfo@s require @parent@ and @child@ to be instances of
-- 'Model'.)
--
-- When your 'Model's are instances of 'Generic' (which will usually
-- be the case), a 'DBRefInfo' structure can be computed automatically
-- by 'defaultDBRefInfo'. This is the recommended way to produce a
-- @GDBRefInfo@. (Alternatively, see 'has' and 'belongsTo' to make
-- use of an entirely implicit @DBRefInfo@.)
data GDBRefInfo reftype child parent = DBRefInfo {
dbrefSelector :: !(child -> GDBRef reftype parent)
-- ^ Field selector returning a reference.
, dbrefQColumn :: !S.ByteString
-- ^ Literal SQL for the database column storing the reference.
-- This should be double-quoted and table-qualified, in case the
-- column name is a reserved keyword, contains capital letters, or
-- conflicts with the name of a column in the joined table. An
-- example would be:
--
-- > dbrefQColumn = "\"table_name\".\"column_name\""
}
instance Show (GDBRefInfo rt c p) where
show ri = "DBRefInfo ? " ++ show (dbrefQColumn ri)
-- | @DBRefInfo@ is a type alias for the common case that the
-- reference in a 'GDBRefInfo' is a 'DBRef' (as opposed to a
-- 'DBRefUnique'). The functions in this library do not care what
-- type of reference is used. The type is generalized to 'GDBRefInfo'
-- just to make it easier to assign a selector to 'dbrefSelector' when
-- the selector returns a 'DBRefUnique'. Note, however, that
-- 'defaultDBRefInfo' returns a 'DBRefInfo' regardless of the flavor
-- of reference actually encountered.
type DBRefInfo = GDBRefInfo NormalRef
data ExtractRef a = ExtractRef deriving (Show)
instance Extractor ExtractRef (GDBRef rt a) (DBRef a) THasOne where
extract _ (DBRef k) = THasOne $ DBRef k
instance Extractor ExtractRef (GDBRef rt a) (DBRef (As alias a)) THasOne where
extract _ (DBRef k) = THasOne $ DBRef k
instance Extractor ExtractRef (Maybe (GDBRef rt a)) (DBRef a) THasOne where
extract _ (Just (DBRef k)) = THasOne $ DBRef k
extract _ _ = error "Maybe DBRef is Nothing"
instance Extractor ExtractRef (Maybe (GDBRef rt a)) (DBRef (As alias a))
THasOne where
extract _ (Just (DBRef k)) = THasOne $ DBRef k
extract _ _ = error "Maybe DBRef is Nothing"
-- | Creates a 'DBRefInfo' from a model @child@ that references
-- @parent@. For this to work, the @child@ type must be an instance
-- of 'Generic' and must contain exactly one field of the any of the
-- following types:
--
-- 1. @'GDBRef' rt parent@, which matches both @'DBRef' parent@ and
-- @'DBRefUnique' parent@.
--
-- 2. @Maybe ('GDBRef' rt parent)@, for cases where the reference
-- might be @NULL@. Note, however, that an exception will be thrown
-- if you call 'findAssoc' on a child whose reference is 'Nothing'.
--
-- A special case arises when a Model contains a 'DBRef' to itself.
-- If you just wish to find parents and children given an existing
-- structure (i.e., 'findAssoc'), it is okay to declare an
-- @'Association' MyType MyType@. However, in this case attempts to
-- use 'assocSelect' will then fail. To work around this problem, the
-- parent must use a row alias.
--
-- Note that currently /aliasing the child will not work/, since the
-- 'As' data structure will not contain a 'DBRef' field, only the
-- contents of the 'As' data structure. An example of doing this
-- correctly (using 'has' and 'belongsTo', both of which wrap
-- @defaultDBRefInfo@):
--
-- > data Bar = Bar {
-- > barId :: !DBKey
-- > , barName :: !String
-- > , barParent :: !(Maybe (DBRef Bar))
-- > } deriving (Show, Generic)
-- > instance Model Bar where modelInfo = underscoreModelInfo "bar"
-- >
-- > data ParentBar = ParentBar
-- > instance RowAlias ParentBar where rowAliasName _ = "parent_bar"
-- >
-- > toParent :: Association Bar (As ParentBar Bar)
-- > toParent = belongsTo
-- >
-- > toChild :: Association (As ParentBar Bar) Bar
-- > toChild = has
defaultDBRefInfo :: forall child parent.
(Model child, Model parent
, GetField ExtractRef child (DBRef parent)) =>
DBRefInfo child parent
defaultDBRefInfo = ri
where extractor = (const ExtractRef :: g p -> ExtractRef (DBRef p)) ri
child = undefined :: child
childids = modelIdentifiers :: ModelIdentifiers child
ri = DBRefInfo {
dbrefSelector = getFieldVal extractor
, dbrefQColumn = modelQColumns childids !! getFieldPos extractor child
}
-- | Generate both the child-parent and parent-child 'Association's
-- implied by a 'GDBRefInfo'.
dbrefAssocs :: forall child parent rt.
(Model child, Model parent) =>
GDBRefInfo rt child parent
-> (Association child parent, Association parent child)
dbrefAssocs ri = (c_p, p_c)
where idp = modelIdentifiers :: ModelIdentifiers parent
on = Query $ "ON " <> modelQPrimaryColumn idp
<> " = " <> dbrefQColumn ri
c_p = Association {
assocSelect = dbJoinModels "JOIN" on
, assocSelectOnlyB = modelDBSelect
, assocWhereQuery = Query $ modelQPrimaryColumn idp <> " = ?"
, assocWhereParam = \child -> [toField $ dbrefSelector ri child]
}
p_c = Association {
assocSelect = dbJoinModels "JOIN" on
, assocSelectOnlyB = modelDBSelect
, assocWhereQuery = Query $ dbrefQColumn ri <> " = ?"
, assocWhereParam = \parent -> [toField $ primaryKey parent]
}
-- | Short for
--
-- > snd $ dbrefAssocs defaultDBRefInfo
--
-- Note the inverse 'Association' is given by 'belongsTo'. For
-- example, given the @Author@ and @Post@ models described in the
-- documentation for 'GDBRefInfo', in which each @Post@ references an
-- @Author@, you might say:
--
-- > author_post :: Association Author Post
-- > author_post = has
-- >
-- > post_author :: Association Post Author
-- > post_author = belongsTo
has :: (Model child, Model parent, GetField ExtractRef child (DBRef parent)) =>
Association parent child
has = snd $ dbrefAssocs defaultDBRefInfo
-- | The inverse of 'has'. Short for
--
-- > fst $ dbrefAssocs defaultDBRefInfo
--
-- See an example at 'has'.
belongsTo :: (Model child, Model parent
, GetField ExtractRef child (DBRef parent)) =>
Association child parent
belongsTo = fst $ dbrefAssocs defaultDBRefInfo
-- | A data structure representing a dedicated join table in the
-- database. A join table differs from a model in that rows do not
-- have primary keys. Hence, model operations do not apply.
-- Nonetheless a join table conveys information about a relationship
-- between models.
--
-- Note that all names in a @JoinTable@ should be unquoted.
data JoinTable a b = JoinTable {
jtTable :: !S.ByteString
-- ^ Name of the join table in the database. (Not quoted.)
, jtColumnA :: !S.ByteString
-- ^ Name of the column in table 'jtTable' that contains a 'DBRef'
-- to model @a@. (Not quoted or table-qualified.)
, jtColumnB :: !S.ByteString
-- ^ Like 'jtColumnA' for model @b@.
} deriving (Show)
-- | The default join table has the following fields:
--
-- * 'jtName' is the name of the two models (in alphabetical order),
-- separated by an @\'_\'@ character.
--
-- * 'jtColumnA' is the name of model @a@, an @\'_\'@ character, and
-- the name of the primary key column in table @a@.
--
-- * 'jtColumnB' is the name of model @b@, an @\'_\'@ character, and
-- the name of the primary key column in table @b@.
--
-- Note that 'defaultJoinTable' cannot create a default join table for
-- joining a model to itself, as following these rules the two columns
-- would have the same name. If you wish to join a table to itself,
-- you have two options: First, you can define the join table and
-- assign the column names manually. This will permit you to call
-- 'findAssoc', but you still will not be able to use 'assocSelect'
-- for more complex queries, since SQL does not permit joins between
-- two tables with the same name. The second option is to give one of
-- the sides of the join table a row alias with 'As'. For example:
--
-- > data ParentBar = ParentBar
-- > instance RowAlias ParentBar where rowAliasName _ = "parent_bar"
-- >
-- > selfJoinTable :: JoinTable Bar (As ParentBar Bar)
-- > selfJoinTable = defaultJoinTable
-- >
-- > selfJoin :: Association Bar (As ParentBar Bar)
-- > otherSelfJoin :: Association (As ParentBar Bar) Bar
-- > (selfJoin, otherSelfJoin) = jtAssocs selfJoinTable
defaultJoinTable :: forall a b. (Model a, Model b) => JoinTable a b
defaultJoinTable
| colA == colB = error "defaultJoinTable has default for self joins"
| otherwise = jti
where a = modelInfo :: ModelInfo a
b = modelInfo :: ModelInfo b
colA = S.intercalate "_"
[modelTable a, modelColumns a !! modelPrimaryColumn a]
colB = S.intercalate "_"
[modelTable b, modelColumns b !! modelPrimaryColumn b]
jti = JoinTable {
jtTable = S.intercalate "_" $ sort [modelTable a, modelTable b]
, jtColumnA = colA
, jtColumnB = colB
}
jtQTable :: JoinTable a b -> S.ByteString
jtQTable = quoteIdent . jtTable
jtQColumnA :: JoinTable a b -> S.ByteString
jtQColumnA jt = S.concat [ jtQTable jt, ".", quoteIdent $ jtColumnA jt]
jtQColumnB :: JoinTable a b -> S.ByteString
jtQColumnB jt = S.concat [ jtQTable jt, ".", quoteIdent $ jtColumnB jt]
-- | Flip a join table. This doesn't change the name of the table
-- (since the same join table is used in both directions, and the
-- default join table name glues together the two model names in
-- alphabetical order anyway).
jtFlip :: JoinTable a b -> JoinTable b a
jtFlip jt = jt { jtColumnA = jtColumnB jt , jtColumnB = jtColumnA jt }
-- | A SQL statement suitable for adding a pair to a join table. Note
-- that the statement takes two parameters (i.e., contains two @\'?\'@
-- characters) corresponding to the primary keys of the two models
-- being associated. These parameters can be supplied by 'jtParam'.
jtAddStatement :: JoinTable a b -> Query
jtAddStatement jt = Query $ S.concat [
"INSERT INTO ", jtQTable jt, " ("
, quoteIdent $ jtColumnA jt, ", ", quoteIdent $ jtColumnB jt
, ") VALUES (?, ?) EXCEPT SELECT "
, jtQColumnA jt, ", ", jtQColumnB jt, " FROM ", quoteIdent $ jtTable jt
]
-- | Add an association between two models to a join table. Returns
-- 'True' if the association was not already there.
jtAdd :: (Model a, Model b) => JoinTable a b -> Connection -> a -> b -> IO Bool
{-# INLINE jtAdd #-}
jtAdd jt = \c a b -> (/= 0) <$> execute c q (jtParam jt a b)
where {-# NOINLINE q #-}
q = jtAddStatement jt
-- | A SQL statement for removing a pair from a join table. Like
-- 'jtAddStatement', the query is parameterized by two primary keys.
jtRemoveStatement :: JoinTable a b -> Query
jtRemoveStatement jt = Query $ S.concat [
"DELETE FROM ", quoteIdent $ jtTable jt, " WHERE "
, jtQColumnA jt, " = ? AND ", jtQColumnB jt, " = ?"
]
-- | Remove an association from a join table. Returns 'True' if the
-- association was previously there.
jtRemove :: (Model a, Model b) =>
JoinTable a b -> Connection -> a -> b -> IO Bool
{-# INLINE jtRemove #-}
jtRemove jt = \c a b -> (/= 0) <$> execute c q (jtParam jt a b)
where {-# NOINLINE q #-}
q = jtRemoveStatement jt
-- | Remove an assocation from a join table when you don't have the
-- target instances of the two models handy, but do have references.
jtRemoveByRef :: (Model a, Model b) => JoinTable a b
-> Connection -> GDBRef rt a -> GDBRef rt b -> IO Bool
{-# INLINE jtRemoveByRef #-}
jtRemoveByRef jt = \c a b -> (/= 0) <$> execute c q (a, b)
where {-# NOINLINE q #-}
q = jtRemoveStatement jt
-- | Generate parameters for 'jtAddStatement' and 'jtRemoveStatement'.
-- The returned list is suitable for use as a 'ToRow' instance. For
-- example:
--
-- > execute conn (jtAddStatement my_join_table) (jtParam a b)
jtParam :: (Model a, Model b) => JoinTable a b -> a -> b -> [Action]
jtParam _ a b = [toField $ primaryKey a, toField $ primaryKey b]
-- | Generate a one-way association from a 'JoinTable'. Use
-- 'jtAssocs' instead.
jtAssoc :: forall a b. (Model a, Model b) => JoinTable a b -> Association a b
jtAssoc jt = Association {
assocSelect = dbJoin modelDBSelect "JOIN" onlyB $ Query $ S.concat [
"ON ", priA, " = ", jtQColumnA jt]
, assocSelectOnlyB = onlyB
, assocWhereQuery = Query $ jtQColumnA jt <> " = ?"
, assocWhereParam = \a -> [toField $ primaryKey a]
}
where priA = modelQPrimaryColumn (modelIdentifiers :: ModelIdentifiers a)
priB = modelQPrimaryColumn (modelIdentifiers :: ModelIdentifiers b)
selB = modelDBSelect :: DBSelect b
fromB = FromJoin
(FromModel (Query $ jtQTable jt) (jtQTable jt))
"JOIN" (selFrom selB)
(Query $ S.concat ["ON ", jtQColumnB jt, " = ", priB])
(jtQTable jt <> "->B")
onlyB = selB { selFrom = fromB }
-- | Generate the two associations implied by a 'JoinTable'.
jtAssocs :: (Model a, Model b) =>
JoinTable a b -> (Association a b, Association b a)
jtAssocs jt = (jtAssoc jt, jtAssoc $ jtFlip jt)
-- | Generate a one-way association based on the default join table
-- naming scheme described at 'defaultJoinTable'. Defined as:
--
-- > joinTable = jtAssoc defaultJoinTable
--
-- For example:
--
-- > aToB :: Association A B
-- > aToB = joinTable
-- >
-- > bToA :: Association B A
-- > bToA = joinTable
joinTable :: (Model a, Model b) => Association a b
joinTable = jtAssoc defaultJoinTable