Tuesday, February 13, 2018

Can you swim? (11)

The last post added a swim boolean column to the database, but that is ignored by everything on the Haskell side. The code will compile and run, but silently ignore this extra column.

You can try this out, but remember that we deleted all the registrations in the last post so you might need to INSERT some more, just like in this earlier post.

I want it to appear as a checkbox on the registration form.

So here's a list of things that need changing:

Modify the Registration data type in src/Registration.hs. Add on a new field there:

  swim :: Bool

This now breaks compilation - the resulting type errors will lead us towards some (but not all) of the places we need to make changes.

The first type error reports something like:

Couldn't match type `Bool -> Registration' with `Registration'

... in the definition for registrationDigestiveForm. This is where digestive-functors is told about all the fields in a Registration. The three existing fields have all been string based, using wrappers around DF.string. There's a fairly obviously named DF.bool that can be used in a similar fashion. There won't be any extra validation on this field, so we don't need any validation wrappers like dateLikeString.

The next compile error is related to CSV generation:

    * No instance for (CSV.ToField Bool)

That means that cassava, the CSV library, doesn't know how to render a Haskell bool into a CSV file.

So we can write this in a new module in src/Orphans.hs. add cassava as a library dependency in package.yaml, and then import Orphans in app/Main.hs.

module Orphans where

import qualified Data.Csv as CSV

instance CSV.ToField Bool
  where
    toField bool = CSV.toField (show bool)

... which defines an orphan instance, generally regarded as a bad thing. So don't do this unless you're lazy. We're going to be lazy. (a good thing to do is define this either where Bool is defined or where ToField is defined - which we can't do without editing the source for those - or define a new wrapper datatype and define the instance next to that). At least, I'm trying to keep any orphan instances in one place.

What the instance above does is turn the Bool into a String, and then uses the existing cassava-provided code to render that String into CSV. (render is a bit of a pretentious term for that - pretty much all it does is dump the string into the CSV file with a few escapes for " and ,).

So now the code compiles! And the server starts up!

But when we view a registration (for example http://localhost:8080/registration/1) there's a runtime server error - which I have formatted for easier reading:

ConversionFailed {
  errSQLType = "3 values: [\"text\",\"text\",\"text\"]",
  errSQLTableOid = Nothing,
  errSQLField = "",
  errHaskellType = "at least 4 slots in target type",
  errMessage = "mismatch between number of columns to convert and number in target type"
}

Where is your compile time static type checking god now?

What's happened is that the SQL SELECT statement asks for three fields (firstname, lastname and dob), and then tries to turn those into a Registration, which used to have three fields too. But now it has four.

The most immediate solution is to fix that SELECT, and all the others, and the one UPDATE too, to list all four fields.

But that *still* doesn't work. Yet another undetected type mismatch is that in a Haskell Registration, the swim boolean always has a value - no nulls. But the swim column in the database is nullable, as PostgreSQL columns are nullable by default. Whoops.

It's too late to go fix this in the last posts though so I'll use the lovely migration infrastructure to fix it now:

Create migrations/0002-swim-mandatory.sql:

UPDATE registration SET swim = FALSE WHERE swim IS NULL;
ALTER TABLE registration ALTER COLUMN swim SET DEFAULT FALSE;
ALTER TABLE registration ALTER COLUMN swim SET NOT NULL;

... and ensure that migration is applied:

$ stack exec migrate migrate user=postgres migrations/

There's a data modelling issue here: I might like partially completed registrations to be stored in the database without an answer for this question rather than defaulting one way or another. In that case, a better Haskell representation might be Maybe Bool. I'll ignore that for now and maybe come back to the issue much later.

At this point, we can view and edit registrations again. But the swim field doesn't appear in the HTML form. So here's another place where the type system doesn't detect that we're missing fields (although it's more reasonable to expect that a web form might not include all of the fields in a database row, so we can't be too annoyed).

digestive-functor's view of a form is gatewayed to HTML in the htmlForRegistration, and that's where we'll add in the new swim field. Previously we used DB.inputText for next. There's a similar inputCheckbox that will give a checkbox (surprise).

htmlForRegistration view =
...
      B.p $ do  "Date of Birth: "
                DB.errorList "dob" view
                DB.inputText "dob" view
      B.p $ do  "Can participant swim?: "
                DB.errorList "swim" view
                DB.inputCheckbox "swim" view
      B.p $     DB.inputSubmit "Save"

With this change, we now can edit the swim field, see it saved in the database, and see it successfully exported in CSV.

Next post I'm going to look at getting rid of some of the manual work / opportunities for mistakes around those SQL SELECT statements; and the changes for today's post are in commit e8848d2dd.

No comments:

Post a Comment