packages feed

beam-duckdb-0.1.0.0: tests/Database/Beam/DuckDB/Test/Query.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE ExplicitForAll #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}

module Database.Beam.DuckDB.Test.Query (tests) where

import Control.Monad (void)
import Data.Int (Int32)
import Data.List (nubBy, sort, sortOn)
import Data.Text (Text)
import Database.Beam
  ( Beamable,
    Columnar,
    Database,
    DatabaseSettings,
    Generic,
    Identity,
    SqlEq ((==.)),
    SqlOrd ((>.)),
    SqlValable (val_),
    Table (..),
    TableEntity,
    all_,
    dbModification,
    defaultDbSettings,
    guard_,
    insert,
    insertValues,
    leftJoin_,
    modifyTableFields,
    related_,
    runInsert,
    runSelectReturningList,
    select,
    tableModification,
    withDbModification,
    (>=.),
  )
import Database.Beam.DuckDB (DuckDB, runBeamDuckDB)
import Database.DuckDB.Simple (Connection, execute_, withConnection)
import Hedgehog (Gen, annotate, evalIO, forAll, property, (===))
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.Hedgehog (testProperty)

tests :: TestTree
tests =
  testGroup
    "Query"
    [ testGroup
        "Selection"
        [ testSelectAll,
          testSelectWithFilter,
          testSelectEquality
        ],
      testGroup
        "Projection"
        [ testProjectSingleColumn,
          testProjectMultipleColumns,
          testProjectWithExpression
        ],
      testGroup
        "Join"
        [ testInnerJoin,
          testMultiInnerJoin,
          testMixingJoinsWithFilters,
          testLeftJoin
        ]
    ]

testSelectAll :: TestTree
testSelectAll = testProperty "selecting all users should return the users initially inserted" $ property $ do
  users <- forAll genUsers
  results <- evalIO $
    withTestDb users [] [] $ \conn ->
      runBeamDuckDB conn $
        runSelectReturningList $
          select (all_ (_dbUsers testDb))
  sortOn _userId results === sortOn _userId users

testSelectWithFilter :: TestTree
testSelectWithFilter = testProperty "selecting users satisfying a condition works as expected" $ property $ do
  users <- forAll genUsers
  ageThreshold <- forAll genAge
  results <- evalIO $
    withTestDb users [] [] $ \conn ->
      runBeamDuckDB conn $
        runSelectReturningList $
          select $ do
            u <- all_ (_dbUsers testDb)
            guard_ (_userAge u >. val_ ageThreshold)
            pure u
  let expected = filter (\u -> _userAge u > ageThreshold) users
  sortOn _userId results === sortOn _userId expected

testSelectEquality :: TestTree
testSelectEquality = testProperty "" $ property $ do
  users <- forAll genUsers
  target <- forAll (Gen.element users)
  results <- evalIO $
    withTestDb users [] [] $ \conn ->
      runBeamDuckDB conn $
        runSelectReturningList $
          select $ do
            u <- all_ (_dbUsers testDb)
            guard_ (_userId u ==. val_ (_userId target))
            pure u
  results === [target]

testProjectSingleColumn :: TestTree
testProjectSingleColumn = testProperty "Single column projection works as expected" $ property $ do
  users <- forAll genUsers
  results <- evalIO $
    withTestDb users [] [] $ \conn ->
      runBeamDuckDB conn $
        runSelectReturningList $
          select $ do
            u <- all_ (_dbUsers testDb)
            pure (_userName u)
  sort results === sort (map _userName users)

testProjectMultipleColumns :: TestTree
testProjectMultipleColumns = testProperty "Multiple column projection works as expected" $ property $ do
  users <- forAll genUsers
  results <- evalIO $
    withTestDb users [] [] $ \conn ->
      runBeamDuckDB conn $
        runSelectReturningList $
          select $ do
            u <- all_ (_dbUsers testDb)
            pure (_userName u, _userAge u)
  let expected = map (\u -> (_userName u, _userAge u)) users
  sort results === sort expected

testProjectWithExpression :: TestTree
testProjectWithExpression = testProperty "Projection using an expression works as expected" $ property $ do
  products <- forAll genProducts
  results <- evalIO $
    withTestDb [] products [] $ \conn ->
      runBeamDuckDB conn $
        runSelectReturningList $
          select $ do
            p <- all_ (_dbProducts testDb)
            pure (_productId p, _productPrice p * val_ 2)
  let expected = map (\p -> (_productId p, _productPrice p * 2)) products
  sortOn fst results === sortOn fst expected

testInnerJoin :: TestTree
testInnerJoin = testProperty "Inner joins work as expected" $ property $ do
  users <- forAll genUsers
  products <- forAll genProducts
  orders <- forAll (genOrders users products)
  results <- evalIO $
    withTestDb users products orders $ \conn ->
      runBeamDuckDB conn $
        runSelectReturningList $
          select $ do
            o <- all_ (_dbOrders testDb)
            u <- related_ (_dbUsers testDb) (_orderUserId o)
            pure (_userName u, _orderQuantity o)

  let expected = do
        o <- orders
        let UserId uid = _orderUserId o
        u <- filter (\u -> _userId u == uid) users
        pure (_userName u, _orderQuantity o)
  sort results === sort expected

testMultiInnerJoin :: TestTree
testMultiInnerJoin = testProperty "Multi-inner joins work as expected" $ property $ do
  users <- forAll genUsers
  products <- forAll genProducts
  orders <- forAll (genOrders users products)
  results <- evalIO $
    withTestDb users products orders $ \conn ->
      runBeamDuckDB conn $
        runSelectReturningList $
          select $ do
            o <- all_ (_dbOrders testDb)
            u <- related_ (_dbUsers testDb) (_orderUserId o)
            p <- related_ (_dbProducts testDb) (_orderProductId o)
            pure (_userName u, _productName p, _orderQuantity o)
  let expected = do
        o <- orders
        let UserId uid = _orderUserId o
            ProductId pid = _orderProductId o
        u <- filter (\u -> _userId u == uid) users
        p <- filter (\p -> _productId p == pid) products
        pure (_userName u, _productName p, _orderQuantity o)
  sort results === sort expected

testMixingJoinsWithFilters :: TestTree
testMixingJoinsWithFilters = testProperty "Mixing joins and filters works as expected" $ property $ do
  users <- forAll genUsers
  products <- forAll genProducts
  orders <- forAll (genOrders users products)
  minQty <- forAll (Gen.int32 (Range.linear 1 50))
  results <- evalIO $
    withTestDb users products orders $ \conn ->
      runBeamDuckDB conn $
        runSelectReturningList $
          select $ do
            o <- all_ (_dbOrders testDb)
            guard_ (_orderQuantity o >=. val_ minQty)
            u <- related_ (_dbUsers testDb) (_orderUserId o)
            pure (_userName u, _orderQuantity o)
  let expected = do
        o <- filter (\o -> _orderQuantity o >= minQty) orders
        let UserId uid = _orderUserId o
        u <- filter (\u -> _userId u == uid) users
        pure (_userName u, _orderQuantity o)
  sort results === sort expected

testLeftJoin :: TestTree
testLeftJoin = testProperty "Left joins work as expected" $ property $ do
  users <- forAll genUsers
  products <- forAll genProducts
  -- Generate orders for only a subset of users
  let halfUsers = take (length users `div` 2) users
  orders <- forAll (genOrders halfUsers products)
  results <- evalIO $
    withTestDb users products orders $ \conn ->
      runBeamDuckDB conn $
        runSelectReturningList $
          select $ do
            u <- all_ (_dbUsers testDb)
            o <-
              leftJoin_
                (all_ (_dbOrders testDb))
                (\o -> _orderUserId o ==. primaryKey u)
            pure (_userId u, _orderQuantity o)

  let resultUserIds = nubBy (\a b -> fst a == fst b) (sortOn fst results)
  length resultUserIds === length users

  let usersWithoutOrders = filter (\u -> _userId u `notElem` map _userId halfUsers) users
      nothingRows = filter (\(uid, _) -> uid `elem` map _userId usersWithoutOrders) results
  annotate "Users without orders should have Nothing quantity"
  mapM_ (\(_, mq) -> mq === Nothing) nothingRows

data UserT f = User
  { _userId :: Columnar f Int32,
    _userName :: Columnar f Text,
    _userAge :: Columnar f Int32
  }
  deriving (Generic)

type User = UserT Identity

type UserId = PrimaryKey UserT Identity

deriving instance Show UserId

deriving instance Eq UserId

deriving instance Ord UserId

deriving instance Show User

deriving instance Eq User

deriving instance Ord User

instance Beamable UserT

instance Table UserT where
  data PrimaryKey UserT f = UserId (Columnar f Int32)
    deriving (Generic)
  primaryKey = UserId . _userId

instance Beamable (PrimaryKey UserT)

data ProductT f = Product
  { _productId :: Columnar f Int32,
    _productName :: Columnar f Text,
    _productPrice :: Columnar f Int32 -- cents, to avoid floating point
  }
  deriving (Generic)

type Product = ProductT Identity

type ProductId = PrimaryKey ProductT Identity

deriving instance Show ProductId

deriving instance Eq ProductId

deriving instance Ord ProductId

deriving instance Show Product

deriving instance Eq Product

deriving instance Ord Product

instance Beamable ProductT

instance Table ProductT where
  data PrimaryKey ProductT f = ProductId (Columnar f Int32)
    deriving (Generic)
  primaryKey = ProductId . _productId

instance Beamable (PrimaryKey ProductT)

data OrderT f = Order
  { _orderId :: Columnar f Int32,
    _orderUserId :: PrimaryKey UserT f,
    _orderProductId :: PrimaryKey ProductT f,
    _orderQuantity :: Columnar f Int32
  }
  deriving (Generic)

type Order = OrderT Identity

type OrderId = PrimaryKey OrderT Identity

deriving instance Show OrderId

deriving instance Eq OrderId

deriving instance Ord OrderId

deriving instance Show Order

deriving instance Eq Order

deriving instance Ord Order

instance Beamable OrderT

instance Table OrderT where
  data PrimaryKey OrderT f = OrderId (Columnar f Int32)
    deriving (Generic)
  primaryKey = OrderId . _orderId

instance Beamable (PrimaryKey OrderT)

data TestDB f = TestDB
  { _dbUsers :: f (TableEntity UserT),
    _dbProducts :: f (TableEntity ProductT),
    _dbOrders :: f (TableEntity OrderT)
  }
  deriving (Generic, Database be)

testDb :: DatabaseSettings DuckDB TestDB
testDb =
  defaultDbSettings
    `withDbModification` dbModification
      { _dbUsers =
          modifyTableFields
            tableModification
              { _userId = "id",
                _userName = "name",
                _userAge = "age"
              },
        _dbProducts =
          modifyTableFields
            tableModification
              { _productId = "id",
                _productName = "name",
                _productPrice = "price"
              },
        _dbOrders =
          modifyTableFields
            tableModification
              { _orderId = "id",
                _orderUserId = UserId "user_id",
                _orderProductId = ProductId "product_id",
                _orderQuantity = "quantity"
              }
      }

genName :: Gen Text
genName = Gen.text (Range.linear 1 50) Gen.alphaNum

genAge :: Gen Int32
genAge = Gen.int32 (Range.linear 1 120)

genPrice :: Gen Int32
genPrice = Gen.int32 (Range.linear 100 100000)

genQuantity :: Gen Int32
genQuantity = Gen.int32 (Range.linear 1 100)

genUsers :: Gen [User]
genUsers = do
  n <- Gen.int (Range.linear 3 20)
  traverse (\i -> User (fromIntegral i) <$> genName <*> genAge) [1 .. n]

genProducts :: Gen [Product]
genProducts = do
  n <- Gen.int (Range.linear 2 10)
  traverse (\i -> Product (fromIntegral i) <$> genName <*> genPrice) [1 .. n]

genOrders :: [User] -> [Product] -> Gen [Order]
genOrders users products = do
  n <- Gen.int (Range.linear 1 (length users * length products))
  traverse
    ( \i -> do
        uid <- Gen.element (map _userId users)
        pid <- Gen.element (map _productId products)
        Order (fromIntegral i) (UserId uid) (ProductId pid) <$> genQuantity
    )
    [1 .. n]

createTables :: Connection -> IO ()
createTables conn = do
  void $
    execute_
      conn
      "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER NOT NULL)"
  void $
    execute_
      conn
      "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price INTEGER NOT NULL)"
  void $
    execute_
      conn
      "CREATE TABLE orders (\
      \  id INTEGER PRIMARY KEY, \
      \  user_id INTEGER NOT NULL REFERENCES users(id), \
      \  product_id INTEGER NOT NULL REFERENCES products(id), \
      \  quantity INTEGER NOT NULL)"

seedData :: Connection -> [User] -> [Product] -> [Order] -> IO ()
seedData conn users products orders = runBeamDuckDB conn $ do
  runInsert $ insert (_dbUsers testDb) $ insertValues users
  runInsert $ insert (_dbProducts testDb) $ insertValues products
  runInsert $ insert (_dbOrders testDb) $ insertValues orders

-- Run a test with a fresh in-memory DB populated with the given data
withTestDb ::
  [User] ->
  [Product] ->
  [Order] ->
  (Connection -> IO a) ->
  IO a
withTestDb users products orders action =
  withConnection ":memory:" $ \conn -> do
    createTables conn
    seedData conn users products orders
    action conn