Skip to content

Generalizing insert and gracefully handling defaults #995

@parsonsmatt

Description

@parsonsmatt

Raise your hand if you've been frustrated by needing to specify a time or use Maybe for createdAt UTCTime fields.

If you're one of the few folks that hasn't, well, let me introduce the problem.

User
  name String

This is fine and easy. To insert a new user into the database, we write insert User { userName = "Matt" }. There is an implied surrogate key that is associated, and it should be an auto-incrementing integer. Because it has a default in the database, we don't need to specify it. This pattern is so common that we have the Entity type, which includes the Key entity for the entity.

Then we want to record when a user is created.

User
  name String
  createdAt UTCTIme

Database users are accustomed to writing a schema like:

CREATE TABLE user (
  id   SERIAL PRIMARY KEY,
  name VARCHAR NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT=NOW()
);

And the persistent library happily supports the default= syntax, which does The Right Thing with migrations.

User
  name String
  createdAt UTCTIme default=NOW()

Unfortunately, we reach a problem when we go to insert a new value. insert's type is insert :: entity -> SqlPersistT m (Key entity). And userCreatedAt :: UTCTime - it's a required field!! So now we have two options:

  1. Make the timestamp in Haskell and forego the database default, writing:

    newUser :: String -> SqlPersistT m UserId
    newUser userName = do
      userCreatedAt <- liftIO getCurrentTime
      insert User {..}

    But this gets really annoying as the User gets additional arguments, and people really don't like writing this out when the database defaulting mechanism is designed to provide exactly this.

  2. Make the definition nullable and provide Nothing:

    insert User { userName = "Matt", userCreatedAt = Nothing }

    The database defaulting mechanism works out here, hooray. But now we have to care about Maybe at every use site! Gross.

So here's my plan:

  1. Extend the PersistEntity class with an associated type New:
    class PersistEntity entity where
        type New entity :: *
  2. Change the signature of insert to be: `insert :: (PersistEntity entity) => New entity -> SqlPersistT m (Key entity)
  3. For a definition with no default= clauses, define type New User = User
  4. For a definition with default= clauses,
  5. define a datatype NewUser with the required fields of a User and Maybe fields for any defaultable types
  6. define type New User = NewUser

In the QQ syntax, we can introduce a new attribute !default-only, and any field with a default-only attribute does not include that field in the New type. So we could write this:

User
  name String
  createdAt UTCTime default=NOW() !default-only

and we'd be able to write simply insert NewUser { newUserName = "Matt" } and it Just Works, precisely like you'd want it to.

Alternatively, we might want to default to default= things not being in the NewUser, and an attribute !allow-override, which puts a Maybe in the New record.

This also helps solve some of the issues with custom Id and Primary declarations. For example, consider this Person type with a UUID:

Person
  Id    UUID
  name String

With this example, it's a SQL-time error to do insert Person { personName "Matt" } - there won't be a default given for the UUID. So in this case, we actually want to define type New Person = NewPerson:

data NewPerson = NewPerson
  { newPersonId :: UUID
  , newPersonName :: String
  }

But if we specify a default, then we can have this pairing:

Person
  Id    UUID default=uuid_generate_v4()
  name String
instance PersistEntity Person where
  type New Person = Person

This design seems to work pretty well to solve all the pain points I experience with this stuff. I'm curious if anyone else has any input on pain points that may be addressed by this, or if there are design flaws that I haven't considered.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions