Compare commits

...

5 Commits

Author SHA1 Message Date
jbrechtel e995844bc5 Add -Werror, fix unused import warnings
Build and Test / build-and-test (push) Successful in 4m51s
- Add -Werror to ghc-options for both library and test suite
- Remove unused Data.Maybe import from Internal.hs
- Remove unused Data.Int and Data.Text imports from test files
2026-05-30 09:57:14 -04:00
jbrechtel 469527f03c fix: use column names from statement metadata in getRowData
Build and Test / build-and-test (push) Failing after 3m34s
2026-05-30 09:26:36 -04:00
jbrechtel b6622c0a01 feat: add raw SQL execution helpers (execute, executeWith, query_, queryWith)
Build and Test / build-and-test (push) Successful in 4m46s
2026-05-30 07:51:31 -04:00
jbrechtel dfc231604b Update README with usage examples, module overview, and scope
Build and Test / build-and-test (push) Successful in 5m33s
2026-05-30 07:13:48 -04:00
jbrechtel b9d43a63bd Add Gitea Actions CI: build and test workflow
Build and Test / build-and-test (push) Successful in 9m16s
2026-05-30 07:11:36 -04:00
12 changed files with 332 additions and 64 deletions
+41
View File
@@ -0,0 +1,41 @@
name: Build and Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Install system dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq libsqlite3-dev
- name: Install Stack
run: |
curl -sSL https://get.haskellstack.org/ | sh
- name: Cache Stack artifacts
uses: actions/cache@v4
with:
path: |
~/.stack
.stack-work
key: stack-${{ runner.os }}-${{ hashFiles('stack.yaml', 'orville-sqlite.cabal') }}
restore-keys: |
stack-${{ runner.os }}-
- name: Build
run: stack build --pedantic
- name: Test
run: stack test
+131 -38
View File
@@ -1,61 +1,154 @@
# orville-sqlite # orville-sqlite
This library is intended to be a SQLite Haskell API similar in spirit to https://github.com/flipstone/orville/ A type-safe SQLite API for Haskell, modeled after the [Flipstone Orville](https://github.com/flipstone/orville/) PostgreSQL library.
The goal of this library is to provide a type safe approach to mapping Haskell types to SQLite data. We aim for type-safe queries and migrations. Maps Haskell data types to SQLite tables with compile-time schema guarantees.
The Flipstone orville library is robust and deep. This library aspires to the same but realizes it's unlikely to get there anytime soon. ## Status — MVP
## Goal The MVP provides the core pipeline: define columns and record marshallers, auto-migrate schemas, and execute basic CRUD queries.
This library intends to provide a similar API for describing the database schema and executing queries as the Flipstone Orville library. ## Usage
We will not port the PostgreSQL implementation directly but instead target a similar API.
The APIs that concern us the most are:
- Orville.PostgreSQL.Marshall.SqlMarshaller
- Orville.PostgreSQL.AutoMigration as AutoMigration
- Orville.PostgreSQL.Execution
and even for those, we only what's strictly necessary to define a way to take a Haskell data type like
```haskell ```haskell
{-# LANGUAGE DataKinds #-}
data Person = import Orville.SQLite
data Person = Person
{ personId :: Int64
, firstName :: Text
, lastName :: Text
, age :: Int64
}
deriving (Show, Eq)
-- Column definitions (type-safe, parameterized by nullability)
personIdField :: FieldDefinition 'NotNull Int64
personIdField = integerField "id"
firstNameField :: FieldDefinition 'NotNull Text
firstNameField = textField "first_name"
lastNameField :: FieldDefinition 'NotNull Text
lastNameField = textField "last_name"
ageField :: FieldDefinition 'NotNull Int64
ageField = integerField "age"
-- SqlMarshaller: maps the record to/from SQL columns
personMarshaller :: SqlMarshaller Person Person
personMarshaller =
Person Person
{ firstName :: Text <$> marshallReadOnlyField personIdField -- auto-increment PK
, lastName :: Text <*> marshallField firstName firstNameField
, age :: Int <*> marshallField lastName lastNameField
} <*> marshallField age ageField
-- TableDefinition ties a table name, primary key, and marshaller together
personTable :: TableDefinition Int64 Person Person
personTable =
mkTableDefinition "person" (primaryKey personId personIdField) personMarshaller
main :: IO ()
main = do
db <- openConnection "people.db"
withConnection db $ do
-- Auto-migrate: creates the table if it doesn't exist
autoMigrateSchema defaultOptions [schemaTable personTable []]
-- Insert
insertEntity personTable (Person 0 "Alice" "Smith" 30)
insertEntity personTable (Person 0 "Bob" "Jones" 25)
-- Query by primary key
mAlice <- findEntity personTable 1
-- mAlice = Just (Person 1 "Alice" "Smith" 30)
-- Find all
all <- findAll personTable
-- all = [Person 1 "Alice" "Smith" 30, Person 2 "Bob" "Jones" 25]
-- Update
updateEntity personTable (Person 1 "Alice" "Jones" 31)
-- Delete
deleteEntity personTable 2
pure ()
closeConnection db
``` ```
and map it to a table of schema The above creates this table:
```sql ```sql
CREATE TABLE person ( CREATE TABLE IF NOT EXISTS person (
first_name VARCHAR(100) NOT NULL, age INTEGER NOT NULL,
last_name VARCHAR(100) NOT NULL, last_name TEXT NOT NULL,
age INT first_name TEXT NOT NULL,
id INTEGER PRIMARY KEY
); );
``` ```
and then give us an API for marshalling that Haskell type to that table definition ## Modules
then using that marshaller we should have APIs that can; | Module | Purpose |
|--------|---------|
| `Orville.SQLite` | Top-level re-exports |
| `Orville.SQLite.Monad` | `OrvilleM` reader monad and connection management |
| `Orville.SQLite.SqlType` | Type encoding/decoding for SQLite storage classes |
| `Orville.SQLite.FieldDefinition` | GADT column definitions with `NotNull`/`Nullable` |
| `Orville.SQLite.SqlMarshaller` | GADT record-to-table mapping (applicative syntax) |
| `Orville.SQLite.TableDefinition` | `PrimaryKey` and `mkTableDefinition` |
| `Orville.SQLite.AutoMigration` | Schema introspection via `PRAGMA table_info`, DDL generation |
| `Orville.SQLite.Execution` | `insertEntity`, `findEntity`, `findAll`, `updateEntity`, `deleteEntity` |
- create the table if it does not exist ## Feature Coverage
- alter the table to match the schema if the table does exist but its columns don't match
- query the table and retrieve Person values
- insert Person values into the table
- update the table from Person values.
## Development environment ### Included (MVP)
The development environment should be entirely inside of Docker - no local - `FieldDefinition` with `NotNull`/`Nullable` type parameters
tools should be installed on the machine directly. use the `hs` wrapper script - `SqlMarshaller` GADT with `marshallField`, `marshallReadOnlyField`, `marshallMaybe`
to execute Haskell tooling inside of Docker. - `TableDefinition` with single-column primary keys
- `AutoMigration`: create tables, add nullable columns, drop columns (explicit opt-in), dry-run
- `Execution`: insert, find-by-PK, find-all, update, delete
- In-memory databases (`:memory:`) and file-based databases
## Base SQLite library ### Not yet implemented
Our base library for interacting with SQLite should be direct-sqlite - https://github.com/IreneKnapp/direct-sqlite - Foreign keys and composite primary keys
- Table indexes
- Plans (N+1 query prevention via `Plan`)
- Batch operations
- Upsert (`upsertEntity`)
- `findEntitiesBy` (filtering on non-PK columns)
- `updateFields` (partial update)
- Raw SQL execution (`executeAndDecode`)
- Nested record marshalling and `prefixMarshaller`
- `AnnotatedSqlMarshaller`
- `SyntheticField`
- Connection pooling
## Development
All tooling runs inside Docker via the `./hs` wrapper. No local Haskell installation needed.
```bash
./hs stack build # build
./hs stack test # run tests (53 tests)
./hs stack ghci # REPL
./hs fourmolu -i src/ # format
```
Or use the convenience scripts:
```bash
./scripts/build # build with pedantic
./scripts/test # run tests
```
### Dependencies
- GHC 9.10.3
- [direct-sqlite](https://github.com/IreneKnapp/direct-sqlite) — low-level FFI binding to SQLite
- `text`, `bytestring`, `mtl`
+5 -1
View File
@@ -19,7 +19,9 @@ library
Orville.SQLite.AutoMigration Orville.SQLite.AutoMigration
Orville.SQLite.Execution Orville.SQLite.Execution
Orville.SQLite.FieldDefinition Orville.SQLite.FieldDefinition
Orville.SQLite.Internal
Orville.SQLite.Monad Orville.SQLite.Monad
Orville.SQLite.Raw
Orville.SQLite.RawSql Orville.SQLite.RawSql
Orville.SQLite.SqlMarshaller Orville.SQLite.SqlMarshaller
Orville.SQLite.SqlType Orville.SQLite.SqlType
@@ -32,7 +34,7 @@ library
, text , text
, bytestring , bytestring
default-language: Haskell2010 default-language: Haskell2010
ghc-options: -Wall ghc-options: -Wall -Werror
test-suite spec test-suite spec
type: exitcode-stdio-1.0 type: exitcode-stdio-1.0
@@ -42,6 +44,7 @@ test-suite spec
Test.AutoMigration Test.AutoMigration
Test.EntityOperations Test.EntityOperations
Test.FieldDefinition Test.FieldDefinition
Test.Raw
Test.SqlMarshaller Test.SqlMarshaller
hs-source-dirs: test hs-source-dirs: test
build-depends: build-depends:
@@ -53,3 +56,4 @@ test-suite spec
, hspec , hspec
, text , text
default-language: Haskell2010 default-language: Haskell2010
ghc-options: -Wall -Werror
+3
View File
@@ -13,12 +13,15 @@ module Orville.SQLite
, module Orville.SQLite.AutoMigration , module Orville.SQLite.AutoMigration
-- * Execution -- * Execution
, module Orville.SQLite.Execution , module Orville.SQLite.Execution
-- * Raw
, module Orville.SQLite.Raw
) where ) where
import Orville.SQLite.AutoMigration import Orville.SQLite.AutoMigration
import Orville.SQLite.Execution import Orville.SQLite.Execution
import Orville.SQLite.FieldDefinition import Orville.SQLite.FieldDefinition
import Orville.SQLite.Monad import Orville.SQLite.Monad
import Orville.SQLite.Raw
import Orville.SQLite.SqlMarshaller import Orville.SQLite.SqlMarshaller
import Orville.SQLite.SqlType import Orville.SQLite.SqlType
import Orville.SQLite.TableDefinition import Orville.SQLite.TableDefinition
+2 -20
View File
@@ -15,8 +15,8 @@ import Control.Monad.Reader (ask)
import Data.List (intercalate) import Data.List (intercalate)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Database.SQLite3 as SQLite3 import qualified Database.SQLite3 as SQLite3
import Database.SQLite3.Direct (columnCount)
import Orville.SQLite.FieldDefinition (fieldColumnName, fieldToSqlValue) import Orville.SQLite.FieldDefinition (fieldColumnName, fieldToSqlValue)
import Orville.SQLite.Internal (getRowData)
import Orville.SQLite.Monad (OrvilleM) import Orville.SQLite.Monad (OrvilleM)
import Orville.SQLite.SqlMarshaller ( import Orville.SQLite.SqlMarshaller (
marshallerDecodeRow, marshallerDecodeRow,
@@ -148,22 +148,4 @@ deleteEntity tableDef key = do
_ <- SQLite3.step stmt _ <- SQLite3.step stmt
SQLite3.finalize 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
+28
View File
@@ -0,0 +1,28 @@
{-# LANGUAGE ScopedTypeVariables #-}
module Orville.SQLite.Internal (
getRowData,
) where
import Control.Monad (forM)
import qualified Data.Text as T
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 ..]
forM indexes $ \i -> do
let idx :: Int = fromIntegral i
-- Try column name from statement metadata first, then fall back to provided names, then empty
mName <- SQLite3.columnName stmt i
let colName = case mName of
Just n | not (T.null n) -> T.unpack n
_ -> if idx < length cols then cols !! idx else ""
sqlVal <- SQLite3.column stmt i
pure (colName, sqlVal)
+71
View File
@@ -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 []
+2
View File
@@ -7,6 +7,7 @@ import Test.Hspec
import qualified Test.AutoMigration as AutoMigration import qualified Test.AutoMigration as AutoMigration
import qualified Test.EntityOperations as EntityOperations import qualified Test.EntityOperations as EntityOperations
import qualified Test.FieldDefinition as FieldDefinition import qualified Test.FieldDefinition as FieldDefinition
import qualified Test.Raw as Raw
import qualified Test.SqlMarshaller as SqlMarshaller import qualified Test.SqlMarshaller as SqlMarshaller
main :: IO () main :: IO ()
@@ -15,3 +16,4 @@ main = hspec $ do
describe "SqlMarshaller" SqlMarshaller.sqlMarshallerTests describe "SqlMarshaller" SqlMarshaller.sqlMarshallerTests
describe "EntityOperations" EntityOperations.entityOperationsTests describe "EntityOperations" EntityOperations.entityOperationsTests
describe "AutoMigration" AutoMigration.autoMigrationTests describe "AutoMigration" AutoMigration.autoMigrationTests
describe "Raw" Raw.rawTests
+1 -2
View File
@@ -7,7 +7,6 @@ module Test.AutoMigration where
import Control.Exception (SomeException, try) import Control.Exception (SomeException, try)
import Control.Monad.IO.Class (liftIO) import Control.Monad.IO.Class (liftIO)
import Data.Int (Int64)
import Test.Hspec import Test.Hspec
import Orville.SQLite import Orville.SQLite
@@ -23,7 +22,7 @@ autoMigrationTests = do
pure () pure ()
closeConnection db closeConnection db
it "is idempotent running twice does not error" $ do it "is idempotent -- running twice does not error" $ do
db <- openConnection ":memory:" db <- openConnection ":memory:"
withConnection db $ do withConnection db $ do
autoMigrateSchema defaultOptions [schemaTable personTable []] autoMigrateSchema defaultOptions [schemaTable personTable []]
-2
View File
@@ -4,8 +4,6 @@
module Test.EntityOperations where module Test.EntityOperations where
import Control.Monad.IO.Class (liftIO) import Control.Monad.IO.Class (liftIO)
import Data.Int (Int64)
import Data.Text (Text)
import Test.Hspec import Test.Hspec
import Orville.SQLite import Orville.SQLite
+48
View File
@@ -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
-1
View File
@@ -4,7 +4,6 @@
module Test.SqlMarshaller where module Test.SqlMarshaller where
import Data.Int (Int64)
import qualified Data.Text as T import qualified Data.Text as T
import Test.Hspec import Test.Hspec