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.
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 User gets 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 Maybe at every use site! Gross.
So here's my plan:
- Extend the
PersistEntity class with an associated type New:
class PersistEntity entity where
type New entity :: *
- Change the signature of
insert to be: `insert :: (PersistEntity entity) => New entity -> SqlPersistT m (Key entity)
- For a definition with no
default= clauses, define type New User = User
- For a definition with
default= clauses,
- define a datatype
NewUser with the required fields of a User and Maybe fields for any defaultable 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 = 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.
Raise your hand if you've been frustrated by needing to specify a time or use
MaybeforcreatedAt UTCTimefields.If you're one of the few folks that hasn't, well, let me introduce the problem.
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 adefaultin the database, we don't need to specify it. This pattern is so common that we have theEntitytype, which includes theKey entityfor theentity.Then we want to record when a user is created.
Database users are accustomed to writing a schema like:
And the
persistentlibrary happily supports thedefault=syntax, which does The Right Thing with migrations.Unfortunately, we reach a problem when we go to insert a new value.
insert's type isinsert :: entity -> SqlPersistT m (Key entity). AnduserCreatedAt :: UTCTime- it's a required field!! So now we have two options:Make the timestamp in Haskell and forego the database default, writing:
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: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:
PersistEntityclass with an associated typeNew:insertto be: `insert :: (PersistEntity entity) => New entity -> SqlPersistT m (Key entity)default=clauses, definetype New User = Userdefault=clauses,NewUserwith the required fields of aUserandMaybefields for anydefaultable typestype New User = NewUserIn the QQ syntax, we can introduce a new attribute
!default-only, and any field with adefault-onlyattribute does not include that field in theNewtype. So we could write this: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 theNewUser, and an attribute!allow-override, which puts aMaybein theNewrecord.This also helps solve some of the issues with custom
IdandPrimarydeclarations. For example, consider this Person type with aUUID: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 definetype New Person = NewPerson:But if we specify a default, then we can have this pairing:
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.