carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1-- | SQLite storage: one file, WAL mode, one connection shared behind a
2-- lock. SQLite runs one writer at a time anyway, and a TodoMVC request
3-- is a handful of indexed statements, so a pool would buy nothing.
4module Todo.Db
5 ( Db
6 , open
7 , close
8 , findList
9 , createList
10 , listTodos
11 , addTodo
12 , toggleTodo
13 , renameTodo
14 , deleteTodo
15 , toggleAll
16 , clearCompleted
17 ) where
18
19import Control.Concurrent.MVar
20import Control.Monad (forM_)
21import Data.Text (Text)
22import Data.Text qualified as T
23import Database.SQLite.Simple (Connection, Only (..), Query (..), execute, execute_, query, query_, withTransaction)
24import Database.SQLite.Simple qualified as SQL
25import Todo.Model
26
27newtype Db = Db (MVar Connection)
28
29-- | Opens (creating if needed) the database and brings its schema up to
30-- date.
31open :: FilePath -> IO Db
32open path = do
33 conn <- SQL.open path
34 -- journal_mode answers with a row, so it goes through query_.
35 _ <- query_ conn "PRAGMA journal_mode = WAL" :: IO [Only Text]
36 execute_ conn "PRAGMA synchronous = NORMAL"
37 execute_ conn "PRAGMA foreign_keys = ON"
38 execute_ conn "PRAGMA busy_timeout = 5000"
39 migrate conn
40 Db <$> newMVar conn
41
42-- | Folds the WAL back into the main file and closes the connection.
43close :: Db -> IO ()
44close (Db var) = withMVar var $ \conn -> do
45 _ <- query_ conn "PRAGMA wal_checkpoint(TRUNCATE)" :: IO [(Int, Int, Int)]
46 SQL.close conn
47
48-- | The schema's history. Each entry runs once, in order, and the number
49-- applied is kept in @user_version@. Entries are only ever appended, and
50-- they only ever add: no drops, no rewrites.
51migrations :: [[Query]]
52migrations =
53 [ [ "CREATE TABLE lists (\
54 \ id INTEGER PRIMARY KEY,\
55 \ token TEXT NOT NULL UNIQUE,\
56 \ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"
57 , "CREATE TABLE todos (\
58 \ id INTEGER PRIMARY KEY,\
59 \ list_id INTEGER NOT NULL REFERENCES lists (id),\
60 \ title TEXT NOT NULL,\
61 \ completed INTEGER NOT NULL DEFAULT 0,\
62 \ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"
63 , "CREATE INDEX todos_by_list ON todos (list_id, id)"
64 ]
65 ]
66
67migrate :: Connection -> IO ()
68migrate conn = do
69 applied <- query_ conn "PRAGMA user_version"
70 let done = case applied of
71 [Only n] -> n
72 _ -> 0
73 forM_ (drop done (zip [1 :: Int ..] migrations)) $ \(n, statements) ->
74 withTransaction conn $ do
75 mapM_ (execute_ conn) statements
76 -- PRAGMA takes no parameters, so the number is written in.
77 execute_ conn (Query ("PRAGMA user_version = " <> T.pack (show n)))
78
79with :: Db -> (Connection -> IO a) -> IO a
80with (Db var) = withMVar var
81
82findList :: Db -> Text -> IO (Maybe ListId)
83findList db token = with db $ \conn -> do
84 rows <- query conn "SELECT id FROM lists WHERE token = ?" (Only token)
85 pure $ case rows of
86 [Only n] -> Just (ListId n)
87 _ -> Nothing
88
89createList :: Db -> Text -> IO ListId
90createList db token = with db $ \conn -> do
91 execute conn "INSERT INTO lists (token) VALUES (?)" (Only token)
92 ListId <$> SQL.lastInsertRowId conn
93
94listTodos :: Db -> ListId -> IO [Todo]
95listTodos db (ListId list) = with db $ \conn -> do
96 rows <- query conn "SELECT id, title, completed FROM todos WHERE list_id = ? ORDER BY id" (Only list)
97 pure [Todo (TodoId i) t c | (i, t, c) <- rows]
98
99addTodo :: Db -> ListId -> Text -> IO ()
100addTodo db (ListId list) title = with db $ \conn ->
101 execute conn "INSERT INTO todos (list_id, title) VALUES (?, ?)" (list, title)
102
103-- The functions that change one todo answer whether it was in that list.
104
105toggleTodo :: Db -> ListId -> TodoId -> IO Bool
106toggleTodo db (ListId list) (TodoId todo) = with db $ \conn -> do
107 execute conn "UPDATE todos SET completed = NOT completed WHERE id = ? AND list_id = ?" (todo, list)
108 changed conn
109
110renameTodo :: Db -> ListId -> TodoId -> Text -> IO Bool
111renameTodo db (ListId list) (TodoId todo) title = with db $ \conn -> do
112 execute conn "UPDATE todos SET title = ? WHERE id = ? AND list_id = ?" (title, todo, list)
113 changed conn
114
115deleteTodo :: Db -> ListId -> TodoId -> IO Bool
116deleteTodo db (ListId list) (TodoId todo) = with db $ \conn -> do
117 execute conn "DELETE FROM todos WHERE id = ? AND list_id = ?" (todo, list)
118 changed conn
119
120changed :: Connection -> IO Bool
121changed conn = (> 0) <$> SQL.changes conn
122
123-- | Completes everything, unless everything already is, in which case it
124-- makes everything active again.
125toggleAll :: Db -> ListId -> IO ()
126toggleAll db (ListId list) = with db $ \conn -> withTransaction conn $ do
127 rows <- query conn "SELECT EXISTS (SELECT 1 FROM todos WHERE list_id = ? AND NOT completed)" (Only list)
128 let anyActive = case rows of
129 [Only b] -> b
130 _ -> False
131 execute conn "UPDATE todos SET completed = ? WHERE list_id = ?" (anyActive, list)
132
133clearCompleted :: Db -> ListId -> IO ()
134clearCompleted db (ListId list) = with db $ \conn ->
135 execute conn "DELETE FROM todos WHERE list_id = ? AND completed" (Only list)
136