Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## v4.2.0 - TODO

- Added `Range`, `Bound` and `Inclusivity` types to represent PostgreSQL range types
- Added `range_decoder` and `range` functions to decode and encode ranges

## v4.1.0 - 2025-07-14

- Added a `numeric_decoder` to decode numeric types coming from postgres.
Expand Down
142 changes: 142 additions & 0 deletions src/pog.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import exception
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode.{type Decoder}
import gleam/erlang/atom
import gleam/erlang/process.{type Name, type Pid}
import gleam/erlang/reference.{type Reference}
import gleam/float
Expand Down Expand Up @@ -409,6 +410,41 @@ pub fn calendar_time_of_day(time: TimeOfDay) -> Value {
coerce_value(#(time.hours, time.minutes, seconds))
}

pub fn range(converter: fn(a) -> Value, range: Range(a)) -> Value {
case range {
Range(lower, upper) -> {
case lower, upper {
Unbound, Unbound ->
coerce_value(#(#(Unbound, Unbound), #(False, False)))
Bound(value, inclusivity), Unbound ->
coerce_value(
#(#(converter(value), Unbound), #(
inclusivity_to_bool(inclusivity),
False,
)),
)
Unbound, Bound(value, inclusivity) ->
coerce_value(
#(#(Unbound, converter(value)), #(
False,
inclusivity_to_bool(inclusivity),
)),
)
Bound(lower_value, lower_inclusivity),
Bound(upper_value, upper_inclusivity)
->
coerce_value(
#(#(converter(lower_value), converter(upper_value)), #(
inclusivity_to_bool(lower_inclusivity),
inclusivity_to_bool(upper_inclusivity),
)),
)
}
}
Empty -> coerce_value(Empty)
}
}

@external(erlang, "pog_ffi", "coerce")
fn coerce_value(a: anything) -> Value

Expand Down Expand Up @@ -911,3 +947,109 @@ fn seconds_decoder() -> decode.Decoder(#(Int, Int)) {
pub fn numeric_decoder() -> decode.Decoder(Float) {
decode.one_of(decode.float, [decode.int |> decode.map(int.to_float)])
}

/// Generic representation of the PostgreSQL [Range Types](https://www.postgresql.org/docs/current/rangetypes.html)
pub type Range(t) {
/// Every non-empty range has two bounds, the lower bound and the upper bound.
/// All points between these values are included in the range.
Range(lower: Bound(t), upper: Bound(t))
/// The range includes no points
Empty
}

pub type Bound(t) {
/// The lower (leftmost) or upper (rightmost) point of the [Range](#Range), i.e. the start or end of the range
Bound(value: t, inclusivity: Inclusivity)
/// If the lower bound of a range is `Unbound`, then all values less than the upper bound are included in the range.
/// Likewise, if the upper bound of the range is `Unbound`, then all values greater than the lower bound are included in the range.
/// If both lower and upper bounds are `Unbound`, all values of the type `t` are considered to be in the range.
Unbound
}

/// Indicates whether the boundary point of the lower or upper [Bound](#Bound) of a [Range](#Range)
/// is included in the range of values.
///
/// > **Note**: PostgreSQL automatically normalizes ranges for discrete types (such as `int4range`, `int8range` or `daterange`)
/// to use an inclusive lower bound and an exclusive upper bound.
/// >
/// > For example, inserting:
/// > ```gleam
/// > pog.Range(pog.Bound(0, pog.Exclusive), pog.Bound(10, pog.Inclusive))
/// > ```
/// > is stored as:
/// > ```gleam
/// > pog.Range(pog.Bound(1, pog.Inclusive), pog.Bound(11, pog.Exclusive))
/// > ```
/// > However, continuous range types (such as `numrange` and `tsrange`) are not normalized this way
/// > — their bounds remain exactly as specified.
///
pub type Inclusivity {
/// The boundary point itself is included in the range
Inclusive
/// The boundary point itself is **not** included in the range
Exclusive
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document the new data types please 🙏

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Please double check if it's correct and clear.


pub fn range_decoder(
value_decoder: decode.Decoder(t),
) -> decode.Decoder(Range(t)) {
let range_decoder = {
use lower_inclusivity <- decode.subfield([1, 0], inclusivity_decoder())
use upper_inclusivity <- decode.subfield([1, 1], inclusivity_decoder())
use lower_bound <- decode.subfield(
[0, 0],
bound_decoder(value_decoder, lower_inclusivity),
)
use upper_bound <- decode.subfield(
[0, 1],
bound_decoder(value_decoder, upper_inclusivity),
)
decode.success(Range(lower_bound, upper_bound))
}

let empty_decoder = {
use decoded_atom <- decode.then(atom.decoder())
case decoded_atom == atom.create("empty") {
True -> decode.success(Empty)
False -> decode.failure(Empty, "`empty` atom")
}
}

decode.one_of(range_decoder, [empty_decoder])
|> decode.collapse_errors("`#(#(t, t), #(bool, bool))` tuple or `empty` atom")
}

fn bound_decoder(
value_decoder: decode.Decoder(t),
inclusivity: Inclusivity,
) -> decode.Decoder(Bound(t)) {
let bound_decoder = {
use value <- decode.then(value_decoder)
decode.success(Bound(value:, inclusivity:))
}

let unbound_decoder = {
use decoded_atom <- decode.then(atom.decoder())
case decoded_atom == atom.create("unbound") {
True -> decode.success(Unbound)
False -> decode.failure(Unbound, "`unbound` atom")
}
}

decode.one_of(bound_decoder, [unbound_decoder])
}

fn inclusivity_decoder() -> decode.Decoder(Inclusivity) {
use decoded_bool <- decode.then(decode.bool)
case decoded_bool {
True -> decode.success(Inclusive)
False -> decode.success(Exclusive)
}
}

fn inclusivity_to_bool(inclusivity: Inclusivity) {
case inclusivity {
Inclusive -> True
Exclusive -> False
}
}
158 changes: 158 additions & 0 deletions test/pog_test.gleam
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import exception
import gleam/dynamic
import gleam/dynamic/decode.{type Decoder}
import gleam/erlang/atom
import gleam/erlang/process
import gleam/option.{None, Some}
import gleam/otp/actor
Expand Down Expand Up @@ -432,6 +434,162 @@ pub fn nullable_test() {
|> disconnect
}

pub fn range_of_timestamps_test() {
let encoder = pog.range(pog.timestamp, _)
let decoder = pog.range_decoder(pog.timestamp_decoder())
let assert Ok(lower_ts) =
timestamp.parse_rfc3339("2025-10-09T12:34:56.123456Z")
let assert Ok(upper_ts) =
timestamp.parse_rfc3339("2025-11-04T11:11:11.111111Z")

start_default()
|> assert_roundtrip(pog.Empty, "tsrange", encoder, decoder)
|> assert_roundtrip(
pog.Range(pog.Unbound, pog.Unbound),
"tsrange",
encoder,
decoder,
)
|> assert_roundtrip(
pog.Range(
pog.Bound(lower_ts, pog.Inclusive),
pog.Bound(lower_ts, pog.Inclusive),
),
"tsrange",
encoder,
decoder,
)
|> assert_roundtrip(
pog.Range(pog.Bound(lower_ts, pog.Exclusive), pog.Unbound),
"tsrange",
encoder,
decoder,
)
|> assert_roundtrip(
pog.Range(pog.Unbound, pog.Bound(upper_ts, pog.Inclusive)),
"tsrange",
encoder,
decoder,
)
|> assert_roundtrip(
pog.Range(
pog.Bound(lower_ts, pog.Exclusive),
pog.Bound(upper_ts, pog.Inclusive),
),
"tsrange",
encoder,
decoder,
)
|> disconnect
}

pub fn range_of_dates_test() {
let encoder = pog.range(pog.calendar_date, _)
let decoder = pog.range_decoder(pog.calendar_date_decoder())

start_default()
|> assert_roundtrip(
pog.Range(
pog.Bound(calendar.Date(2025, calendar.November, 4), pog.Inclusive),
pog.Bound(calendar.Date(2026, calendar.January, 1), pog.Exclusive),
),
"daterange",
encoder,
decoder,
)
|> disconnect
}

pub fn range_of_ints_test() {
let encoder = pog.range(pog.int, _)
let decoder = pog.range_decoder(decode.int)

start_default()
|> assert_roundtrip(
pog.Range(pog.Bound(-123, pog.Inclusive), pog.Bound(333, pog.Exclusive)),
"int4range",
encoder,
decoder,
)
|> assert_roundtrip(
pog.Range(
pog.Bound(-9_223_372_036_854_775_808, pog.Inclusive),
pog.Bound(9_223_372_036_854_775_807, pog.Exclusive),
),
"int8range",
encoder,
decoder,
)
|> disconnect
}

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add tests for each of the possible ranges that could be decoded please 🙏 Using postgresql literals sounds good.

pub fn range_of_numerics_test() {
let encoder = pog.range(pog.float, _)
let decoder = pog.range_decoder(pog.numeric_decoder())

start_default()
|> assert_roundtrip(
pog.Range(pog.Unbound, pog.Bound(1.23, pog.Exclusive)),
"numrange",
encoder,
decoder,
)
// this test doesn't work!
|> assert_roundtrip(
pog.Range(pog.Bound(1.23, pog.Exclusive), pog.Unbound),
"numrange",
encoder,
decoder,
)
// this test doesn't work!
|> assert_roundtrip(
pog.Range(pog.Bound(-3.14, pog.Exclusive), pog.Bound(3.14, pog.Inclusive)),
"numrange",
encoder,
decoder,
)
Comment on lines +537 to +550
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests are failing with the following errors:

An unexpected error occurred:

  Badmatch(<<0, 0, 0, 12, 0, 2, 0, 0, 0, 0, 0, 2, 0, 1, 8, 252>>)
An unexpected error occurred:

  Badmatch("\u{0000}\u{0000}\u{0000}\f\u{0000}\u{0002}\u{0000}\u{0000}@\u{0000}\u{0000}\u{0002}\u{0000}\u{0003}\u{0005}x\u{0000}\u{0000}\u{0000}\f\u{0000}\u{0002}\u{0000}\u{0000}\u{0000}\u{0000}\u{0000}\u{0002}\u{0000}\u{0003}\u{0005}x")

This appears to be a bug, but I'm not sure where the root cause is.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue is on the pg_types side. I've created tsloughter/pg_types#21 to track its progress.

|> disconnect
}

pub fn range_decoder_test() {
assert Error([
decode.DecodeError(
"`#(#(t, t), #(bool, bool))` tuple or `empty` atom",
"Atom",
[],
),
])
== atom.create("invalid")
|> atom.to_dynamic()
|> decode.run(pog.range_decoder(decode.int))

assert Ok(pog.Empty)
== atom.create("empty")
|> atom.to_dynamic()
|> decode.run(pog.range_decoder(decode.int))

let numrange =
dynamic.array([
dynamic.array([dynamic.int(1), dynamic.float(3.14)]),
dynamic.array([dynamic.bool(False), dynamic.bool(True)]),
])

assert Error([
decode.DecodeError(
"`#(#(t, t), #(bool, bool))` tuple or `empty` atom",
"Array",
[],
),
])
== decode.run(numrange, pog.range_decoder(decode.int))

assert Ok(pog.Range(
pog.Bound(1.0, pog.Exclusive),
pog.Bound(3.14, pog.Inclusive),
))
== decode.run(numrange, pog.range_decoder(pog.numeric_decoder()))
}

pub fn expected_argument_type_test() {
let db = start_default()

Expand Down