generic-persistence (empty) → 0.2.0.0
raw patch · 17 files changed
+1944/−0 lines, 17 filesdep +HDBCdep +HDBC-sqlite3dep +QuickChecksetup-changed
Dependencies added: HDBC, HDBC-sqlite3, QuickCheck, base, bytestring, convertible, exceptions, generic-persistence, ghc, ghc-prim, hspec, hspec-discover, rio, syb, text, time, transformers
Files
- LICENSE +30/−0
- README.md +372/−0
- Setup.hs +2/−0
- app/Main.hs +133/−0
- generic-persistence.cabal +119/−0
- src/Database/GP.hs +37/−0
- src/Database/GP/Entity.hs +146/−0
- src/Database/GP/GenericPersistence.hs +193/−0
- src/Database/GP/RecordtypeReflection.hs +146/−0
- src/Database/GP/SqlGenerator.hs +137/−0
- src/Database/GP/TypeInfo.hs +71/−0
- test/EmbeddedSpec.hs +83/−0
- test/EnumSpec.hs +60/−0
- test/GenericPersistenceSpec.hs +184/−0
- test/OneToManySpec.hs +140/−0
- test/ReferenceSpec.hs +89/−0
- test/Spec.hs +2/−0
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Author name here (c) 2022++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Author name here nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,372 @@+# GenericPersistence - A Haskell persistence layer using Generics and Reflection++++## Introduction++GenericPersistence is a minimalistic Haskell persistence layer (on top of HDBC). +The approach relies on Generics (`Data.Data`, `Data.Typeable`) and Reflection (`Type.Reflection`).++The *functional goal* of the persistence layer is to provide hassle-free RDBMS persistence for Haskell data types in +Record notation (for brevity I call them *Entities*).++That is, it provides means for inserting, updating, deleting and quering such enties to/from relational databases.++The main *design goal* is to minimize the *boilerplate* code required:++- no manual instantiation of type classes+- no implementation of encoders/decoders+- no special naming convention for types and their attributes +- no special types to define entities and attributes+- no Template Haskell scaffolding of glue code++In an ideal world we would be able to take any POHO (Plain old Haskell Object) +and persist it to any RDBMS without any additional effort.++A lot of things are still missing:++- A query language+- Handling of nested transactions+- Handling auto-incrementing primary keys+- ...+++## Short demo++Here now follows a short demo that shows how the library looks and feels from the user's point of view.++```haskell+-- allows automatic derivation from Entity type class+{-# LANGUAGE DeriveAnyClass #-}++module Main (main) where++import Data.Data (Data)+import Database.GP (Entity (..), GP, delete, insert, liftIO,+ persist, retrieveAll, retrieveById,+ runGP, setupTableFor, update)+import Database.HDBC (IConnection (disconnect), fromSql,+ toSql)+import Database.HDBC.Sqlite3 (connectSqlite3)+++-- | An Entity data type with several fields, using record syntax.+data Person = Person+ { personID :: Int,+ name :: String,+ age :: Int,+ address :: String+ }+ deriving (Data, Entity, Show) -- deriving Entity allows to handle the type with GenericPersistence++data Book = Book+ { book_id :: Int,+ title :: String,+ author :: String,+ year :: Int+ }+ deriving (Data, Show) -- no auto deriving of Entity, so we have to implement the Entity type class:++instance Entity Book where+ -- this is the primary key field of the Book data type+ idField _ = "book_id"++ -- this defines the mapping between the field names of the Book data type and the column names of the database table+ fieldsToColumns _ = [("book_id", "bookId"), ("title", "bookTitle"), ("author", "bookAuthor"), ("year", "bookYear")]++ -- this is the name of the database table+ tableName _ = "BOOK_TBL"++ -- this is the function that converts a row from the database table into a Book data type+ fromRow row = return $ Book (col 0) (col 1) (col 2) (col 3)+ where+ col i = fromSql (row !! i)++ -- this is the function that converts a Book data type into a row for the database table+ toRow b = return [toSql (book_id b), toSql (title b), toSql (author b), toSql (year b)]++main :: IO ()+main = do+ -- connect to a database+ conn <- connectSqlite3 "sqlite.db"+ -- take the connection and execute all persistence operations in the GP monad (type alias for RIO Ctx)+ runGP conn $ do+ _ <- setupTableFor :: GP Person+ _ <- setupTableFor :: GP Book++ let alice = Person 123456 "Alice" 25 "123 Main St"+ book = Book 1 "The Hobbit" "J.R.R. Tolkien" 1937++ -- insert a Person into the database (persist will either insert or update)+ persist alice++ -- insert a second Person+ persist alice {personID = 123457, name = "Bob"}++ -- update a Person+ persist alice {address = "Elmstreet 1"}++ -- select a Person from a database+ alice' <- retrieveById (123456 :: Int) :: GP (Maybe Person)+ liftIO $ print alice'++ -- select all Persons from the database+ allPersons <- retrieveAll :: GP [Person]+ liftIO $ print allPersons++ -- delete a Person+ delete alice++ -- select all Persons from a database. The deleted Person is not in the result.+ allPersons' <- retrieveAll :: GP [Person]+ liftIO $ print allPersons'++ let book2 = Book {book_id = 2, title = "The Lord of the Ring", author = "J.R.R. Tolkien", year = 1954}++ -- this time we are using insert directly+ insert book+ insert book2+ allBooks <- retrieveAll :: GP [Book]+ liftIO $ print allBooks++ -- explicitly updating a Book+ update book2 {title = "The Lord of the Rings"}+ delete book++ allBooks' <- retrieveAll :: GP [Book]+ liftIO $ print allBooks'+```++## Handling enumeration fields++Say we have a data type `Book` with an enumeration field of type `BookCategory`:++```haskell+data Book = Book+ { bookID :: Int,+ title :: String,+ author :: String,+ year :: Int,+ category :: BookCategory+ }+ deriving (Data, Show)++data BookCategory = Fiction | Travel | Arts | Science | History | Biography | Other+ deriving (Data, Show, Enum)+```++In this case the `Entity` type class instance for `Book` has to be implemented manually, +as the automatic derivation of `Entity` does not cover this case (yet)++```haskell+instance Entity Book where+ fromRow row = return $ Book (col 0) (col 1) (col 2) (col 3) (col 4)+ where+ col i = fromSql (row !! i)++ toRow b = return [toSql (bookID b), toSql (title b), toSql (author b), toSql (year b), toSql (category b)]+```++`toSql` and `fromSql` expect `Convertible` instances as arguments. This works for `BookCatagory` as GenericPersistence provides `Convertible` instances for all `Enum` types.++If you do not want to use `Enum` types for your enumeration fields, you can implement `Convertible` instances for your own types:++```haskell+data BookCategory = Fiction | Travel | Arts | Science | History | Biography | Other+ deriving (Data, Show, Read)++instance Convertible BookCategory SqlValue where+ safeConvert = Right . toSql . show+ +instance Convertible SqlValue BookCategory where+ safeConvert = Right . read . fromSql +```++## Handling embedded Objects++Say we have a data type `Article` with an field of type `Author`:++```haskell+data Article = Article+ { articleID :: Int,+ title :: String,+ author :: Author,+ year :: Int+ }+ deriving (Data, Show, Eq)++data Author = Author+ { authorID :: Int,+ name :: String,+ address :: String+ }+ deriving (Data, Show, Eq) +```++If we don't want to store the `Author` as a separate table, we can use the following approach to embed the `Author` into the `Article` table:++```haskell+instance Entity Article where+ -- in the fields to column mapping we specify that all fields of the + -- Author type are also mapped to columns of the Article table:+ fieldsToColumns :: Article -> [(String, String)]+ fieldsToColumns _ = [("articleID", "articleID"),+ ("title", "title"), + ("authorID", "authorID"), + ("authorName", "authorName"), + ("authorAddress", "authorAddress"),+ ("year", "year")+ ]++ -- in fromRow we have to manually construct the Author object from the + -- respective columns of the Article table and insert it + -- into the Article object:+ fromRow row = return $ Article (col 0) (col 1) author (col 5)+ where+ col i = fromSql (row !! i)+ author = Author (col 2) (col 3) (col 4)++ -- in toRow we have to manually extract the fields of the Author object+ -- and insert them into the respective columns of the Article table:+ toRow a = return [toSql (articleID a), toSql (title a), toSql authID, toSql authorName, toSql authorAddress, toSql (year a)]+ where + authID = authorID (author a)+ authorName = name (author a)+ authorAddress = address (author a)+```++## Handling 1:1 references++If we have the same data types as in the previous example, but we want to store the `Author` in a separate table, we can use the following approach:++```haskell+data Article = Article+ { articleID :: Int,+ title :: String,+ author :: Author,+ year :: Int+ }+ deriving (Data, Show, Eq)++data Author = Author+ { authorID :: Int,+ name :: String,+ address :: String+ }+ deriving (Data, Entity, Show, Eq) -- we derive Entity for Author++instance Entity Article where+ -- in the fields to column mapping we specify an additional authorID field + -- that will be used to store the id of the referenced Author object:+ fieldsToColumns :: Article -> [(String, String)]+ fieldsToColumns _ = [("articleID", "articleID"),+ ("title", "title"), + ("authorID", "authorID"),+ ("year", "year")+ ]++ -- in fromRow we have to manually retrieve the Author object from the + -- database (by using authorID as a foreign key)+ fromRow row = do+ maybeAuthor <- retrieveById (row !! 2) :: GP (Maybe Author)+ let author = fromJust maybeAuthor+ pure $ Article (col 0) (col 1) author (col 3)+ where+ col i = fromSql (row !! i)++ -- in toRow we have manually persist the Author object and include+ -- the authorID of the Author object in the Article row: + toRow a = do + persist (author a)+ return [toSql (articleID a), toSql (title a), toSql $ authorID (author a), toSql (year a)]++```+## Handling 1:n references++Now let's extend the previous example by also having a list of Àrticle`s in the `Author` type:++```haskell+data Article = Article+ { articleID :: Int,+ title :: String,+ author :: Author,+ year :: Int+ }+ deriving (Data, Show, Eq)++data Author = Author+ { authorID :: Int,+ name :: String,+ address :: String,+ articles :: [Article]+ }+ deriving (Data, Show, Eq) +```++So now we have a 1:n relationship between `Author` and `Article`. And in addtion we have the 1:1 relationship between `Article` and `Author` that we have seen in the previous example.++This situation is a bit more complicated, as we have to handle relationships between `Article` and `Author` at the same time. And we have to make sure that we don't end up in an infinite loop when we persist an `Author` object that contains a list of `Article` objects that in turn contain the same `Author` object. ++The same problem occurs when we retrieve an `Author` object that contains a list of `Article` objects that in turn contain the same `Author` object.++We can handle this situation by using the following approach:++```haskell+instance Entity Article where+ -- in the fields to column mapping we specify an additional authorID field:+ fieldsToColumns :: Article -> [(String, String)]+ fieldsToColumns _ = [("articleID", "articleID"),+ ("title", "title"), + ("authorID", "authorID"),+ ("year", "year")+ ]++ -- in fromRow we have to take care that we don't end up in an infinite loop+ -- so we first place a dummy Article object into the cache and then+ -- retrieve the Author object either from cache or from the db:+ fromRow :: [SqlValue] -> GP Article+ fromRow row = local (extendCtxCache rawArticle) $ do+ maybeAuthor <- getElseRetrieve (entityId rawAuthor)+ let author = fromJust maybeAuthor+ pure $ Article (col 0) (col 1) author (col 3)+ where+ col i = fromSql (row !! i)+ rawAuthor = (evidence :: Author) {authorID = col 2}+ rawArticle = Article (col 0) (col 1) rawAuthor (col 3)+ + toRow a = do + persist (author a)+ return [toSql (articleID a), toSql (title a), toSql $ authorID (author a), toSql (year a)]+++instance Entity Author where+ -- in the fields to column mapping we have anything for the articles field:+ fieldsToColumns :: Author -> [(String, String)]+ fieldsToColumns _ = [("authorID", "authorID"),+ ("name", "name"), + ("address", "address")+ ]++ -- in fromRow we have to take care that we don't end up in an infinite loop.+ -- So we first place a dummy Author object into the cache and then+ -- retrieve matching list of Article objects (from cache or from the db):+ fromRow :: [SqlValue] -> GP Author+ fromRow row = local (extendCtxCache rawAuthor) $ do+ articlesByAuth <- retrieveAllWhere (idField rawAuthor) (idValue rawAuthor) :: GP [Article]+ pure $ rawAuthor {articles= articlesByAuth}+ where+ col i = fromSql (row !! i)+ rawAuthor = Author (col 0) (col 1) (col 2) []++ -- in toRow we do not safe the articles field to avoid infinite loops:+ toRow :: Author -> GP [SqlValue]+ toRow a = do + return [toSql (authorID a), toSql (name a), toSql (address a)]+```++## Todo++- coding free support for 1:1 and 1:n relationships+- coding free support for Enums+- resolution cache with proper Map+
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ app/Main.hs view
@@ -0,0 +1,133 @@+-- allows automatic derivation from Entity type class+{-# LANGUAGE DeriveAnyClass #-}++module Main (main, main1) where++import Data.Data (Data)+import Database.GP (Entity (..), GP, delete, insert, liftIO,+ persist, retrieveAll, retrieveById,+ runGP, setupTableFor, update)+import Database.HDBC (IConnection (disconnect), fromSql,+ toSql)+import Database.HDBC.Sqlite3 (connectSqlite3)++-- | An Entity data type with several fields, using record syntax.+data Person = Person+ { personID :: Int,+ name :: String,+ age :: Int,+ address :: String+ }+ deriving (Data, Entity, Show) -- deriving Entity allows to handle the type with GenericPersistence++data Book = Book+ { book_id :: Int,+ title :: String,+ author :: String,+ year :: Int+ }+ deriving (Data, Show) -- no auto deriving of Entity, so we have to implement the Entity type class:++instance Entity Book where+ -- this is the primary key field of the Book data type+ idField _ = "book_id"++ -- this defines the mapping between the field names of the Book data type and the column names of the database table+ fieldsToColumns _ = [("book_id", "bookId"), ("title", "bookTitle"), ("author", "bookAuthor"), ("year", "bookYear")]++ -- this is the name of the database table+ tableName _ = "BOOK_TBL"++ -- this is the function that converts a row from the database table into a Book data type+ fromRow row = return $ Book (col 0) (col 1) (col 2) (col 3)+ where+ col i = fromSql (row !! i)++ -- this is the function that converts a Book data type into a row for the database table+ toRow b = return [toSql (book_id b), toSql (title b), toSql (author b), toSql (year b)]++main :: IO ()+main = do+ -- connect to a database+ conn <- connectSqlite3 "sqlite.db"+ -- take the connection and execute all persistence operations in the GP monad (type alias for RIO Ctx)+ runGP conn $ do+ _ <- setupTableFor :: GP Person+ _ <- setupTableFor :: GP Book++ let alice = Person 123456 "Alice" 25 "123 Main St"+ book = Book 1 "The Hobbit" "J.R.R. Tolkien" 1937++ -- insert a Person into the database (persist will either insert or update)+ persist alice++ -- insert a second Person+ persist alice {personID = 123457, name = "Bob"}++ -- update a Person+ persist alice {address = "Elmstreet 1"}++ -- select a Person from a database+ alice' <- retrieveById (123456 :: Int) :: GP (Maybe Person)+ liftIO $ print alice'++ -- select all Persons from the database+ allPersons <- retrieveAll :: GP [Person]+ liftIO $ print allPersons++ -- delete a Person+ delete alice++ -- select all Persons from a database. The deleted Person is not in the result.+ allPersons' <- retrieveAll :: GP [Person]+ liftIO $ print allPersons'++ let book2 = Book {book_id = 2, title = "The Lord of the Ring", author = "J.R.R. Tolkien", year = 1954}++ -- this time we are using insert directly+ insert book+ insert book2+ allBooks <- retrieveAll :: GP [Book]+ liftIO $ print allBooks++ -- explicitly updating a Book+ update book2 {title = "The Lord of the Rings"}+ delete book++ allBooks' <- retrieveAll :: GP [Book]+ liftIO $ print allBooks'++ -- close connection+ disconnect conn++main1 :: IO ()+main1 = do+ -- connect to a database+ conn <- connectSqlite3 "sqlite.db"+ runGP conn $ do+ -- initialize Person table+ _ <- setupTableFor :: GP Person++ -- create a Person entity+ let alice = Person {personID = 123456, name = "Alice", age = 25, address = "Elmstreet 1"}++ -- insert a Person into a database+ persist alice++ -- update a Person+ persist alice {address = "Main Street 200"}++ -- select a Person from a database+ -- The result type must be provided explicitly, as `retrieveEntityById` has a polymorphic return type `IO a`.+ alice' <- retrieveById "123456" :: GP (Maybe Person)+ liftIO $ print alice'++ alice'' <- retrieveById "123456" :: GP (Maybe Person)++ liftIO $ print alice''++ -- delete a Person from a database+ delete alice++ -- close connection+ disconnect conn
+ generic-persistence.cabal view
@@ -0,0 +1,119 @@+cabal-version: 1.12+name: generic-persistence+version: 0.2.0.0+license: BSD3+license-file: LICENSE+copyright: 2023 Thomas Mahler+maintainer: thma@apache.org+author: Thomas Mahler+tested-with: ghc ==9.2.5 ghc ==9.0.2 ghc ==8.10.7+homepage: https://github.com/githubuser/generic-persistence#readme+bug-reports: https://github.com/githubuser/generic-persistence/issues+synopsis: Database persistence using generics+description:+ Please see the README on GitHub at <https://github.com/githubuser/generic-persistence#readme>++category: Database+build-type: Simple+extra-source-files: README.md++source-repository head+ type: git+ location: https://github.com/githubuser/generic-persistence++library+ exposed-modules:+ Database.GP+ Database.GP.Entity+ Database.GP.GenericPersistence+ Database.GP.RecordtypeReflection+ Database.GP.SqlGenerator+ Database.GP.TypeInfo++ hs-source-dirs: src+ other-modules: Paths_generic_persistence+ default-language: GHC2021+ ghc-options:+ -Wall -Wcompat -Widentities -Wincomplete-record-updates+ -Wincomplete-uni-patterns -Wmissing-export-lists+ -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints++ build-depends:+ HDBC <2.5,+ HDBC-sqlite3 <2.4,+ base >=4.7 && <5,+ bytestring <0.12,+ convertible <1.2,+ exceptions <0.11,+ ghc <9.3,+ ghc-prim <0.9,+ rio <0.2,+ syb <0.8,+ text <1.3,+ time <1.12,+ transformers <0.6++executable generic-persistence-demo+ main-is: Main.hs+ hs-source-dirs: app+ other-modules: Paths_generic_persistence+ default-language: GHC2021+ ghc-options:+ -Wall -Wcompat -Widentities -Wincomplete-record-updates+ -Wincomplete-uni-patterns -Wmissing-export-lists+ -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints+ -threaded -rtsopts -with-rtsopts=-N++ build-depends:+ HDBC <2.5,+ HDBC-sqlite3 <2.4,+ base >=4.7 && <5,+ bytestring <0.12,+ convertible <1.2,+ exceptions <0.11,+ generic-persistence,+ ghc <9.3,+ ghc-prim <0.9,+ rio <0.2,+ syb <0.8,+ text <1.3,+ time <1.12,+ transformers <0.6++test-suite generic-persistence-test+ type: exitcode-stdio-1.0+ main-is: Spec.hs+ hs-source-dirs: test+ other-modules:+ EmbeddedSpec+ EnumSpec+ GenericPersistenceSpec+ OneToManySpec+ ReferenceSpec+ Paths_generic_persistence++ default-language: GHC2021+ ghc-options:+ -Wall -Wcompat -Widentities -Wincomplete-record-updates+ -Wincomplete-uni-patterns -Wmissing-export-lists+ -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints+ -threaded -rtsopts -with-rtsopts=-N++ build-depends:+ HDBC <2.5,+ HDBC-sqlite3 <2.4,+ QuickCheck <2.15,+ base >=4.7 && <5,+ bytestring <0.12,+ convertible <1.2,+ exceptions <0.11,+ generic-persistence,+ ghc <9.3,+ ghc-prim <0.9,+ hspec <2.10,+ hspec-discover <2.10,+ rio <0.2,+ syb <0.8,+ text <1.3,+ time <1.12,+ transformers <0.6
+ src/Database/GP.hs view
@@ -0,0 +1,37 @@+module Database.GP ( retrieveById,+ retrieveAll,+ retrieveAllWhere,+ persist,+ insert,+ update,+ delete,+ setupTableFor,+ idValue,+ Entity (..),+ columnNameFor,+ fieldTypeFor,+ maybeFieldTypeFor,+ toString,+ evidence,+ evidenceFrom,+ ResolutionCache,+ EntityId,+ entityId,+ getElseRetrieve,+ TypeInfo (..),+ typeInfoFromContext,+ typeInfo,+ Ctx (..),+ GP,+ extendCtxCache,+ runGP,+ liftIO,+ local,+ ask,+ )+where++-- We are just re-exporting the functions from the GenericPersistence module. +import Database.GP.GenericPersistence++
+ src/Database/GP/Entity.hs view
@@ -0,0 +1,146 @@+{-# LANGUAGE DefaultSignatures #-}++module Database.GP.Entity+ ( Entity (..),+ columnNameFor,+ fieldTypeFor,+ maybeFieldTypeFor,+ toString,+ evidence,+ evidenceFrom,+ ResolutionCache,+ EntityId,+ Ctx (..),+ GP,+ )+where++import Data.Char (toLower)+import Data.Data +import Database.HDBC (SqlValue, fromSql, ConnWrapper)+import Database.GP.RecordtypeReflection (gFromRow, gToRow)+import Database.GP.TypeInfo +import Data.Dynamic+import RIO++{--+This is the Entity class. It is a type class that is used to define the mapping +between a Haskell product type in record notation and a database table.+The class has a default implementation for all methods. +The default implementation uses the type information to determine a simple 1:1 mapping.++That means that +- the type name is used as the table name and the +- field names are used as the column names.+- A field named '<lowercase typeName>ID' is used as the primary key field.++The default implementation can be overridden by defining a custom instance for a type.++Please note the following constraints, which apply to all valid Entity type, +but that are not explicitely encoded in the type class definition:++- The type must be a product type in record notation.+- The type must have exactly one constructor.+- There must be single primary key field, compund primary keys are not supported.++--}++class (Data a) => Entity a where+ -- | Converts a database row to a value of type 'a'.+ fromRow :: [SqlValue] -> GP a++ -- | Converts a value of type 'a' to a database row.+ toRow :: a -> GP [SqlValue]++ -- | Returns the name of the primary key field for a type 'a'.+ idField :: a -> String++ -- | Returns a list of tuples that map field names to column names for a type 'a'.+ fieldsToColumns :: a -> [(String, String)]++ -- | Returns the name of the table for a type 'a'.+ tableName :: a -> String++ -- | generic default implementation+ default fromRow :: [SqlValue] -> GP a+ fromRow = pure . gFromRow++ -- | generic default implementation+ default toRow :: a -> GP [SqlValue]+ toRow = pure . gToRow++ -- | default implementation: the ID field is the field with the same name+ -- as the type name in lower case and appended with "ID", e.g. "bookID"+ default idField :: a -> String+ idField = idFieldName . typeInfo+ where+ idFieldName :: TypeInfo a -> String+ idFieldName ti = map toLower (typeName ti) ++ "ID"++ -- | default implementation: the field names are used as column names+ default fieldsToColumns :: a -> [(String, String)]+ fieldsToColumns x = zip (fieldNames (typeInfo x)) (fieldNames (typeInfo x))++ -- | default implementation: the type name is used as table name+ default tableName :: a -> String+ tableName = typeName . typeInfo++-- | type Ctx defines the context in which the persistence operations are executed.+-- It contains a connection to the database and a resolution cache for circular lookups.+data Ctx = + Ctx+ {connection :: ConnWrapper,+ cache :: ResolutionCache+ }++type GP = RIO Ctx++-- | The EntityId is a tuple of the TypeRep and the primary key value of an Entity.+-- It is used as a key in the resolution cache.+type EntityId = (TypeRep, SqlValue)++-- | The resolution cache maps an EntityId to a Dynamic value (representing an Entity).+-- It is used to resolve circular references during loading and storing of Entities.+type ResolutionCache = [(EntityId, Dynamic)]++-- | A convenience function: returns the name of the column for a field of a type 'a'.+columnNameFor :: Entity a => a -> String -> String+columnNameFor x fieldName =+ case maybeColumnNameFor x fieldName of+ Just columnName -> columnName+ Nothing -> error ("columnNameFor: " ++ toString x ++ + " has no column mapping for " ++ fieldName)+ where+ maybeColumnNameFor :: Entity a => a -> String -> Maybe String+ maybeColumnNameFor a field = lookup field (fieldsToColumns a)++-- | A convenience function: returns the TypeRep of a field of a type 'a'. +fieldTypeFor :: Entity a => a -> String -> TypeRep+fieldTypeFor x fieldName =+ case maybeFieldTypeFor x fieldName of+ Just tyRep -> tyRep+ Nothing -> error ("fieldTypeFor: " ++ toString x ++ + " has no field " ++ fieldName)++maybeFieldTypeFor :: Entity a => a -> String -> Maybe TypeRep+maybeFieldTypeFor a field = lookup field (fieldsAndTypes (typeInfo a))+ where+ fieldsAndTypes :: TypeInfo a -> [(String, TypeRep)]+ fieldsAndTypes ti = zip (fieldNames ti) (fieldTypes ti)++-- | Returns a string representation of a value of type 'a'.+toString :: (Entity a) => a -> String+toString x = typeName (typeInfo x) ++ " " ++ unwords mappedRow+ where+ mappedRow = map fromSql (gToRow x)++-- | A convenience function: returns an evidence instance of type 'a'.+-- This is useful for type inference where no instance is available.+evidence :: forall a. (Entity a) => a +evidence = evidenceFrom ti+ where + ti = typeInfoFromContext :: TypeInfo a+++evidenceFrom :: forall a. (Entity a) => TypeInfo a -> a+evidenceFrom = fromConstr . typeConstructor
+ src/Database/GP/GenericPersistence.hs view
@@ -0,0 +1,193 @@+{-# OPTIONS_GHC -Wno-orphans #-}+module Database.GP.GenericPersistence+ ( retrieveById,+ retrieveAll,+ retrieveAllWhere,+ persist,+ insert,+ update,+ delete,+ setupTableFor,+ idValue,+ Entity (..),+ columnNameFor,+ fieldTypeFor,+ maybeFieldTypeFor,+ toString,+ evidence,+ evidenceFrom,+ ResolutionCache,+ EntityId,+ entityId,+ getElseRetrieve,+ TypeInfo (..),+ typeInfoFromContext,+ typeInfo,+ Ctx (..),+ GP,+ extendCtxCache,+ runGP,+ liftIO,+ local,+ ask,+ )+where++import Data.Convertible ( Convertible, ConvertResult )+import Database.HDBC +import Database.GP.Entity+import Database.GP.RecordtypeReflection+import Database.GP.SqlGenerator+import Database.GP.TypeInfo+import Data.Dynamic (toDyn, fromDynamic)+import Data.Data +import Data.Convertible.Base (Convertible(safeConvert))+import RIO++{--+ This module defines RDBMS Persistence operations for Record Data Types that are instances of 'Data'.+ I call instances of such a data type Entities.++ The Persistence operations are using Haskell generics to provide compile time reflection capabilities.+ HDBC is used to access the RDBMS.+--}+++++-- | A function that retrieves an entity from a database.+-- The function takes entity id as parameter.+-- If an entity with the given id exists in the database, it is returned as a Just value.+-- If no such entity exists, Nothing is returned.+-- An error is thrown if there are more than one entity with the given id.+retrieveById :: forall a id. (Entity a, Convertible id SqlValue) => id -> GP (Maybe a)+retrieveById idx = do+ conn <- askConnection+ resultRowsSqlValues <- liftIO $ quickQuery conn stmt [eid]+ case resultRowsSqlValues of+ [] -> pure Nothing+ [singleRow] -> Just <$> fromRow singleRow+ _ -> error $ "More than one" ++ show (typeConstructor ti) ++ " found for id " ++ show eid+ where+ ti = typeInfoFromContext :: TypeInfo a+ stmt = selectStmtFor ti+ eid = toSql idx+++-- | This function retrieves all entities of type `a` from a database.+-- The function takes an HDBC connection as parameter.+-- The type `a` is determined by the context of the function call.+retrieveAll :: forall a. (Entity a) => GP [a]+retrieveAll = do+ conn <- askConnection+ resultRows <- liftIO $ quickQuery conn stmt []+ mapM fromRow resultRows+ where+ ti = typeInfoFromContext :: TypeInfo a+ stmt = selectAllStmtFor ti ++retrieveAllWhere :: forall a. (Entity a) => String -> SqlValue -> GP [a]+retrieveAllWhere field val = do+ conn <- askConnection+ resultRows <- liftIO $ quickQuery conn stmt [val]+ mapM fromRow resultRows+ where+ ti = typeInfoFromContext :: TypeInfo a+ stmt = selectAllWhereStmtFor ti field++-- | A function that persists an entity to a database.+-- The function takes an HDBC connection and an entity as parameters.+-- The entity is either inserted or updated, depending on whether it already exists in the database.+-- The required SQL statements are generated dynamically using Haskell generics and reflection+persist :: (Entity a) => a -> GP ()+persist entity = do+ conn <- askConnection+ resultRows <- liftIO $ quickQuery conn preparedSelectStmt [eid]+ case resultRows of+ [] -> insert entity+ [_singleRow] -> update entity+ _ -> error $ "More than one entity found for id " ++ show eid+ where+ ti = typeInfo entity+ eid = idValue entity+ preparedSelectStmt = selectStmtFor ti++-- | A function that explicitely inserts an entity into a database.+insert :: (Entity a) => a -> GP ()+insert entity = do+ conn <- askConnection+ row <- toRow entity+ _rowcount <- liftIO $ run conn (insertStmtFor entity) row+ liftIO $ commit conn++-- | A function that explicitely updates an entity in a database.+update :: (Entity a) => a -> GP ()+update entity = do+ conn <- askConnection+ row <- toRow entity+ _rowcount <- liftIO $ run conn (updateStmtFor entity) (row ++ [idValue entity])+ liftIO $ commit conn++delete :: (Entity a) => a -> GP ()+delete entity = do+ conn <- askConnection+ _rowCount <- liftIO $ run conn (deleteStmtFor entity) [idValue entity]+ liftIO $ commit conn++-- | set up a table for a given entity type. The table is dropped and recreated.+setupTableFor :: forall a. (Entity a) => GP a+setupTableFor = do+ conn <- askConnection+ _ <- liftIO $ runRaw conn (dropTableStmtFor ti)+ _ <- liftIO $ runRaw conn (createTableStmtFor ti)+ liftIO $ commit conn+ return x+ where+ ti = typeInfoFromContext :: TypeInfo a+ x = evidenceFrom ti :: a+++-- | Lookup an entity in the cache, or retrieve it from the database.+-- The Entity is identified by its EntityId, which is a (typeRep, idValue) tuple.+getElseRetrieve :: forall a . (Entity a) => EntityId -> GP (Maybe a)+getElseRetrieve eid@(_tr,pk) = do+ rc <- askCache+ case lookup eid rc of+ Just dyn -> case fromDynamic dyn :: Maybe a of+ Just e -> pure (Just e)+ Nothing -> error "should not be possible" + Nothing -> retrieveById pk :: GP (Maybe a)+++extendCtxCache :: Entity a => a -> Ctx -> Ctx+extendCtxCache x (Ctx conn rc) = Ctx conn (cacheEntry : rc)+ where+ cacheEntry = (entityId x, toDyn x)+++-- | Computes the EntityId of an entity.+-- The EntityId of an entity is a (typeRep, idValue) tuple.+entityId :: (Entity a) => a -> EntityId+entityId x = (typeOf x, idValue x)++-- | A function that returns the primary key value of an entity as a SqlValue.+idValue :: forall a. (Entity a) => a -> SqlValue+idValue x = fieldValue x (idField x)++askConnection :: GP ConnWrapper+askConnection = connection <$> ask++askCache :: GP ResolutionCache+askCache = cache <$> ask++runGP :: (MonadIO m, IConnection conn) => conn -> RIO Ctx a -> m a+runGP conn = runRIO (Ctx (ConnWrapper conn) mempty)++-- These instances are needed to make the Convertible type class work with Enum types out of the box.+instance {-# OVERLAPS #-} forall a . (Enum a) => Convertible SqlValue a where+ safeConvert :: SqlValue -> ConvertResult a+ safeConvert = Right . toEnum . fromSql++instance {-# OVERLAPS #-} forall a . (Enum a) => Convertible a SqlValue where+ safeConvert :: a -> ConvertResult SqlValue+ safeConvert = Right . toSql . fromEnum
+ src/Database/GP/RecordtypeReflection.hs view
@@ -0,0 +1,146 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE AllowAmbiguousTypes #-}+++module Database.GP.RecordtypeReflection+ ( + fieldValue,+ gFromRow,+ gToRow,+ )+where++import Control.Monad (zipWithM)+import Control.Monad.Trans.Class (lift)+import Control.Monad.Trans.State.Lazy (StateT (..))+import qualified Data.ByteString as B+import Data.Data hiding (typeRep)+import Data.Dynamic (Dynamic, fromDynamic, toDyn)+import Data.Int (Int32, Int64)+import Data.List (elemIndex, uncons)+import Data.Ratio (Ratio)+import qualified Data.Text as TS+import qualified Data.Text.Lazy as TL+import Data.Time (Day, LocalTime,+ NominalDiffTime, TimeOfDay,+ UTCTime, ZonedTime)+import Data.Time.Clock.POSIX (POSIXTime)+import Data.Word (Word32, Word64)+import Database.HDBC (SqlValue, fromSql, toSql)+import GHC.Data.Maybe (expectJust)+import Type.Reflection (SomeTypeRep (..), eqTypeRep,+ typeRep)+import Database.GP.TypeInfo++-- | A function that takes an entity and a field name as input parameters and returns the value of the field as a String.+-- Example: fieldValue (Person "John" 42) "name" = SqlString "John"+-- Example: fieldValue (Person "John" 42) "age" = SqlInt64 42+-- if the field is not present in the entity, an error is thrown.+fieldValue :: Data a => a -> String -> SqlValue+fieldValue x field =+ convertToSqlValue fieldType (valueList !! index)+ where+ ti = typeInfo x+ fieldList = fieldNames ti+ valueList = fieldValues x+ index =+ expectJust+ ("Field " ++ field ++ " is not present in type " ++ typeName ti)+ (elemIndex field fieldList)+ fieldType = fieldTypes ti !! index++fieldValues :: (Data a) => a -> [Dynamic]+fieldValues = gmapQ toDyn++gFromRow :: forall a. (Data a) => [SqlValue] -> a+gFromRow row = expectJust errMsg (buildFromRecord ti row)+ where+ ti = typeInfoFromContext+ tName = typeName ti+ errMsg = "can't construct an " ++ tName ++ " instance from " ++ show row++gToRow :: (Data a) => a -> [SqlValue]+gToRow x = zipWith convertToSqlValue types values+ where+ ti = typeInfo x+ types = fieldTypes ti+ values = fieldValues x++-- | This function takes a `TypeInfo a`and a List of HDBC `SqlValue`s and returns a `Maybe a`.+-- If the conversion fails, Nothing is returned, otherwise Just a.+buildFromRecord :: (Data a) => TypeInfo a -> [SqlValue] -> Maybe a+buildFromRecord ti record = applyConstr ctor dynamicsArgs+ where+ ctor = typeConstructor ti+ types = fieldTypes ti+ dynamicsArgs =+ expectJust+ ("buildFromRecord: error in converting record " ++ show record)+ (zipWithM convertToDynamic types record)++-- | This function takes a `Constr` and a list of `Dynamic` values and returns a `Maybe a`.+-- If an `a`entity could be constructed, Just a is returned, otherwise Nothing.+-- See also https://stackoverflow.com/questions/47606189/fromconstrb-or-something-other-useful+-- for Info on how to use fromConstrM+applyConstr :: Data a => Constr -> [Dynamic] -> Maybe a+applyConstr ctor args =+ let nextField :: forall d. Data d => StateT [Dynamic] Maybe d+ nextField = StateT uncons >>= lift . fromDynamic+ in case runStateT (fromConstrM nextField ctor) args of+ Just (x, []) -> Just x+ _ -> Nothing -- runtime type error or too few / too many arguments++-- | convert a SqlValue into a Dynamic value that is backed by a value of the type represented by the SomeTypeRep parameter.+-- If conversion fails, return Nothing.+-- conversion to Dynamic is required to allow the use of fromDynamic in applyConstr+-- see also https://stackoverflow.com/questions/46992740/how-to-specify-type-of-value-via-typerep+convertToDynamic :: SomeTypeRep -> SqlValue -> Maybe Dynamic+convertToDynamic (SomeTypeRep rep) val+ | Just HRefl <- eqTypeRep rep (typeRep @Int) = Just $ toDyn (fromSql val :: Int)+ | Just HRefl <- eqTypeRep rep (typeRep @Double) = Just $ toDyn (fromSql val :: Double)+ | Just HRefl <- eqTypeRep rep (typeRep @String) = Just $ toDyn (fromSql val :: String)+ | Just HRefl <- eqTypeRep rep (typeRep @Char) = Just $ toDyn (fromSql val :: Char)+ | Just HRefl <- eqTypeRep rep (typeRep @B.ByteString) = Just $ toDyn (fromSql val :: B.ByteString)+ | Just HRefl <- eqTypeRep rep (typeRep @Word32) = Just $ toDyn (fromSql val :: Word32)+ | Just HRefl <- eqTypeRep rep (typeRep @Word64) = Just $ toDyn (fromSql val :: Word64)+ | Just HRefl <- eqTypeRep rep (typeRep @Int32) = Just $ toDyn (fromSql val :: Int32)+ | Just HRefl <- eqTypeRep rep (typeRep @Int64) = Just $ toDyn (fromSql val :: Int64)+ | Just HRefl <- eqTypeRep rep (typeRep @Integer) = Just $ toDyn (fromSql val :: Integer)+ | Just HRefl <- eqTypeRep rep (typeRep @Bool) = Just $ toDyn (fromSql val :: Bool)+ | Just HRefl <- eqTypeRep rep (typeRep @UTCTime) = Just $ toDyn (fromSql val :: UTCTime)+ | Just HRefl <- eqTypeRep rep (typeRep @POSIXTime) = Just $ toDyn (fromSql val :: POSIXTime)+ | Just HRefl <- eqTypeRep rep (typeRep @LocalTime) = Just $ toDyn (fromSql val :: LocalTime)+ | Just HRefl <- eqTypeRep rep (typeRep @ZonedTime) = Just $ toDyn (fromSql val :: ZonedTime)+ | Just HRefl <- eqTypeRep rep (typeRep @TimeOfDay) = Just $ toDyn (fromSql val :: TimeOfDay)+ | Just HRefl <- eqTypeRep rep (typeRep @Day) = Just $ toDyn (fromSql val :: Day)+ | Just HRefl <- eqTypeRep rep (typeRep @NominalDiffTime) = Just $ toDyn (fromSql val :: NominalDiffTime)+ | Just HRefl <- eqTypeRep rep (typeRep @Ratio) = Just $ toDyn (fromSql val :: Ratio Integer)+ | Just HRefl <- eqTypeRep rep (typeRep @TL.Text) = Just $ toDyn (fromSql val :: TL.Text)+ | Just HRefl <- eqTypeRep rep (typeRep @TS.Text) = Just $ toDyn (fromSql val :: TS.Text)+ | otherwise = Nothing++convertToSqlValue :: SomeTypeRep -> Dynamic -> SqlValue+convertToSqlValue (SomeTypeRep rep) dyn+ | Just HRefl <- eqTypeRep rep (typeRep @Int) = toSql (expectJust ("Not an Int: " ++ show dyn) (fromDynamic dyn) :: Int)+ | Just HRefl <- eqTypeRep rep (typeRep @Double) = toSql (expectJust ("Not a Double: " ++ show dyn) (fromDynamic dyn) :: Double)+ | Just HRefl <- eqTypeRep rep (typeRep @String) = toSql (expectJust ("Not a String: " ++ show dyn) (fromDynamic dyn) :: String)+ | Just HRefl <- eqTypeRep rep (typeRep @Char) = toSql (expectJust ("Not a Char: " ++ show dyn) (fromDynamic dyn) :: Char)+ | Just HRefl <- eqTypeRep rep (typeRep @B.ByteString) = toSql (expectJust ("Not a ByteString: " ++ show dyn) (fromDynamic dyn) :: B.ByteString)+ | Just HRefl <- eqTypeRep rep (typeRep @Word32) = toSql (expectJust ("Not a Word32: " ++ show dyn) (fromDynamic dyn) :: Word32)+ | Just HRefl <- eqTypeRep rep (typeRep @Word64) = toSql (expectJust ("Not a Word64: " ++ show dyn) (fromDynamic dyn) :: Word64)+ | Just HRefl <- eqTypeRep rep (typeRep @Int32) = toSql (expectJust ("Not an Int32: " ++ show dyn) (fromDynamic dyn) :: Int32)+ | Just HRefl <- eqTypeRep rep (typeRep @Int64) = toSql (expectJust ("Not an Int64: " ++ show dyn) (fromDynamic dyn) :: Int64)+ | Just HRefl <- eqTypeRep rep (typeRep @Integer) = toSql (expectJust ("Not an Integer: " ++ show dyn) (fromDynamic dyn) :: Integer)+ | Just HRefl <- eqTypeRep rep (typeRep @Bool) = toSql (expectJust ("Not a Bool: " ++ show dyn) (fromDynamic dyn) :: Bool)+ | Just HRefl <- eqTypeRep rep (typeRep @UTCTime) = toSql (expectJust ("Not a UTCTime: " ++ show dyn) (fromDynamic dyn) :: UTCTime)+ | Just HRefl <- eqTypeRep rep (typeRep @POSIXTime) = toSql (expectJust ("Not a PosixTime: " ++ show dyn) (fromDynamic dyn) :: POSIXTime)+ | Just HRefl <- eqTypeRep rep (typeRep @LocalTime) = toSql (expectJust ("Not a LocalTime: " ++ show dyn) (fromDynamic dyn) :: LocalTime)+ | Just HRefl <- eqTypeRep rep (typeRep @ZonedTime) = toSql (expectJust ("Not a ZonedTime: " ++ show dyn) (fromDynamic dyn) :: ZonedTime)+ | Just HRefl <- eqTypeRep rep (typeRep @TimeOfDay) = toSql (expectJust ("Not a TimeOfDay: " ++ show dyn) (fromDynamic dyn) :: TimeOfDay)+ | Just HRefl <- eqTypeRep rep (typeRep @Day) = toSql (expectJust ("Not a Day: " ++ show dyn) (fromDynamic dyn) :: Day)+ | Just HRefl <- eqTypeRep rep (typeRep @NominalDiffTime) = toSql (expectJust ("Not a NominalTimeDiff: " ++ show dyn) (fromDynamic dyn) :: NominalDiffTime)+ | Just HRefl <- eqTypeRep rep (typeRep @Ratio) = toSql (expectJust ("Not a Ratio: " ++ show dyn) (fromDynamic dyn) :: Ratio Integer)+ | Just HRefl <- eqTypeRep rep (typeRep @TL.Text) = toSql (expectJust ("Not a TL.Text: " ++ show dyn) (fromDynamic dyn) :: TL.Text)+ | Just HRefl <- eqTypeRep rep (typeRep @TS.Text) = toSql (expectJust ("Not a TS.Text: " ++ show dyn) (fromDynamic dyn) :: TS.Text)+ | otherwise = error $ "convertToSqlValue: " ++ show rep ++ " not supported"
+ src/Database/GP/SqlGenerator.hs view
@@ -0,0 +1,137 @@+module Database.GP.SqlGenerator+ ( insertStmtFor,+ updateStmtFor,+ selectStmtFor,+ deleteStmtFor,+ selectAllStmtFor,+ selectAllWhereStmtFor,+ createTableStmtFor,+ dropTableStmtFor,+ )+where++import Data.List (intercalate)+import Database.GP.Entity+import Database.GP.TypeInfo++-- | A function that returns an SQL insert statement for an entity. Type 'a' must be an instance of Data.+-- The function will use the field names of the data type to generate the column names in the insert statement.+-- The values of the fields will be used as the values in the insert statement.+-- Output example: INSERT INTO Person (id, name, age, address) VALUES (123456, "Alice", 25, "123 Main St");+insertStmtFor :: Entity a => a -> String+insertStmtFor x =+ "INSERT INTO "+ ++ tableName x+ ++ " ("+ ++ intercalate ", " columns+ ++ ") VALUES ("+ ++ intercalate ", " (params (length columns))+ ++ ");"+ where+ columns = columnNamesFor x+++columnNamesFor :: Entity a => a -> [String]+columnNamesFor x = map snd fieldColumnPairs+ where+ fieldColumnPairs = fieldsToColumns x +++params :: Int -> [String]+params n = replicate n "?"++-- | A function that returns an SQL update statement for an entity. Type 'a' must be an instance of Entity.+updateStmtFor :: Entity a => a -> String+updateStmtFor x =+ "UPDATE "+ ++ tableName x+ ++ " SET "+ ++ intercalate ", " updatePairs+ ++ " WHERE "+ ++ idColumn x+ ++ " = ?"+ ++ ";"+ where+ updatePairs = map (++ " = ?") (columnNamesFor x)++idColumn :: Entity a => a -> String+idColumn x = columnNameFor x (idField x)++-- | A function that returns an SQL select statement for entity type `a` with primary key `id`.+selectStmtFor :: forall a. (Entity a) => TypeInfo a -> String+selectStmtFor ti =+ "SELECT "+ ++ intercalate ", " (columnNamesFor x)+ ++ " FROM "+ ++ tableName x+ ++ " WHERE "+ ++ idColumn x+ ++ " = ?;"+ where+ x = evidenceFrom ti :: a++selectAllStmtFor :: forall a. (Entity a) => TypeInfo a -> String+selectAllStmtFor ti =+ "SELECT "+ ++ intercalate ", " (columnNamesFor x)+ ++ " FROM "+ ++ tableName x+ ++ ";"+ where+ x = evidenceFrom ti :: a++selectAllWhereStmtFor :: forall a. (Entity a) => TypeInfo a -> String -> String+selectAllWhereStmtFor ti field =+ "SELECT "+ ++ intercalate ", " (columnNamesFor x)+ ++ " FROM "+ ++ tableName x+ ++ " WHERE "+ ++ column+ ++ " = ?;"+ where+ x = evidenceFrom ti :: a+ column = columnNameFor x field++deleteStmtFor :: Entity a => a -> String+deleteStmtFor x =+ "DELETE FROM "+ ++ tableName x+ ++ " WHERE "+ ++ idColumn x+ ++ " = ?;"++createTableStmtFor :: forall a. (Entity a) => TypeInfo a -> String+createTableStmtFor ti =+ "CREATE TABLE "+ ++ tableName x+ ++ " ("+ ++ intercalate ", " (map (\(f,c) -> c ++ " " ++ columnTypeFor x f ++ optionalPK f) (fieldsToColumns x))+ ++ ");"+ where+ x = evidenceFrom ti :: a+ isIdField f = f == idField x+ optionalPK f = if isIdField f then " PRIMARY KEY" else ""+ + +columnTypeFor :: forall a. (Entity a) => a -> String -> String+columnTypeFor x field = + case fType of+ "Int" -> "INTEGER"+ "String" -> "TEXT"+ "Double" -> "REAL"+ "Float" -> "REAL"+ "Bool" -> "INT"+ _ -> "TEXT"+ where+ maybeFType = maybeFieldTypeFor x field+ fType = maybe "OTHER" show maybeFType+++dropTableStmtFor :: forall a. (Entity a) => TypeInfo a -> String+dropTableStmtFor ti =+ "DROP TABLE IF EXISTS "+ ++ tableName x+ ++ ";"+ where+ x = evidenceFrom ti :: a
+ src/Database/GP/TypeInfo.hs view
@@ -0,0 +1,71 @@+--{-# LANGUAGE RankNTypes #-}+--{-# LANGUAGE ScopedTypeVariables #-}+module Database.GP.TypeInfo+ ( TypeInfo,+ typeConstructor,+ fieldNames,+ fieldTypes,+ typeName,+ typeInfo,+ typeInfoFromContext,+ )+where++import Data.Data++-- | A data type holding meta-data about a type. +-- The Phantom type parameter `a` ensures type safety for reflective functions+-- that use this type to create type instances (See module RecordtypeReflection).+data TypeInfo a = TypeInfo+ { typeConstructor :: Constr,+ fieldNames :: [String],+ fieldTypes :: [TypeRep]+ }+ deriving (Show)++-- | this function is a smart constructor for TypeInfo objects.+-- It takes a value of type `a` and returns a `TypeInfo a` object.+-- If the type has no named fields, an error is thrown.+-- If the type has more than one constructor, an error is thrown.+typeInfo :: Data a => a -> TypeInfo a+typeInfo x =+ TypeInfo+ { typeConstructor = ensureSingleConstructor (dataTypeOf x),+ fieldNames = fieldNamesOf x,+ fieldTypes = gmapQ typeOf x+ }++-- | This function ensures that the type of `a` has exactly one constructor.+-- If the type has exactly one constructor, the constructor is returned.+-- otherwise, an error is thrown.+ensureSingleConstructor :: DataType -> Constr+ensureSingleConstructor dt =+ case dataTypeConstrs dt of+ [cnstr] -> cnstr+ _ -> error $ "ensureSingleConstructor: Only types with one constructor are supported (" ++ show dt ++ ")"++-- | This function creates a TypeInfo object from the context of a function call.+-- The Phantom Type parameter `a` is used to convince the compiler that the `TypeInfo a` object really describes type `a`.+-- See also https://stackoverflow.com/questions/75171829/how-to-obtain-a-data-data-constr-etc-from-a-type-representation+typeInfoFromContext :: forall a. Data a => TypeInfo a+typeInfoFromContext =+ let dt = dataTypeOf (undefined :: a) -- This is the trick to get the type a from the context.+ constr = ensureSingleConstructor dt+ evidence = fromConstr constr :: a -- this is evidence for the compiler that we have a value of type a+ in typeInfo evidence++-- | This function returns the (unqualified) type name of `a` from a `TypeInfo a` object.+typeName :: TypeInfo a -> String+typeName = dataTypeName . constrType . typeConstructor++-- | This function returns the list of field names of an entity of type `a`.+fieldNamesOf :: (Data a) => a -> [String]+fieldNamesOf x = names+ where+ constructor = toConstr x+ candidates = constrFields constructor+ constrs = gmapQ toConstr x+ names =+ if length candidates == length constrs+ then candidates+ else error $ "fieldNamesOf: Type " ++ show (typeOf x) ++ " does not have named fields"
+ test/EmbeddedSpec.hs view
@@ -0,0 +1,83 @@+module EmbeddedSpec+ ( test+ , spec+ ) where++import Test.Hspec+import Data.Data+import Database.HDBC+import Database.HDBC.Sqlite3+import Database.GP.GenericPersistence+import RIO ++-- `test` is here so that this module can be run from GHCi on its own. It is+-- not needed for automatic spec discovery. (start up stack repl --test to bring up ghci and have access to all the test functions)+test :: IO ()+test = hspec spec++withDatabase :: RIO Ctx a -> IO a+withDatabase action = do+ conn <- connectSqlite3 ":memory:"+ runGP conn $ do+ _ <- setupTableFor :: GP Article+ action++data Article = Article+ { articleID :: Int,+ title :: String,+ author :: Author,+ year :: Int+ }+ deriving (Data, Show, Eq)++data Author = Author+ { authorID :: Int,+ name :: String,+ address :: String+ }+ deriving (Data, Show, Eq) ++instance Entity Article where++ fieldsToColumns :: Article -> [(String, String)]+ fieldsToColumns _ = [("articleID", "articleID"),+ ("title", "title"), + ("authorID", "authorID"), + ("authorName", "authorName"), + ("authorAddress", "authorAddress"),+ ("year", "year")+ ]++ fromRow row = return $ Article (col 0) (col 1) author (col 5)+ where+ col i = fromSql (row !! i)+ author = Author (col 2) (col 3) (col 4)++ toRow a = return [toSql (articleID a), toSql (title a), toSql authID, toSql authorName, toSql authorAddress, toSql (year a)]+ where + authID = authorID (author a)+ authorName = name (author a)+ authorAddress = address (author a)++article :: Article+article = Article + { articleID = 1, + title = "Persistence without Boilerplate", + author = Author + {authorID = 1, + name = "Arthur Dent", + address = "Boston"}, + year = 2018}++spec :: Spec+spec = do+ describe "Handling of Embedded Objects" $ do+ it "works like a charm" $ + withDatabase $ do+ insert article+ article' <- retrieveById "1" :: GP (Maybe Article)+ liftIO $ article' `shouldBe` Just article+ allArticles <- retrieveAll :: GP [Article]+ liftIO $ allArticles `shouldBe` [article]++
+ test/EnumSpec.hs view
@@ -0,0 +1,60 @@+module EnumSpec+ ( test+ , spec+ ) where++import Test.Hspec+import Data.Data+import Database.HDBC+import Database.HDBC.Sqlite3+import Database.GP.GenericPersistence+import RIO ++-- `test` is here so that this module can be run from GHCi on its own. It is+-- not needed for automatic spec discovery. (start up stack repl --test to bring up ghci and have access to all the test functions)+test :: IO ()+test = hspec spec++withDatabase :: RIO Ctx a -> IO a+withDatabase action = do+ conn <- connectSqlite3 ":memory:"+ runGP conn $ do+ _ <- setupTableFor :: GP Book+ action++data Book = Book+ { bookID :: Int,+ title :: String,+ author :: String,+ year :: Int,+ category :: BookCategory+ }+ deriving (Data, Show, Eq)++data BookCategory = Fiction | Travel | Arts | Science | History | Biography | Other+ deriving (Data, Show, Read, Eq, Enum)+ +instance Entity Book where+ fromRow row = return $ Book (col 0) (col 1) (col 2) (col 3) (col 4)+ where+ col i = fromSql (row !! i)++ toRow b = return [toSql (bookID b), toSql (title b), toSql (author b), toSql (year b), toSql (category b)]++-- instance Convertible BookCategory SqlValue where+-- safeConvert = Right . toSql . show+ +-- instance Convertible SqlValue BookCategory where+-- safeConvert = Right . read . fromSql++spec :: Spec+spec = do+ describe "Handling of Enum Fields" $ do+ it "works like a charm" $ + withDatabase $ do+ let book = Book 1 "The Hobbit" "J.R.R. Tolkien" 1937 Fiction+ insert book+ allBooks <- retrieveAll :: GP [Book]+ liftIO $ allBooks `shouldBe` [book]++
+ test/GenericPersistenceSpec.hs view
@@ -0,0 +1,184 @@+{-# LANGUAGE DeriveAnyClass #-} -- allows automatic derivation from Entity type class+module GenericPersistenceSpec+ ( test+ , spec+ , withDatabase+ ) where+++import Test.Hspec+import Data.Data +import Database.HDBC +import Database.HDBC.Sqlite3+import Database.GP.GenericPersistence+import RIO++++-- `test` is here so that this module can be run from GHCi on its own. It is+-- not needed for automatic spec discovery. (start up stack repl --test to bring up ghci and have access to all the test functions)+test :: IO ()+test = hspec spec++withDatabase :: RIO Ctx a -> IO a+withDatabase action = do+ conn <- connectSqlite3 ":memory:"+ let ctx = Ctx (ConnWrapper conn) mempty+ runRIO ctx $ do+ _ <- setupTableFor :: GP Person+ _ <- setupTableFor :: GP Book+ action++-- | A data type with several fields, using record syntax.+data Person = Person+ { personID :: Int,+ name :: String,+ age :: Int,+ address :: String+ }+ deriving (Data, Entity, Show, Eq)++data Book = Book+ { book_id :: Int,+ title :: String,+ author :: String,+ year :: Int,+ category :: BookCategory+ }+ deriving (Data, Show, Eq)++data BookCategory = Fiction | Travel | Arts | Science | History | Biography | Other+ deriving (Data, Read, Show, Eq, Enum)++ +instance Entity Book where+ idField _ = "book_id"+ fieldsToColumns _ = [("book_id", "bookId"), ("title", "bookTitle"), ("author", "bookAuthor"), ("year", "bookYear"), ("category", "bookCategory")]+ tableName _ = "BOOK_TBL"+ fromRow row = pure $ Book (col 0) (col 1) (col 2) (col 3) (col 4)+ where+ col i = fromSql (row !! i)++ toRow b = pure [toSql (book_id b), toSql (title b), toSql (author b), toSql (year b), toSql (category b)]+++person :: Person+person = Person 123456 "Alice" 25 "123 Main St"++book :: Book+book = Book 1 "The Hobbit" "J.R.R. Tolkien" 1937 Fiction+++spec :: Spec+spec = do+ describe "GenericPersistence" $ do+ it "retrieves Entities using Generics" $ + withDatabase $ do+ let bob = Person 1 "Bob" 36 "7 West Street"+ Ctx conn _ <- ask+ liftIO $ runRaw conn "INSERT INTO Person (personID, name, age, address) VALUES (1, \"Bob\", 36, \"7 West Street\");"+ liftIO $ runRaw conn "INSERT INTO Person (personID, name, age, address) VALUES (2, \"Alice\", 25, \"7 West Street\");"+ liftIO $ runRaw conn "INSERT INTO Person (personID, name, age, address) VALUES (3, \"Frank\", 56, \"7 West Street\");"+ allPersons <- retrieveAll :: GP [Person]+ liftIO $ length allPersons `shouldBe` 3+ liftIO $ head allPersons `shouldBe` bob+ person' <- retrieveById (1 :: Int) :: GP (Maybe Person)+ liftIO $ person' `shouldBe` Just bob+ it "retrieves Entities using user implementation" $+ withDatabase $ do+ let hobbit = Book 1 "The Hobbit" "J.R.R. Tolkien" 1937 Fiction+ Ctx conn _ <- ask+ liftIO $ runRaw conn "INSERT INTO BOOK_TBL (bookId, bookTitle, bookAuthor, bookYear, bookCategory) VALUES (1, \"The Hobbit\", \"J.R.R. Tolkien\", 1937, 0);"+ liftIO $ runRaw conn "INSERT INTO BOOK_TBL (bookId, bookTitle, bookAuthor, bookYear, bookCategory) VALUES (2, \"The Lord of the Rings\", \"J.R.R. Tolkien\", 1955, 0);"+ liftIO $ runRaw conn "INSERT INTO BOOK_TBL (bookId, bookTitle, bookAuthor, bookYear, bookCategory) VALUES (3, \"Smith of Wootton Major\", \"J.R.R. Tolkien\", 1967, 0);"+ allBooks <- retrieveAll :: GP [Book]+ liftIO $ length allBooks `shouldBe` 3+ liftIO $ head allBooks `shouldBe` hobbit+ book' <- retrieveById(1 :: Int) :: GP (Maybe Book)+ liftIO $ book' `shouldBe` Just hobbit+ + it "persists new Entities using Generics" $ + withDatabase $ do+ allPersons <- retrieveAll :: GP [Person]+ liftIO $ length allPersons `shouldBe` 0+ persist person+ allPersons' <- retrieveAll :: GP [Person]+ liftIO $ length allPersons' `shouldBe` 1+ person' <- retrieveById (123456 :: Int) :: GP (Maybe Person)+ liftIO $ person' `shouldBe` Just person+ it "persists new Entities using user implementation" $ + withDatabase $ do+ allbooks <- retrieveAll :: GP [Book]+ liftIO $ length allbooks `shouldBe` 0+ persist book+ allbooks' <- retrieveAll :: GP [Book]+ liftIO $ length allbooks' `shouldBe` 1+ book' <- retrieveById (1 :: Int) :: GP (Maybe Book) + liftIO $ book' `shouldBe` Just book+ it "persists existing Entities using Generics" $+ withDatabase $ do+ allPersons <- retrieveAll :: GP [Person]+ liftIO $ length allPersons `shouldBe` 0+ persist person+ allPersons' <- retrieveAll :: GP [Person]+ liftIO $ length allPersons' `shouldBe` 1+ persist person {age = 26}+ person' <- retrieveById (123456 :: Int) :: GP (Maybe Person)+ liftIO $ person' `shouldBe` Just person {age = 26}+ it "persists existing Entities using user implementation" $+ withDatabase $ do+ allbooks <- retrieveAll :: GP [Book]+ liftIO $ length allbooks `shouldBe` 0+ persist book+ allbooks' <- retrieveAll :: GP [Book]+ liftIO $ length allbooks' `shouldBe` 1+ persist book {year = 1938}+ book' <- retrieveById (1 :: Int) :: GP (Maybe Book)+ liftIO $ book' `shouldBe` Just book {year = 1938}+ it "inserts Entities using Generics" $+ withDatabase $ do+ allPersons <- retrieveAll :: GP [Person]+ liftIO $ length allPersons `shouldBe` 0+ insert person+ allPersons' <- retrieveAll :: GP [Person]+ liftIO $ length allPersons' `shouldBe` 1+ person' <- retrieveById (123456 :: Int) :: GP (Maybe Person)+ liftIO $ person' `shouldBe` Just person+ it "inserts Entities using user implementation" $+ withDatabase $ do+ allbooks <- retrieveAll :: GP [Book]+ liftIO $ length allbooks `shouldBe` 0+ insert book+ allbooks' <- retrieveAll :: GP [Book]+ liftIO $ length allbooks' `shouldBe` 1+ book' <- retrieveById (1 :: Int) :: GP (Maybe Book)+ liftIO $ book' `shouldBe` Just book+ it "updates Entities using Generics" $+ withDatabase $ do+ insert person+ update person {name = "Bob"}+ person' <- retrieveById (123456 :: Int) :: GP (Maybe Person)+ liftIO $ person' `shouldBe` Just person {name = "Bob"}+ it "updates Entities using user implementation" $+ withDatabase $ do+ insert book+ update book {title = "The Lord of the Rings"}+ book' <- retrieveById (1 :: Int) :: GP (Maybe Book)+ liftIO $ book' `shouldBe` Just book {title = "The Lord of the Rings"}+ it "deletes Entities using Generics" $+ withDatabase $ do+ insert person+ allPersons <- retrieveAll :: GP [Person]+ liftIO $ length allPersons `shouldBe` 1+ delete person+ allPersons' <- retrieveAll :: GP [Person]+ liftIO $ length allPersons' `shouldBe` 0 + it "deletes Entities using user implementation" $+ withDatabase $ do+ insert book+ allBooks <- retrieveAll :: GP [Book]+ liftIO $ length allBooks `shouldBe` 1+ delete book+ allBooks' <- retrieveAll :: GP [Book]+ liftIO $ length allBooks' `shouldBe` 0+
+ test/OneToManySpec.hs view
@@ -0,0 +1,140 @@+{-# LANGUAGE DeriveAnyClass #-} -- allows automatic derivation from Entity type class+module OneToManySpec+ ( test+ , spec+ ) where++import Test.Hspec+import Data.Data+import Database.HDBC+import Database.HDBC.Sqlite3+import Database.GP.GenericPersistence+import RIO +import Data.Maybe (fromJust)++-- `test` is here so that this module can be run from GHCi on its own. It is+-- not needed for automatic spec discovery. (start up stack repl --test to bring up ghci and have access to all the test functions)+test :: IO ()+test = hspec spec++withDatabase :: RIO Ctx a -> IO a+withDatabase action = do+ conn <- connectSqlite3 ":memory:"+ runGP conn $ do+ _ <- setupTableFor :: GP Article+ _ <- setupTableFor :: GP Author+ action++data Article = Article+ { articleID :: Int,+ title :: String,+ author :: Author,+ year :: Int+ }+ deriving (Data, Show, Eq)++data Author = Author+ { authorID :: Int,+ name :: String,+ address :: String,+ articles :: [Article]+ }+ deriving (Data, Show, Eq) ++instance Entity Article where+ fieldsToColumns :: Article -> [(String, String)]+ fieldsToColumns _ = [("articleID", "articleID"),+ ("title", "title"), + ("authorID", "authorID"),+ ("year", "year")+ ]++ fromRow :: [SqlValue] -> GP Article+ fromRow row = local (extendCtxCache rawArticle) $ do+ maybeAuthor <- getElseRetrieve (entityId rawAuthor)+ let author = fromJust maybeAuthor+ pure $ Article (col 0) (col 1) author (col 3)+ where+ col i = fromSql (row !! i)+ rawAuthor = (evidence :: Author) {authorID = col 2}+ rawArticle = Article (col 0) (col 1) rawAuthor (col 3)+ + toRow a = do + persist (author a)+ return [toSql (articleID a), toSql (title a), toSql $ authorID (author a), toSql (year a)]+++instance Entity Author where+ fieldsToColumns :: Author -> [(String, String)]+ fieldsToColumns _ = [("authorID", "authorID"),+ ("name", "name"), + ("address", "address")+ ]++ fromRow :: [SqlValue] -> GP Author+ fromRow row = local (extendCtxCache rawAuthor) $ do+ articlesByAuth <- retrieveAllWhere (idField rawAuthor) (idValue rawAuthor) :: GP [Article]+ pure $ rawAuthor {articles= articlesByAuth}+ where+ col i = fromSql (row !! i)+ rawAuthor = Author (col 0) (col 1) (col 2) []++ toRow :: Author -> GP [SqlValue]+ toRow a = do + return [toSql (authorID a), toSql (name a), toSql (address a)]++article1 :: Article+article1 = Article + { articleID = 1, + title = "Persistence without Boilerplate", + author = Author + {authorID = 1, + name = "Max Millian", + address = "Boston",+ articles = []}, + year = 2018}++article2 :: Article+article2 = Article + { articleID = 2, + title = "Boilerplate for Dummies", + author = arthur, + year = 2020}++article3 :: Article+article3 = Article + { articleID = 3, + title = "The return of the boilerplate", + author = arthur, + year = 2022}++arthur :: Author+arthur = Author + {authorID = 2, + name = "Arthur Miller", + address = "Denver", + articles = [article2, article3]} ++spec :: Spec+spec = do+ describe "Handling of 1:N References" $ do+ it "works like a charm" $ + withDatabase $ do+ insert article1+ insert article2+ insert article3++ authors <- retrieveAll :: GP [Author]+ liftIO $ length authors `shouldBe` 2+ --liftIO $ print authors+ articles <- retrieveAll :: GP [Article]+ liftIO $ length articles `shouldBe` 3+ + article' <- retrieveById "3" :: GP (Maybe Article)+ let art = fromJust article'+ liftIO $ (name (author art)) `shouldBe` "Arthur Miller" + + +++
+ test/ReferenceSpec.hs view
@@ -0,0 +1,89 @@+{-# LANGUAGE DeriveAnyClass #-} -- allows automatic derivation from Entity type class+module ReferenceSpec+ ( test+ , spec+ ) where++import Test.Hspec+import Data.Data+import Database.HDBC+import Database.HDBC.Sqlite3+import Database.GP.GenericPersistence+import RIO ++-- `test` is here so that this module can be run from GHCi on its own. It is+-- not needed for automatic spec discovery. (start up stack repl --test to bring up ghci and have access to all the test functions)+test :: IO ()+test = hspec spec++withDatabase :: RIO Ctx a -> IO a+withDatabase action = do+ conn <- connectSqlite3 ":memory:"+ runGP conn $ do+ _ <- setupTableFor :: GP Article+ _ <- setupTableFor :: GP Author+ action++data Article = Article+ { articleID :: Int,+ title :: String,+ author :: Author,+ year :: Int+ }+ deriving (Data, Show, Eq)++data Author = Author+ { authorID :: Int,+ name :: String,+ address :: String+ }+ deriving (Data, Entity, Show, Eq) ++instance Entity Article where+ fieldsToColumns :: Article -> [(String, String)]+ fieldsToColumns _ = [("articleID", "articleID"),+ ("title", "title"), + ("authorID", "authorID"),+ ("year", "year")+ ]++ fromRow row = do+ maybeAuthor <- retrieveById (row !! 2) :: GP (Maybe Author)+ let author = fromMaybe (error "Author not found") maybeAuthor+ pure $ Article (col 0) (col 1) author (col 3)+ where+ col i = fromSql (row !! i)+ + toRow a = do + persist (author a)+ return [toSql (articleID a), toSql (title a), toSql $ authorID (author a), toSql (year a)]++article :: Article+article = Article + { articleID = 1, + title = "Persistence without Boilerplate", + author = arthur, + year = 2018}+ +arthur :: Author+arthur = Author + {authorID = 2, + name = "Arthur Miller", + address = "Denver"} ++spec :: Spec+spec = do+ describe "Handling of 1:1 References" $ do+ it "works like a charm" $ + withDatabase $ do+ insert article++ author' <- retrieveById "2" :: GP (Maybe Author)+ liftIO $ author' `shouldBe` Just arthur+ + article' <- retrieveById "1" :: GP (Maybe Article)+ liftIO $ article' `shouldBe` Just article+ +++
+ test/Spec.hs view
@@ -0,0 +1,2 @@+{-# OPTIONS_GHC -Wno-missing-export-lists #-} -- Avoid warning for missing export list in test/Spec.hs+{-# OPTIONS_GHC -F -pgmF hspec-discover #-} -- this compiler pragma allows GHC to automatically discover all Hspec Test Specs.