{-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE ExistentialQuantification #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE TypeOperators #-} {-# LANGUAGE UndecidableInstances #-} {-# LANGUAGE OverloadedLabels #-} {-# OPTIONS_GHC -fplugin=Data.Record.Plugin #-} -- For lens derivation -- {-# LANGUAGE ImpredicativeTypes #-} {-# OPTIONS_GHC -Wno-missing-signatures -Wno-unused-top-binds #-} module Test.Record.Beam.Tutorial3 (tests) where import Prelude hiding (product) import Data.Int import Data.Kind import Data.Text (Text) import Data.Time import Database.Beam hiding (countAll_) import Database.Beam.Backend.SQL import Database.Beam.Sqlite import Optics.Core ((&), (^.), (.~), (%), coerced, Lens') import qualified Data.Text as T import qualified Database.SQLite.Simple as SQLite import qualified GHC.Generics as GHC import Test.Tasty import Test.Tasty.HUnit import Data.Record.Beam () import Test.Record.Beam.Tutorial1 hiding (tests) import Test.Record.Beam.Tutorial2 hiding (tests) import Test.Record.Beam.Util.Compat import Test.Record.Beam.Util.SQLite {------------------------------------------------------------------------------- New table: Product This does not introduce a new beam features. -------------------------------------------------------------------------------} {-# ANN type ProductT largeRecord #-} data ProductT (f :: Type -> Type) = Product { productId :: C f Int32 , productTitle :: C f Text , productDescription :: C f Text , productPrice :: C f Int32 {- Price in cents -} } deriving (Show, Eq) deriving anyclass (Beamable) instance Table ProductT where newtype PrimaryKey ProductT f = ProductId (Columnar f Int32) deriving stock (GHC.Generic) deriving anyclass (Beamable) primaryKey p = ProductId (p ^. #productId) deriving instance Show (Columnar f Int32) => Show (PrimaryKey ProductT f) deriving instance Eq (Columnar f Int32) => Eq (PrimaryKey ProductT f) type Product = ProductT Identity {------------------------------------------------------------------------------- New table: Order This introduces the use of 'Nullable' (as well as the use of a custom datatype for a column). -------------------------------------------------------------------------------} data ShippingCarrier = USPS | FedEx | UPS | DHL deriving (Show, Read, Eq, Ord, Enum) instance HasSqlValueSyntax be String => HasSqlValueSyntax be ShippingCarrier where sqlValueSyntax = autoSqlValueSyntax instance FromBackendRow Sqlite ShippingCarrier where fromBackendRow = read . T.unpack <$> fromBackendRow {-# ANN type OrderT largeRecord #-} data OrderT (f :: Type -> Type) = Order { orderId :: Columnar f Int32 , orderDate :: Columnar f LocalTime , orderForUser :: PrimaryKey UserT f , orderShipToAddress :: PrimaryKey AddressT f , orderShippingInfo :: PrimaryKey ShippingInfoT (Nullable f) } deriving (Show, Eq) deriving anyclass (Beamable) {-# ANN type ShippingInfoT largeRecord #-} data ShippingInfoT (f :: Type -> Type) = ShippingInfo { shippingInfoId :: Columnar f Int32 , shippingInfoCarrier :: Columnar f ShippingCarrier , shippingInfoTrackingNumber :: Columnar f Text } deriving (Show, Eq) deriving anyclass (Beamable) instance Table OrderT where newtype PrimaryKey OrderT f = OrderId (Columnar f Int32) deriving stock (GHC.Generic) deriving anyclass (Beamable) primaryKey o = OrderId (o ^. #orderId) instance Table ShippingInfoT where newtype PrimaryKey ShippingInfoT f = ShippingInfoId (Columnar f Int32) deriving stock (GHC.Generic) deriving anyclass (Beamable) primaryKey s = ShippingInfoId (s ^. #shippingInfoId) deriving instance Show (Columnar f Int32) => Show (PrimaryKey OrderT f) deriving instance Eq (Columnar f Int32) => Eq (PrimaryKey OrderT f) deriving instance Show (Columnar f Int32) => Show (PrimaryKey ShippingInfoT f) deriving instance Eq (Columnar f Int32) => Eq (PrimaryKey ShippingInfoT f) type Order = OrderT Identity type ShippingInfo = ShippingInfoT Identity {------------------------------------------------------------------------------- New table: 'LineItem' (many-to-many relation) -------------------------------------------------------------------------------} {-# ANN type LineItemT largeRecord #-} data LineItemT (f :: Type -> Type) = LineItem { lineItemInOrder :: PrimaryKey OrderT f , lineItemForProduct :: PrimaryKey ProductT f , lineItemQuantity :: Columnar f Int32 } deriving (Show, Eq) deriving anyclass (Beamable) type LineItem = LineItemT Identity instance Table LineItemT where data PrimaryKey LineItemT f = LineItemId (PrimaryKey OrderT f) (PrimaryKey ProductT f) deriving stock (GHC.Generic) deriving anyclass (Beamable) primaryKey l = LineItemId (l ^. #lineItemInOrder) (l ^. #lineItemForProduct) {------------------------------------------------------------------------------- Version 3 of the DB -------------------------------------------------------------------------------} {-# ANN type ShoppingCart3Db largeRecord #-} data ShoppingCart3Db (f :: Type -> Type) = ShoppingCart3Db { shoppingCart3Users :: f (TableEntity UserT) , shoppingCart3UserAddresses :: f (TableEntity AddressT) , shoppingCart3Products :: f (TableEntity ProductT) , shoppingCart3Orders :: f (TableEntity OrderT) , shoppingCart3ShippingInfos :: f (TableEntity ShippingInfoT) , shoppingCart3LineItems :: f (TableEntity LineItemT) } instance Database be ShoppingCart3Db {------------------------------------------------------------------------------- DB settings -------------------------------------------------------------------------------} shoppingCart3Db :: DatabaseSettings be ShoppingCart3Db shoppingCart3Db = withDbModification defaultDbSettings $ dbModification & #shoppingCart3UserAddresses .~ (setEntityName "addresses" <> modifyTableFields (tableModification & #addressLine1 .~ "address1" & #addressLine2 .~ "address2")) & #shoppingCart3Products .~ setEntityName "products" & #shoppingCart3Orders .~ (setEntityName "orders" <> modifyTableFields (tableModification & #orderShippingInfo .~ ShippingInfoId "shipping_info__id")) & #shoppingCart3ShippingInfos .~ (setEntityName "shipping_info" <> modifyTableFields (tableModification & #shippingInfoId .~ "id" & #shippingInfoCarrier .~ "carrier" & #shippingInfoTrackingNumber .~ "tracking_number")) & #shoppingCart3LineItems .~ setEntityName "line_items" --- | Lens from 'Order' to the primary key of the shipping info --- --- Note that nullability translates to 'Maybe'. shippingInfo :: Lens' Order (Maybe Int32) shippingInfo = #orderShippingInfo % coerced {------------------------------------------------------------------------------- Tests proper -------------------------------------------------------------------------------} tests :: TestTree tests = testGroup "Test.Record.Beam.Tutorial3" [ testCase "lensToNullableField" test_lensToNullableField , testCase "SQL" test_SQL ] test_lensToNullableField :: Assertion test_lensToNullableField = do now <- zonedTimeToLocalTime <$> getZonedTime assertEqual "Just" (Just 3) (exampleOrder1 now ^. shippingInfo) assertEqual "Nothing" Nothing (exampleOrder2 now ^. shippingInfo) assertEqual "set" (exampleOrder1 now) $ (exampleOrder2 now) & shippingInfo .~ Just 3 where exampleOrder1, exampleOrder2 :: LocalTime -> Order exampleOrder1 now = Order { orderId = 1 , orderDate = now , orderForUser = UserId "a@b.c" , orderShipToAddress = AddressId 1 , orderShippingInfo = ShippingInfoId (Just 3) } exampleOrder2 now = Order { orderId = 1 , orderDate = now , orderForUser = UserId "a@b.c" , orderShipToAddress = AddressId 1 , orderShippingInfo = ShippingInfoId Nothing } test_SQL :: Assertion test_SQL = runInMemory $ \conn -> do liftIO $ SQLite.execute_ conn $ "CREATE TABLE cart3_users (email VARCHAR NOT NULL, first_name VARCHAR NOT NULL, last_name VARCHAR NOT NULL, password VARCHAR NOT NULL, PRIMARY KEY( email ));" liftIO $ SQLite.execute_ conn $ "CREATE TABLE addresses ( id INTEGER PRIMARY KEY AUTOINCREMENT, address1 VARCHAR NOT NULL, address2 VARCHAR, city VARCHAR NOT NULL, state VARCHAR NOT NULL, zip VARCHAR NOT NULL, for_user__email VARCHAR NOT NULL );" liftIO $ SQLite.execute_ conn $ "CREATE TABLE products ( id INTEGER PRIMARY KEY AUTOINCREMENT, title VARCHAR NOT NULL, description VARCHAR NOT NULL, price INT NOT NULL );" liftIO $ SQLite.execute_ conn $ "CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TIMESTAMP NOT NULL, for_user__email VARCHAR NOT NULL, ship_to_address__id INT NOT NULL, shipping_info__id INT);" liftIO $ SQLite.execute_ conn $ "CREATE TABLE shipping_info ( id INTEGER PRIMARY KEY AUTOINCREMENT, carrier VARCHAR NOT NULL, tracking_number VARCHAR NOT NULL);" liftIO $ SQLite.execute_ conn $ "CREATE TABLE line_items (item_in_order__id INTEGER NOT NULL, item_for_product__id INTEGER NOT NULL, item_quantity INTEGER NOT NULL)" (jamesAddress1, bettyAddress1, _bettyAddress2, redBall, mathTextbook, introToHaskell, _suitcase) <- do runInsert $ insert (shoppingCart3Db ^. #shoppingCart3Users) $ insertValues users [jamesAddress1, bettyAddress1, bettyAddress2] <- runInsertReturningList $ insertReturning (shoppingCart3Db ^. #shoppingCart3UserAddresses) $ insertExpressions addresses [redBall, mathTextbook, introToHaskell, suitcase] <- runInsertReturningList $ insertReturning (shoppingCart3Db ^. #shoppingCart3Products) $ insertExpressions products pure ( jamesAddress1, bettyAddress1, bettyAddress2, redBall, mathTextbook, introToHaskell, suitcase ) -- Check autoincrement field liftIO $ assertEqual "jamesAddress1" addr1 jamesAddress1 -- Marshalling custom types bettyShippingInfo <- do [bettyShippingInfo] <- runInsertReturningList $ insertReturning (shoppingCart3Db ^. #shoppingCart3ShippingInfos) $ insertExpressions [ ShippingInfo default_ (val_ USPS) (val_ "12345790ABCDEFGHI") ] pure bettyShippingInfo liftIO $ assertEqual "bettyShippingInfo" info1 bettyShippingInfo -- Timestamps now <- liftIO $ zonedTimeToLocalTime <$> getZonedTime [jamesOrder1, bettyOrder1, jamesOrder2] <- runInsertReturningList $ insertReturning (shoppingCart3Db ^. #shoppingCart3Orders) $ insertExpressions [ Order default_ currentTimestamp_ (val_ (pk james)) (val_ (pk jamesAddress1)) nothing_ , Order default_ currentTimestamp_ (val_ (pk betty)) (val_ (pk bettyAddress1)) (just_ (val_ (pk bettyShippingInfo))) , Order default_ currentTimestamp_ (val_ (pk james)) (val_ (pk jamesAddress1)) nothing_ ] -- Less than one second should have passed in between us taking a timestamp -- and sqlite actually creating the row liftIO $ assertBool "timestamp" $ nominalDiffTimeToSeconds ((jamesOrder1 ^. #orderDate) `diffLocalTime` now) < 1 -- Create line items let lineItems :: [LineItem] lineItems = [ LineItem (pk jamesOrder1) (pk redBall) 10 , LineItem (pk jamesOrder1) (pk mathTextbook) 1 , LineItem (pk jamesOrder1) (pk introToHaskell) 4 , LineItem (pk bettyOrder1) (pk mathTextbook) 3 , LineItem (pk bettyOrder1) (pk introToHaskell) 3 , LineItem (pk jamesOrder2) (pk mathTextbook) 1 ] runInsert $ insert (shoppingCart3Db ^. #shoppingCart3LineItems) $ insertValues lineItems -- LEFT JOIN: Users and orders usersAndOrders <- runSelectReturningList $ select $ do user <- all_ (shoppingCart3Db ^. #shoppingCart3Users) order <- leftJoin_ (all_ (shoppingCart3Db ^. #shoppingCart3Orders)) (\order -> (order ^. #orderForUser) `references_` user) pure (user, order) let expectedUsersAndOrders :: [(User, Maybe Order)] expectedUsersAndOrders = [ (james, Just jamesOrder1) , (james, Just jamesOrder2) , (betty, Just bettyOrder1) , (sam, Nothing) ] liftIO $ assertEqual "usersAndOrders" expectedUsersAndOrders usersAndOrders -- LEFT JOIN: Users without any orders usersWithNoOrders <- runSelectReturningList $ select $ do user <- all_ (shoppingCart3Db ^. #shoppingCart3Users) order <- leftJoin_ (all_ (shoppingCart3Db ^. #shoppingCart3Orders)) (\order -> (order ^. #orderForUser) `references_` user) guard_ (isNothing_ order) pure user liftIO $ assertEqual "usersWithNoOrders" [sam] usersWithNoOrders -- Alternative way to get users with no orders usersWithNoOrders' <- runSelectReturningList $ select $ do user <- all_ (shoppingCart3Db ^. #shoppingCart3Users) guard_ (not_ (exists_ (filter_ (\order -> (order ^. #orderForUser) `references_` user) (all_ (shoppingCart3Db ^. #shoppingCart3Orders))))) pure user liftIO $ assertEqual "usersWithNoOrders'" [sam] usersWithNoOrders' -- Aggregates ordersWithCostOrdered <- runSelectReturningList $ select $ orderBy_ (\(_order, total) -> desc_ total) $ aggregate_ (\(order, lineItem, product) -> (group_ order, sum_ (lineItem ^. #lineItemQuantity * product ^. #productPrice))) $ do lineItem <- all_ (shoppingCart3Db ^. #shoppingCart3LineItems) order <- related_ (shoppingCart3Db ^. #shoppingCart3Orders) (lineItem ^. #lineItemInOrder) product <- related_ (shoppingCart3Db ^. #shoppingCart3Products) (lineItem ^. #lineItemForProduct) pure (order, lineItem, product) let totalJamesOrder1, totalJamesOrder2, totalBettyOrder1 :: Int32 totalJamesOrder1 = 10 * (redBall ^. #productPrice) + 1 * (mathTextbook ^. #productPrice) + 4 * (introToHaskell ^. #productPrice) totalJamesOrder2 = 1 * (mathTextbook ^. #productPrice) totalBettyOrder1 = 3 * (mathTextbook ^. #productPrice) + 3 * (introToHaskell ^. #productPrice) expectedOrdersWithCostOrdered :: [(Order, Maybe Int32)] expectedOrdersWithCostOrdered = [ (jamesOrder1, Just totalJamesOrder1) , (bettyOrder1, Just totalBettyOrder1) , (jamesOrder2, Just totalJamesOrder2) ] liftIO $ assertEqual "ordersWithCostOrdered" expectedOrdersWithCostOrdered ordersWithCostOrdered -- LEFT JOIN: Using 'maybe_' allUsersAndTotals <- runSelectReturningList $ select $ orderBy_ (\(_user, total) -> desc_ total) $ aggregate_ (\(user, lineItem, product) -> (group_ user, sum_ (maybe_ 0 id (lineItem ^. #lineItemQuantity) * maybe_ 0 id (product ^. #productPrice)))) $ do user <- all_ (shoppingCart3Db ^. #shoppingCart3Users) order <- leftJoin_ (all_ (shoppingCart3Db ^. #shoppingCart3Orders)) (\order -> (order ^. #orderForUser) `references_` user) lineItem <- leftJoin_ (all_ (shoppingCart3Db ^. #shoppingCart3LineItems)) (\lineItem -> maybe_ (val_ False) (\order' -> (lineItem ^. #lineItemInOrder) `references_` order') order) product <- leftJoin_ (all_ (shoppingCart3Db ^. #shoppingCart3Products)) (\product -> maybe_ (val_ False) (\lineItem' -> (lineItem' ^. #lineItemForProduct) `references_` product) lineItem) pure (user, lineItem, product) -- Bug in beam? Original tutorial (without LR) has same problem. -- let expectedAllUsersAndTotals :: [(User, Maybe Int32)] expectedAllUsersAndTotals = [ (betty, Just totalBettyOrder1) , (james, Just 0) -- TODO: Should be (totalJamesOrder1 + totalJamesOrder2)) , (sam, Just 0) ] liftIO $ assertEqual "allUsersAndTotals" expectedAllUsersAndTotals allUsersAndTotals -- LEFT JOIN: Using 'leftJoin_'' allUsersAndTotals2 <- runSelectReturningList $ select $ orderBy_ (\(_user, total) -> desc_ total) $ aggregate_ (\(user, lineItem, product) -> (group_ user, sum_ (maybe_ 0 id (lineItem ^. #lineItemQuantity) * maybe_ 0 id (product ^. #productPrice)))) $ do user <- all_ (shoppingCart3Db ^. #shoppingCart3Users) order <- leftJoin_ (all_ (shoppingCart3Db ^. #shoppingCart3Orders)) (\order -> (order ^. #orderForUser) `references_` user) lineItem <- leftJoin_' (all_ (shoppingCart3Db ^. #shoppingCart3LineItems)) (\lineItem -> just_ (lineItem ^. #lineItemInOrder) ==?. pk order) product <- leftJoin_' (all_ (shoppingCart3Db ^. #shoppingCart3Products)) (\product -> (lineItem ^. #lineItemForProduct) ==?. just_ (pk product)) pure (user, lineItem, product) let expectedAllUsersAndTotals2 :: [(User, Maybe Int32)] expectedAllUsersAndTotals2 = [ (james, Just (totalJamesOrder1 + totalJamesOrder2)) , (betty, Just totalBettyOrder1) , (sam, Just 0) ] liftIO $ assertEqual "allUsersAndTotals2" expectedAllUsersAndTotals2 allUsersAndTotals2 -- Dealing with nullable foreign keys allUnshippedOrders <- runSelectReturningList $ select $ filter_ (\info -> isNothing_ (info ^. #orderShippingInfo)) $ all_ (shoppingCart3Db ^. #shoppingCart3Orders) let expectedAllUnshippedOrders :: [Order] expectedAllUnshippedOrders = [jamesOrder1, jamesOrder2] liftIO $ assertEqual "allUnshippedOrders" expectedAllUnshippedOrders allUnshippedOrders -- .. in combination with aggregation shippingInformationByUser <- runSelectReturningList $ select $ aggregate_ (\(user, order) -> let ShippingInfoId siId = order ^. #orderShippingInfo in ( group_ user , as_ @Int32 $ count_ (as_ @(Maybe Int32) (maybe_ (just_ 1) (\_ -> nothing_) siId)) , as_ @Int32 $ count_ siId )) $ do user <- all_ (shoppingCart3Db ^. #shoppingCart3Users) order <- leftJoin_ (all_ (shoppingCart3Db ^. #shoppingCart3Orders)) (\order -> (order ^. #orderForUser) `references_` user) pure (user, order) let expectedShippingInformationByUser :: [(User, Int32, Int32)] expectedShippingInformationByUser = [ (betty, 0, 1) , (james, 2, 0) , (sam, 1, 0) -- Incorrect value (expected problem, mentioned in tutorial) ] liftIO $ assertEqual "shippingInformationByUser" expectedShippingInformationByUser shippingInformationByUser -- Using implicit subselects shippingInformationByUser' <- runSelectReturningList $ select $ do forUser <- all_ (shoppingCart3Db ^. #shoppingCart3Users) (email, unshippedCount) <- aggregate_ (\(email, _order) -> (group_ email, countAll_)) $ do user <- all_ (shoppingCart3Db ^. #shoppingCart3Users) order <- leftJoin_ (all_ (shoppingCart3Db ^. #shoppingCart3Orders)) (\order -> (order ^. #orderForUser) `references_` user &&. isNothing_ (order ^. #orderShippingInfo)) pure (pk user, order) guard_ (email `references_` forUser) (email', shippedCount) <- aggregate_ (\(email', _order) -> (group_ email', countAll_)) $ do user <- all_ (shoppingCart3Db ^. #shoppingCart3Users) order <- leftJoin_ (all_ (shoppingCart3Db ^. #shoppingCart3Orders)) (\order -> (order ^. #orderForUser) `references_` user &&. isJust_ (order ^. #orderShippingInfo)) pure (pk user, order) guard_ (email' `references_` forUser) pure (forUser, unshippedCount, shippedCount) -- TODO: These numbers make no sense -- let expectedShippingInformationByUser' :: [(User, Int32, Int32)] expectedShippingInformationByUser' = [ (betty, 1, 1) , (james, 2, 1) , (sam, 1, 1) ] liftIO $ assertEqual "shippingInformationByUser'" expectedShippingInformationByUser' shippingInformationByUser' -- Alternative formulation: using explicit subselects shippingInformationByUser'' <- runSelectReturningList $ select $ do forUser <- all_ (shoppingCart3Db ^. #shoppingCart3Users) (email, unshippedCount) <- subselect_ $ aggregate_ (\(email, _order) -> (group_ email, countAll_)) $ do user <- all_ (shoppingCart3Db ^. #shoppingCart3Users) order <- leftJoin_ (all_ (shoppingCart3Db ^. #shoppingCart3Orders)) (\order -> (order ^. #orderForUser) `references_` user &&. isNothing_ (order ^. #orderShippingInfo)) pure (pk user, order) guard_ (email `references_` forUser) (email', shippedCount) <- subselect_ $ aggregate_ (\(email', _order) -> (group_ email', countAll_)) $ do user <- all_ (shoppingCart3Db ^. #shoppingCart3Users) order <- leftJoin_ (all_ (shoppingCart3Db ^. #shoppingCart3Orders)) (\order -> (order ^. #orderForUser) `references_` user &&. isJust_ (order ^. #orderShippingInfo)) pure (pk user, order) guard_ (email' `references_` forUser) pure (forUser, unshippedCount, shippedCount) liftIO $ assertEqual "shippingInformationByUser''" expectedShippingInformationByUser' shippingInformationByUser'' where james, betty, sam :: User james = User "james@example.com" "James" "Smith" "b4cc344d25a2efe540adbf2678e2304c" betty = User "betty@example.com" "Betty" "Jones" "82b054bd83ffad9b6cf8bdb98ce3cc2f" sam = User "sam@example.com" "Sam" "Taylor" "332532dcfaa1cbf61e2a266bd723612c" users :: [User] users = [james, betty, sam] addresses :: [AddressT (QExpr Sqlite s)] addresses = [ Address default_ (val_ "123 Little Street") (val_ Nothing) (val_ "Boston") (val_ "MA") (val_ "12345") (val_ (pk james)) , Address default_ (val_ "222 Main Street") (val_ (Just "Ste 1")) (val_ "Houston") (val_ "TX") (val_ "8888") (val_ (pk betty)) , Address default_ (val_ "9999 Residence Ave") (val_ Nothing) (val_ "Sugarland") (val_ "TX") (val_ "8989") (val_ (pk betty)) ] products :: [ProductT (QExpr Sqlite s)] products = [ Product default_ (val_ "Red Ball") (val_ "A bright red, very spherical ball") (val_ 1000) , Product default_ (val_ "Math Textbook") (val_ "Contains a lot of important math theorems and formulae") (val_ 2500) , Product default_ (val_ "Intro to Haskell") (val_ "Learn the best programming language in the world") (val_ 3000) , Product default_ (val_ "Suitcase") "A hard durable suitcase" 15000 ] addr1 :: Address addr1 = Address 1 "123 Little Street" Nothing "Boston" "MA" "12345" (pk james) info1 :: ShippingInfo info1 = ShippingInfo 1 USPS "12345790ABCDEFGHI"