1426 lines
42 KiB
Markdown
1426 lines
42 KiB
Markdown
# orville-sqlite MVP Implementation Plan
|
|
|
|
**Goal:** Build a type-safe SQLite API with compile-time schema guarantees, modeled after Flipstone Orville PostgreSQL.
|
|
|
|
**Architecture:** GADT-based `SqlMarshaller` maps Haskell records to SQLite tables via `FieldDefinition`s. A simple reader monad (`OrvilleM`) wraps `direct-sqlite`'s `Database` handle. `AutoMigration` compares expected schema against `PRAGMA table_info` and generates DDL. Execution layer provides `insertEntity`, `findEntity`, `updateEntity`, `deleteEntity`.
|
|
|
|
**Tech Stack:** GHC 9.10.3, Stack (LTS 24.43), direct-sqlite, text, bytestring
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
| File | Responsibility |
|
|
|------|---------------|
|
|
| `stack.yaml` | Stack project config with direct-sqlite extra-dep |
|
|
| `package.yaml` | hpack package definition (cabal is generated) |
|
|
| `src/Orville/SQLite/Monad.hs` | `OrvilleM` reader monad, `withConnection`, `openConnection`, `closeConnection` |
|
|
| `src/Orville/SQLite/RawSql.hs` | Minimal `RawSql` newtype + `Semigroup`/`Monoid` instances |
|
|
| `src/Orville/SQLite/SqlType.hs` | `SqlType` with encode/decode for SQLite storage classes |
|
|
| `src/Orville/SQLite/FieldDefinition.hs` | GADT with `NotNull`/`Nullable`, construction functions |
|
|
| `src/Orville/SQLite/SqlMarshaller.hs` | GADT marshaller + `marshallField`, `marshallReadOnlyField`, `marshallMaybe` |
|
|
| `src/Orville/SQLite/TableDefinition.hs` | `PrimaryKey`, `TableDefinition`, `mkTableDefinition` |
|
|
| `src/Orville/SQLite/AutoMigration.hs` | `autoMigrateSchema`, `SchemaItem`, schema comparison, DDL generation |
|
|
| `src/Orville/SQLite/Execution.hs` | `insertEntity`, `findEntity`, `findAll`, `updateEntity`, `deleteEntity` |
|
|
| `src/Orville/SQLite.hs` | Top-level re-exports module |
|
|
| `test/Main.hs` | Test runner entry point |
|
|
|
|
---
|
|
|
|
## Task 1: Project Setup
|
|
|
|
**Files:**
|
|
- Create: `stack.yaml`
|
|
- Create: `package.yaml`
|
|
|
|
- [ ] **Step 1: Create stack.yaml**
|
|
|
|
Write `/work/personal/orville-sqlite/stack.yaml`:
|
|
|
|
```yaml
|
|
resolver: lts-24.43
|
|
|
|
packages:
|
|
- .
|
|
|
|
extra-deps:
|
|
- direct-sqlite-2.3.29
|
|
|
|
nix:
|
|
enable: false
|
|
```
|
|
|
|
- [ ] **Step 2: Create package.yaml**
|
|
|
|
Write `/work/personal/orville-sqlite/package.yaml`:
|
|
|
|
```yaml
|
|
name: orville-sqlite
|
|
version: 0.1.0.0
|
|
synopsis: A type-safe SQLite API modeled after Flipstone Orville
|
|
description: >
|
|
Maps Haskell data types to SQLite tables with compile-time schema guarantees.
|
|
Provides type-safe column mapping, automatic schema migration, and basic CRUD
|
|
query execution.
|
|
category: database, sqlite
|
|
author: ""
|
|
maintainer: ""
|
|
license: MIT
|
|
license-file: LICENSE
|
|
github: ""
|
|
|
|
extra-source-files:
|
|
- README.md
|
|
|
|
dependencies:
|
|
- base >=4.17 && <5
|
|
- direct-sqlite
|
|
- text
|
|
- bytestring
|
|
|
|
library:
|
|
source-dirs: src
|
|
exposed-modules:
|
|
- Orville.SQLite
|
|
- Orville.SQLite.Monad
|
|
- Orville.SQLite.RawSql
|
|
- Orville.SQLite.SqlType
|
|
- Orville.SQLite.FieldDefinition
|
|
- Orville.SQLite.SqlMarshaller
|
|
- Orville.SQLite.TableDefinition
|
|
- Orville.SQLite.AutoMigration
|
|
- Orville.SQLite.Execution
|
|
|
|
tests:
|
|
spec:
|
|
main: Main.hs
|
|
source-dirs: test
|
|
dependencies:
|
|
- orville-sqlite
|
|
- hspec
|
|
```
|
|
|
|
- [ ] **Step 3: Run hpack to generate .cabal and verify build setup**
|
|
|
|
Run:
|
|
```bash
|
|
cd /work/personal/orville-sqlite && ./hs hpack
|
|
./hs stack build --dry-run 2>&1 | head -30
|
|
```
|
|
|
|
Expected: The dry run should show orville-sqlite library and spec test suite. If `direct-sqlite-2.3.29` isn't available, check the latest version with `./hs stack ls dependencies --filter direct-sqlite 2>&1` and update `stack.yaml` accordingly.
|
|
|
|
- [ ] **Step 4: Create LICENSE file (MIT)**
|
|
|
|
Write `/work/personal/orville-sqlite/LICENSE`:
|
|
|
|
```
|
|
MIT License
|
|
|
|
Copyright (c) 2026
|
|
|
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
of this software and associated documentation files (the "Software"), to deal
|
|
in the Software without restriction, including without limitation the rights
|
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
copies of the Software, and to permit persons to whom the Software is
|
|
furnished to do so, subject to the following conditions:
|
|
|
|
The above copyright notice and this permission notice shall be included in all
|
|
copies or substantial portions of the Software.
|
|
|
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
SOFTWARE.
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /work/personal/orville-sqlite && git add stack.yaml package.yaml LICENSE && git commit -m "Add project build configuration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: OrvilleM Monad + RawSql
|
|
|
|
**Files:**
|
|
- Create: `src/Orville/SQLite/Monad.hs`
|
|
- Create: `src/Orville/SQLite/RawSql.hs`
|
|
|
|
- [ ] **Step 1: Write the RawSql module**
|
|
|
|
Write `/work/personal/orville-sqlite/src/Orville/SQLite/RawSql.hs`:
|
|
|
|
```haskell
|
|
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
|
|
|
module Orville.SQLite.RawSql
|
|
( RawSql(..)
|
|
, fromString
|
|
, toRawSql
|
|
, intercalate
|
|
, fromText
|
|
, space
|
|
, comma
|
|
, leftParen
|
|
, rightParen
|
|
, equals
|
|
) where
|
|
|
|
import Data.Monoid (Endo(..))
|
|
|
|
newtype RawSql = RawSql { unRawSql :: String }
|
|
deriving (Show, Eq, Semigroup, Monoid)
|
|
|
|
fromString :: String -> RawSql
|
|
fromString = RawSql
|
|
|
|
toRawSql :: RawSql -> RawSql
|
|
toRawSql = id
|
|
|
|
fromText :: Text -> RawSql
|
|
fromText = RawSql . T.unpack
|
|
|
|
intercalate :: RawSql -> [RawSql] -> RawSql
|
|
intercalate sep parts = RawSql . intercalateStr (unRawSql sep) $ map unRawSql parts
|
|
where
|
|
intercalateStr _ [] = ""
|
|
intercalateStr _ [x] = x
|
|
intercalateStr s (x:xs) = x <> s <> intercalateStr s xs
|
|
|
|
space :: RawSql
|
|
space = RawSql " "
|
|
|
|
comma :: RawSql
|
|
comma = RawSql ", "
|
|
|
|
leftParen :: RawSql
|
|
leftParen = RawSql "("
|
|
|
|
rightParen :: RawSql
|
|
rightParen = RawSql ")"
|
|
|
|
equals :: RawSql
|
|
equals = RawSql " = "
|
|
```
|
|
|
|
- [ ] **Step 2: Write the OrvilleM monad module**
|
|
|
|
Write `/work/personal/orville-sqlite/src/Orville/SQLite/Monad.hs`:
|
|
|
|
```haskell
|
|
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
|
|
|
module Orville.SQLite.Monad
|
|
( OrvilleM
|
|
, withConnection
|
|
, openConnection
|
|
, closeConnection
|
|
, runOrvilleM
|
|
, withTransaction
|
|
) where
|
|
|
|
import Control.Monad.IO.Class (MonadIO(liftIO))
|
|
import Control.Monad.Reader (ReaderT, runReaderT, ask, MonadReader)
|
|
import qualified Database.SQLite3 as SQLite3
|
|
|
|
newtype OrvilleM a = OrvilleM
|
|
{ unOrvilleM :: ReaderT SQLite3.Database IO a
|
|
} deriving (Functor, Applicative, Monad, MonadIO, MonadReader SQLite3.Database)
|
|
|
|
runOrvilleM :: SQLite3.Database -> OrvilleM a -> IO a
|
|
runOrvilleM db action = runReaderT (unOrvilleM action) db
|
|
|
|
withConnection :: SQLite3.Database -> OrvilleM a -> IO a
|
|
withConnection = runOrvilleM
|
|
|
|
openConnection :: String -> IO SQLite3.Database
|
|
openConnection path = SQLite3.open (T.pack path)
|
|
|
|
closeConnection :: SQLite3.Database -> IO ()
|
|
closeConnection = SQLite3.close
|
|
|
|
withTransaction :: OrvilleM a -> OrvilleM a
|
|
withTransaction action = do
|
|
db <- ask
|
|
liftIO $ SQLite3.exec db "BEGIN TRANSACTION"
|
|
result <- action
|
|
liftIO $ SQLite3.exec db "COMMIT"
|
|
pure result
|
|
```
|
|
|
|
- [ ] **Step 3: Verify compilation**
|
|
|
|
Run:
|
|
```bash
|
|
cd /work/personal/orville-sqlite && ./hs hpack && ./hs stack build 2>&1
|
|
```
|
|
|
|
Expected: Successful build (only these two modules, no warnings from -Wall).
|
|
|
|
If `Database.SQLite3` module name is wrong (e.g. `Database.SQLite3.Direct`), the build will fail with "Could not find module". Fix the import and retry.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /work/personal/orville-sqlite && git add src/Orville/SQLite/Monad.hs src/Orville/SQLite/RawSql.hs && git commit -m "Add OrvilleM monad and RawSql modules"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: SqlType — SQLite Type Encoding/Decoding
|
|
|
|
**Files:**
|
|
- Create: `src/Orville/SQLite/SqlType.hs`
|
|
|
|
- [ ] **Step 1: Write the SqlType module**
|
|
|
|
Write `/work/personal/orville-sqlite/src/Orville/SQLite/SqlType.hs`:
|
|
|
|
```haskell
|
|
module Orville.SQLite.SqlType
|
|
( SqlType(..)
|
|
, integerType
|
|
, textType
|
|
, realType
|
|
, blobType
|
|
, convertSqlType
|
|
) where
|
|
|
|
import qualified Data.Text as T
|
|
import qualified Data.ByteString as BS
|
|
import qualified Database.SQLite3 as SQLite3
|
|
|
|
data SqlType a = SqlType
|
|
{ sqlTypeName :: String
|
|
, sqlTypeToSql :: a -> SQLite3.SQLData
|
|
, sqlTypeFromSql :: SQLite3.SQLData -> Either String a
|
|
}
|
|
|
|
integerType :: SqlType Int64
|
|
integerType = SqlType
|
|
{ sqlTypeName = "INTEGER"
|
|
, sqlTypeToSql = SQLite3.SQLInteger
|
|
, sqlTypeFromSql = \case
|
|
SQLite3.SQLInteger i -> Right i
|
|
SQLite3.SQLNull -> Right 0
|
|
other -> Left $ "Expected INTEGER, got " <> show (sqlDataKind other)
|
|
}
|
|
|
|
textType :: SqlType T.Text
|
|
textType = SqlType
|
|
{ sqlTypeName = "TEXT"
|
|
, sqlTypeToSql = SQLite3.SQLText
|
|
, sqlTypeFromSql = \case
|
|
SQLite3.SQLText t -> Right t
|
|
SQLite3.SQLNull -> Right T.empty
|
|
SQLite3.SQLInteger i -> Right (T.pack (show i))
|
|
SQLite3.SQLFloat d -> Right (T.pack (show d))
|
|
other -> Left $ "Expected TEXT, got " <> show (sqlDataKind other)
|
|
}
|
|
|
|
realType :: SqlType Double
|
|
realType = SqlType
|
|
{ sqlTypeName = "REAL"
|
|
, sqlTypeToSql = SQLite3.SQLFloat
|
|
, sqlTypeFromSql = \case
|
|
SQLite3.SQLFloat d -> Right d
|
|
SQLite3.SQLInteger i -> Right (fromIntegral i)
|
|
SQLite3.SQLNull -> Right 0.0
|
|
other -> Left $ "Expected REAL, got " <> show (sqlDataKind other)
|
|
}
|
|
|
|
blobType :: SqlType BS.ByteString
|
|
blobType = SqlType
|
|
{ sqlTypeName = "BLOB"
|
|
, sqlTypeToSql = SQLite3.SQLBlob
|
|
, sqlTypeFromSql = \case
|
|
SQLite3.SQLBlob b -> Right b
|
|
SQLite3.SQLNull -> Right BS.empty
|
|
other -> Left $ "Expected BLOB, got " <> show (sqlDataKind other)
|
|
}
|
|
|
|
convertSqlType :: (a -> b) -> (b -> a) -> SqlType a -> SqlType b
|
|
convertSqlType to from sqlType = SqlType
|
|
{ sqlTypeName = sqlTypeName sqlType
|
|
, sqlTypeToSql = sqlTypeToSql sqlType . from
|
|
, sqlTypeFromSql = fmap to . sqlTypeFromSql sqlType
|
|
}
|
|
|
|
sqlDataKind :: SQLite3.SQLData -> String
|
|
sqlDataKind = \case
|
|
SQLite3.SQLInteger _ -> "SQLInteger"
|
|
SQLite3.SQLFloat _ -> "SQLFloat"
|
|
SQLite3.SQLText _ -> "SQLText"
|
|
SQLite3.SQLBlob _ -> "SQLBlob"
|
|
SQLite3.SQLNull -> "SQLNull"
|
|
```
|
|
|
|
If `SQLite3.SQLData` constructors use different names (some libraries use `SQLInt64`, `SQLDouble`, etc.), the build will fail — adjust to match the actual constructors.
|
|
|
|
- [ ] **Step 2: Verify compilation**
|
|
|
|
Run:
|
|
```bash
|
|
cd /work/personal/orville-sqlite && ./hs stack build 2>&1
|
|
```
|
|
|
|
Expected: Successful build.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd /work/personal/orville-sqlite && git add src/Orville/SQLite/SqlType.hs && git commit -m "Add SqlType module for SQLite type encoding/decoding"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: FieldDefinition — Column Definitions with Nullability
|
|
|
|
**Files:**
|
|
- Create: `src/Orville/SQLite/FieldDefinition.hs`
|
|
|
|
- [ ] **Step 1: Write the FieldDefinition module**
|
|
|
|
Write `/work/personal/orville-sqlite/src/Orville/SQLite/FieldDefinition.hs`:
|
|
|
|
```haskell
|
|
{-# LANGUAGE DataKinds #-}
|
|
{-# LANGUAGE GADTs #-}
|
|
{-# LANGUAGE KindSignatures #-}
|
|
|
|
module Orville.SQLite.FieldDefinition
|
|
( Nullability(..)
|
|
, FieldDefinition(..)
|
|
, integerField
|
|
, textField
|
|
, realField
|
|
, blobField
|
|
, nullableField
|
|
, convertField
|
|
, fieldToSqlValue
|
|
, fieldFromSqlValue
|
|
, fieldColumnName
|
|
, fieldSqlTypeName
|
|
, fieldIsNullable
|
|
) where
|
|
|
|
import Data.Kind (Type)
|
|
import qualified Database.SQLite3 as SQLite3
|
|
import Orville.SQLite.SqlType (SqlType, integerType, textType, realType, blobType)
|
|
|
|
data Nullability = NotNull | Nullable
|
|
|
|
data FieldDefinition (nullability :: Nullability) :: Type -> Type where
|
|
NotNullField ::
|
|
{ notNullFieldName :: String
|
|
, notNullFieldSqlType :: SqlType a
|
|
} -> FieldDefinition 'NotNull a
|
|
NullableField ::
|
|
{ nullableFieldName :: String
|
|
, nullableFieldSqlType :: SqlType a
|
|
} -> FieldDefinition 'Nullable a
|
|
|
|
fieldColumnName :: FieldDefinition null a -> String
|
|
fieldColumnName = \case
|
|
NotNullField n _ -> n
|
|
NullableField n _ -> n
|
|
|
|
fieldSqlTypeName :: FieldDefinition null a -> String
|
|
fieldSqlTypeName = \case
|
|
NotNullField _ st -> sqlTypeName st
|
|
NullableField _ st -> sqlTypeName st
|
|
|
|
fieldIsNullable :: FieldDefinition null a -> Bool
|
|
fieldIsNullable = \case
|
|
NotNullField _ _ -> False
|
|
NullableField _ _ -> True
|
|
|
|
fieldToSqlValue :: a -> FieldDefinition null a -> SQLite3.SQLData
|
|
fieldToSqlValue val = \case
|
|
NotNullField _ st -> sqlTypeToSql st val
|
|
NullableField _ st -> sqlTypeToSql st val
|
|
|
|
fieldFromSqlValue :: SQLite3.SQLData -> FieldDefinition null a -> Either String a
|
|
fieldFromSqlValue sqlVal = \case
|
|
NotNullField _ st -> sqlTypeFromSql st sqlVal
|
|
NullableField _ st -> case sqlVal of
|
|
SQLite3.SQLNull -> Left "Unexpected NULL for nullable field"
|
|
_ -> sqlTypeFromSql st sqlVal
|
|
|
|
integerField :: String -> FieldDefinition 'NotNull Int64
|
|
integerField name = NotNullField name integerType
|
|
|
|
textField :: String -> FieldDefinition 'NotNull T.Text
|
|
textField name = NotNullField name textType
|
|
|
|
realField :: String -> FieldDefinition 'NotNull Double
|
|
realField name = NotNullField name realType
|
|
|
|
blobField :: String -> FieldDefinition 'NotNull BS.ByteString
|
|
blobField name = NotNullField name blobType
|
|
|
|
nullableField :: FieldDefinition 'NotNull a -> FieldDefinition 'Nullable a
|
|
nullableField = \case
|
|
NotNullField n st -> NullableField n st
|
|
|
|
convertField ::
|
|
(a -> b) ->
|
|
(b -> a) ->
|
|
FieldDefinition null a ->
|
|
FieldDefinition null b
|
|
convertField to from = \case
|
|
NotNullField n st -> NotNullField n (convertSqlType to from st)
|
|
NullableField n st -> NullableField n (convertSqlType to from st)
|
|
```
|
|
|
|
- [ ] **Step 2: Add missing imports to FieldDefinition**
|
|
|
|
Open `src/Orville/SQLite/FieldDefinition.hs` and add at the top with the other imports:
|
|
```haskell
|
|
import qualified Data.Text as T
|
|
import qualified Data.ByteString as BS
|
|
import Orville.SQLite.SqlType (SqlType, integerType, textType, realType, blobType, convertSqlType, sqlTypeName)
|
|
```
|
|
|
|
- [ ] **Step 3: Verify compilation**
|
|
|
|
Run:
|
|
```bash
|
|
cd /work/personal/orville-sqlite && ./hs stack build 2>&1
|
|
```
|
|
|
|
Expected: Successful build.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
cd /work/personal/orville-sqlite && git add src/Orville/SQLite/FieldDefinition.hs && git commit -m "Add FieldDefinition module with nullability type safety"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: SqlMarshaller — GADT Record-to-Table Mapping
|
|
|
|
**Files:**
|
|
- Create: `src/Orville/SQLite/SqlMarshaller.hs`
|
|
|
|
- [ ] **Step 1: Write the SqlMarshaller module**
|
|
|
|
Write `/work/personal/orville-sqlite/src/Orville/SQLite/SqlMarshaller.hs`:
|
|
|
|
```haskell
|
|
{-# LANGUAGE GADTs #-}
|
|
{-# LANGUAGE RankNTypes #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
{-# LANGUAGE TypeApplications #-}
|
|
|
|
module Orville.SQLite.SqlMarshaller
|
|
( SqlMarshaller
|
|
, marshallField
|
|
, marshallReadOnlyField
|
|
, marshallMaybe
|
|
, marshallerDerivedColumns
|
|
, marshallerEncodeWrite
|
|
, marshallerDecodeRow
|
|
, marshallerFieldDefinitions
|
|
) where
|
|
|
|
import Control.Applicative (liftA2)
|
|
import qualified Data.Text as T
|
|
import qualified Database.SQLite3 as SQLite3
|
|
import Orville.SQLite.FieldDefinition
|
|
( FieldDefinition(..)
|
|
, Nullability(..)
|
|
, fieldColumnName
|
|
, fieldToSqlValue
|
|
, fieldFromSqlValue
|
|
, fieldIsNullable
|
|
, fieldSqlTypeName
|
|
)
|
|
|
|
data SqlMarshaller writeEntity readEntity where
|
|
MarshallPure :: readEntity -> SqlMarshaller writeEntity readEntity
|
|
MarshallApply ::
|
|
SqlMarshaller writeEntity (a -> b) ->
|
|
SqlMarshaller writeEntity a ->
|
|
SqlMarshaller writeEntity b
|
|
MarshallNest ::
|
|
(writeEntity -> a) ->
|
|
SqlMarshaller a readEntity ->
|
|
SqlMarshaller writeEntity readEntity
|
|
MarshallField ::
|
|
{-# UNPACK #-} !(FieldDefinition nullability a) ->
|
|
SqlMarshaller a a
|
|
MarshallReadOnly ::
|
|
SqlMarshaller a readEntity ->
|
|
SqlMarshaller b readEntity
|
|
|
|
instance Functor (SqlMarshaller w) where
|
|
fmap f m = MarshallPure f `MarshallApply` m
|
|
|
|
instance Applicative (SqlMarshaller w) where
|
|
pure = MarshallPure
|
|
(<*>) = MarshallApply
|
|
|
|
marshallField ::
|
|
(writeEntity -> a) ->
|
|
FieldDefinition 'NotNull a ->
|
|
SqlMarshaller writeEntity a
|
|
marshallField accessor fieldDef = MarshallNest accessor (MarshallField fieldDef)
|
|
|
|
marshallReadOnlyField ::
|
|
FieldDefinition nullability a ->
|
|
SqlMarshaller writeEntity a
|
|
marshallReadOnlyField fieldDef = MarshallReadOnly (MarshallField fieldDef)
|
|
|
|
marshallMaybe ::
|
|
FieldDefinition 'Nullable a ->
|
|
SqlMarshaller writeEntity (Maybe a)
|
|
marshallMaybe fieldDef@(NullableField name sqlType) =
|
|
MarshallNest (const ()) (MarshallField fieldDef) `withDecoder` decodeMaybe
|
|
where
|
|
decodeMaybe :: a -> Maybe a
|
|
decodeMaybe = Just
|
|
|
|
marshallerDerivedColumns ::
|
|
SqlMarshaller writeEntity readEntity ->
|
|
[String]
|
|
marshallerDerivedColumns marshaller =
|
|
reverse $ collect marshaller
|
|
where
|
|
collect :: SqlMarshaller w r -> [String] -> [String]
|
|
collect (MarshallPure _) acc = acc
|
|
collect (MarshallApply m1 m2) acc = collect m1 (collect m2 acc)
|
|
collect (MarshallNest _ m) acc = collect m acc
|
|
collect (MarshallField fieldDef) acc = fieldColumnName fieldDef : acc
|
|
collect (MarshallReadOnly m) acc = collect m acc
|
|
|
|
marshallerEncodeWrite ::
|
|
SqlMarshaller writeEntity readEntity ->
|
|
writeEntity ->
|
|
[(String, SQLite3.SQLData)]
|
|
marshallerEncodeWrite marshaller entity =
|
|
reverse $ go marshaller entity []
|
|
where
|
|
go :: SqlMarshaller w r -> w -> [(String, SQLite3.SQLData)] -> [(String, SQLite3.SQLData)]
|
|
go (MarshallPure _) _ acc = acc
|
|
go (MarshallApply m1 m2) w acc =
|
|
go m1 w (go m2 w acc)
|
|
go (MarshallNest accessor m) w acc =
|
|
go m (accessor w) acc
|
|
go (MarshallField fieldDef) a acc =
|
|
(fieldColumnName fieldDef, fieldToSqlValue a fieldDef) : acc
|
|
go (MarshallReadOnly _) _ acc = acc
|
|
|
|
marshallerDecodeRow ::
|
|
SqlMarshaller writeEntity readEntity ->
|
|
[(String, SQLite3.SQLData)] ->
|
|
Either String readEntity
|
|
marshallerDecodeRow marshaller rowData =
|
|
go marshaller rowData
|
|
where
|
|
go :: SqlMarshaller w r -> [(String, SQLite3.SQLData)] -> Either String r
|
|
go (MarshallPure r) _ = Right r
|
|
go (MarshallApply m1 m2) rd = do
|
|
f <- go m1 rd
|
|
a <- go m2 rd
|
|
Right (f a)
|
|
go (MarshallNest _ m) rd = go m rd
|
|
go (MarshallField fieldDef) rd =
|
|
case lookup (fieldColumnName fieldDef) rd of
|
|
Just sqlVal -> fieldFromSqlValue sqlVal fieldDef
|
|
Nothing -> Left $
|
|
"Column '" <> fieldColumnName fieldDef <> "' not found in result row"
|
|
go (MarshallReadOnly m) rd = go m rd
|
|
|
|
marshallerFieldDefinitions ::
|
|
SqlMarshaller writeEntity readEntity ->
|
|
[(FieldDefinition nullability a -> r) -> r]
|
|
marshallerFieldDefinitions marshaller =
|
|
reverse $ collect marshaller []
|
|
where
|
|
collect :: SqlMarshaller w r -> [forall r. (forall n a. FieldDefinition n a -> r) -> r] -> [forall r. (forall n a. FieldDefinition n a -> r) -> r]
|
|
collect (MarshallPure _) acc = acc
|
|
collect (MarshallApply m1 m2) acc = collect m1 (collect m2 acc)
|
|
collect (MarshallNest _ m) acc = collect m acc
|
|
collect (MarshallField fieldDef) acc = (\k -> k fieldDef) : acc
|
|
collect (MarshallReadOnly m) acc = collect m acc
|
|
```
|
|
|
|
- [ ] **Step 2: Verify compilation**
|
|
|
|
Run:
|
|
```bash
|
|
cd /work/personal/orville-sqlite && ./hs stack build 2>&1
|
|
```
|
|
|
|
Expected: Successful build. If `RankNTypes` approach for `marshallerFieldDefinitions` fails, simplify to just return a list of field names and types for migration:
|
|
|
|
```haskell
|
|
data FieldInfo = FieldInfo
|
|
{ fieldInfoName :: String
|
|
, fieldInfoType :: String
|
|
, fieldInfoIsNullable :: Bool
|
|
}
|
|
|
|
marshallerFieldInfo ::
|
|
SqlMarshaller writeEntity readEntity ->
|
|
[FieldInfo]
|
|
marshallerFieldInfo marshaller =
|
|
reverse $ go marshaller []
|
|
where
|
|
go (MarshallPure _) acc = acc
|
|
go (MarshallApply m1 m2) acc = go m1 (go m2 acc)
|
|
go (MarshallNest _ m) acc = go m acc
|
|
go (MarshallField fieldDef) acc =
|
|
FieldInfo
|
|
(fieldColumnName fieldDef)
|
|
(fieldSqlTypeName fieldDef)
|
|
(fieldIsNullable fieldDef) : acc
|
|
go (MarshallReadOnly m) acc = go m acc
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd /work/personal/orville-sqlite && git add src/Orville/SQLite/SqlMarshaller.hs && git commit -m "Add SqlMarshaller GADT with applicative combinators"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: TableDefinition — PrimaryKey and Table Construction
|
|
|
|
**Files:**
|
|
- Create: `src/Orville/SQLite/TableDefinition.hs`
|
|
|
|
- [ ] **Step 1: Write the TableDefinition module**
|
|
|
|
Write `/work/personal/orville-sqlite/src/Orville/SQLite/TableDefinition.hs`:
|
|
|
|
```haskell
|
|
{-# LANGUAGE GADTs #-}
|
|
|
|
module Orville.SQLite.TableDefinition
|
|
( PrimaryKey(..)
|
|
, primaryKey
|
|
, TableDefinition(..)
|
|
, mkTableDefinition
|
|
, mkTableDefinitionWithoutKey
|
|
) where
|
|
|
|
import Orville.SQLite.FieldDefinition (FieldDefinition(..), Nullability(..), fieldColumnName, fieldSqlTypeName)
|
|
import Orville.SQLite.SqlMarshaller (SqlMarshaller)
|
|
|
|
data PrimaryKey writeEntity key where
|
|
PrimaryKey ::
|
|
(writeEntity -> key) ->
|
|
FieldDefinition 'NotNull key ->
|
|
PrimaryKey writeEntity key
|
|
|
|
primaryKey ::
|
|
(writeEntity -> key) ->
|
|
FieldDefinition 'NotNull key ->
|
|
PrimaryKey writeEntity key
|
|
primaryKey = PrimaryKey
|
|
|
|
data TableDefinition key writeEntity readEntity = TableDefinition
|
|
{ tableName :: String
|
|
, tablePrimaryKey :: PrimaryKey writeEntity key
|
|
, tableMarshaller :: SqlMarshaller writeEntity readEntity
|
|
}
|
|
|
|
mkTableDefinition ::
|
|
String ->
|
|
PrimaryKey writeEntity key ->
|
|
SqlMarshaller writeEntity readEntity ->
|
|
TableDefinition key writeEntity readEntity
|
|
mkTableDefinition = TableDefinition
|
|
|
|
mkTableDefinitionWithoutKey ::
|
|
String ->
|
|
SqlMarshaller writeEntity readEntity ->
|
|
TableDefinition () writeEntity readEntity
|
|
mkTableDefinitionWithoutKey name marshaller = TableDefinition
|
|
{ tableName = name
|
|
, tablePrimaryKey = PrimaryKey (const ()) (NotNullField "__no_key__" undefined) -- TODO: improve dummy key
|
|
, tableMarshaller = marshaller
|
|
}
|
|
```
|
|
|
|
**Note:** `mkTableDefinitionWithoutKey` with the dummy key is a known limitation. A proper fix would use a type-level distinction between keyed and keyless tables, but that's deferred past MVP. Since MVP focuses on keyed tables, this is acceptable.
|
|
|
|
- [ ] **Step 2: Verify compilation**
|
|
|
|
Run:
|
|
```bash
|
|
cd /work/personal/orville-sqlite && ./hs stack build 2>&1
|
|
```
|
|
|
|
Expected: Successful build.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd /work/personal/orville-sqlite && git add src/Orville/SQLite/TableDefinition.hs && git commit -m "Add TableDefinition with PrimaryKey"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: AutoMigration — Schema Comparison and DDL Generation
|
|
|
|
**Files:**
|
|
- Create: `src/Orville/SQLite/AutoMigration.hs`
|
|
|
|
- [ ] **Step 1: Write the AutoMigration module**
|
|
|
|
Write `/work/personal/orville-sqlite/src/Orville/SQLite/AutoMigration.hs`:
|
|
|
|
```haskell
|
|
module Orville.SQLite.AutoMigration
|
|
( MigrationOptions(..)
|
|
, defaultOptions
|
|
, SchemaItem(..)
|
|
, SchemaTable(..)
|
|
, schemaTable
|
|
, dropColumns
|
|
, autoMigrateSchema
|
|
, MigrationStep(..)
|
|
, generateMigrationPlan
|
|
, executeMigrationPlan
|
|
) where
|
|
|
|
import Control.Monad (when, forM_)
|
|
import Control.Monad.IO.Class (MonadIO, liftIO)
|
|
import Data.List (find, (\\))
|
|
import qualified Data.Text as T
|
|
import qualified Database.SQLite3 as SQLite3
|
|
import Orville.SQLite.FieldDefinition (FieldDefinition(..), Nullability(..), fieldColumnName, fieldSqlTypeName, fieldIsNullable)
|
|
import Orville.SQLite.SqlMarshaller (marshallerFieldDefinitions, marshallerDerivedColumns, marshallerFieldInfo)
|
|
import Orville.SQLite.SqlMarshaller (SqlMarshaller, marshallerDerivedColumns)
|
|
import Orville.SQLite.TableDefinition (TableDefinition(..), PrimaryKey(..))
|
|
import Orville.SQLite.Monad (OrvilleM)
|
|
```
|
|
|
|
Then continue the file:
|
|
|
|
```haskell
|
|
data MigrationOptions = MigrationOptions
|
|
{ runSchemaChanges :: Bool
|
|
}
|
|
|
|
defaultOptions :: MigrationOptions
|
|
defaultOptions = MigrationOptions { runSchemaChanges = True }
|
|
|
|
newtype SchemaItem = SchemaItem
|
|
{ unSchemaItem :: SchemaItemRep
|
|
}
|
|
|
|
data SchemaItemRep
|
|
= SchemaTableItem
|
|
{ schemaTableName :: String
|
|
, schemaMarshaller :: SqlMarshaller w r
|
|
, schemaPkName :: String
|
|
, schemaDropColumns :: [String]
|
|
}
|
|
|
|
schemaTable :: TableDefinition key writeEntity readEntity -> [String] -> SchemaItem
|
|
schemaTable tableDef dropCols =
|
|
let
|
|
pk = tablePrimaryKey tableDef
|
|
pkFieldDef = case pk of PrimaryKey _ fd -> fd
|
|
in SchemaItem $ SchemaTableItem
|
|
{ schemaTableName = tableName tableDef
|
|
, schemaMarshaller = tableMarshaller tableDef
|
|
, schemaPkName = fieldColumnName pkFieldDef
|
|
, schemaDropColumns = dropCols
|
|
}
|
|
|
|
dropColumns :: [String] -> TableDefinition key w r -> [String]
|
|
dropColumns = const
|
|
|
|
data MigrationStep
|
|
= CreateTable String [(String, String, Bool)] (String, String)
|
|
| AddColumn String String String Bool
|
|
| DropColumn String String
|
|
deriving (Show, Eq)
|
|
|
|
data ExistingColumn = ExistingColumn
|
|
{ existingName :: String
|
|
, existingType :: String
|
|
, existingNotNull :: Bool
|
|
, existingPk :: Bool
|
|
} deriving (Show, Eq)
|
|
|
|
generateMigrationPlan :: [SchemaItem] -> OrvilleM [MigrationStep]
|
|
generateMigrationPlan items = do
|
|
steps <- concat <$> mapM planItem items
|
|
pure steps
|
|
|
|
planItem :: SchemaItem -> OrvilleM [MigrationStep]
|
|
planItem (SchemaItem (SchemaTableItem tableName marshaller pkName dropCols)) = do
|
|
existingCols <- getExistingColumns tableName
|
|
let expectedCols = marshallerExpectedColumns marshaller pkName
|
|
pure $ planTableChanges tableName expectedCols existingCols dropCols
|
|
|
|
getExistingColumns :: String -> OrvilleM [ExistingColumn]
|
|
getExistingColumns tableName = do
|
|
db <- ask
|
|
liftIO $ do
|
|
stmt <- SQLite3.prepare db ("PRAGMA table_info(" <> T.pack tableName <> ")")
|
|
let loop acc = do
|
|
stepResult <- SQLite3.step stmt
|
|
case stepResult of
|
|
SQLite3.Row -> do
|
|
-- PRAGMA table_info columns: cid, name, type, notnull, dflt_value, pk
|
|
name <- SQLite3.columnText stmt 1
|
|
colType <- SQLite3.columnText stmt 2
|
|
notNull <- SQLite3.columnInt stmt 3
|
|
isPk <- SQLite3.columnInt stmt 4
|
|
loop (ExistingColumn (T.unpack name) (T.unpack colType) (notNull /= 0) (isPk /= 0) : acc)
|
|
SQLite3.Done -> do
|
|
SQLite3.finalize stmt
|
|
pure (reverse acc)
|
|
loop []
|
|
|
|
marshallerExpectedColumns ::
|
|
SqlMarshaller w r ->
|
|
String -> -- pk name
|
|
[(String, String, Bool)] -- (colName, colType, isNullable)
|
|
marshallerExpectedColumns marshaller pkName =
|
|
let fields = marshallerFieldInfo marshaller
|
|
in [(fieldInfoName f, fieldInfoType f, not (fieldInfoName f == pkName || not (fieldInfoIsNullable f))) | f <- fields]
|
|
|
|
planTableChanges ::
|
|
String ->
|
|
[(String, String, Bool)] ->
|
|
[ExistingColumn] ->
|
|
[String] -> -- columns to drop
|
|
[MigrationStep]
|
|
planTableChanges tableName expected existing dropCols
|
|
| null existing = [CreateTable tableName expected (findPk existing)]
|
|
| otherwise = addColumns ++ dropColumns
|
|
where
|
|
existingNames = map existingName existing
|
|
expectedNames = map (\(n,_,_) -> n) expected
|
|
|
|
-- Columns to add: in expected but not in existing
|
|
addColumns = do
|
|
(name, colType, _) <- expected
|
|
if name `notElem` existingNames
|
|
then [AddColumn tableName name colType True] -- added columns must be nullable
|
|
else []
|
|
|
|
-- Columns to drop: explicitly listed columns that exist
|
|
dropColumns = do
|
|
name <- dropCols
|
|
if name `elem` existingNames
|
|
then [DropColumn tableName name]
|
|
else []
|
|
|
|
-- Check for type / nullability mismatches (warn but don't error for MVP)
|
|
findPk cols =
|
|
case find existingPk cols of
|
|
Just col -> (existingName col, existingType col)
|
|
Nothing -> ("", "")
|
|
|
|
autoMigrateSchema :: MigrationOptions -> [SchemaItem] -> OrvilleM ()
|
|
autoMigrateSchema opts items = do
|
|
plan <- generateMigrationPlan items
|
|
when (runSchemaChanges opts) $
|
|
executeMigrationPlan plan
|
|
|
|
executeMigrationPlan :: [MigrationStep] -> OrvilleM ()
|
|
executeMigrationPlan = mapM_ executeStep
|
|
where
|
|
executeStep step = do
|
|
db <- ask
|
|
case step of
|
|
CreateTable name cols (pkName, _) ->
|
|
liftIO $ SQLite3.exec db (mkCreateTable name cols pkName)
|
|
AddColumn name colName colType _ ->
|
|
liftIO $ SQLite3.exec db ("ALTER TABLE " <> T.pack name <> " ADD COLUMN " <> T.pack colName <> " " <> T.pack colType)
|
|
DropColumn name colName ->
|
|
liftIO $ SQLite3.exec db ("ALTER TABLE " <> T.pack name <> " DROP COLUMN " <> T.pack colName)
|
|
|
|
mkCreateTable :: String -> [(String, String, Bool)] -> String -> T.Text
|
|
mkCreateTable name cols pkName =
|
|
T.pack $ "CREATE TABLE IF NOT EXISTS " <> name <> " (\n " <>
|
|
intercalate ",\n " (map mkColumnDef cols) <>
|
|
",\n PRIMARY KEY (" <> pkName <> ")\n)"
|
|
where
|
|
mkColumnDef (colName, colType, isNullable) =
|
|
colName <> " " <> colType <> if not isNullable then " NOT NULL" else ""
|
|
```
|
|
|
|
**Known issues to fix during build:**
|
|
- `marshallerFieldInfo` is defined in SqlMarshaller — need to export it
|
|
- `ask` needs `MonadReader` import
|
|
- `intercalate` needs to be `Data.List.intercalate` or a local definition
|
|
- The `generateMigrationPlan` return type doesn't need `OrvilleM` — could be pure. But accessing the database to query `PRAGMA table_info` requires `OrvilleM`, so the type is correct.
|
|
|
|
- [ ] **Step 2: Fix SqlMarshaller exports**
|
|
|
|
Edit `src/Orville/SQLite/SqlMarshaller.hs` to add `marshallerFieldInfo` and `FieldInfo(..)` to exports:
|
|
Add to the module export list:
|
|
```haskell
|
|
, FieldInfo(..)
|
|
, marshallerFieldInfo
|
|
```
|
|
Add the `FieldInfo` type definition:
|
|
```haskell
|
|
data FieldInfo = FieldInfo
|
|
{ fieldInfoName :: String
|
|
, fieldInfoType :: String
|
|
, fieldInfoIsNullable :: Bool
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Fix AutoMigration imports**
|
|
|
|
Review and add missing imports to `src/Orville/SQLite/AutoMigration.hs`:
|
|
```haskell
|
|
import Control.Monad.Reader (ask)
|
|
import Orville.SQLite.Monad (OrvilleM)
|
|
import qualified Data.Text as T
|
|
```
|
|
|
|
- [ ] **Step 4: Add `intercalate` helper to AutoMigration**
|
|
|
|
Add before `mkCreateTable`:
|
|
```haskell
|
|
import Data.List (intercalate)
|
|
```
|
|
|
|
- [ ] **Step 5: Verify compilation**
|
|
|
|
Run:
|
|
```bash
|
|
cd /work/personal/orville-sqlite && ./hs stack build 2>&1
|
|
```
|
|
|
|
Expected: Compilation errors related to `direct-sqlite` API differences (module names, type names). Fix each error:
|
|
- If `Database.SQLite3` doesn't export `exec` the way expected: look up the module with `./hs stack ghci -e ':browse Database.SQLite3'`
|
|
- If `SQLite3.step` returns `Either Error StepResult`: adjust pattern matches
|
|
- If column accessor names differ (`columnText` vs `column`): fix to match
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
cd /work/personal/orville-sqlite && git add src/Orville/SQLite/AutoMigration.hs src/Orville/SQLite/SqlMarshaller.hs && git commit -m "Add AutoMigration with schema comparison and DDL generation"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Execution — CRUD Operations
|
|
|
|
**Files:**
|
|
- Create: `src/Orville/SQLite/Execution.hs`
|
|
|
|
- [ ] **Step 1: Write the Execution module**
|
|
|
|
Write `/work/personal/orville-sqlite/src/Orville/SQLite/Execution.hs`:
|
|
|
|
```haskell
|
|
module Orville.SQLite.Execution
|
|
( insertEntity
|
|
, findEntity
|
|
, findAll
|
|
, updateEntity
|
|
, deleteEntity
|
|
) where
|
|
|
|
import Control.Monad.IO.Class (MonadIO, liftIO)
|
|
import Control.Monad.Reader (ask)
|
|
import qualified Data.Text as T
|
|
import qualified Database.SQLite3 as SQLite3
|
|
import Orville.SQLite.FieldDefinition (fieldColumnName, fieldToSqlValue, fieldFromSqlValue)
|
|
import Orville.SQLite.SqlMarshaller
|
|
( SqlMarshaller
|
|
, marshallerDerivedColumns
|
|
, marshallerEncodeWrite
|
|
, marshallerDecodeRow
|
|
)
|
|
import Orville.SQLite.TableDefinition (TableDefinition(..), PrimaryKey(..))
|
|
import Orville.SQLite.Monad (OrvilleM)
|
|
|
|
insertEntity ::
|
|
TableDefinition key writeEntity readEntity ->
|
|
writeEntity ->
|
|
OrvilleM ()
|
|
insertEntity tableDef entity = do
|
|
db <- ask
|
|
let pairs = marshallerEncodeWrite (tableMarshaller tableDef) entity
|
|
let colNames = map fst pairs
|
|
let placeholders = map (const "?") colNames
|
|
let sql = T.pack $
|
|
"INSERT INTO " <> tableName tableDef <>
|
|
" (" <> intercalate ", " colNames <> ") VALUES (" <>
|
|
intercalate ", " placeholders <> ")"
|
|
liftIO $ do
|
|
stmt <- SQLite3.prepare db sql
|
|
forM_ (zip [1..] pairs) $ \(i, (_, sqlVal)) ->
|
|
SQLite3.bind stmt i sqlVal
|
|
_ <- SQLite3.step stmt
|
|
SQLite3.finalize stmt
|
|
|
|
findEntity ::
|
|
TableDefinition key writeEntity readEntity ->
|
|
key ->
|
|
OrvilleM (Maybe readEntity)
|
|
findEntity tableDef key = do
|
|
db <- ask
|
|
let pkFieldDef = case tablePrimaryKey tableDef of
|
|
PrimaryKey _ fd -> fd
|
|
let cols = marshallerDerivedColumns (tableMarshaller tableDef)
|
|
let sql = T.pack $
|
|
"SELECT " <> intercalate ", " cols <>
|
|
" FROM " <> tableName tableDef <>
|
|
" WHERE " <> fieldColumnName pkFieldDef <> " = ?"
|
|
liftIO $ do
|
|
stmt <- SQLite3.prepare db sql
|
|
SQLite3.bind stmt 1 (fieldToSqlValue key pkFieldDef)
|
|
stepResult <- SQLite3.step stmt
|
|
case stepResult of
|
|
SQLite3.Done -> do
|
|
SQLite3.finalize stmt
|
|
pure Nothing
|
|
SQLite3.Row -> do
|
|
rowData <- getRowData stmt cols
|
|
SQLite3.finalize stmt
|
|
case marshallerDecodeRow (tableMarshaller tableDef) rowData of
|
|
Left err -> error $ "Decode error in findEntity: " <> err
|
|
Right entity -> pure (Just entity)
|
|
|
|
findAll ::
|
|
TableDefinition key writeEntity readEntity ->
|
|
OrvilleM [readEntity]
|
|
findAll tableDef = do
|
|
db <- ask
|
|
let cols = marshallerDerivedColumns (tableMarshaller tableDef)
|
|
let sql = T.pack $
|
|
"SELECT " <> intercalate ", " cols <>
|
|
" FROM " <> tableName tableDef
|
|
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 cols
|
|
case marshallerDecodeRow (tableMarshaller tableDef) rowData of
|
|
Left err -> error $ "Decode error in findAll: " <> err
|
|
Right entity -> loop (entity : acc)
|
|
loop []
|
|
|
|
updateEntity ::
|
|
TableDefinition key writeEntity readEntity ->
|
|
writeEntity ->
|
|
OrvilleM ()
|
|
updateEntity tableDef entity = do
|
|
db <- ask
|
|
let pairs = marshallerEncodeWrite (tableMarshaller tableDef) entity
|
|
let PrimaryKey pkAccessor pkFieldDef = tablePrimaryKey tableDef
|
|
let pkValue = pkAccessor entity
|
|
let setClauses = map (\(col, _) -> col <> " = ?") pairs
|
|
let sql = T.pack $
|
|
"UPDATE " <> tableName tableDef <>
|
|
" SET " <> intercalate ", " setClauses <>
|
|
" WHERE " <> fieldColumnName pkFieldDef <> " = ?"
|
|
liftIO $ do
|
|
stmt <- SQLite3.prepare db sql
|
|
forM_ (zip [1..] pairs) $ \(i, (_, sqlVal)) ->
|
|
SQLite3.bind stmt i sqlVal
|
|
let pkIndex = length pairs + 1
|
|
SQLite3.bind stmt pkIndex (fieldToSqlValue pkValue pkFieldDef)
|
|
_ <- SQLite3.step stmt
|
|
SQLite3.finalize stmt
|
|
|
|
deleteEntity ::
|
|
TableDefinition key writeEntity readEntity ->
|
|
key ->
|
|
OrvilleM ()
|
|
deleteEntity tableDef key = do
|
|
db <- ask
|
|
let pkFieldDef = case tablePrimaryKey tableDef of
|
|
PrimaryKey _ fd -> fd
|
|
let sql = T.pack $
|
|
"DELETE FROM " <> tableName tableDef <>
|
|
" WHERE " <> fieldColumnName pkFieldDef <> " = ?"
|
|
liftIO $ do
|
|
stmt <- SQLite3.prepare db sql
|
|
SQLite3.bind stmt 1 (fieldToSqlValue key pkFieldDef)
|
|
_ <- SQLite3.step stmt
|
|
SQLite3.finalize stmt
|
|
|
|
getRowData ::
|
|
SQLite3.Statement ->
|
|
[String] ->
|
|
IO [(String, SQLite3.SQLData)]
|
|
getRowData stmt cols = do
|
|
columnCount <- SQLite3.columnCount stmt
|
|
let indexes = [0 .. columnCount - 1]
|
|
mapM (\i -> do
|
|
colName <- if i < length cols
|
|
then pure (cols !! i)
|
|
else SQLite3.columnName stmt i
|
|
sqlVal <- SQLite3.column stmt i
|
|
pure (T.unpack colName, sqlVal)
|
|
) indexes
|
|
|
|
intercalate :: String -> [String] -> String
|
|
intercalate sep = \case
|
|
[] -> ""
|
|
[x] -> x
|
|
(x:xs) -> x <> sep <> intercalate sep xs
|
|
```
|
|
|
|
- [ ] **Step 2: Verify compilation**
|
|
|
|
Run:
|
|
```bash
|
|
cd /work/personal/orville-sqlite && ./hs stack build 2>&1
|
|
```
|
|
|
|
Expected: Build errors from `direct-sqlite` API mismatch. Fixes needed:
|
|
- `SQLite3.prepare` argument order or return type
|
|
- `SQLite3.bind` expects index + value or just value
|
|
- `SQLite3.step` return type (`Row`/`Done` vs `Either Error StepResult`)
|
|
- `SQLite3.column` return type
|
|
- `SQLite3.columnCount` availability
|
|
- `SQLite3.columnName` availability
|
|
|
|
Iterate on compilation until clean.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd /work/personal/orville-sqlite && git add src/Orville/SQLite/Execution.hs && git commit -m "Add Execution module with insertEntity, findEntity, findAll, updateEntity, deleteEntity"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Top-Level Module Re-exports
|
|
|
|
**Files:**
|
|
- Create: `src/Orville/SQLite.hs`
|
|
|
|
- [ ] **Step 1: Write the top-level module**
|
|
|
|
Write `/work/personal/orville-sqlite/src/Orville/SQLite.hs`:
|
|
|
|
```haskell
|
|
module Orville.SQLite
|
|
( -- * Monad
|
|
module Orville.SQLite.Monad
|
|
-- * SqlType
|
|
, module Orville.SQLite.SqlType
|
|
-- * FieldDefinition
|
|
, module Orville.SQLite.FieldDefinition
|
|
-- * SqlMarshaller
|
|
, module Orville.SQLite.SqlMarshaller
|
|
-- * TableDefinition
|
|
, module Orville.SQLite.TableDefinition
|
|
-- * AutoMigration
|
|
, module Orville.SQLite.AutoMigration
|
|
-- * Execution
|
|
, module Orville.SQLite.Execution
|
|
) where
|
|
|
|
import Orville.SQLite.Monad
|
|
import Orville.SQLite.SqlType
|
|
import Orville.SQLite.FieldDefinition
|
|
import Orville.SQLite.SqlMarshaller
|
|
import Orville.SQLite.TableDefinition
|
|
import Orville.SQLite.AutoMigration
|
|
import Orville.SQLite.Execution
|
|
```
|
|
|
|
- [ ] **Step 2: Fix re-export conflicts**
|
|
|
|
If any module re-exports clash (e.g., both export `intercalate`), either:
|
|
- Remove `intercalate` from sub-module exports
|
|
- Or qualify the re-export with `hiding`
|
|
|
|
Run:
|
|
```bash
|
|
cd /work/personal/orville-sqlite && ./hs stack build 2>&1
|
|
```
|
|
|
|
Fix any "duplicate export" or "ambiguous re-export" warnings.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd /work/personal/orville-sqlite && git add src/Orville/SQLite.hs && git commit -m "Add top-level Orville.SQLite re-export module"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Write and Run Acceptance Tests
|
|
|
|
**Files:**
|
|
- Create: `test/Main.hs`
|
|
|
|
- [ ] **Step 1: Write the test file**
|
|
|
|
Write `/work/personal/orville-sqlite/test/Main.hs`:
|
|
|
|
```haskell
|
|
{-# LANGUAGE DataKinds #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
module Main where
|
|
|
|
import Test.Hspec
|
|
import Orville.SQLite
|
|
|
|
data Person = Person
|
|
{ personId :: Int64
|
|
, firstName :: Text
|
|
, lastName :: Text
|
|
, age :: Int64
|
|
} deriving (Show, Eq)
|
|
|
|
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"
|
|
|
|
personMarshaller :: SqlMarshaller Person Person
|
|
personMarshaller =
|
|
Person
|
|
<$> marshallReadOnlyField personIdField
|
|
<*> marshallField firstName firstNameField
|
|
<*> marshallField lastName lastNameField
|
|
<*> marshallField age ageField
|
|
|
|
personTable :: TableDefinition Int64 Person Person
|
|
personTable =
|
|
mkTableDefinition "person" (primaryKey personId personIdField) personMarshaller
|
|
|
|
main :: IO ()
|
|
main = hspec $ do
|
|
describe "Orville.SQLite" $ do
|
|
it "creates a table and inserts a row" $ do
|
|
db <- openConnection ":memory:"
|
|
withConnection db $ do
|
|
autoMigrateSchema defaultOptions [schemaTable personTable []]
|
|
insertEntity personTable (Person 0 "Alice" "Smith" 30)
|
|
mAlice <- findEntity personTable 1
|
|
liftIO $ mAlice `shouldBe`
|
|
Just (Person 1 "Alice" "Smith" 30)
|
|
closeConnection db
|
|
|
|
it "finds all rows" $ do
|
|
db <- openConnection ":memory:"
|
|
withConnection db $ do
|
|
autoMigrateSchema defaultOptions [schemaTable personTable []]
|
|
insertEntity personTable (Person 0 "Alice" "Smith" 30)
|
|
insertEntity personTable (Person 0 "Bob" "Jones" 25)
|
|
results <- findAll personTable
|
|
liftIO $ length results `shouldBe` 2
|
|
closeConnection db
|
|
|
|
it "updates a row" $ do
|
|
db <- openConnection ":memory:"
|
|
withConnection db $ do
|
|
autoMigrateSchema defaultOptions [schemaTable personTable []]
|
|
insertEntity personTable (Person 0 "Alice" "Smith" 30)
|
|
updateEntity personTable (Person 1 "Alice" "Jones" 31)
|
|
mAlice <- findEntity personTable 1
|
|
liftIO $ mAlice `shouldBe`
|
|
Just (Person 1 "Alice" "Jones" 31)
|
|
closeConnection db
|
|
|
|
it "deletes a row" $ do
|
|
db <- openConnection ":memory:"
|
|
withConnection db $ do
|
|
autoMigrateSchema defaultOptions [schemaTable personTable []]
|
|
insertEntity personTable (Person 0 "Alice" "Smith" 30)
|
|
deleteEntity personTable 1
|
|
mAlice <- findEntity personTable 1
|
|
liftIO $ mAlice `shouldBe` Nothing
|
|
closeConnection db
|
|
|
|
it "returns Nothing for missing row" $ do
|
|
db <- openConnection ":memory:"
|
|
withConnection db $ do
|
|
autoMigrateSchema defaultOptions [schemaTable personTable []]
|
|
mAlice <- findEntity personTable 999
|
|
liftIO $ mAlice `shouldBe` Nothing
|
|
closeConnection db
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests**
|
|
|
|
Run:
|
|
```bash
|
|
cd /work/personal/orville-sqlite && ./hs stack test 2>&1
|
|
```
|
|
|
|
Expected: Tests may fail due to `direct-sqlite` API mismatches or logic bugs. Debug each failure:
|
|
|
|
- If `openConnection ":memory:"` returns connection but prepare fails: check `direct-sqlite` module API (e.g., `Database.SQLite3` vs `Database.SQLite3.Direct`)
|
|
- If `schemaTable personTable []` has type errors: verify `schemaTable` signature accepts args correctly
|
|
- If `insertEntity` succeeds but `findEntity` returns `Nothing` for auto-increment PK: check that SQLite's `INTEGER PRIMARY KEY` actually auto-increments (SQLite auto-increments `INTEGER PRIMARY KEY` columns by default for NULL values; ensure insert binds correct value)
|
|
- If `findEntity` can't decode results: check `getRowData` column index and name mapping
|
|
|
|
Iterate until all 5 tests pass.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd /work/personal/orville-sqlite && git add test/Main.hs && git commit -m "Add acceptance tests for CRUD operations"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Build Verification and Cleanup
|
|
|
|
- [ ] **Step 1: Full build with warnings**
|
|
|
|
Run:
|
|
```bash
|
|
cd /work/personal/orville-sqlite && ./hs stack build --pedantic 2>&1
|
|
```
|
|
|
|
Fix all warnings (unused imports, unused bindings, etc.).
|
|
|
|
- [ ] **Step 2: Run tests one final time**
|
|
|
|
Run:
|
|
```bash
|
|
cd /work/personal/orville-sqlite && ./hs stack test 2>&1
|
|
```
|
|
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 3: Format code**
|
|
|
|
Run:
|
|
```bash
|
|
cd /work/personal/orville-sqlite && ./hs fourmolu -i src/ test/ 2>&1
|
|
```
|
|
|
|
- [ ] **Step 4: Final commit**
|
|
|
|
```bash
|
|
cd /work/personal/orville-sqlite && git add -A && git commit -m "Clean up warnings and formatting"
|
|
```
|