weekdaze-0.0.0.1: src-lib/WeekDaze/Database/Selector.hs
{-# LANGUAGE CPP, FlexibleContexts, MultiParamTypeClasses #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-
Copyright (C) 2013-2015 Dr. Alistair Ward
This file is part of WeekDaze.
WeekDaze is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
WeekDaze is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with WeekDaze. If not, see <http://www.gnu.org/licenses/>.
-}
{- |
[@AUTHOR@] Dr. Alistair Ward
[@DESCRIPTION@]
* Provides an interface for data-types which can construct themselves from a database.
* Provides a convenient function to run a simple SQL-query.
* Some common column-names are also exported.
-}
module WeekDaze.Database.Selector(
-- * Types
-- ** Type-synonyms
ColumnName,
TableName,
-- * Constants
locationIdColumnName,
-- mySqlSetSeparator,
projectIdColumnName,
synchronisationIdColumnName,
tablePrefix,
teacherIdColumnName,
timeslotIdColumnName,
campusColumnName,
-- * Type-classes
Selector(..),
-- * Functions
queryConcurrently,
-- fromDatabaseConcurrently',
round',
fromMySqlSet,
toMySqlSet,
fromSqlFractional,
-- mkParameterisedSelection,
prepare,
select
) where
import qualified Control.Concurrent
import qualified Control.DeepSeq
import Control.DeepSeq(($!!))
import qualified Control.Monad
import qualified Database.HDBC
import qualified Data.Convertible
import qualified Data.List
import qualified Data.Maybe
import qualified Data.Ratio
import qualified Data.Set
import qualified Data.Typeable
#if !MIN_VERSION_base(4,8,0)
import Control.Applicative((<$>), (<*>))
#endif
#ifdef PRINT_DB_QUERIES
import qualified System.IO
#endif
-- | Synonym.
type ColumnName = String
-- | Synonym.
type TableName = String
-- | Database column-name.
locationIdColumnName :: ColumnName
locationIdColumnName = "locationId"
-- | Database column-name.
projectIdColumnName :: ColumnName
projectIdColumnName = "projectId"
-- | Database column-name.
synchronisationIdColumnName :: ColumnName
synchronisationIdColumnName = "synchronisationId"
{- |
* Database table-identifier.
* Consistent use of this for all table-names, prevents a clash with SQL-keywords, without requiring table-names to be quoted.
-}
tablePrefix :: TableName
tablePrefix = "tbl_"
-- | Database column-name.
teacherIdColumnName :: ColumnName
teacherIdColumnName = "teacherId"
-- | Database column-name.
timeslotIdColumnName :: ColumnName
timeslotIdColumnName = "timeslotId"
-- | Database column-name.
campusColumnName :: ColumnName
campusColumnName = "campus"
{- |
An interface to which data-types capable of constructing themselves from a database may conform.
Since the database can contain many projects, & only one is required, the identity of the specific project must be defined.
-}
class Selector selector where
-- | Reads an instance from the connected database.
fromDatabase
:: Database.HDBC.IConnection connection
=> connection -- ^ Abstract database-connection.
-> Database.HDBC.SqlValue -- ^ The integral projectId.
-> IO selector
-- | Reads an instance from the connected database, using concurrent queries.
fromDatabaseConcurrently
:: Database.HDBC.IConnection connection
=> connection -- ^ Abstract database-connection.
-> Database.HDBC.SqlValue -- ^ The integral projectId.
-> IO selector
fromDatabaseConcurrently = fromDatabase -- Default to serial query.
instance (
Control.DeepSeq.NFData a,
Control.DeepSeq.NFData b,
Selector a,
Selector b
) => Selector (a, b) where
fromDatabase connection projectIdSql = (,) <$> fromDatabase connection projectIdSql <*> fromDatabase connection projectIdSql
fromDatabaseConcurrently connection projectIdSql = do
x <- Control.Concurrent.newEmptyMVar
y <- Control.Concurrent.newEmptyMVar
fromDatabaseConcurrently' connection projectIdSql x
fromDatabaseConcurrently' connection projectIdSql y
(,) <$> Control.Concurrent.takeMVar x <*> Control.Concurrent.takeMVar y
instance (
Control.DeepSeq.NFData a,
Control.DeepSeq.NFData b,
Control.DeepSeq.NFData c,
Selector a,
Selector b,
Selector c
) => Selector (a, b, c) where
fromDatabase connection projectIdSql = (,,) <$> fromDatabase connection projectIdSql <*> fromDatabase connection projectIdSql <*> fromDatabase connection projectIdSql
fromDatabaseConcurrently connection projectIdSql = do
x <- Control.Concurrent.newEmptyMVar
y <- Control.Concurrent.newEmptyMVar
z <- Control.Concurrent.newEmptyMVar
fromDatabaseConcurrently' connection projectIdSql x
fromDatabaseConcurrently' connection projectIdSql y
fromDatabaseConcurrently' connection projectIdSql z
(,,) <$> Control.Concurrent.takeMVar x <*> Control.Concurrent.takeMVar y <*> Control.Concurrent.takeMVar z
instance (
Control.DeepSeq.NFData a,
Control.DeepSeq.NFData b,
Control.DeepSeq.NFData c,
Control.DeepSeq.NFData d,
Selector a,
Selector b,
Selector c,
Selector d
) => Selector (a, b, c, d) where
fromDatabase connection projectIdSql = (,,,) <$> fromDatabase connection projectIdSql <*> fromDatabase connection projectIdSql <*> fromDatabase connection projectIdSql <*> fromDatabase connection projectIdSql
fromDatabaseConcurrently connection projectIdSql = do
w <- Control.Concurrent.newEmptyMVar
x <- Control.Concurrent.newEmptyMVar
y <- Control.Concurrent.newEmptyMVar
z <- Control.Concurrent.newEmptyMVar
fromDatabaseConcurrently' connection projectIdSql w
fromDatabaseConcurrently' connection projectIdSql x
fromDatabaseConcurrently' connection projectIdSql y
fromDatabaseConcurrently' connection projectIdSql z
(,,,) <$> Control.Concurrent.takeMVar w <*> Control.Concurrent.takeMVar x <*> Control.Concurrent.takeMVar y <*> Control.Concurrent.takeMVar z
-- | Issue the specified database-query on a new thread.
queryConcurrently :: (
Control.DeepSeq.NFData selection,
Database.HDBC.IConnection connection
)
=> (connection -> Database.HDBC.SqlValue -> IO selection) -- ^ The query.
-> connection -- ^ Abstract database-connection.
-> Database.HDBC.SqlValue -- ^ The integral projectId.
-> Control.Concurrent.MVar selection -- ^ Where to store the result.
-> IO ()
queryConcurrently query connection projectIdSql mVar = do
connection' <- Database.HDBC.clone connection -- CAVEAT: from the HDBC-documentation; not all databases support more than one active statement for a single connection, therefore for maximum portability, use a different connection to the database for each simultaneous query you wish to use.
Control.Monad.void . Control.Concurrent.forkIO $ query connection' projectIdSql >>= (Control.Concurrent.putMVar mVar $!!) -- CAVEAT: force the thread to work by strictly evaluating the structure.
-- | Construct an arbitrary datum from a database-query issued on a new thread.
fromDatabaseConcurrently' :: (
Control.DeepSeq.NFData selector,
Database.HDBC.IConnection connection,
Selector selector
)
=> connection -- ^ Abstract database-connection.
-> Database.HDBC.SqlValue -- ^ The integral projectId.
-> Control.Concurrent.MVar selector -- ^ Where to store the result.
-> IO ()
fromDatabaseConcurrently' = queryConcurrently fromDatabase
-- | Round any error introduced by conversion to a rational number.
round' :: RealFrac r => r -> Rational
round' = (`Data.Ratio.approxRational` doublePrecisionEpsilon) where
doublePrecisionEpsilon = recip 2 ^ floatDigits (undefined :: Double)
{- |
* Converts a /SqlValue/ to the specified fractional type.
* CAVEAT: required to avoid any rounding-error in the floating-point number read from the database resulting in an inefficiently large 'Rational' number.
-}
fromSqlFractional :: (
Data.Convertible.Convertible Database.HDBC.SqlValue a, -- Flexible context.
Data.Typeable.Typeable a,
RealFrac a
)
=> a -- ^ Default value to use if the SQL-value is null.
-> Database.HDBC.SqlValue
-> a
fromSqlFractional defaultValue = Data.Maybe.maybe defaultValue (
\x -> if Data.Typeable.typeOf x == Data.Typeable.typeOf (undefined :: Data.Ratio.Rational)
then realToFrac $ round' x
else x
) . either (
error . showString "WeekDaze.Database.Selector.fromSqlFractional:\tfailed to parse a value read from the database; " . show
) id . Database.HDBC.safeFromSql
-- | The separator used by MySql, when representing the members of a set.
mySqlSetSeparator :: Char
mySqlSetSeparator = ','
-- | Constructs a list from a MySql set.
fromMySqlSet :: Read a => String -> [a]
fromMySqlSet s
| null s = [] -- Terminate.
| null remainder = [day] -- Terminate.
| otherwise = day : fromMySqlSet (tail {-drop the separator-} remainder) {-recurse-}
where
(s', remainder) = break (== mySqlSetSeparator) s
day = case reads s' of
[(value, [])] -> value
_ -> error $ "WeekDaze.Database.Selector.fromMySqlSet:\tfailed to parse " ++ show s'
-- | Constructs a MySql set from a list.
toMySqlSet :: Show a => [a] -> String
toMySqlSet = Data.List.intercalate [mySqlSetSeparator] . map show
-- N.B.: orphan instance.
instance (Ord a, Read a) => Data.Convertible.Convertible Database.HDBC.SqlValue (Data.Set.Set a {-MultiParamTypeClasses-}) where
safeConvert = fmap (Data.Set.fromList . fromMySqlSet) . Data.Convertible.safeConvert
{- |
* Constructs a parameterised SQL-select query for the specified columns from the specified tables,
from those rows whose columns exactly match the currently unspecified actual parameters.
* CAVEAT: identifiers aren't delimited, since MySQL uses back-ticks rather than the standard double-quote;
so identifiers can contain any white space or be SQL-keywords.
If this presents a problem, then they can be pre-quoted before the call.
* CAVEAT: this is an ad-hoc solution to the general problem of constructing an SQL-query,
in that it only caters for the specific type of query required by this application.
-}
mkParameterisedSelection
:: [ColumnName] -- ^ Select columns from the matching rows.
-> [TableName] -- ^ Typically only a single table is referenced, but the facility exists to implicitly specify a /join/.
-> [ColumnName] -- ^ Select rows matching (by equality) values (currently represented by place-holders) of the specified (case-insensitive) column-names.
-> String
mkParameterisedSelection columnNames tableNames rowConditions
| null rowConditions = showString selectFrom ";"
| otherwise = showString selectFrom . showString " WHERE " $ showString (
Data.List.intercalate " AND " {-arbitrarily-} $ map (
++ "=?" -- Lacking any requirement for more sophistication, all parameters are matched using equality; sorry.
) rowConditions
) ";"
where
selectFrom :: String
selectFrom = showString "SELECT " . showString (separateIdentifiers columnNames) . showString " FROM " $ separateIdentifiers tableNames where
separateIdentifiers :: [String] -> String
separateIdentifiers = Data.List.intercalate ","
-- | Composes a parameterised SQL-select query.
prepare
:: Database.HDBC.IConnection connection
=> connection -- ^ Abstract database-connection.
-> [ColumnName] -- ^ Select the specified columns from matching rows.
-> [TableName] -- ^ Typically only a single table is referenced, but the facility exists to implicitly specify a /join/.
-> [ColumnName] -- ^ The formal parameters which will be subsequently be required to match corresponding actual parameters to filter rows.
-> IO Database.HDBC.Statement
prepare connection columnNames tableNames formalParameters = do
statement <- Database.HDBC.prepare connection $ mkParameterisedSelection columnNames tableNames formalParameters
#ifdef PRINT_DB_QUERIES
System.IO.hPutStrLn System.IO.stderr . showString "INFO:\tDB-query=" $ shows (Database.HDBC.originalQuery statement) "."
#endif
return {-to IO-monad-} statement
{- |
* Prepares a parameterised SQL-select query, then executes it using the specified database-connection and actual parameters.
* Because the SQL is parsed before replacing the formal parameters with actual parameters, it shouldn't be vulnerable to SQL-injection.
-}
select
:: Database.HDBC.IConnection connection
=> connection -- ^ Abstract database-connection.
-> [ColumnName] -- ^ Select the specified columns from matching rows.
-> [TableName] -- ^ Typically only a single table is referenced, but the facility exists to implicitly specify a /join/.
-> [(ColumnName, Database.HDBC.SqlValue)] -- ^ Select those rows matching the specified parameters.
-> IO [[Database.HDBC.SqlValue]]
select connection columnNames tableNames parameters = let
(formalParameters, actualParameters) = unzip parameters
in do
statement <- prepare connection columnNames tableNames formalParameters
0 <- Database.HDBC.execute statement actualParameters -- For 'SELECT' queries, the number of rows modified is always zero.
rows <- Database.HDBC.fetchAllRows' statement -- CAVEAT: the strict version of this call is necessary to ensure that a new query isn't executed before these rows have been read; else SQL-error "Commands out of sync; you can't run this command now".
#ifdef PRINT_DB_ROWS
System.IO.hPutStrLn System.IO.stderr . showString "INFO:\tDB-rows=" $ shows rows "."
#endif
return {-to IO-monad-} rows