Todo Kata - FSharp Part 1
Posted on October 05, 2020 in Tutorial
Welcome to Part 1 of the F# kata to implement to todo list manager discussed in the introduction.
In this post, we will implement the done
command.
We start with done
rather than todo
because todo
will actually depend on functionality in done
to save completed items whereas done
has no dependencies.
Series Outline
- Intro
- F# Series
- Part 1 - Done (you are here)
- Part 2 - Todo
- Part 3 - SQLite
- Python Series
Full source code is available here.
Setup
We start by setting up our .NET Core (I'm using 3.1) console app, adding it to a solution, and opening VS Code:
$ mkdir FsTodo
$ cd FsTodo
$ dotnet new console -n Done -lang F#
$ dotnet new sln
$ dotnet sln add Done/Done.fsproj
$ code .
Next we will create the Domain of our application which will hold all of the logic for creating and working with completed items - the main subjects of the done
command.
Add the Domain.fs file above Program.fs.
Domain.fs
The done
command should allow us to record completed items and query for them later by how long ago they were completed.
First, we create a module defining the type and some functions for creating and printing.
Putting the CompletedItem
type and the functions for working with it in a module with module Done =
allows us to more easily group and discover related operations.
Basically, it allows for "dot-driven development" as we get intellisense with Done.
for Done.create
, Done.CompletedItem
, etc.
module Done.Domain
open System
module Done =
type CompletedItem = {CompletedOn: DateTime; Item: string}
let create (completedOn: DateTime) (item: string) =
{CompletedOn = completedOn; Item = item}
let createDefault item = item |> create DateTime.Now
let toString item =
sprintf "[%s] %s" (item.CompletedOn.ToString("s")) item.Item
The toString
function is how we will serialize a completed item to a text file, so it will have a representation like "[2020-10-02T17:53:47] hello world".
We will also need a way to turn that text representation back into a CompletedItem
(deserialize it) to work with it again within our domain logic.
Add a reference to open System.Text.RegularExpressions
to access Regex
so that we can parse the date from the completed item.
The tryParse
function will return a CompletedItem option
because the file could potentially be "corrupted" - someone could tamper with the file and put in something like "[this is not a date] ugh" or even just leave out the "[]" like "there is no date here".
Some item
gives us a type-safe way of saying we got something rather than None
.
open System.Text.RegularExpressions
// ...
let tryParse (s: string) =
let result =
try
s
|> fun s -> Regex.Match(s, "^\[(?<completedOn>.*)\] (?<item>.*)")
|> fun m -> if m.Success then Some m.Groups else None
|> Option.map (
fun g -> (
(g |> Seq.filter (fun x -> x.Name = "completedOn") |> Seq.exactlyOne).Value,
(g |> Seq.filter (fun x -> x.Name = "item") |> Seq.exactlyOne).Value
)
)
|> Option.map (fun (date, item) -> (DateTime.TryParse(date), item))
|> Option.map (fun ((success, date), item) -> if success then Some (create date item) else None)
with
| :? ArgumentException -> None
match result with
| Some(Some(completedItem)) -> Some completedItem
| _ -> None
There is a lot going on there just to deal with potentially bad entries in the file.
If we were confident the file will always be clean, we could use something like the below.
However, we would still want to return a CompletedItem option
as the cleanest way to handle DateTime.TryParse
.
let tryParse (s: string) =
let result =
s
|> fun s -> Regex.Match(s, "^\[(?<completedOn>.*)\] (?<item>.*)")
|> fun m -> (
(m.Groups |> Seq.filter (fun x -> x.Name = "completedOn") |> Seq.exactlyOne).Value,
(m.Groups |> Seq.filter (fun x -> x.Name = "item") |> Seq.exactlyOne).Value
)
|> fun (date, item) -> (DateTime.TryParse(date), item)
|> fun ((success, date), item) -> if success then Some (create date item) else None
The whole point of storing our completed items is to be able to query for ones completed some time ago, so we can tackle that next. First we will define some helper functions at the top of the file to make working with dates easier:
let private startOfDay (date: DateTime) = date.Date
let private daysAgo (date: DateTime) days = date.AddDays(-days) |> startOfDay
let private startOfWeek (date: DateTime) = date.AddDays(-(float)date.DayOfWeek) |> startOfDay
let private weeksAgo (date: DateTime) weeks = date.AddDays(-7.0 * weeks) |> startOfWeek |> startOfDay
Notice they are marked as private
because we will only use them within this module and do not want to clutter the public API.
We can represent the period back we would like to query (days or weeks) nicely as a discriminated union:
type Period =
| Days of float
| Weeks of float
This combined with our helper functions earlier makes the completedSince
function pretty straightforward:
let completedSince period (item: CompletedItem) =
let since =
match period with
| Days x -> daysAgo DateTime.Now x
| Weeks x -> weeksAgo DateTime.Now x
since < item.CompletedOn
Finally, at the bottom of the file we will define some types that we expect our persistence logic to implement. This tells us we expect to be able to:
- Save a completed item and get a
Result
back either of success or an error message - Retrieve all completed items (which we can then filter by date with
completedSince
)
type SaveCompletedItem = Done.CompletedItem -> Result<unit,string>
type GetCompletedItems = unit -> Done.CompletedItem seq
As already mentioned, we will first provide implementations of these types to read/write from a file. Later, we will see how we can switch to using a SQLite database with minimal changes outside of implementing the SQLite logic.
Persistence.File.fs
Beneath Domain.fs, we create Persistence.File.fs. There is not much to implementing file persistence - the entire implementation is below.
module Done.Persistence.File
open Done.Domain
open System.IO
[<LiteralAttribute>]
let FilePath = "todo.done.txt"
let saveCompletedItem path : SaveCompletedItem =
fun item ->
use writer = File.AppendText path
item |> Done.toString |> writer.WriteLine
Ok ()
let getCompletedItems path : GetCompletedItems =
fun _ ->
if (File.Exists path) then File.ReadAllLines path else [||]
|> Array.map Done.tryParse
|> Array.filter Option.isSome
|> Array.map (fun i -> i.Value)
|> Array.toSeq
There are a couple things to call out.
First, notice these functions take a path
and return SaveCompletedItem
or GetCompletedItems
- that is, they return functions.
We will see after this how we can partially apply the path to configure these functions for use.
Second, it is generally bad practice to extract the value from an option like in Array.map (fun i -> i.Value)
since an exception will be thrown in the case of None
.
However, we filter immediately before on Option.isSome
, so this is ok to make the return value work out.
Also, note we are careful to handle the case of the file not existing yet by checking if (File.Exists path)
- it is easy to forget edge cases like this!
File.AppendText
will create the file if it does not exist, so no need for defensive coding there.
Config.fs
The Config file is where we will configure the persistence logic for use by our application and others.
This way, consumers do not need to worry about the implementation details of how data is stored.
We will see how useful this is when we switch to using a SQLite database and only need to update this file instead of scouring the code for references to the Persistence.File
functions.
module Done.Config
open Persistence.File
let save = saveCompletedItem FilePath
let get = getCompletedItems FilePath
Program.fs
We are finally ready to code the actual application. The final usages will look like the below:
$ done a "this is a completed item"
$ done d 1
$ done w 1
These commands show how to add a completed item, get items completed since yesterday, and get items completed since last week. It will be useful to keep this API in mind as we develop the logic.
We start by defining the types of commands our application can respond to: it can either Add
a new completed item or it can Query
for them by how long ago they were completed (some number of Period
s ago - either "d" or "w").
(Hopefully CQRS practitioners can forgive making a Query
a type of Command
!)
type Command =
| Add of string
| Query of Period
Next, because the arguments we receive from the command line will be strings, we will define a helper method to parse the period for a Query
(we will need to be sure not to use this when we receive an Add
command):
let tryParsePeriod (period: string) amount =
match period.ToLowerInvariant() with
| "d" -> Some (Query (Days amount))
| "w" -> Some (Query (Weeks amount))
| _ -> None
The heavily nested Some (Query (Days amount))
may look intimidating at first glance.
Breaking it down, we can see it reflects:
- We may have received invalid input - use an Option (
Some
/None
) to indicate whether a validPeriod
is requested - We mentioned earlier this will only be used for a
Query
which expects aPeriod
(eitherDays
orWeeks
) - The "d" or "w" specifies whether the
amount
is inDays
orWeeks
(amount
is inferred to be afloat
)
With that helper function defined, we can try to parse the entire command line argument array:
let tryParseArgs (argv: string []) =
match argv with
| [|command;param|] ->
if command = "a" then Some (Add param) else
match Double.TryParse(param) with
| (true, x) -> tryParsePeriod command x
| (false, _) -> None
| [|period|] -> tryParsePeriod period 0.0
| _ -> None
We pattern match on the string array
of incoming arguments:
- If we received two arguments (
[|command;param|]
) - If the first argument is "a" then we are adding a new completed item (the
param
):if command = "a" then Some (Add param)
- Otherwise, assume it is a query
- Then the
param
needs to be parsed to aDouble
- If the
param
is valid, then try to determine thePeriod
(using the helper we defined above)
- Then the
- If we received one argument, assume it is a query for the current
Day
orWeek
(this is a nice convenience) - Otherwise, we have received bad arguments, so return
None
The use of pattern matching and helper methods makes this kind of deeply nested logic more readable.
Now we'll define a smaller helper function to display any errors if we receive them or nothing if not (we have actually not even defined any errors at this point).
The implicit argument is a Result<'a,string>
.
let printIfError = function
| Error e -> printfn "%s" e
| Ok _ -> ()
And the final bit of code before the main
function is a help message if we received bad arguments and the dispatching logic to run the actual code requested:
open Done.Domain
open Done.Config
// ...
[<Literal>]
let HelpMessage =
"Usage: `done d <number>` or `done w <number>` to get items done <number> of days/weeks ago or add with `done a`"
let dispatch argv =
let cmd = tryParseArgs argv
match cmd with
| Some c ->
match c with
| Query p ->
get()
|> Seq.filter (Done.completedSince p)
|> Seq.iter (Done.toString >> printfn "%s")
| Add s -> s |> Done.createDefault |> save |> printIfError
| None -> printfn "%s" HelpMessage
We can see in the use of get()
and save
(with a reference to open Done.Config
) that we can change the definitions of those functions (e.g. to use SQLite instead of a text file) without the program knowing any difference.
Wrapping Up
That completes the done
implementation.
We can test it out with the commands below:
$ dotnet run a "complete our first todo"
$ dotnet run d
[2020-10-05T21:38:56] complete our first todo
Nice.
The next part of the series will cover creating the todo
application.