-
Notifications
You must be signed in to change notification settings - Fork 301
Description
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:
-
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
Usergets additional arguments, and people really don't like writing this out when the database defaulting mechanism is designed to provide exactly this. -
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
Maybeat every use site! Gross.
So here's my plan:
- Extend the
PersistEntityclass with an associated typeNew:class PersistEntity entity where type New entity :: *
- Change the signature of
insertto be: `insert :: (PersistEntity entity) => New entity -> SqlPersistT m (Key entity) - For a definition with no
default=clauses, definetype New User = User - For a definition with
default=clauses, - define a datatype
NewUserwith the required fields of aUserandMaybefields for anydefaultable types - 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 = PersonThis 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.