module Interactive.SQL
import Derive.Sqlite3
import Control.RIO.Sqlite3
import Control.RIO.App
import Control.RIO
import Data.Sigma
import Data.Container
import Data.Container.Morphism
import Data.Container.Kleene
import Data.Container.Descriptions.Maybe
import Data.Maybe
import Data.Either
import Data.Sum
import Data.String.Parser
import JSON.Derive
import System.File
%language ElabReflection
%default total
%hide Data.List.Alternating.infixr.(<+)
%hide Data.List.Alternating.infixl.(<+)
%hide Data.String.Extra.infixr.(<+)
%hide Data.String.Extra.infixl.(+>)Do demonstrate how to use container morphisms to build software, I build a simple to-do app using the building blocs we’ve seen.
A to-do app is a piece of software aimed to help users store and recall items representing actions to take in the future. I will implement this app as a
Datatypes
First we describe datatypes, we need a couple of base definitions, the first is an ID type for uniquely identifying todo items.
public export
ID : Type
ID = IntegerA todo item has a boolean status, either it is completed, or it is not done yet.
-- And a status to indicate if the item is marked as done.
data Status = Done | NotDone
toBool : Status -> Bool
toBool Done = True
toBool NotDone = False
fromBool : Bool -> Status
fromBool True = Done
fromBool False = NotDoneA todo item is a record of an ID to identify it, a content string for the text entered by the user, and a Status to know if it’s been marked done or not.
record Todo where
constructor MkTodo
id : ID
content : String
status : StatusThe app will accept commands that will perform operations relevant to the app’s functionality, in particular, we need to be able to create a todo item,
delete an item using its ID, mark an item completed using its ID and obtain the list of all todo items.
data TodoCommand
= Create String
| Delete ID
| MarkComplete ID
| RetrieveAllFor each of those commands, we provide the type of the answers sent back to the clients from the servers.
Most commands do not result in a value sent back to the client, but only perform a side effect, and so the type
of the returned value is Unit. Only RetrieveAll returns a value to the user, and therefore its type is Table Todo.
Table is the type of tables returned by SQL queries, and Todo is the type we defined above.
CmdReturn : TodoCommand -> Type
CmdReturn (Create str) = Unit
CmdReturn (Delete i) = Unit
CmdReturn (MarkComplete i) = Unit
CmdReturn RetrieveAll = Table Todo
SQL setup
For this app, I will use SQL bindings. They require some boilerplate to work with the types defined above, once the bindings are defined, we can seamlessly interact with the SQL database using the messages we defined.
First the boilerplate to serialise and deserialise types into database fields and rows:
%runElab derive "TodoCommand" [Show,Eq,ToJSON,FromJSON]
FromCell Status where
fromCellType = INTEGER
fromCell = map fromBool . fromCell
ToCell Status where
toCellType = INTEGER
toCell = toCell . toBool
FromRow Todo where
fromRowTypes = [INTEGER, TEXT, INTEGER]
fromRow [i, c, s] = do id <- fromCell i
cont <- fromCell c
stat <- fromCell s
pure (MkTodo id cont stat)
ToRow Todo where
toRowTypes = [INTEGER, TEXT, INTEGER]
toRow (MkTodo id cont stat) = [toCell (cast {to = Int32} id), toCell cont, toCell stat]
-- A valid query is one with a type that has the appropriate instances
-- for row conversions
record ValidQuery where
constructor MkValidQuery
0 ty : Type
tr : ToRow ty
qy : Query ty
{auto 0 chk : ToRowTypes ty = FromRowTypes ty}
-- We can run any valid query and obtain the list of rows for that query
runValid : Has SqlError e => DB => (x : ValidQuery) -> App e (Table (x .ty))
runValid (MkValidQuery ty tr qy ) = queryTable qy 1000
-- The schema of our database. We onlye have
-- a single table with all the todo items.
Todos : SQLTable
Todos =
table "todos"
[ C "todo_id" INTEGER
, C "content" TEXT
, C "status" BOOL
]
-- We hard-code a SQL command to create our table. This is going to
-- be called when we setup the table
createTodos : Cmd TCreate
createTodos =
IF_NOT_EXISTS $ CREATE_TABLE Todos
[ PRIMARY_KEY ["todo_id"]
, AUTOINCREMENT "todo_id"
, NOT_NULL "content"
, NOT_NULL "status"
]
-- SQL command to add an item to the todo table
addTodoItem : String -> Cmd TInsert
addTodoItem str = INSERT Todos ["content", "status"] (val str :: 0 :: [])
-- SQL command to delete an item from the todo table
deleteItem : Bits32 -> Cmd TDelete
deleteItem i = DELETE Todos ("todo_id" == val i)
-- SQL command to mark an item done
itemMarkDone : Bits32 -> Cmd TUpdate
itemMarkDone i = UPDATE Todos ["status" .= True] ("todo_id" == val i)
-- SQL query to obtain all the todo items
getAllItems : Query Todo
getAllItems = SELECT ["todo_id", "content", "status"] [< FROM Todos]
Containers for the app
Next, we need to figure out what are the APIs that we are going to use, and how they are represented as containers.
First
-- The CLI is represented as a container that expects strings as input
-- and returns strings as output.
CLI : Container
CLI = String :- String
-- AppOp is the abstract API of our app, we take in commands, and returns
-- either unit, or tables from queries. Will run in FM.
AppOp : Container
AppOp = (!>) TodoCommand CmdReturn
-- Interface for database commands. Commands only perform side-effects so the
-- results are always Unit
DBCmd : Container
DBCmd = Σ CmdType Cmd :- ()
-- Interface of a database queries. Queries always return tables of a matching type
0 DBQry : Container
DBQry = (x : ValidQuery) !> Table x.ty
----------------------------------------------------------------------------------
-- Containers Morphisms --
----------------------------------------------------------------------------------
-- The errors that our program will deal with
0 Errs : List Type
Errs = [SqlError]
-- The monad in which we run our program as an endofunctor in Cont
0 FM : Container -> Container
FM x = F (App Errs) x
---------------------------------
-- Running database operations --
---------------------------------
-- Running a command as a costate
CmdCostate : DB => Costate (FM DBCmd)
CmdCostate = costate (\x => cmd {t = x.π1} x.π2)
-- Running a query as a costate
DBQueryCostate : DB => Costate (FM DBQry)
DBQueryCostate = costate runValid
-- Running either a command or a query on a database as a costate
DBCostate : DB => Costate (FM (DBCmd + DBQry))
DBCostate = distrib_plus {f = App Errs, a = DBCmd, b = DBQry}
|> CmdCostate + DBQueryCostate |> dia
---------------------------------------
-- Interacting with the command line --
---------------------------------------
-- We need a parser to parse command line arguments and translate them into
-- internal Todo commands.
cmdParser : Parser TodoCommand
cmdParser = assert_total $
string "create" *> spaces *> char '"' *> Create <$> takeWhile (/= '"')
<|> string "done" *> spaces *> MarkComplete <$> cast <$> natural
<|> string "delete" *> spaces *> Delete <$> cast <$> natural
<|> string "print" *> pure RetrieveAll
parseInput' : String -> Maybe TodoCommand
parseInput' str = eitherToMaybe $ map fst $ parse cmdParser str
-- Convert a string-based api into the AppOp interface
-- MaybeA is the error monad where all errors need to be handled
cliParser : CLI =%> MaybeAll AppOp
cliParser = MkMorphism parseInput' printOutput
where
printResult : (v : TodoCommand ** CmdReturn v) -> String
printResult (RetrieveAll ** b) = prettyTable b
printResult (MarkComplete id ** y) = "done"
printResult (Create content ** y) = "done"
printResult (Delete id ** y) = "deleted \{show id}"
printOutput : (x : String) -> (All CmdReturn (parseInput' x)) -> String
printOutput x y with (parseInput' x)
printOutput x Nay | Nothing = "could not parse '\{x}'"
printOutput x (Yay y) | (Just z) = printResult (z ** y)
-- The parser lifted into effectful bidirectional programs
-- Note that the parser itself does not concern itself with effects but
-- we can lift it to make to compatible with effectful programs.
parser : FM CLI =%> FM (MaybeAll AppOp)
parser = f_functor cliParser
---------------------------------------
-- Converting to database operations --
---------------------------------------
-- Converting from internal app commands to database commands
-- Again, notice that this is a pure interface, but the monad on containers
-- will make this compatible with the rest of the infrastructure.
OpToDB : AppOp =%> DBCmd + DBQry
OpToDB = MkMorphism convertCommand convertBack
where
convertCommand : TodoCommand -> Σ CmdType Cmd + ValidQuery
convertCommand (Create str) = <+ (_ ## addTodoItem str)
convertCommand (Delete i) = <+ (_ ## deleteItem (cast i))
convertCommand (MarkComplete i) = <+ (_ ## itemMarkDone (cast i))
convertCommand RetrieveAll = +> MkValidQuery _ %search getAllItems
convertBack : (x : TodoCommand) -> (DBCmd + DBQry).pos (convertCommand x) ->
CmdReturn x
convertBack (Create str) y = ()
convertBack (Delete i) y = ()
convertBack (MarkComplete i) y = ()
convertBack RetrieveAll y = y
-- Commands translated to database operations lifted into the correct monads
-- We lift our translatin to database operation through both FM and MaybeA
commands : DB => FM (MaybeAll AppOp) =%> MaybeAll (FM (DBCmd + DBQry))
commands = distribMaybeAF {a = AppOp} |> map_MaybeAll (f_functor OpToDB)
-- The application itself as a Costate of FM CLI
-- - First run the parser
-- - Then convert commands into database operations
-- - Then run the database
appCont : DB => Costate (FM CLI)
appCont = parser |> commands |> map_MaybeAll DBCostate |> maybeAUnit
----------------------------------------------------------------------------------
-- Running the app as a REPL --
----------------------------------------------------------------------------------
partial
repl : HasIO io => (String -> io String) -> io ()
repl f = do
line <- getLine
output <- f line
putStrLn output
repl f
-- Running the repl with our String -> String function extracted from the
-- costate `Costate (FM CLI)`
partial
app : DB => App Errs Unit
app = repl (extract appCont)
-- Running the database commands to setup an initial state
partial
runDB : App Errs Unit
runDB = withDB ":memory:" $
cmd createTodos *> app
-- Running the main app using RIO
partial
main : IO ()
main = runApp [ printLn ] runDB