{-# 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