bitemyapp / esqueleto

New home of Esqueleto, please file issues so we can get things caught up!
BSD 3-Clause "New" or "Revised" License
370 stars 107 forks source link

Improved Postgres Update #306

Open parsonsmatt opened 2 years ago

parsonsmatt commented 2 years ago

This is going to be a mega issue that documents improving update for Postgresql specifically.

Related issues:

Postgres Docs

These are the Postgres docs for update. Copying the syntax, we have:

[ WITH [ RECURSIVE ] with_query [, ...] ]
UPDATE [ ONLY ] table_name [ * ] [ [ AS ] alias ]
    SET { column_name = { expression | DEFAULT } |
          ( column_name [, ...] ) = [ ROW ] ( { expression | DEFAULT } [, ...] ) |
          ( column_name [, ...] ) = ( sub-SELECT )
        } [, ...]
    [ FROM from_item [, ...] ]
    [ WHERE condition | WHERE CURRENT OF cursor_name ]
    [ RETURNING * | output_expression [ [ AS ] output_name ] [, ...] ]

Right now, our update function in esqueleto only supports updating a single table (no FROM clause allowed), and we don't have the ability to return anything from it aside from the count of rows modified.

A type and API

The current signature is:

update :: (SqlExpr (Entity val) -> SqlQuery ()) -> SqlWriteT m ()

To account for the above, we want to do something like:

update
    :: (SqlSelect a r, PersistEntity val, _)
    => (UpdateTarget val -> SqlQuery a)
    -> SqlWriteT m [r]

UpdateTarget

This would be a new type for introducing the entity. Presumably, it would wrap SqlExpr somehow. An ideal design would be to retain transparency and compatibility with the old API and existing functions, while also providing type-safety for the UPDATE clause's SETs (which can only touch the target table).

We can simply have type UpdateTarget val = SqlExpr (Entity val) - aka, no distinction between "a table targeted for update" and "a table brought into scope via from." This would be the easiest thing to do - no modification to anything required really. It's also the least safe option.

Having it be a newtype wrapper of some sort allows us to provide: readUpdateTarget :: UpdateTarget val -> SqlExpr (Entity val). Then the user can do:

update $ \fooWrite -> do
  let fooRead = readUpdateTarget fooWrite
  set fooWrite [FooName =. val "hello", FooAge +=. val 1]
  where_ $ fooRead ^. FooName ==. val "goodbye"

This combination maybe common enough that we'd want to just pass both in the lambda. But that may be confusing too.

We could generalize (^.) to accept either a SqlExpr (Entity val) or UpdateTarget val. But there'd be work to generalize this to all other cases, too.