From b6622c0a01c21535b5210f61ed4cfa7644eb5a76 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Sat, 30 May 2026 07:51:31 -0400 Subject: [PATCH] feat: add raw SQL execution helpers (execute, executeWith, query_, queryWith) --- orville-sqlite.cabal | 3 ++ src/Orville/SQLite.hs | 3 ++ src/Orville/SQLite/Execution.hs | 22 +--------- src/Orville/SQLite/Internal.hs | 28 +++++++++++++ src/Orville/SQLite/Raw.hs | 71 +++++++++++++++++++++++++++++++++ test/Main.hs | 2 + test/Test/AutoMigration.hs | 2 +- test/Test/Raw.hs | 48 ++++++++++++++++++++++ 8 files changed, 158 insertions(+), 21 deletions(-) create mode 100644 src/Orville/SQLite/Internal.hs create mode 100644 src/Orville/SQLite/Raw.hs create mode 100644 test/Test/Raw.hs diff --git a/orville-sqlite.cabal b/orville-sqlite.cabal index f77047b..b5155b0 100644 --- a/orville-sqlite.cabal +++ b/orville-sqlite.cabal @@ -19,7 +19,9 @@ library Orville.SQLite.AutoMigration Orville.SQLite.Execution Orville.SQLite.FieldDefinition + Orville.SQLite.Internal Orville.SQLite.Monad + Orville.SQLite.Raw Orville.SQLite.RawSql Orville.SQLite.SqlMarshaller Orville.SQLite.SqlType @@ -42,6 +44,7 @@ test-suite spec Test.AutoMigration Test.EntityOperations Test.FieldDefinition + Test.Raw Test.SqlMarshaller hs-source-dirs: test build-depends: diff --git a/src/Orville/SQLite.hs b/src/Orville/SQLite.hs index bbf7826..ace7f56 100644 --- a/src/Orville/SQLite.hs +++ b/src/Orville/SQLite.hs @@ -13,12 +13,15 @@ module Orville.SQLite , module Orville.SQLite.AutoMigration -- * Execution , module Orville.SQLite.Execution + -- * Raw + , module Orville.SQLite.Raw ) where import Orville.SQLite.AutoMigration import Orville.SQLite.Execution import Orville.SQLite.FieldDefinition import Orville.SQLite.Monad +import Orville.SQLite.Raw import Orville.SQLite.SqlMarshaller import Orville.SQLite.SqlType import Orville.SQLite.TableDefinition diff --git a/src/Orville/SQLite/Execution.hs b/src/Orville/SQLite/Execution.hs index d59a089..b52df94 100644 --- a/src/Orville/SQLite/Execution.hs +++ b/src/Orville/SQLite/Execution.hs @@ -15,8 +15,8 @@ import Control.Monad.Reader (ask) import Data.List (intercalate) import qualified Data.Text as T import qualified Database.SQLite3 as SQLite3 -import Database.SQLite3.Direct (columnCount) import Orville.SQLite.FieldDefinition (fieldColumnName, fieldToSqlValue) +import Orville.SQLite.Internal (getRowData) import Orville.SQLite.Monad (OrvilleM) import Orville.SQLite.SqlMarshaller ( marshallerDecodeRow, @@ -148,22 +148,4 @@ deleteEntity tableDef key = do _ <- SQLite3.step stmt SQLite3.finalize stmt -getRowData :: - SQLite3.Statement -> - [String] -> - IO [(String, SQLite3.SQLData)] -getRowData stmt cols = do - colCount <- columnCount stmt - let count :: Int = fromIntegral colCount - indexes = take count [0 :: SQLite3.ColumnIndex ..] - mapM - ( \i -> do - let idx :: Int = fromIntegral i - colName = - if idx < length cols - then cols !! idx - else "" - sqlVal <- SQLite3.column stmt i - pure (colName, sqlVal) - ) - indexes + diff --git a/src/Orville/SQLite/Internal.hs b/src/Orville/SQLite/Internal.hs new file mode 100644 index 0000000..3959354 --- /dev/null +++ b/src/Orville/SQLite/Internal.hs @@ -0,0 +1,28 @@ +{-# LANGUAGE ScopedTypeVariables #-} + +module Orville.SQLite.Internal ( + getRowData, +) where + +import qualified Database.SQLite3 as SQLite3 +import Database.SQLite3.Direct (columnCount) + +getRowData :: + SQLite3.Statement -> + [String] -> + IO [(String, SQLite3.SQLData)] +getRowData stmt cols = do + colCount <- columnCount stmt + let count :: Int = fromIntegral colCount + indexes = take count [0 :: SQLite3.ColumnIndex ..] + mapM + ( \i -> do + let idx :: Int = fromIntegral i + colName = + if idx < length cols + then cols !! idx + else "" + sqlVal <- SQLite3.column stmt i + pure (colName, sqlVal) + ) + indexes diff --git a/src/Orville/SQLite/Raw.hs b/src/Orville/SQLite/Raw.hs new file mode 100644 index 0000000..c86bbbe --- /dev/null +++ b/src/Orville/SQLite/Raw.hs @@ -0,0 +1,71 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} + +module Orville.SQLite.Raw ( + execute, + executeWith, + query_, + queryWith, +) where + +import Control.Monad.IO.Class (liftIO) +import Control.Monad.Reader (ask) +import qualified Data.Text as T +import qualified Database.SQLite3 as SQLite3 + +import Orville.SQLite.Internal (getRowData) +import Orville.SQLite.Monad (OrvilleM) + +-- | Execute a raw SQL statement with no parameters. +execute :: T.Text -> OrvilleM () +execute sql = do + db <- ask + liftIO $ do + stmt <- SQLite3.prepare db sql + _ <- SQLite3.step stmt + SQLite3.finalize stmt + +-- | Execute a raw SQL statement with bound parameters. +executeWith :: T.Text -> [SQLite3.SQLData] -> OrvilleM () +executeWith sql params = do + db <- ask + liftIO $ do + stmt <- SQLite3.prepare db sql + SQLite3.bind stmt params + _ <- SQLite3.step stmt + SQLite3.finalize stmt + +-- | Execute a raw SQL query with no parameters and collect all rows. +query_ :: T.Text -> OrvilleM [[(String, SQLite3.SQLData)]] +query_ sql = do + db <- ask + liftIO $ do + stmt <- SQLite3.prepare db sql + let loop acc = do + stepResult <- SQLite3.step stmt + case stepResult of + SQLite3.Done -> do + SQLite3.finalize stmt + pure (reverse acc) + SQLite3.Row -> do + rowData <- getRowData stmt [] + loop (rowData : acc) + loop [] + +-- | Execute a raw SQL query with bound parameters and collect all rows. +queryWith :: T.Text -> [SQLite3.SQLData] -> OrvilleM [[(String, SQLite3.SQLData)]] +queryWith sql params = do + db <- ask + liftIO $ do + stmt <- SQLite3.prepare db sql + SQLite3.bind stmt params + let loop acc = do + stepResult <- SQLite3.step stmt + case stepResult of + SQLite3.Done -> do + SQLite3.finalize stmt + pure (reverse acc) + SQLite3.Row -> do + rowData <- getRowData stmt [] + loop (rowData : acc) + loop [] diff --git a/test/Main.hs b/test/Main.hs index 8115f16..2094abd 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -7,6 +7,7 @@ import Test.Hspec import qualified Test.AutoMigration as AutoMigration import qualified Test.EntityOperations as EntityOperations import qualified Test.FieldDefinition as FieldDefinition +import qualified Test.Raw as Raw import qualified Test.SqlMarshaller as SqlMarshaller main :: IO () @@ -15,3 +16,4 @@ main = hspec $ do describe "SqlMarshaller" SqlMarshaller.sqlMarshallerTests describe "EntityOperations" EntityOperations.entityOperationsTests describe "AutoMigration" AutoMigration.autoMigrationTests + describe "Raw" Raw.rawTests diff --git a/test/Test/AutoMigration.hs b/test/Test/AutoMigration.hs index 07728e5..35c60de 100644 --- a/test/Test/AutoMigration.hs +++ b/test/Test/AutoMigration.hs @@ -23,7 +23,7 @@ autoMigrationTests = do pure () closeConnection db - it "is idempotent — running twice does not error" $ do + it "is idempotent -- running twice does not error" $ do db <- openConnection ":memory:" withConnection db $ do autoMigrateSchema defaultOptions [schemaTable personTable []] diff --git a/test/Test/Raw.hs b/test/Test/Raw.hs new file mode 100644 index 0000000..f722b92 --- /dev/null +++ b/test/Test/Raw.hs @@ -0,0 +1,48 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Test.Raw where + +import qualified Database.SQLite3 as SQLite3 +import Test.Hspec + +import Orville.SQLite + +rawTests :: Spec +rawTests = do + describe "execute" $ do + it "runs a CREATE TABLE and INSERT statement" $ do + db <- openConnection ":memory:" + runOrvilleM db $ execute "CREATE TABLE raw_test (id INTEGER, name TEXT)" + runOrvilleM db $ execute "INSERT INTO raw_test VALUES (1, 'hello')" + rows <- runOrvilleM db $ query_ "SELECT * FROM raw_test" + length rows `shouldBe` 1 + closeConnection db + + describe "executeWith" $ do + it "runs parameterized INSERT" $ do + db <- openConnection ":memory:" + runOrvilleM db $ execute "CREATE TABLE raw_test2 (id INTEGER, name TEXT)" + runOrvilleM db $ executeWith "INSERT INTO raw_test2 VALUES (?, ?)" [SQLite3.SQLInteger 42, SQLite3.SQLText "world"] + rows <- runOrvilleM db $ query_ "SELECT * FROM raw_test2" + length rows `shouldBe` 1 + closeConnection db + + describe "query_" $ do + it "returns rows from a SELECT" $ do + db <- openConnection ":memory:" + runOrvilleM db $ execute "CREATE TABLE raw_test3 (a INTEGER)" + runOrvilleM db $ execute "INSERT INTO raw_test3 VALUES (1)" + runOrvilleM db $ execute "INSERT INTO raw_test3 VALUES (2)" + rows <- runOrvilleM db $ query_ "SELECT * FROM raw_test3 ORDER BY a" + length rows `shouldBe` 2 + closeConnection db + + describe "queryWith" $ do + it "returns rows matching parameters" $ do + db <- openConnection ":memory:" + runOrvilleM db $ execute "CREATE TABLE raw_test4 (name TEXT)" + runOrvilleM db $ executeWith "INSERT INTO raw_test4 VALUES (?)" [SQLite3.SQLText "alpha"] + runOrvilleM db $ executeWith "INSERT INTO raw_test4 VALUES (?)" [SQLite3.SQLText "beta"] + rows <- runOrvilleM db $ queryWith "SELECT * FROM raw_test4 WHERE name = ?" [SQLite3.SQLText "beta"] + length rows `shouldBe` 1 + closeConnection db