13 KiB
orville-sqlite MVP Design
Date: 2026-05-29
Status: Draft
Scope: Minimal Viable Product — type-safe SQLite API modeled after Flipstone Orville
Overview
orville-sqlite is a Haskell library providing a type-safe SQLite API modeled after the Flipstone Orville PostgreSQL library. It maps Haskell data types to SQLite tables with compile-time schema guarantees.
The MVP covers three layers:
- FieldDefinition & SqlType — type-safe column mapping
- SqlMarshaller & TableDefinition — record-to-table mapping with primary keys
- AutoMigration — automatic schema creation and column migration
- Execution — basic CRUD operations
What's NOT in MVP
- Foreign keys
- Indexes (beyond implicit primary key indexes)
- Composite primary keys
- Table comments
- Plans (N+1 query prevention)
- Batch operations
- Upsert
findEntitiesBy(filter on non-PK columns)updateFields(partial update)marshallPartial,marshallQualifyFields,prefixMarshallerAnnotatedSqlMarshaller/marshallResultFromSqlSyntheticField- Nested record marshalling
- Raw SQL execution (
executeAndDecode)
Module Structure
Single package orville-sqlite. Flat module hierarchy under Orville.SQLite.*.
src/
Orville/SQLite.hs
Orville/SQLite/Monad.hs
Orville/SQLite/RawSql.hs
Orville/SQLite/SqlType.hs
Orville/SQLite/FieldDefinition.hs
Orville/SQLite/SqlMarshaller.hs
Orville/SQLite/TableDefinition.hs
Orville/SQLite/AutoMigration.hs
Orville/SQLite/Execution.hs
Dependencies: base >=4.17 && <5, direct-sqlite, text, bytestring
Comparison to Orville PostgreSQL: Drops ~60+ submodules. No Expression sub-hierarchy, no Execution sub-hierarchy, no extensions/sequences/triggers/indexes/windows/locking.
Component Designs
1. SqlType (Orville.SQLite.SqlType)
SQLite has 5 storage classes: NULL, INTEGER, REAL, TEXT, BLOB. The SqlType encodes how Haskell values convert to/from SQLite values.
data SqlType a = SqlType
{ sqlTypeName :: String -- "INTEGER", "TEXT", "BLOB", "REAL"
, sqlTypeToSql :: a -> SqlValue -- Haskell → SQLite value
, sqlTypeFromSql :: SqlValue -> Either String a -- SQLite value → Haskell
}
Pre-built instances:
integerType :: SqlType Int64textType :: SqlType TextrealType :: SqlType DoubleblobType :: SqlType ByteString
Helper: convertSqlType :: (a -> b) -> (b -> a) -> SqlType a -> SqlType b for wrapping custom types.
2. FieldDefinition (Orville.SQLite.FieldDefinition)
A GADT parameterized by nullability. Maps a Haskell type to a named SQL column.
data Nullability = NotNull | Nullable
data FieldDefinition (nullability :: Nullability) a where
FieldDefinition ::
{ fieldName :: String
, fieldSqlType :: SqlType a
} -> FieldDefinition 'NotNull a
NullableFieldDefinition ::
{ nullableFieldName :: String
, nullableFieldSqlType :: SqlType a
} -> FieldDefinition 'Nullable a
Construction functions:
integerField :: String -> FieldDefinition 'NotNull Int64textField :: String -> FieldDefinition 'NotNull TextrealField :: String -> FieldDefinition 'NotNull DoubleblobField :: String -> FieldDefinition 'NotNull ByteStringnullableField :: FieldDefinition 'NotNull a -> FieldDefinition 'Nullable aconvertField :: (a -> b) -> (b -> a) -> FieldDefinition null a -> FieldDefinition null b
3. SqlMarshaller (Orville.SQLite.SqlMarshaller)
A GADT combining field definitions into record-level marshalling using applicative syntax. This is the core type-safe mapping between Haskell records and SQLite tables.
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 :: FieldDefinition nullability a -> SqlMarshaller a a
Combinators:
marshallField :: (writeEntity -> a) -> FieldDefinition 'NotNull a -> SqlMarshaller writeEntity a— read+write columnmarshallReadOnlyField :: FieldDefinition nullability a -> SqlMarshaller writeEntity a— for DB-populated columns (e.g., auto-increment PK). Excluded from INSERT/UPDATE, included in SELECT.marshallMaybe :: FieldDefinition 'Nullable a -> SqlMarshaller writeEntity (Maybe a)— wraps nullable column asMaybe, handles SQL NULL.
Internal operations (not exposed as public API):
- Collect derived columns for SELECT
- Encode writeEntity to
[(String, SqlValue)]for INSERT/UPDATE - Decode a result row into readEntity
- Enumerate
FieldDefinitions for AutoMigration schema comparison
Usage pattern:
data Person = Person { firstName :: Text, lastName :: Text, age :: Int64 }
personMarshaller :: SqlMarshaller Person Person
personMarshaller =
Person
<$> marshallField firstName firstNameField
<*> marshallField lastName lastNameField
<*> marshallField age ageField
4. TableDefinition (Orville.SQLite.TableDefinition)
Ties a table name, primary key, and marshaller together.
data PrimaryKey writeEntity key where
PrimaryKey ::
(writeEntity -> key) -> -- accessor to extract key from writeEntity
FieldDefinition 'NotNull key -> -- column definition
PrimaryKey writeEntity key
data TableDefinition key writeEntity readEntity = TableDefinition
{ tableName :: String
, tablePrimaryKey :: PrimaryKey writeEntity key
, tableMarshaller :: SqlMarshaller writeEntity readEntity
}
The PrimaryKey includes an accessor function so that updateEntity and deleteEntity can extract the key value from the writeEntity for the WHERE clause, even when the PK field is mapped as read-only in the marshaller.
Construction:
primaryKey :: (writeEntity -> key) -> FieldDefinition 'NotNull key -> PrimaryKey writeEntity keymkTableDefinition :: String -> PrimaryKey writeEntity key -> SqlMarshaller writeEntity readEntity -> TableDefinition key writeEntity readEntitymkTableDefinitionWithoutKey :: String -> SqlMarshaller w r -> TableDefinition () w r
5. AutoMigration (Orville.SQLite.AutoMigration)
Compares expected schema (from TableDefinition) against actual SQLite schema (via PRAGMA table_info) and generates DDL.
API
data MigrationOptions = MigrationOptions
{ runSchemaChanges :: Bool -- True = apply changes, False = plan only
}
defaultOptions :: MigrationOptions
defaultOptions = MigrationOptions { runSchemaChanges = True }
data SchemaItem where
SchemaTable :: TableDefinition key writeEntity readEntity -> SchemaItem
autoMigrateSchema :: MigrationOptions -> [SchemaItem] -> OrvilleM ()
generateMigrationPlan :: [SchemaItem] -> OrvilleM [MigrationStep]
executeMigrationPlan :: [MigrationStep] -> OrvilleM ()
data MigrationStep
= CreateTable String [(String, String, Bool)] (String, String)
-- table name, [(colName, colType, notNull)], (pkName, pkType)
| AddColumn String String String Bool
-- table name, colName, colType, notNull
| DropColumn String String
-- table name, colName
Explicit column drop opt-in (mirroring Orville):
dropColumns :: [String] -> TableDefinition key w r -> SchemaItem
Schema Comparison Logic
- Query
PRAGMA table_info(<tableName>)which returns(cid, name, type, notnull, dflt_value, pk) - Table missing → generate
CREATE TABLE IF NOT EXISTSwith all expected columns and primary key constraint - Column missing in DB but expected → generate
ALTER TABLE ... ADD COLUMN. The column must be nullable. If the ColumnDefinition specifies NOT NULL, error with guidance suggesting multi-step migration. - Column in DB but not expected → ignored (kept). Columns are only dropped with explicit
dropColumnsopt-in. - Column type or nullability mismatch → error with guidance explaining the mismatch and suggesting multi-step migration approach.
Error Handling for Incompatible Changes
When a column type or nullability changes in a way SQLite can't handle via ALTER TABLE:
- Error message includes: table name, column name, expected vs actual type/nullability
- Suggestion: "Consider a multi-step migration: add a new nullable column, backfill data, then use dropColumns to remove the old column"
SQLite DDL Constraints (Informing Design)
ALTER TABLEcan: rename table, add column (to end), rename column, drop columnALTER TABLEcannot: change column type, change NOT NULL constraint, remove column without explicit drop- Added columns must be nullable or have a default value
6. Execution (Orville.SQLite.Execution)
Basic CRUD operations running in OrvilleM.
API
-- INSERT
insertEntity :: TableDefinition key w r -> w -> OrvilleM ()
-- SELECT
findEntity :: TableDefinition key w r -> key -> OrvilleM (Maybe r)
findAll :: TableDefinition key w r -> OrvilleM [r]
-- UPDATE
updateEntity :: TableDefinition key w r -> w -> OrvilleM ()
-- DELETE
deleteEntity :: TableDefinition key w r -> key -> OrvilleM ()
How Operations Work
insertEntity: Extracts (colName, sqlValue) pairs from writeEntity via marshaller (excluding read-only fields), builds INSERT INTO table (cols) VALUES (vals), binds values as parameters, executes via direct-sqlite.
findEntity: Uses marshaller to generate SELECT cols FROM table WHERE pk = ?, binds the key value, decodes the single result row. Returns Nothing if no row found.
findAll: Generates SELECT cols FROM table, decodes all rows.
updateEntity: Generates UPDATE table SET col=val, ... WHERE pk = val. Only non-read-only fields from the marshaller are included in the SET clause. The PK value for the WHERE clause is extracted from writeEntity via PrimaryKey's accessor function (which works even when the PK is mapped as read-only in the marshaller for auto-increment columns).
deleteEntity: DELETE FROM table WHERE pk = ? — the PK value is extracted from the provided key argument via the PrimaryKey's FieldDefinition for encoding.
Error Handling
Uses MarshallError for decoding failures — identifies which column failed and why (type mismatch, out of range, etc.).
7. Monad (Orville.SQLite.Monad)
A simple reader monad wrapping a direct-sqlite Database handle.
newtype OrvilleM a = OrvilleM (ReaderT Database IO a)
deriving (Functor, Applicative, Monad, MonadIO, MonadReader Database)
withConnection :: Database -> OrvilleM a -> IO a
openConnection :: String -> IO Database
closeConnection :: Database -> IO ()
No connection pool. No OrvilleState. No transaction callbacks. For MVP, transactions are explicit:
withTransaction :: OrvilleM a -> OrvilleM a
Example Usage (Target API)
import Orville.SQLite
data Person = Person
{ personId :: Int64
, firstName :: Text
, lastName :: Text
, age :: Int64
}
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 = do
db <- openConnection "people.db"
withConnection db $ do
autoMigrateSchema defaultOptions [SchemaTable personTable]
insertEntity personTable (Person 0 "Alice" "Smith" 30) -- 0 is ignored (read-only PK)
mAlice <- findEntity personTable 1
-- mAlice = Just (Person 1 "Alice" "Smith" 30)
updateEntity personTable (Person 1 "Alice" "Smith" 31) -- PK extracted for WHERE
pure ()
closeConnection db
Implementation Order
- Monad + RawSql — Foundation:
OrvilleM,withConnection,openConnection, minimal raw SQL builder - SqlType — Type encoding/decoding for SQLite storage classes
- FieldDefinition — GADT with
NotNull/Nullable, construction functions - SqlMarshaller — GADT with applicative combinators, encode/decode/column listing internals
- TableDefinition —
mkTableDefinition,PrimaryKey - AutoMigration —
PRAGMA table_infointrospection, schema comparison, DDL generation - Execution —
insertEntity,findEntity,findAll,updateEntity,deleteEntity - Top-level re-exports —
Orville.SQLitemodule