Small TODO apps
Posted on 2021-03-06 in Blog
I recently decided to lean a bit of Haskell for fun and see what the language was like. In this context, I decided to create a small TODO app. Since I also wanted to compare my solution with solutions in other languages, I decided to write the TODO app three times:
- In Haskell to practice the language as well as monads.
- In Rust because I like the language.
- In Clojure because I already knew it and I wanted to practice it. It's a functional language like Haskell, so I though it would be interesting to include it in the comparison.
Each app will do the exact same thing:
- Initialize the database on startup, this means create the SQLite file and the table todo if required. This way, we can easily use it later.
- Allow the database file to be specified to use a specific file if needed.
- Store new TODO items in the database. Each TODO will be made of an id, a title and a completion status (ie done or not done).
- List all TODOs.
- Insert test data in the database.
- Mark a TODO as done.
I'll talk a bit about each app and then try to conclude my experience. I'll start with the Rust one, then the Clojure one and finally the Haskell one (that's the order in which I wrote them). You will find the full source code in each section.
Rust
Rust is the language that most alike to the languages I use everyday: Python and JavaScript. The goal of the language is to write fast and memory safe code without the need for a garbage collector. It's done thanks to its compiler and its type system (which I won't explain here). The errors of the compiler are generally explicit and clear. While it borrows some concepts from functional programming like immutability by default or if expressions, we can still write procedural or object like code. We also have to make some variables mutable from time to time (which is not possible in functional programming languages). So it's a mix, but an efficient one if you ask me.
I also think the community is striving and I found without any difficulty many libraries to parse command line arguments or communicate with the database. I settled with clap for argument parsing and rusqlite for SQLite communication. They both seems like popular and robust choices for what I was aiming for while staying simple to use.
Now, let's talk about the code itself. The definition of the parser resembles what I could do in Python or JS, as you can see with the code sample below. I also find it very readable and expressive.
let matches = App::new("Todo application") .author("Jujens <jujens@jujens.eu>") .about("Small app to manage todos in a local SQLite database.") .setting(AppSettings::SubcommandRequiredElseHelp) .arg( Arg::with_name("file") .long("file") .takes_value(true) .default_value(DEFAULT_DB_PATH) .help("Path to the database.") ) .subcommand( App::new("insert-test-data") .about("Insert test TODOs in the database") );
Since I have 4 different actions with different arguments, I decided to divide the code in subcommands. A pattern I reused for all apps.
If creating the parser is very similar to what I could have done in Python, when I had to use it, I saw some particularities of Rust: Rust doesn't have exceptions and forces you to handle all errors. For instance, when I create a TODO in handle_create, I pass a title with the --title option. Since the argument may not be provided, I get an Option from the parser: it's a data structure used in Rust to handle optional values, like Maybe in Haskell. When you have this structure, you need to extract the value from it before you can use it which forces you to handle the case where you have a value and the case where you haven't one. So the compiler, forces you to handle potential missing data. There is also a similar structure named Result which holds a value or an error. It's useful when you need to provide a useful error message and not just a value or None.
This is how it looks like:
let title = match matches.value_of("title") { Some(title) => title, None => { println!("You must supply a title for the match"); exit(1); }, };
This is very interesting and allowed me to provide custom and precise error messages at each step. But, this makes the code quite verbose. To help a bit, you can use things like unwrap (which can result in a crash if the structure doesn't hold a value) or unwrap_or to extract the value or get a default value if needed. You can also use the ? operator like that:
conn.execute("INSERT INTO todo (title, is_done) VALUES (?1, ?2)", params![title, false])?;
The compiler will propagate the error, meaning the compiler will replace the code above by:
let value = match conn.execute("INSERT INTO todo (title, is_done) VALUES (?1, ?2)", params![title, false]) { Ok(value) => value, Err(e) => return Error(e), };
which is much more compact and achieve the same result if you don't want/need to handle the error at call site. That's a very nice syntactic sugar I didn't used much to handle errors in order to provide the user with relevant messages.
The thing to remember (from my perspective at least) is that you can write code that crash at runtime if you don't pay attention or don't know what you are doing. The language will just give you tools to avoid this, it's then up to you to use those tools correctly.
Since all my other commands are written in the same way, I won't detail more: I think I have given you the gist of the code. To dig deeper, here is my Cargo.toml file with the project dependencies:
1 [package] 2 name = "todos" 3 version = "0.1.0" 4 authors = ["Julien Enselme <jenselme@jujens.eu>"] 5 edition = "2018" 6 7 [dependencies] 8 clap = "2.33.3" 9 10 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 12 [dependencies.rusqlite] 13 version = "0.24.2" 14 features = ["bundled"]
and the full src/main.rs file:
1 use std::path::Path; 2 use std::process::{ exit }; 3 4 use clap::{Arg, App, AppSettings, ArgMatches}; 5 6 use rusqlite::{params, Connection, Result}; 7 use rusqlite::NO_PARAMS; 8 9 #[derive(Debug)] 10 struct Todo { 11 id: i32, 12 title: String, 13 is_done: bool, 14 } 15 16 fn main() { 17 const DEFAULT_DB_PATH: &str = "./todos.db"; 18 let matches = App::new("Todo application") 19 .author("Jujens <jujens@jujens.eu>") 20 .about("Small app to manage todos in a local SQLite database.") 21 .setting(AppSettings::SubcommandRequiredElseHelp) 22 .arg( 23 Arg::with_name("file") 24 .short("f") 25 .long("file") 26 .takes_value(true) 27 .default_value(DEFAULT_DB_PATH) 28 .help("Path to the database.") 29 ) 30 .subcommand( 31 App::new("insert-test-data") 32 .about("Insert test TODOs in the database") 33 ) 34 .subcommand( 35 App::new("list") 36 .about("List all TODOs from the database") 37 ) 38 .subcommand( 39 App::new("create") 40 .about("Create a new TODOs") 41 .setting(AppSettings::ArgRequiredElseHelp) 42 .arg( 43 Arg::with_name("title") 44 .short("t") 45 .long("title") 46 .takes_value(true) 47 .required(true) 48 .multiple(false) 49 .help("The title of the TODO to use.") 50 ) 51 ) 52 .subcommand( 53 App::new("complete") 54 .about("Mark a TODO as done") 55 .setting(AppSettings::ArgRequiredElseHelp) 56 .arg( 57 Arg::with_name("id") 58 .required(true) 59 .help("The ID of the TODO to complete.") 60 ) 61 ) 62 .get_matches(); 63 64 let mut conn = match init(matches.value_of("file").unwrap_or(DEFAULT_DB_PATH)) { 65 Ok(conn) => conn, 66 Err(e) => { 67 println!("An error occurred while initializing the database: \"{}\".", e); 68 exit(1); 69 }, 70 }; 71 72 match matches.subcommand() { 73 ("insert-test-data", _) => handle_insert_test_data(&mut conn), 74 ("list", _) => handle_list_all(conn), 75 ("create", creation_matches) => handle_create(conn, creation_matches), 76 ("complete", Some(complete_matches)) => handle_complete(conn, complete_matches), 77 _ => { 78 println!("No valid subcommand was used"); 79 exit(1); 80 }, 81 } 82 } 83 84 fn init(db_path: &str) -> Result<Connection> { 85 if Path::new(db_path).exists() { 86 return Ok(create_connection(db_path)?); 87 } 88 89 let conn = create_connection(db_path)?; 90 conn.execute("CREATE TABLE IF NOT EXISTS todo (id integer primary key, title text not null, is_done boolean not null)", NO_PARAMS)?; 91 92 return Ok(conn); 93 } 94 95 fn create_connection(db_path: &str) -> Result<Connection> { 96 return Connection::open(db_path); 97 } 98 99 fn handle_insert_test_data(conn: &mut Connection) { 100 match insert_test_data(conn) { 101 Ok(()) => {}, 102 Err(e) => { 103 println!("An error occurred while inserting test data: \"{}\"", e); 104 exit(1); 105 } 106 } 107 } 108 109 fn insert_test_data(conn: &mut Connection) -> Result<()> { 110 let tx = conn.transaction()?; 111 112 tx.execute("DELETE FROM todo;", NO_PARAMS).unwrap(); 113 tx.execute("INSERT INTO todo (title, is_done) VALUES (?1, ?2)", params!["Start project", true])?; 114 tx.execute("INSERT INTO todo (title, is_done) VALUES (?1, ?2)", params!["Complete project", false])?; 115 116 return tx.commit(); 117 } 118 119 fn handle_list_all(conn: Connection) { 120 match list_all(conn) { 121 Ok(results) => display_all(results), 122 Err(e) => { 123 println!("An error occurred while listing all todos: \"{}\".", e); 124 exit(1); 125 } 126 } 127 } 128 129 fn display_all(todos: Vec<Todo>) { 130 for todo in todos.iter() { 131 println!("- id: {}, title: \"{}\", done: {}", todo.id, todo.title, todo.is_done); 132 } 133 } 134 135 fn list_all(conn: Connection) -> Result<Vec<Todo>> { 136 let mut stmt = conn.prepare("SELECT id, title, is_done FROM todo")?; 137 let todos_iter = stmt.query_map(NO_PARAMS, |row| { 138 Ok(Todo { 139 id: row.get(0)?, 140 title: row.get(1)?, 141 is_done: row.get(2)?, 142 }) 143 })?; 144 145 let mut todos = vec![]; 146 for todo_result in todos_iter { 147 match todo_result { 148 Ok(todo) => todos.push(todo), 149 Err(e) => println!("Error while fetching some todos: \"{}\".", e), 150 } 151 } 152 153 return Ok(todos); 154 } 155 156 fn handle_create(conn: Connection, create_matches: Option<&ArgMatches>) { 157 let matches = match create_matches { 158 Some(matches) => matches, 159 None => { 160 println!("You must supply a title for the match."); 161 exit(1); 162 } 163 }; 164 let title = match matches.value_of("title") { 165 Some(title) => title, 166 None => { 167 println!("You must supply a title for the match"); 168 exit(1); 169 }, 170 }; 171 172 match create(conn, title) { 173 Ok(()) => {}, 174 Err(e) => { 175 println!("An error occurred while creating a new TODO: \"{}\"", e); 176 exit(1); 177 } 178 }; 179 } 180 181 fn create(conn: Connection, title: &str) -> Result<()> { 182 conn.execute("INSERT INTO todo (title, is_done) VALUES (?1, ?2)", params![title, false])?; 183 184 return Ok(()); 185 } 186 187 fn handle_complete(conn: Connection, complete_matches: &ArgMatches) { 188 let raw_id = match complete_matches.value_of("id") { 189 Some(raw_id) => raw_id, 190 None => { 191 println!("You must supply an id"); 192 exit(1); 193 } 194 }; 195 196 let id = match raw_id.parse::<u32>() { 197 Ok(id) => id, 198 Err(e) => { 199 println!("You must supply a valid ID (an interger): \"{}\"", e); 200 exit(1); 201 } 202 }; 203 204 match complete(conn, id) { 205 Ok(()) => {}, 206 Err(e) => { 207 println!("An error occurred while completing the TODO {}: \"{}\"", id, e); 208 } 209 } 210 } 211 212 fn complete(conn: Connection, id: u32) -> Result<(), String> { 213 let nb_rows_updated = conn.execute("UPDATE todo SET is_done = ?1 WHERE id = ?2", params![true, id]); 214 215 return match nb_rows_updated { 216 Ok(0) => Err(format!("No row found for supplied id: {}.", id)), 217 Err(e) => Err(format!("{}", e)), 218 _ => Ok(()), 219 }; 220 }
Clojure
Clojure is very different. It's a LISP where all the code is written as lists (like in all LISPs) that runs on the JVM. All data structures are immutable and variables are dynamically typed. The language aims to focus on the data instead of the program.
It was easy to find libraries but I found that the parser was less complete than the Rust or Haskell one (or Python ones for that matter). I guess it comes from the fact that Clojure is a niche language that is less known and has a smaller community.
I had more manual work to do to parse my arguments, including the validation. I think that when you are not used to the language or another LISP, the program is hard to decode with everything having the same structure. Since it's also very different, it's also way harder to understand if you don't already know the language.
I defined the options of the parser in a vector (similar to a list in Python):
(def cli-options [["-f" "--file PORT" "Path to the database file to use." :default "./todos.db"] ["-h" "--help"]])
I can then use this with the parse-opts function to extract my arguments (and potential errors):
(let [{:keys [options arguments errors summary]} (parse-opts args cli-options :in-order true)] "The code")
The parse-opts function will return a map (a dict in Python), the :keys allows me to extract values from this map (options, argument…) and the let expression allows me to bind these values to local variables (with the same name they had in the map). I can then use these values to build a map either to exit the program later with a message or to execute actual code based on the supplied arguments. I used a similar pattern to define and parse the options of my subcommands.
I won't detail much here since I think it would take too much time to teach Clojure here. I just say that all the fonctions themselves are way shorter than in the Rust version since I could easily handle all exceptions globally with a catch handler (Clojure relies on exception for errors). The price is: I'm less precise in the messages I give. But I could avoid this by adding more catch handlers at the price of verbosity. I'll also point out that all functions that ends with an exclamation mark like insert! mutate the sate by convention and must not be confused by by the exclamation mark used to identify macros in Rust. With that, you should be able to get a high level understanding of what the program does.
Like above, here is my project.clj with project dependencies:
1 (defproject todo "0.1.0-SNAPSHOT" 2 :description "FIXME: write description" 3 :url "http://example.com/FIXME" 4 :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0" 5 :url "https://www.eclipse.org/legal/epl-2.0/"} 6 :dependencies [[org.clojure/clojure "1.10.1"] 7 [org.clojure/tools.cli "1.0.194"] 8 [org.clojure/java.jdbc "0.7.12"] 9 [org.xerial/sqlite-jdbc "3.34.0"]] 10 :main ^:skip-aot todo.core 11 :target-path "target/%s" 12 :profiles {:uberjar {:aot :all}})
and my src/todo/core.clj source file:
1 (ns todo.core 2 (:require [clojure.tools.cli :refer [parse-opts]] 3 [clojure.string :as string] 4 [clojure.java.jdbc :as jdbc]) 5 (:gen-class) 6 (:import (clojure.lang ExceptionInfo))) 7 8 (def allowed-commands #{"insert-test-data", "list", "create", "complete"}) 9 10 (def command->options { 11 "insert-test-data" [["-h" "--help"]] 12 "list" [["-h" "--help"]] 13 "create" [["-h" "--help"] 14 ["-t" "--title TITLE" "The title of the todo"]] 15 "complete" [["-h" "--help"] 16 [nil "--id ID" "The ID of the TODO to mark as done" 17 :parse-fn #(Integer/parseInt %)]] 18 }) 19 20 (def command->desc { 21 "insert-test-data" "Insert test TODOs into the database" 22 "list" "List all TODOs from the database" 23 "create" "Create a new TODO" 24 "complete" "Mark the TODO as done" 25 }) 26 27 (def cli-options 28 [["-f" "--file PORT" "Path to the database file to use." 29 :default "./todos.db"] 30 ["-h" "--help"]]) 31 32 (defn- usage [options-summary] 33 (->> ["This is a sample TODO program to create and update TODOs" 34 "Usage: todo [options] COMMAND [command-options]" 35 "" 36 "Options:" 37 options-summary 38 "" 39 "Commands:" 40 (string/join \newline allowed-commands)] 41 (string/join \newline))) 42 43 (defn- error-msg [errors] 44 (str "The following errors occurred while parsing your command:\n" 45 (string/join \newline errors))) 46 47 (defn- exit [status msg] 48 (println msg) 49 (System/exit status)) 50 51 (defn- command-usage [command summary] 52 (string/join \newline [(command->desc command) summary])) 53 54 (defn- validate-command-args 55 "Validate arguments for each subcommands" 56 [args global-options] 57 (let [command (first args) 58 command-args (rest args) 59 {:keys [options arguments errors summary]} (parse-opts args (command->options command))] 60 (cond 61 errors {:exit-message (error-msg errors) :ok? false} 62 (:help options) {:exit-message (command-usage command summary) :ok? true} 63 :else {:command command :options options :global-options global-options}))) 64 65 (defn- validate-args 66 "Validate command line arguments. Either return a map indicating the program 67 should exit (with a error message, and optional ok status), or a map 68 indicating the action the program should take and the options provided." 69 [args] 70 (let [{:keys [options arguments errors summary]} (parse-opts args cli-options :in-order true)] 71 (cond 72 (:help options) {:exit-message (usage summary) :ok? true} 73 errors {:exit-message (error-msg errors) :ok? false} 74 (and (<= 1 (count arguments)) 75 (allowed-commands (first arguments))) (validate-command-args arguments options) 76 :else {:exit-message (usage summary)}))) 77 78 (defn- insert-test-data 79 [db] 80 (jdbc/insert-multi! db :todo 81 [:title :done] 82 [["Start the project" true] 83 ["Complete the project" false]])) 84 85 (defn- display-todo 86 [todo] 87 (->> [(str "- id: " (:id todo)) 88 (str "title: " (:title todo)) 89 (str "done: " (if (= (:done todo) 1) "true" "false"))] 90 (string/join ", ") 91 (println))) 92 93 (defn- list-all 94 [db] 95 (let [rows (jdbc/query db ["SELECT id, title, done FROM todo"])] 96 (doseq [todo rows] 97 (display-todo todo)))) 98 99 (defn- create 100 [conn options] 101 (if-let [title (:title options)] 102 (jdbc/insert! conn :todo {:title title :done false}) 103 (exit 1 (error-msg ["You must supply a title with --title"])))) 104 105 (defn- complete 106 [conn options] 107 (if-let [id (:id options)] 108 (let [nb-rows-updated (first (jdbc/update! conn :todo {:done true} ["id = ?" id]))] 109 (if (not (= nb-rows-updated 1)) 110 (exit 1 (str "No rows could be found for id " id)))) 111 (exit 1 (error-msg ["You must supply the id of the TODO with --id"])))) 112 113 (defn- init 114 [db] 115 (jdbc/db-do-commands db 116 (jdbc/create-table-ddl :todo [[:id :integer :primary :key] 117 [:title :text "not null"] 118 [:done :boolean :default false "not null"]] 119 {:conditional? true}))) 120 121 (defn- run 122 [global-options command options] 123 (let [db {:classname "org.sqlite.JDBC" 124 :subprotocol "sqlite" 125 :subname (:file global-options)}] 126 (jdbc/with-db-transaction [conn db] 127 (try 128 (init conn) 129 (case command 130 "insert-test-data" (insert-test-data conn) 131 "list" (list-all conn) 132 "create" (create conn options) 133 "complete" (complete conn options)) 134 (catch Exception ex 135 (println 136 "An exception occurred while performing " command 137 " with options " options 138 " and global options " global-options 139 ": " 140 (.getMessage ex))))))) 141 142 (defn -main 143 [& args] 144 (let [{:keys [command options global-options exit-message ok?]} (validate-args args)] 145 (if exit-message 146 (exit (if ok? 0 1) exit-message) 147 (run global-options command options))))
Haskell
Haskell is also very different from other languages I normally use and it's often cited as the reference of functional languages. In Haskell, functions are more like mathematical functions, all variables are immutable and all side effects are encapsulated to maintain function purity. It also relies on indentation to express the code and don't need semi-colon or curly braces or to wrap everything in parentheses. The type system is very expressive and is a bit hard to read and understand when you start (mostly when many Monads are involved in functions signatures). Like for Clojure, I'll analyse part of the code but won't attempt to teach you Haskell.
The parsing of the arguments relies heavily on the type system and on the combination of function. For instance, to parse the title used to create a TODO, first I create a union type to hold all the commands (deriving Show is an easy way to be able to print the data):
data Command = List | InsertTestData | Create CreateOptions | Complete CompleteOptions deriving Show
Then, I create a type named CreateOptions to hold the options:
data CreateOptions = CreateOptions { title :: String }
then a function to parse the option themselves:
createOptionsParser :: Parser CreateOptions createOptionsParser = CreateOptions <$> strOption (long "title" <> short 't' <> help "The title of the TODO." <> metavar "TITLE")
That's readable but to really understand what is going on you need to understand what <$> and <> do which isn't simple and which I won't explain there.
I can then create the parser for the command itself:
createParser :: Parser Command createParser = Create <$> createOptionsParser
which can then be used in the subcommand parser.
The arguments will be parsed in a do block to handle side effects (another important point I won't explain).
The most interesting part is the use of pattern matching to run the proper command based on the actual type of the parsed command:
runCommand :: Command -> Connection -> IO () runCommand List = listAll runCommand InsertTestData = insertTestData runCommand (Create CreateOptions { title = title }) = createTodo title runCommand (Complete CompleteOptions { todoId = todoId }) = completeTodo todoId
The parser will create a Command type of "type" List, Create… and we will use this syntax to execute specific code depending on the actual type of the command and extract their parameters with destructuring. I find that very expressive and concise. And it avoids to have complex if or case. I also note that with its usage of parenthesis, Haskell code sometimes makes me thing of LISP.
Now that I managed to parse the options, I can do things, for instance create a TODO:
createTodo :: String -> Connection -> IO() createTodo title conn = do insertResult <- try (execute conn "INSERT INTO todo (title, is_done) values (?, ?)" (title, False :: Bool)) :: IO (Either SomeException ()) case insertResult of (Left error) -> putStrLn $ "Failed to insert the todo: " ++ show error (Right result) -> pure ()
The way I handle the errors highly resemble how it's done in Rust. The try function is a way to catch the potential errors from the executed query. Without it, the code would crash if execute encountered an error. So yes, Haskell code can compile and crash at runtime if you don't pay attention (or don't know what you are doing) in some cases. I find that a bit weird given the preconception I had about the language which was all errors must be handled and if it compiles it works. To be fair, this can also happen in Rust, when you try to access an index that doesn't exist in a array or with invalid use of unwrap for instance. I guess no language is the silver bullet in that matter.
As usual, here's the code of my app/Main.hs file which serves as an entry point with parsing logic:
1 {-# LANGUAGE BlockArguments #-} 2 3 module Main where 4 5 import Lib 6 import Options.Applicative 7 import Data.Semigroup ((<>)) 8 import Database.SQLite.Simple ( Connection ) 9 10 data Options = Options GlobalOptions Command 11 12 data GlobalOptions = GlobalOptions { file :: String } deriving Show 13 14 data CreateOptions = CreateOptions { title :: String } deriving Show 15 16 data CompleteOptions = CompleteOptions { todoId :: Int } deriving Show 17 18 data Command = 19 List 20 | InsertTestData 21 | Create CreateOptions 22 | Complete CompleteOptions 23 deriving Show 24 25 listParser :: Parser Command 26 listParser = pure List 27 28 insertTestDataParser :: Parser Command 29 insertTestDataParser = pure InsertTestData 30 31 createParser :: Parser Command 32 createParser = Create <$> createOptionsParser 33 34 createOptionsParser :: Parser CreateOptions 35 createOptionsParser = CreateOptions 36 <$> strOption (long "title" <> short 't' <> help "The title of the TODO." <> metavar "TITLE") 37 38 completeParser :: Parser Command 39 completeParser = Complete <$> completeOptionsParser 40 41 completeOptionsParser :: Parser CompleteOptions 42 completeOptionsParser = CompleteOptions 43 <$> argument auto (metavar "ID" <> help "The ID of the todo to complete") 44 45 globalOptionsParser :: Parser GlobalOptions 46 globalOptionsParser = GlobalOptions 47 <$> strOption (long "file" <> short 'f' <> help "Path to the database file." <> metavar "FILE" <> value "./todos.db") 48 49 commandParser :: Parser Command 50 commandParser = hsubparser 51 ( 52 command "list" (info listParser (progDesc "List all TODOs")) 53 <> command "insert-test-data" (info insertTestDataParser (progDesc "Add test TODOs in the database")) 54 <> command "create" (info createParser (progDesc "Create a new TODO.")) 55 <> command "complete" (info completeParser (progDesc "Mark a TODO as done.")) 56 ) 57 58 optionsParser :: Parser Options 59 optionsParser = Options 60 <$> globalOptionsParser 61 <*> commandParser 62 63 main :: IO () 64 main = run =<< execParser opts 65 where 66 opts = info (optionsParser <**> helper) 67 (fullDesc <> progDesc "Manage TODOs") 68 69 run :: Options -> IO () 70 run (Options globalOptions command) = do 71 conn <- initDb 72 runCommand command conn 73 74 runCommand :: Command -> Connection -> IO () 75 runCommand List = listAll 76 runCommand InsertTestData = insertTestData 77 runCommand (Create CreateOptions { title = title }) = createTodo title 78 runCommand (Complete CompleteOptions { todoId = todoId }) = completeTodo todoId
and the src/Lib.hs with the rest of the logic:
1 {-# LANGUAGE DeriveGeneric #-} 2 {-# LANGUAGE OverloadedStrings #-} 3 4 module Lib 5 ( listAll, 6 initDb, 7 insertTestData, 8 createTodo, 9 completeTodo, 10 ) where 11 12 import GHC.Generics 13 import Control.Monad (forM, forM_) 14 import Prelude hiding (id) 15 import Control.Applicative 16 import Control.Exception 17 import Database.SQLite.Simple ( open, execute, executeNamed, execute_, query_, changes, withTransaction, Connection, NamedParam( (:=) ) ) 18 import Database.SQLite.Simple.FromRow 19 20 data Todo = Todo Int String Bool deriving (Generic, Show) 21 22 -- The number of field here must match the number of fields we get from the SELECT. 23 instance FromRow Todo where 24 fromRow = Todo <$> field <*> field <*> field 25 26 initDb :: IO Connection 27 initDb = do 28 conn <- open "./todos.db" -- The database file will be created if it doesn't exist. 29 execute_ conn "CREATE TABLE IF NOT EXISTS todo (id INTEGER PRIMARY KEY, title STR NOT NULL, is_done BOOLEAN NOT NULL)" 30 return conn 31 32 insertTestData :: Connection -> IO () 33 insertTestData conn = do 34 withTransaction conn $ replaceByTestData conn 35 36 replaceByTestData :: Connection -> IO () 37 replaceByTestData conn = do 38 execute_ conn "DELETE FROM todo;" 39 execute conn "INSERT INTO todo (title, is_done) values (?, ?)" ("Start project" :: String, True :: Bool) 40 execute conn "INSERT INTO todo (title, is_done) values (?, ?)" ("Complete project" :: String, False :: Bool) 41 42 listAll :: Connection -> IO () 43 listAll conn = do 44 todos <- try (query_ conn "SELECT id, title, is_done FROM todo;" :: IO [Todo]) :: IO (Either SomeException [Todo]) 45 case todos of 46 (Left error) -> putStrLn $ "Failed to list all todos: " ++ show error 47 (Right allTodos) -> mapM_ (putStrLn . formatTodo) allTodos 48 49 formatTodo :: Todo -> String 50 formatTodo (Todo id title isDone) = "- id: " ++ show id ++ ", title: " ++ show title ++ ", done: " ++ show isDone 51 52 createTodo :: String -> Connection -> IO() 53 createTodo title conn = do 54 insertResult <- try (execute conn "INSERT INTO todo (title, is_done) values (?, ?)" (title, False :: Bool)) :: IO (Either SomeException ()) 55 case insertResult of 56 (Left error) -> putStrLn $ "Failed to insert the todo: " ++ show error 57 (Right result) -> pure () 58 59 60 completeTodo :: Int -> Connection -> IO() 61 completeTodo todoId conn = do 62 completeResult <- try (executeNamed conn "UPDATE todo SET is_done = true WHERE id = :id" [":id" := todoId]) :: IO (Either SomeException ()) 63 case completeResult of 64 (Left error) -> putStrLn $ "Failed to update the todo: " ++ show error 65 (Right _) -> do 66 nbUpdatedRows <- changes conn 67 displayUpdateMessage nbUpdatedRows 68 69 displayUpdateMessage :: Int -> IO() 70 displayUpdateMessage 0 = putStrLn "Failed to find a todo with the supplied id." 71 displayUpdateMessage _ = pure ()
Here are also my stack.yaml file and may package.yaml. I didn't include them here since they are way longer that the other dependencies files and give extra details about how the project must be compiled.
Wrapping up
Some stats to compare each solution:
- The full code of the Rust version is 220 lines, but only 164 lines of code when I remove the lines with the closing curly which we don't have in other languages. It's also the app where I have the most detailed and precise error messages. It felt right to write it this way.
- The Clojure version is 147 lines long.
- The Haskell version is 149 lines long.
So I'd say in terms of code length, they all are pretty much the same, with Rust being a bit more verbose.
I'd like to say I enjoyed writing all these apps. The Haskell one was the most challenging to write: it's the language I know the least and is the most different from what I am use to writing. Their usage of Monad and of IO in type signature was (and still is a bit) obscure to me. I think these signatures can be obvious to someone at ease with Haskell, but as a newcomer, without examples to illustrate how the function actually works, it was hard to understand. I often had to search the Internet for examples to help me write the code. I also struggled a bit with the operators <$> and <*>.
I'm also a bit disappointed by the Clojure version: the argument parser is less complete than the other ones and I was expecting more of it. I also still have issues reading the code at time, mostly when I want to scan the code to find something. Maybe it's just a habit to work on.
I think the Rust one shines: it's expressive, errors must always be handled correctly and I found many examples as well as solid library to implement what I was trying to do. This small experiment made the language more interesting for me. It's definitely the one I'm most exited about although I still don't think I'll use the language much aside from toy project for now: I find it a bit verbose and complex for the web app I am writing.
I'll try to write a small web API in the following weeks to complete my experience (and for fun) and write an complementary blog post.
In July, I finally wrote the follow up article: Small API to manage packages.