sq 0.0.1 → 0.0.2
raw patch · 8 files changed
+377/−75 lines, 8 files
Files
- CHANGELOG.md +9/−0
- lib/Sq.hs +50/−65
- lib/Sq/Connection.hs +4/−2
- lib/Sq/Migrations.hs +175/−0
- lib/Sq/Names.hs +6/−2
- lib/Sq/Transactional.hs +87/−2
- sq.cabal +2/−1
- test/Sq/Test.hs +44/−3
CHANGELOG.md view
@@ -1,3 +1,12 @@+# Version 0.0.2++* Added `FromJSON`, `ToJSON` instances for `Name`.++* Improved type-parameter order in `zero`, `one`, `foldM` and similar.++* Added manual transactional migrations suport.++ # Version 0.0.1 * Initial version.
lib/Sq.hs view
@@ -3,9 +3,6 @@ -- | High-level SQLite client library ----- ⚠️ __This is an early preview release of this library. Use at your own risk.__------ -- @ -- import qualified "Sq" -- @@@ -34,6 +31,8 @@ -- * Type-safe __resource management__ (via 'A.Acquire', see 'new', 'with', -- 'uith'). --+-- * Manual __transactional migrations__ ('migrate').+-- -- * 'Savepoint's. -- -- * A lot of logging.@@ -42,14 +41,13 @@ -- -- * Type-safe 'SQL'. ----- * Manual and automatic migrations solution. -- -- * Probably other things. -- -- If you have questions or suggestions, just say so at -- <https://github.com/k0001/hs-sq/issues>. ----- ⚠️ __This is an early preview release of this library. Use at your own risk.__+-- Note: This library is young and needs more testing. module Sq ( -- * Statement Statement@@ -148,6 +146,14 @@ , savepointRollback , savepointRelease + -- * Migrations+ -- $migrations+ , migrate+ , migration+ , Migration+ , MigrationId+ , MigrationsTable+ -- * Miscellaneuos , Retry (..) , BindingName@@ -170,15 +176,12 @@ where import Control.Exception.Safe qualified as Ex-import Control.Foldl qualified as F import Control.Monad hiding (foldM) import Control.Monad.IO.Class import Control.Monad.Trans.Resource qualified as R import Control.Monad.Trans.Resource.Extra qualified as R import Data.Acquire qualified as A import Data.Function-import Data.Int-import Data.List.NonEmpty (NonEmpty) import Database.SQLite3 qualified as S import Di.Df1 qualified as Di import System.FilePath@@ -188,6 +191,7 @@ import Sq.Decoders import Sq.Encoders import Sq.Input+import Sq.Migrations import Sq.Mode import Sq.Names import Sq.Null@@ -290,63 +294,6 @@ -------------------------------------------------------------------------------- --- | Executes a 'Statement' expected to return __zero or one__ rows.------ * Throws 'ErrRows_TooMany' if more than one row.-maybe :: (SubMode t s) => Statement s i o -> i -> Transactional g r t (Maybe o)-maybe = foldM $ foldMaybeM ErrRows_TooMany-{-# INLINE maybe #-}---- | Executes a 'Statement' expected to return exactly __one__ row.------ * Throws 'ErrRows_TooFew' if zero rows, 'ErrRows_TooMany' if more than one row.-one :: (SubMode t s) => Statement s i o -> i -> Transactional g r t o-one = foldM $ foldOneM ErrRows_TooFew ErrRows_TooMany-{-# INLINE one #-}---- | Executes a 'Statement' expected to return exactly __zero__ rows.------ * Throws 'ErrRows_TooMany' if more than zero rows.-zero :: (SubMode t s) => Statement s i o -> i -> Transactional g r t ()-zero = foldM $ foldZeroM ErrRows_TooMany-{-# INLINE zero #-}---- | Executes a 'Statement' expected to return __one or more__ rows.------ * Returns the length of the 'NonEmpty' list, too.------ * Throws 'ErrRows_TooFew' if zero rows.-some- :: (SubMode t s)- => Statement s i o- -> i- -> Transactional g r t (Int64, NonEmpty o)-some = foldM $ foldNonEmptyM ErrRows_TooFew-{-# INLINE some #-}---- | Executes a 'Statement' expected to return __zero or more__ rows.------ * Returns the length of the list, too.-list- :: (SubMode t s)- => Statement s i o- -> i- -> Transactional g r t (Int64, [o])-list = fold foldList-{-# INLINE list #-}---- | __Purely fold__ all the output rows.-fold- :: (SubMode t s)- => F.Fold o z- -> Statement s i o- -> i- -> Transactional g r t z-fold = foldM . F.generalize-{-# INLINE fold #-}----------------------------------------------------------------------------------- -- | Execute a 'Read'-only 'Transactional' in a fresh 'Transaction' that will -- be automatically released when done. read@@ -378,3 +325,41 @@ -> m a rollback p = transactionalRetry $ rollbackTransaction p {-# INLINE rollback #-}++--------------------------------------------------------------------------------++-- $migrations+--+-- 1. List all the 'Migration's in chronological order.+-- Each 'Migration' is a 'Transactional' action on the database, identified+-- by a unique 'MigrationId'. Construct with 'migration'.+--+-- @+-- __migrations__ :: ["Sq".'Migration']+-- __migrations__ =+-- [ "Sq".'migration' /\"create users table\"/ createUsersTable+-- , "Sq".'migration' /\"add email column to users\"/ addUserEmailColumn+-- , "Sq".'migration' /\"create articles table\"/ createArticlesTable+-- , / ... more migrations ... /+-- ]+-- @+--+-- 2. Run any 'Migration's that haven't been run yet, if necessary, by performing+-- 'migrate' once as soon as you obtain your 'Write' connection 'Pool'.+-- 'migrate' will enforce that the 'MigrationId's, be unique, and will+-- make sure that any migration history in the 'MigrationsTable' is+-- compatible with the specified 'Migration's.+--+-- @+-- "Sq".'migrate' pool /\"migrations\"/ __migrations__ \\case+-- [] -> /... No migrations will run. .../+-- mIds -> /... Some migrations will run. Maybe backup things here? .../+-- @+--+-- 3. __Don't change your 'MigrationId's over time__. If you do, then the+-- history in 'MigrationsTable' will become unrecognizable by 'migrate'.+-- Also, avoid having the 'Transactional' code in each 'Migration' use your+-- domain types and functions, as doing so may force you to alter past+-- 'Transactional' if your domain types and functions change. Ideally,+-- you should write each 'Migration' in such a way that you never /have/+-- to modify them in the future.
lib/Sq/Connection.hs view
@@ -561,7 +561,8 @@ -- Note: This could be defined in terms of 'streamIO', but this implementation -- is faster because we avoid per-row resource management. foldIO- :: (MonadIO m, Ex.MonadMask m, SubMode t s)+ :: forall o z i t s m+ . (MonadIO m, Ex.MonadMask m, SubMode t s) => F.FoldM m o z -> A.Acquire (Transaction t) -- ^ How to acquire the 'Transaction' once the @m@ is executed,@@ -596,7 +597,8 @@ -- @m@ by means of 'R.runResourceT' or similar as soon as possible in order to -- release the 'Transaction' lock. streamIO- :: (R.MonadResource m, SubMode t s)+ :: forall o i t s m+ . (R.MonadResource m, SubMode t s) => A.Acquire (Transaction t) -- ^ How to acquire the 'Transaction' once the 'Z.Stream' starts -- being consumed, and how to release it when it's not needed anymore.
+ lib/Sq/Migrations.hs view
@@ -0,0 +1,175 @@+module Sq.Migrations {--}+ ( migrate+ , migration+ , Migration+ , MigrationId+ , MigrationsTable+ ) -- }+where++import Control.Exception.Safe qualified as Ex+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Resource.Extra qualified as R+import Data.Aeson qualified as Ae+import Data.List qualified as List+import Data.Set (Set)+import Data.Set qualified as Set+import Data.String+import Data.Text qualified as T+import Data.Time qualified as Time+import GHC.Records++import Sq.Connection+import Sq.Decoders+import Sq.Encoders+import Sq.Mode+import Sq.Names+import Sq.Pool+import Sq.Statement+import Sq.Transactional++-- | Name of the database table keeping a registry of executed 'Migration's, by+-- their 'MigrationId'.+--+-- * Same syntax rules as 'Name'.+--+-- * This table will be created and updated by 'migrate' as necessary.+newtype MigrationsTable = MigrationsTable Name+ deriving newtype (Eq, Ord, Show, IsString, Ae.FromJSON, Ae.ToJSON)++instance HasField "text" MigrationsTable T.Text where+ getField (MigrationsTable x) = x.text++-- | A single 'Migration' consisting of a 'Transactional' action uniquely+-- identified by a 'MigrationId'.+--+-- * Construct with 'migration'.+--+-- * Run through 'migrate'.+data Migration = Migration MigrationId (Transaction 'Write -> IO ())++instance HasField "id" Migration MigrationId where+ getField (Migration x _) = x++-- | 'Just' if at least one 'MigrationId' is duplicate.+duplicateMigrationId :: [Migration] -> Maybe MigrationId+duplicateMigrationId = go mempty+ where+ go :: Set MigrationId -> [Migration] -> Maybe MigrationId+ go !mIds = \case+ Migration mId _ : ms+ | Set.member mId mIds -> Just mId+ | otherwise -> go (Set.insert mId mIds) ms+ [] -> Nothing++-- | Define a single 'Migration' that, when executed, will perform+-- the given 'Transactional'.+--+-- * See 'Migration'.+migration+ :: MigrationId+ -> (forall g. Transactional g 'NoRetry 'Write ())+ -- ^ Notice the 'NoRetry'. In other words, this 'Transactional'+ -- can't perform 'retry' nor any 'Control.Applicative.Alternative'+ -- nor 'MonadPlus' features.+ -> Migration+migration mId ta = Migration mId \tx -> embed tx ta++-- | Unique identifier for a 'Migration' within a 'MigrationsTable'.+--+-- * You are supposed to type these statically, so construct a 'MigrationId'+-- by typing down the literal string.+newtype MigrationId = MigrationId T.Text+ deriving newtype (Eq, Ord, IsString, Show, EncodeDefault, DecodeDefault)++instance HasField "text" MigrationId T.Text where+ getField (MigrationId x) = x++--------------------------------------------------------------------------------++createMigrationsTable :: MigrationsTable -> Statement 'Write () ()+createMigrationsTable tbl =+ -- We are storing the timestamp in case we need it in the future.+ -- We aren't really using it now.+ writeStatement mempty mempty $+ fromString $+ "CREATE TABLE IF NOT EXISTS "+ <> show tbl+ <> " (ord INTEGER PRIMARY KEY NOT NULL CHECK (ord >= 0)"+ <> ", id TEXT UNIQUE NOT NULL"+ <> ", time TEXT NOT NULL)"++getMigrations+ :: MigrationsTable -> Statement 'Read () (Time.UTCTime, MigrationId)+getMigrations tbl =+ readStatement mempty (liftA2 (,) "time" "id") $+ fromString $+ "SELECT time, id FROM " <> show tbl <> " ORDER BY ord ASC"++pushMigration :: MigrationsTable -> Statement 'Write MigrationId Time.UTCTime+pushMigration tbl =+ writeStatement "id" "time" $+ fromString $+ "INSERT INTO "+ <> show tbl+ <> " (ord, time, id)"+ <> " SELECT t.ord"+ <> ", strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now', 'subsecond')"+ <> ", $id"+ <> " FROM (SELECT coalesce(max(ord) + 1, 0) AS ord FROM "+ <> show tbl+ <> ") AS t"+ <> " RETURNING time"++--------------------------------------------------------------------------------++-- | Run all the migrations in 'Migration's that haven't been run yet.+--+-- * If the 'MigrationId's are not compatible with the current migration+-- history as reported by 'MigrationsTable', there will be an exception.+--+-- * If 'MigrationsTable' doesn't exist, it will be created.+--+-- * All the changes are run in a single 'Transaction', including those to+-- 'MigrationsTable'.+migrate+ :: forall a m+ . (MonadIO m, Ex.MonadMask m)+ => Pool 'Write+ -- ^ Connection 'Pool' to the database to migrate.+ -> MigrationsTable+ -- ^ Name of the table where the registry of ran 'Migration's is kept.+ -> [Migration]+ -- ^ 'Migration's to apply to the database, if necessary, in chronological+ -- order.+ -> ([MigrationId] -> m a)+ -- ^ This will be performed __while the write transaction is active__,+ -- letting you know which 'MigrationId's are to be performed, and in which+ -- order.+ --+ -- This can be a good place to perform a backup of the database if+ -- necessary. Presumably, 'migrate' is being run during the initialization+ -- of your program, suggesting that nobody else is trying to write to the+ -- database at this time, so it's OK if this code takes a while to run.+ --+ -- Don't try to acquire a 'Write' transaction here, it will deadlock.+ -- It's OK to interact with the 'Pool' through 'Read'-only means.+ -> m a+migrate p tbl want k+ | Just mId <- duplicateMigrationId want =+ Ex.throwString $ "Duplicate migration id: " <> show mId+ | otherwise = R.withAcquire (commitTransaction p) \tx -> do+ pending <- embed tx do+ zero (createMigrationsTable tbl) ()+ (nran, ran) <- list (getMigrations tbl) ()+ case List.stripPrefix (fmap snd ran) (fmap (.id) want) of+ Just _ -> pure $ List.drop (fromIntegral nran) want+ Nothing ->+ Ex.throwString $+ "Incompatible migration history: " <> show (fmap snd ran)+ a <- k $ fmap (.id) pending+ forM_ pending \(Migration mId f) -> do+ liftIO $ f tx+ void $ embed tx $ one (pushMigration tbl) mId+ pure a
lib/Sq/Names.hs view
@@ -14,10 +14,11 @@ import Control.Applicative import Control.DeepSeq import Control.Monad-import Data.List.NonEmpty (NonEmpty(..))+import Data.Aeson qualified as Ae import Data.Attoparsec.Text qualified as AT import Data.Char qualified as Ch import Data.Coerce+import Data.List.NonEmpty (NonEmpty (..)) import Data.String import Data.Text qualified as T import GHC.Records@@ -29,10 +30,13 @@ -- -- Construct with 'name' or 'IsString'. newtype Name = Name T.Text- deriving newtype (Eq, Ord, Show, NFData)+ deriving newtype (Eq, Ord, Show, NFData, Ae.ToJSON) instance IsString Name where fromString = either error id . name . T.pack++instance Ae.FromJSON Name where+ parseJSON = Ae.withText "Name" (either fail pure . name) instance HasField "text" Name T.Text where getField = coerce
lib/Sq/Transactional.hs view
@@ -2,6 +2,12 @@ ( Transactional , embed , transactionalRetry+ , one+ , maybe+ , zero+ , some+ , list+ , fold , foldM , Ref , Retry (..)@@ -9,7 +15,7 @@ , orElse ) where -import Control.Applicative+import Control.Applicative hiding (some) import Control.Concurrent import Control.Concurrent.STM hiding (orElse, retry) import Control.Exception.Safe qualified as Ex@@ -24,9 +30,12 @@ import Control.Monad.Trans.Resource.Extra qualified as R hiding (runResourceT) import Data.Acquire qualified as A import Data.Coerce+import Data.Int import Data.IntMap.Strict (IntMap) import Data.IntMap.Strict qualified as IntMap import Data.Kind+import Data.List.NonEmpty (NonEmpty)+import Prelude hiding (Read, maybe, read) import Sq.Connection import Sq.Mode@@ -216,7 +225,8 @@ -- -- * For a non-'Transactional' version of this function, see 'Sq.foldIO'. foldM- :: (SubMode t s)+ :: forall o z i t s g r+ . (SubMode t s) => F.FoldM (Transactional g r t) o z -> Statement s i o -> i@@ -388,3 +398,78 @@ writeTVar tv $! Just a1 pure b Nothing -> Ex.throwM $ resourceVanishedWithCallStack "Ref"++--------------------------------------------------------------------------------++-- | Executes a 'Statement' expected to return __zero or one__ rows.+--+-- * Throws 'ErrRows_TooMany' if more than one row.+maybe+ :: forall o i t s g r+ . (SubMode t s)+ => Statement s i o+ -> i+ -> Transactional g r t (Maybe o)+maybe = foldM $ foldMaybeM ErrRows_TooMany+{-# INLINE maybe #-}++-- | Executes a 'Statement' expected to return exactly __one__ row.+--+-- * Throws 'ErrRows_TooFew' if zero rows, 'ErrRows_TooMany' if more than one row.+one+ :: forall o i t s g r+ . (SubMode t s)+ => Statement s i o+ -> i+ -> Transactional g r t o+one = foldM $ foldOneM ErrRows_TooFew ErrRows_TooMany+{-# INLINE one #-}++-- | Executes a 'Statement' expected to return exactly __zero__ rows.+--+-- * Throws 'ErrRows_TooMany' if more than zero rows.+zero+ :: forall o i t s g r+ . (SubMode t s)+ => Statement s i o+ -> i+ -> Transactional g r t ()+zero = foldM $ foldZeroM ErrRows_TooMany+{-# INLINE zero #-}++-- | Executes a 'Statement' expected to return __one or more__ rows.+--+-- * Returns the length of the 'NonEmpty' list, too.+--+-- * Throws 'ErrRows_TooFew' if zero rows.+some+ :: forall o i t s g r+ . (SubMode t s)+ => Statement s i o+ -> i+ -> Transactional g r t (Int64, NonEmpty o)+some = foldM $ foldNonEmptyM ErrRows_TooFew+{-# INLINE some #-}++-- | Executes a 'Statement' expected to return __zero or more__ rows.+--+-- * Returns the length of the list, too.+list+ :: forall o i t s g r+ . (SubMode t s)+ => Statement s i o+ -> i+ -> Transactional g r t (Int64, [o])+list = fold foldList+{-# INLINE list #-}++-- | __Purely fold__ all the output rows.+fold+ :: forall o z i t s g r+ . (SubMode t s)+ => F.Fold o z+ -> Statement s i o+ -> i+ -> Transactional g r t z+fold = foldM . F.generalize+{-# INLINE fold #-}
sq.cabal view
@@ -1,6 +1,6 @@ cabal-version: 2.4 name: sq-version: 0.0.1+version: 0.0.2 license: Apache-2.0 license-file: LICENSE extra-source-files: README.md CHANGELOG.md@@ -45,6 +45,7 @@ Sq.Decoders Sq.Encoders Sq.Input+ Sq.Migrations Sq.Mode Sq.Names Sq.Null
test/Sq/Test.hs view
@@ -1,6 +1,5 @@ module Sq.Test (tree) where -import Control.Applicative import Control.Concurrent import Control.Concurrent.Async qualified as Async import Control.Exception.Safe qualified as Ex@@ -10,18 +9,17 @@ import Control.Monad.Trans.Resource.Extra qualified as R import Data.Acquire.Internal qualified as A import Data.Foldable-import Data.Text qualified as T import Di qualified import Hedgehog qualified as H import Hedgehog.Gen qualified as H import Hedgehog.Range qualified as HR-import Sq qualified import System.Timeout import Test.Tasty (testGroup, withResource) import Test.Tasty.HUnit (testCase, (@?=)) import Test.Tasty.Hedgehog (testProperty) import Test.Tasty.Runners (TestTree) +import Sq qualified import Sq.Test.Codec qualified --------------------------------------------------------------------------------@@ -55,6 +53,7 @@ ysLen H.=== fromIntegral (length ys) xs H.=== ys , testExample1 di+ , testMigs di ] withAcquire :: A.Acquire a -> (IO a -> TestTree) -> TestTree@@ -132,3 +131,45 @@ timeout 500_000 (example1 pool) >>= \case Just () -> pure () Nothing -> fail "example1: timeout!"++--------------------------------------------------------------------------------++testMigs :: Di.Df1 -> TestTree+testMigs di0 = testCase "migs" do+ Sq.with (Sq.tempPool di0) \pool -> do+ Sq.migrate pool "migs" migsAB \ids -> ids @?= ["A", "B"]+ Sq.migrate pool "migs" migsAB \ids -> ids @?= []+ for_ [migsCD, []] \migs ->+ Ex.catchJust+ ( \case+ Ex.StringException m _ ->+ guard $ m == "Incompatible migration history: [\"A\",\"B\"]"+ )+ (Sq.migrate pool "migs" migs mempty)+ pure+ Sq.migrate pool "migs" migsAB \ids -> ids @?= []+ Sq.migrate pool "migs" (migsAB <> migsCD) \ids -> ids @?= ["C", "D"]+ Sq.migrate pool "migs" (migsAB <> migsCD) \ids -> ids @?= []+ Sq.read pool do+ (2, [7, 8 :: Int]) <-+ Sq.list+ (Sq.readStatement mempty "x" "SELECT x FROM t ORDER BY x ASC")+ ()+ pure ()+ where+ migsAB :: [Sq.Migration]+ migsAB =+ [ Sq.migration "A" (pure ())+ , Sq.migration "B" (pure ())+ ]+ migsCD :: [Sq.Migration]+ migsCD =+ [ Sq.migration "C" do+ Sq.zero @()+ (Sq.writeStatement mempty mempty "CREATE TABLE t (x INTEGER)")+ ()+ , Sq.migration "D" do+ Sq.zero @()+ (Sq.writeStatement mempty mempty "INSERT INTO t (x) VALUES (7), (8)")+ ()+ ]