{-# LANGUAGE QuantifiedConstraints #-} {-# LANGUAGE TypeFamilyDependencies #-} {-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE FunctionalDependencies #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE PolyKinds #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE TypeOperators #-} {-# LANGUAGE UndecidableInstances #-} -- | This module provides a backend-agnostic abstraction on top on tabular data -- allowing projections to be decoupled from their storage. -- -- A 'Table' is a list (if using 'Flat') or a hash map -- (if using 'WithUniqueKeys') of 'Tuple's. -- 'TabularDataAction's can be performed on a 'Table' in memory or a table in a -- database with the help of an adaptor translating them in commands. -- For example, `eventsourcing-postgresql` has a function -- `fromTabularDataActions`. -- -- @ -- type UserCols = -- 'WithUniqueKey -- '[ '("user_id", Int)] -- -- Key columns. (The backtick and the space are important for it to be -- -- parsed correctly since the list only has one element.) -- ['("email", String), '("admin", Bool)] -- Other columns. -- -- type User f = Tuple f UserCols -- -- completeUser :: User Identity -- completeUser = 3 ~: "admin@example.com" ~: True ~: empty -- -- incompleteUser :: User Last -- incompleteUser = -- field @UserCols @"admin" True -- <> field @UserCols @"email" "admin@example.com" -- -- userConditions :: User Conditions -- userConditions = -- ffield @UserCols @"admin" (equal True) -- <> ffield @UserCols @"user_id" (lowerThan 100) -- -- userStrings :: [(String, String)] -- userStrings = toList @Show (maybe "NULL" show . getLast) incompleteUser -- -- [("user_id", "NULL"), ("email", "\"admin@example.com\""), ("admin", "True")] -- @ module Database.CQRS.TabularData ( TabularDataAction(..) , Condition(..) , Conditions(..) , equal , notEqual , lowerThan , lowerThanOrEqual , greaterThan , greaterThanOrEqual , Update(..) , set , plus , minus , Columns(..) , Tuple , Flatten , FlatTuple(..) , pattern (:~) , empty , Table , field , ffield , MergeSplitTuple(..) , applyTabularDataAction , GetKeyAndConditions , AllColumns , toList ) where import Control.Monad (foldM) import Data.Hashable (Hashable(..)) import Data.Kind (Type) import Data.List (foldl') import qualified Control.Monad.Except as Exc import qualified Control.Monad.Identity as Id import qualified Control.Monad.State.Strict as St import qualified Data.HashMap.Strict as HM import Database.CQRS.TabularData.Internal import qualified Database.CQRS as CQRS equal :: Eq a => a -> Conditions a equal = Conditions . pure . Equal notEqual :: Eq a => a -> Conditions a notEqual = Conditions . pure . NotEqual lowerThan :: Ord a => a -> Conditions a lowerThan = Conditions . pure . LowerThan lowerThanOrEqual :: Ord a => a -> Conditions a lowerThanOrEqual = Conditions . pure . LowerThanOrEqual greaterThan :: Ord a => a -> Conditions a greaterThan = Conditions . pure . GreaterThan greaterThanOrEqual :: Ord a => a -> Conditions a greaterThanOrEqual = Conditions . pure . GreaterThanOrEqual set :: a -> Update a set = Set plus :: Num a => a -> Update a plus = Plus minus :: Num a => a -> Update a minus = Minus -- | Action on tabular data with an index. -- -- Its purpose is to be used by an 'EffectfulProjection' to create persisting -- backend-agnostic projections. data TabularDataAction (cols :: Columns) where Insert :: Tuple Id.Identity cols -> TabularDataAction cols Update :: Tuple Update cols -> Tuple Conditions cols -> TabularDataAction cols Upsert :: Tuple Id.Identity ('WithUniqueKey keyCols cols) -> TabularDataAction ('WithUniqueKey keyCols cols) -- ^ Insert a new row or update the row with the same key if it exists. Delete :: Tuple Conditions cols -> TabularDataAction cols deriving instance AllColumns Show (Flatten cols) => Show (TabularDataAction cols) -- | In-memory table that supports 'TabularDataAction'. -- See 'applyTabularDataAction'. type family Table (cols :: Columns) :: Type where Table ('WithUniqueKey keyCols cols) = HM.HashMap (FlatTuple Id.Identity keyCols) (FlatTuple Id.Identity cols) Table ('Flat cols) = [FlatTuple Id.Identity cols] class ApplyTabularDataAction f (cols :: Columns) where -- | Apply some 'TabularDataAction' on an in-memory table and return a new -- table. applyTabularDataAction :: Table cols -> TabularDataAction cols -> f (Table cols) instance Applicative f => ApplyTabularDataAction f ('Flat cols) where applyTabularDataAction tbl = pure . \case Insert tuple -> tuple : tbl Update updates conditions -> map (\tuple -> if tuple `matches` conditions then update updates tuple else tuple) tbl Delete conditions -> filter (`matches` conditions) tbl instance ( AllColumns Show keyCols , Exc.MonadError CQRS.Error m , GetKeyAndConditions keyCols cols , Hashable (FlatTuple Id.Identity keyCols) , Ord (FlatTuple Id.Identity keyCols) , MergeSplitTuple keyCols cols ) => ApplyTabularDataAction m ('WithUniqueKey keyCols cols) where applyTabularDataAction tbl = \case Insert tuple -> do let (keyTuple, otherTuple) = splitTuple tuple op = \case Nothing -> pure $ Just otherTuple Just _ -> Exc.throwError . CQRS.ProjectionError $ "duplicate key on insert: " ++ show keyTuple HM.alterF op keyTuple tbl Update updates conditions -> case getKeyAndConditions conditions of Just (keyTuple, otherConditions) -> case HM.lookup keyTuple tbl of Nothing -> pure tbl Just otherTuple | otherTuple `matches` otherConditions -> do let (keyTuple', otherTuple') = splitTuple . update updates . mergeTuple $ (keyTuple, otherTuple) if keyTuple == keyTuple' then pure $ HM.insert keyTuple otherTuple' tbl else case HM.lookup keyTuple' tbl of Nothing -> pure . HM.delete keyTuple . HM.insert keyTuple' otherTuple' $ tbl Just _ -> Exc.throwError . CQRS.ProjectionError $ "duplicate key on update: " ++ show keyTuple' | otherwise -> pure tbl -- It traverses the hash map collecting changing values if the key -- doesn't change but the row matches the condition. When the key -- changes, it keeps track of the key that has to be deleted and the new -- row that has to be inserted. After the traversal, it deletes all the -- rows in the first list and inserts all the rows from the second -- checking for duplicated keys. Nothing -> do let step keyTuple otherTuple = do let merged = mergeTuple (keyTuple, otherTuple) if merged `matches` conditions then do let (keyTuple', otherTuple') = splitTuple $ update updates merged if keyTuple == keyTuple' then pure $ Just otherTuple' else do St.modify' $ \(tbd, tbi) -> (keyTuple : tbd, (keyTuple', otherTuple') : tbi) pure $ Just otherTuple else pure $ Just otherTuple (toBeDeleted, toBeInserted) = St.execState (HM.traverseWithKey step tbl) ([], []) tbl' = foldl' (flip HM.delete) tbl toBeDeleted foldM (\t (keyTuple, otherTuple) -> case HM.lookup keyTuple t of Just _ -> Exc.throwError . CQRS.ProjectionError $ "duplicate key on update: " ++ show keyTuple Nothing -> pure . HM.insert keyTuple otherTuple $ t ) tbl' toBeInserted Upsert tuple -> do let (keyTuple, otherTuple) = splitTuple tuple pure $ HM.insert keyTuple otherTuple tbl Delete conditions -> case getKeyAndConditions conditions of Just (keyTuple, otherConditions) -> do let op = \case Nothing -> Nothing Just otherTuple | otherTuple `matches` otherConditions -> Nothing | otherwise -> Just otherTuple pure . HM.alter op keyTuple $ tbl Nothing -> do let op keyTuple otherTuple | mergeTuple (keyTuple, otherTuple) `matches` conditions = Nothing | otherwise = Just otherTuple pure . HM.mapMaybeWithKey op $ tbl