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

import Control.Applicative
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