Skip to content

Extend ppx nested to nullable joins - #307

Open
jongleb wants to merge 1 commit into
ygrek:masterfrom
jongleb:ppx-joined
Open

Extend ppx nested to nullable joins#307
jongleb wants to merge 1 commit into
ygrek:masterfrom
jongleb:ppx-joined

Conversation

@jongleb

@jongleb jongleb commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Description

This PR puts a joined table into one field of the record, so where a LEFT JOIN used to give you an option on every column, now only that one field is an option

CREATE TABLE posts    (id INT NOT NULL PRIMARY KEY, body TEXT NULL, channel_id INT NULL);
CREATE TABLE channels (channel_id INT NOT NULL PRIMARY KEY, channel_name TEXT NOT NULL,
                       image_url TEXT NULL, owner_id INT NULL);
CREATE TABLE owners   (owner_id INT NOT NULL PRIMARY KEY, owner_name TEXT NOT NULL);

-- [sqlgg] dynamic_select=true
-- @feed
SELECT p.id, p.body, c.channel_id, c.channel_name, c.image_url
FROM posts p LEFT JOIN channels c ON c.channel_id = p.channel_id
WHERE p.id > @min_id;

[@sqlgg.nested] already exists, but it does not accept an option field

type channel = { channel_id : int64; channel_name : string } [@@deriving sqlgg]

type post = { id : int64; channel : channel option [@sqlgg.nested] } [@@deriving sqlgg]
(* Error: deriving sqlgg: field channel: [@sqlgg.nested] needs a record type deriving sqlgg *)

so you map the row yourself

type row = {
  id : int64;
  body : string option;
  channel_id : int64 option;
  channel_name : string option;
  image_url : string option;
}
[@@deriving sqlgg]

type channel = { channel_id : int64; channel_name : string; image_url : string option }
(* no [@@deriving sqlgg] here *)

type post = { id : int64; body : string option; channel : channel option }
(* and no [@sqlgg.nested] on the field, it would not compile *)

let post_of_row (r : row) =
  let channel =
    match r.channel_id, r.channel_name, r.image_url with
    | Some channel_id, Some channel_name, image_url ->
      Some { channel_id; channel_name; image_url }
    | None, None, None -> None
    | None, _, _ -> failwith "sqlgg: channel.channel_id is NULL"
    | _, None, _ -> failwith "sqlgg: channel.channel_name is NULL"
  in
  { id = r.id; body = r.body; channel }

let () =
  let open Db.Feed in
  List.map post_of_row (List.select () (row_of_cols cols) ~min_id:0L)

and now

type channel = {
  channel_id : int64;
  channel_name : string;
  image_url : string option;
}
[@@deriving sqlgg ~nullable_cols]

type post = {
  id : int64;
  body : string option;
  channel : channel option; [@sqlgg.nested]
}
[@@deriving sqlgg]

let () =
  let open Db.Feed in
  List.select () (post_of_cols cols) ~min_id:0L

If a NULL turns up where it cannot be, this raises, and default_none below is the reader that gives None instead

~nullable_cols

This flag generates the reader that a record option field calls

type channel = { channel_id : int64; channel_name : string } [@@deriving sqlgg]

type post = { id : int64; channel : channel option [@sqlgg.nested] } [@@deriving sqlgg]
(* Error: Unbound type constructor "channel_nullable_cols" *)

The record also needs one field that cannot be NULL

type channel = { channel_name : string option; image_url : string option }
[@@deriving sqlgg ~nullable_cols]

type post = { id : int64; channel : channel option [@sqlgg.nested] } [@@deriving sqlgg]
(* Error: ... but an expression was expected of type "channel_needs_a_strict_column" *)

[@sqlgg.nested default_none]

Reads the same, but a broken row gives None, and it goes all the way down the tree

type post_lax = {
  id : int64;
  body : string option;
  channel : channel option; [@sqlgg.nested default_none]
}
[@@deriving sqlgg]
(* id=1 body="hi" channel_id=10 channel_name=NULL image_url=NULL *)
None

Other attributes inside a relation

They all compose inside a relation

type channel = {
  id : int; [@sqlgg.col "channel_id"] [@sqlgg.map Int64.to_int]
  seen : int; [@sqlgg.by] [@sqlgg.map Int64.to_int]
  image : string; [@sqlgg.col "image_url"] [@sqlgg.default "none"]
}
[@@deriving sqlgg ~nullable_cols]

type post = { pid : int64; channel : channel option [@sqlgg.nested] } [@@deriving sqlgg]

[@sqlgg.by] without a conversion is the one that does not nest

type channel = { channel_id : int64; hits : int [@sqlgg.by] }
[@@deriving sqlgg ~nullable_cols]

type post = { id : int64; channel : channel option [@sqlgg.nested] } [@@deriving sqlgg]
(* Error: Unbound type constructor "channel_nullable_cols" *)

Reading such a record on its own is unchanged

@jongleb
jongleb force-pushed the ppx-joined branch 4 times, most recently from f847606 to 34bc839 Compare August 6, 2026 14:01
@jongleb jongleb changed the title ppx joined Extend ppx nested to nullable joins Aug 6, 2026
@jongleb
jongleb marked this pull request as ready for review August 6, 2026 16:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant