From b7d7c39b14b892e8170753b840336f50b3a5258a Mon Sep 17 00:00:00 2001 From: Igor Barchenkov Date: Thu, 9 Oct 2025 21:44:34 +0300 Subject: [PATCH 1/2] Add Range type, encoder and decoder --- src/pog.gleam | 114 ++++++++++++++++++++++++++++++++++++++++++++ test/README.md | 1 - test/pog_test.gleam | 57 ++++++++++++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) diff --git a/src/pog.gleam b/src/pog.gleam index 2cb6c61..e538060 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -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 @@ -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)) { + 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 @@ -911,3 +947,81 @@ 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)]) } + +pub type Range(t) { + Range(lower: Bound(t), upper: Bound(t)) + Empty +} + +pub type Bound(t) { + Bound(value: t, inclusivity: Inclusivity) + Unbound +} + +pub type Inclusivity { + Inclusive + Exclusive +} + +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") + } + } + + decode.one_of(range_decoder, [empty_decoder]) +} + +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") + } + } + + 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 + } +} diff --git a/test/README.md b/test/README.md index 683d62d..5402f36 100644 --- a/test/README.md +++ b/test/README.md @@ -8,7 +8,6 @@ Initialize a Postgres Database, and use as environment variables: export PGUSER="postgres" export PGHOST="localhost" export PGPASSWORD="postgres" -export PGUSER="postgres" export PGPORT=5432 ``` diff --git a/test/pog_test.gleam b/test/pog_test.gleam index 3a6f7ad..bd1d877 100644 --- a/test/pog_test.gleam +++ b/test/pog_test.gleam @@ -432,6 +432,63 @@ 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(timestamp) = + timestamp.parse_rfc3339("2025-10-09T12:34:56.123456Z") + + 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(timestamp, pog.Inclusive), + pog.Bound(timestamp, pog.Inclusive), + ), + "tsrange", + encoder, + decoder, + ) + |> assert_roundtrip( + pog.Range(pog.Bound(timestamp, pog.Exclusive), pog.Unbound), + "tsrange", + encoder, + decoder, + ) + |> assert_roundtrip( + pog.Range(pog.Unbound, pog.Bound(timestamp, pog.Inclusive)), + "tsrange", + 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.Unbound, pog.Bound(10, pog.Exclusive)), + "int4range", + encoder, + decoder, + ) + |> assert_roundtrip( + pog.Range(pog.Bound(0, pog.Inclusive), pog.Bound(10, pog.Exclusive)), + "int4range", + encoder, + decoder, + ) + |> disconnect +} + pub fn expected_argument_type_test() { let db = start_default() From 533df0673da4a31a33e16633cd812794cf690cc4 Mon Sep 17 00:00:00 2001 From: Igor Barchenkov Date: Mon, 3 Nov 2025 18:26:05 +0300 Subject: [PATCH 2/2] Document new types, add more tests Add tests --- CHANGELOG.md | 5 ++ src/pog.gleam | 34 +++++++++++-- test/README.md | 1 + test/pog_test.gleam | 117 +++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 146 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eeac94..63911ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/pog.gleam b/src/pog.gleam index e538060..3e6fbda 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -410,7 +410,7 @@ 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)) { +pub fn range(converter: fn(a) -> Value, range: Range(a)) -> Value { case range { Range(lower, upper) -> { case lower, upper { @@ -948,18 +948,45 @@ 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 } @@ -984,11 +1011,12 @@ pub fn range_decoder( use decoded_atom <- decode.then(atom.decoder()) case decoded_atom == atom.create("empty") { True -> decode.success(Empty) - False -> decode.failure(Empty, "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( @@ -1004,7 +1032,7 @@ fn bound_decoder( use decoded_atom <- decode.then(atom.decoder()) case decoded_atom == atom.create("unbound") { True -> decode.success(Unbound) - False -> decode.failure(Unbound, "unbound") + False -> decode.failure(Unbound, "`unbound` atom") } } diff --git a/test/README.md b/test/README.md index 5402f36..683d62d 100644 --- a/test/README.md +++ b/test/README.md @@ -8,6 +8,7 @@ Initialize a Postgres Database, and use as environment variables: export PGUSER="postgres" export PGHOST="localhost" export PGPASSWORD="postgres" +export PGUSER="postgres" export PGPORT=5432 ``` diff --git a/test/pog_test.gleam b/test/pog_test.gleam index bd1d877..c68a38f 100644 --- a/test/pog_test.gleam +++ b/test/pog_test.gleam @@ -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 @@ -435,8 +437,10 @@ pub fn nullable_test() { pub fn range_of_timestamps_test() { let encoder = pog.range(pog.timestamp, _) let decoder = pog.range_decoder(pog.timestamp_decoder()) - let assert Ok(timestamp) = + 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) @@ -448,47 +452,144 @@ pub fn range_of_timestamps_test() { ) |> assert_roundtrip( pog.Range( - pog.Bound(timestamp, pog.Inclusive), - pog.Bound(timestamp, pog.Inclusive), + pog.Bound(lower_ts, pog.Inclusive), + pog.Bound(lower_ts, pog.Inclusive), ), "tsrange", encoder, decoder, ) |> assert_roundtrip( - pog.Range(pog.Bound(timestamp, pog.Exclusive), pog.Unbound), + pog.Range(pog.Bound(lower_ts, pog.Exclusive), pog.Unbound), "tsrange", encoder, decoder, ) |> assert_roundtrip( - pog.Range(pog.Unbound, pog.Bound(timestamp, pog.Inclusive)), + 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.Unbound, pog.Bound(10, pog.Exclusive)), + pog.Range(pog.Bound(-123, pog.Inclusive), pog.Bound(333, pog.Exclusive)), "int4range", encoder, decoder, ) |> assert_roundtrip( - pog.Range(pog.Bound(0, pog.Inclusive), pog.Bound(10, pog.Exclusive)), - "int4range", + 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 } +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, + ) + |> 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()