Skip to content
Open
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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ querying, etc, can be found at:

### shards

Add this to your `shard.yml` on a generated crystal project,
Add this to your `shard.yml` on a generated crystal project,
and run `shards install`

``` yml
Expand Down Expand Up @@ -147,6 +147,8 @@ Since it uses protocol version 3, older versions probably also work but are not
- varchar
- regtype
- geo types: point, box, path, lseg, polygon, circle, line
- range types: int4range, int8range, daterange, tsrange, tstzrange, numrange (3)
- multirange types: int4multirange, int8multirange, datemultirange, tsmultirange, tstzmultirange, nummultirange (4)
- array types: int8, int4, int2, float8, float4, bool, text, numeric, timestamptz, date, timestamp
- interval (2)

Expand All @@ -161,6 +163,14 @@ Since it uses protocol version 3, older versions probably also work but are not
in Crystal datatype. Therfore we provide a `PG::Interval` type that can be converted to
`Time::Span` and `Time::MonthSpan`.

3: A note on ranges: PostgreSQL range types map to Crystal's `Range` type with support
for all boundary combinations (`[]`, `()`, etc.), empty ranges, and infinite bounds using
beginless/endless syntax (`..10`, `5..`, `..`). Discrete types (int/date) are canonicalized
to `[lower,upper)` form, while continuous types (timestamp/numeric) preserve exact boundaries.

4: A note on multiranges: PostgreSQL multirange types (PostgreSQL 14+) map to Crystal's
`Array(Range)` type, supporting ordered lists of non-contiguous ranges.

# Authentication Methods

By default this driver will accept `scram-sha-256` and `md5`, as well as
Expand Down
109 changes: 109 additions & 0 deletions spec/pg/decoders/range_decoder_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
require "../../spec_helper"

describe PG::Decoders do
describe "ranges" do
describe "int4range" do
test_decode "(lower,upper) - both exclusive", "'(1,10)'::int4range", 2...10 # PostgreSQL canonicalizes discrete ranges to [a,b) form
test_decode "(lower,upper] - exclusive lower, inclusive upper", "'(1,10]'::int4range", 2...11
test_decode "[lower,upper) - inclusive lower, exclusive upper", "'[1,10)'::int4range", 1...10
test_decode "[lower,upper] - both inclusive", "'[1,10]'::int4range", 1...11 # [a,b] becomes [a,b+1) for discrete types
Copy link
Collaborator

Choose a reason for hiding this comment

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

question: Why not use inclusive Range here?

Copy link
Author

Choose a reason for hiding this comment

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

https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-DISCRETE

The built-in range types int4range, int8range, and daterange all use a canonical form that includes the lower bound and excludes the upper bound; that is, [). User-defined range types can use other conventions, however.

So when you write '[1,10]'::int4range in PostgreSQL:

  1. You specify [1,10] (both inclusive)
  2. PostgreSQL internally converts it to [1,11) (inclusive lower, exclusive upper)
  3. The Crystal decoder receives this canonical [1,11) form
  4. Crystal represents it as 1...11 (which is exactly [1,11))

Copy link
Collaborator

Choose a reason for hiding this comment

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

Understood, this normalization comes from the server 👍

test_decode "empty range", "'empty'::int4range", 0...0
test_decode "(-infinity,upper] - infinite lower bound", "'(,10]'::int4range", nil...11
test_decode "[lower,+infinity) - infinite upper bound", "'[1,)'::int4range", 1...nil
test_decode "(-infinity,+infinity) - both bounds infinite", "'(,)'::int4range", nil...nil
end

describe "int8range" do
test_decode "(lower,upper) - both exclusive", "'(3000000000,4000000000)'::int8range", 3_000_000_001...4_000_000_000
test_decode "(lower,upper] - exclusive lower, inclusive upper", "'(3000000000,4000000000]'::int8range", 3_000_000_001...4_000_000_001
test_decode "[lower,upper) - inclusive lower, exclusive upper", "'[3000000000,4000000000)'::int8range", 3_000_000_000...4_000_000_000
test_decode "[lower,upper] - both inclusive", "'[3000000000,4000000000]'::int8range", 3_000_000_000...4_000_000_001
test_decode "empty range", "'empty'::int8range", 0_i64...0_i64
test_decode "(-infinity,upper] - infinite lower bound", "'(,4000000000]'::int8range", nil...4_000_000_001
test_decode "[lower,+infinity) - infinite upper bound", "'[3000000000,)'::int8range", 3_000_000_000...nil
test_decode "(-infinity,+infinity) - both bounds infinite", "'(,)'::int8range", nil...nil
end

describe "daterange" do
test_decode "(lower,upper) - both exclusive", "'(2023-01-01,2023-12-31)'::daterange", Time.utc(2023, 1, 2)...Time.utc(2023, 12, 31)
test_decode "(lower,upper] - exclusive lower, inclusive upper", "'(2023-01-01,2023-12-31]'::daterange", Time.utc(2023, 1, 2)...Time.utc(2024, 1, 1)
test_decode "[lower,upper) - inclusive lower, exclusive upper", "'[2023-01-01,2023-12-31)'::daterange", Time.utc(2023, 1, 1)...Time.utc(2023, 12, 31)
test_decode "[lower,upper] - both inclusive", "'[2023-01-01,2023-12-31]'::daterange", Time.utc(2023, 1, 1)...Time.utc(2024, 1, 1)
test_decode "empty range", "'empty'::daterange", Time.unix(0)...Time.unix(0)
test_decode "(-infinity,upper] - infinite lower bound", "'(,2023-12-31]'::daterange", nil...Time.utc(2024, 1, 1)
test_decode "[lower,+infinity) - infinite upper bound", "'[2023-01-01,)'::daterange", Time.utc(2023, 1, 1)...nil
test_decode "(-infinity,+infinity) - both bounds infinite", "'(,)'::daterange", nil...nil
end

describe "tsrange" do
test_decode "(lower,upper) - both exclusive", "'(2023-01-01 10:30:00,2023-12-31 15:45:00)'::tsrange", Time.utc(2023, 1, 1, 10, 30, 0)...Time.utc(2023, 12, 31, 15, 45, 0)
test_decode "(lower,upper] - exclusive lower, inclusive upper", "'(2023-01-01 10:30:00,2023-12-31 15:45:00]'::tsrange", Time.utc(2023, 1, 1, 10, 30, 0)..Time.utc(2023, 12, 31, 15, 45, 0) # Crystal can't represent exclusive lower
Copy link
Collaborator

@straight-shoota straight-shoota Nov 14, 2025

Choose a reason for hiding this comment

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

issue: Changing the exclusive lower to an inclusive one is a dangerous change.
Unfortunately, we cannot represent that with Range. But I'd consider silently converting the range to different semantics an error.

For Time we can work around by treating it as a discrete type. Just add one nanosecond:

Suggested change
test_decode "(lower,upper] - exclusive lower, inclusive upper", "'(2023-01-01 10:30:00,2023-12-31 15:45:00]'::tsrange", Time.utc(2023, 1, 1, 10, 30, 0)..Time.utc(2023, 12, 31, 15, 45, 0) # Crystal can't represent exclusive lower
test_decode "(lower,upper] - exclusive lower, inclusive upper", "'(2023-01-01 10:30:00,2023-12-31 15:45:00]'::tsrange", Time.utc(2023, 1, 1, 10, 30, 0, nanosecond: 1)..Time.utc(2023, 12, 31, 15, 45, 0)

This doesn't preserve the exact semantics either, but it's much closer and preserves the intent.

And it probably won't work for PG::Numeric?

Alternatively, we need a different solution (e.g. a custom Range type).
Omitting this case (i.e. raise an error) would also be better than implicitly changing semantics.

Copy link
Author

Choose a reason for hiding this comment

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

Right. Silently converting exclusive lower bounds to inclusive ones changes the semantics and could lead to bugs.

https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-DISCRETE

  • Discrete types (int4, int8, date): have clear "next" values (1 -> 2, 2023-01-01 -> 2023-01-02). This is working.
  • Continuous types (timestamp, numeric): don't have clear "next" values.

From the documentation:

Even though timestamp has limited precision, and so could theoretically be treated as discrete, it's better to consider it continuous since the step size is normally not of interest.

Do you think I should implement these adjustments?

test_decode "[lower,upper) - inclusive lower, exclusive upper", "'[2023-01-01 10:30:00,2023-12-31 15:45:00)'::tsrange", Time.utc(2023, 1, 1, 10, 30, 0)...Time.utc(2023, 12, 31, 15, 45, 0)
test_decode "[lower,upper] - both inclusive", "'[2023-01-01 10:30:00,2023-12-31 15:45:00]'::tsrange", Time.utc(2023, 1, 1, 10, 30, 0)..Time.utc(2023, 12, 31, 15, 45, 0)
test_decode "empty range", "'empty'::tsrange", Time.unix(0)...Time.unix(0)
test_decode "(-infinity,upper] - infinite lower bound", "'(,2023-12-31 15:45:00]'::tsrange", nil..Time.utc(2023, 12, 31, 15, 45, 0)
test_decode "[lower,+infinity) - infinite upper bound", "'[2023-01-01 10:30:00,)'::tsrange", Time.utc(2023, 1, 1, 10, 30, 0)...nil
test_decode "(-infinity,+infinity) - both bounds infinite", "'(,)'::tsrange", nil...nil
end

describe "tstzrange" do
test_decode "(lower,upper) - both exclusive", "'(2023-01-01 10:30:00+00,2023-12-31 15:45:00+00)'::tstzrange", Time.utc(2023, 1, 1, 10, 30, 0)...Time.utc(2023, 12, 31, 15, 45, 0)
test_decode "(lower,upper] - exclusive lower, inclusive upper", "'(2023-01-01 10:30:00+00,2023-12-31 15:45:00+00]'::tstzrange", Time.utc(2023, 1, 1, 10, 30, 0)..Time.utc(2023, 12, 31, 15, 45, 0)
test_decode "[lower,upper) - inclusive lower, exclusive upper", "'[2023-01-01 10:30:00+00,2023-12-31 15:45:00+00)'::tstzrange", Time.utc(2023, 1, 1, 10, 30, 0)...Time.utc(2023, 12, 31, 15, 45, 0)
test_decode "[lower,upper] - both inclusive", "'[2023-01-01 10:30:00+00,2023-12-31 15:45:00+00]'::tstzrange", Time.utc(2023, 1, 1, 10, 30, 0)..Time.utc(2023, 12, 31, 15, 45, 0)
test_decode "empty range", "'empty'::tstzrange", Time.unix(0)...Time.unix(0)
test_decode "(-infinity,upper] - infinite lower bound", "'(,2023-12-31 15:45:00+00]'::tstzrange", nil..Time.utc(2023, 12, 31, 15, 45, 0)
test_decode "[lower,+infinity) - infinite upper bound", "'[2023-01-01 10:30:00+00,)'::tstzrange", Time.utc(2023, 1, 1, 10, 30, 0)...nil
test_decode "(-infinity,+infinity) - both bounds infinite", "'(,)'::tstzrange", nil...nil
end

describe "numrange" do
test_decode "(lower,upper) - both exclusive", "'(1.5,10.75)'::numrange", PG::Numeric.new(2_i16, 0_i16, 0_i16, 1_i16, [1_i16, 5000_i16])...PG::Numeric.new(2_i16, 0_i16, 0_i16, 2_i16, [10_i16, 7500_i16])
test_decode "(lower,upper] - exclusive lower, inclusive upper", "'(1.5,10.75]'::numrange", PG::Numeric.new(2_i16, 0_i16, 0_i16, 1_i16, [1_i16, 5000_i16])..PG::Numeric.new(2_i16, 0_i16, 0_i16, 2_i16, [10_i16, 7500_i16])
test_decode "[lower,upper) - inclusive lower, exclusive upper", "'[1.5,10.75)'::numrange", PG::Numeric.new(2_i16, 0_i16, 0_i16, 1_i16, [1_i16, 5000_i16])...PG::Numeric.new(2_i16, 0_i16, 0_i16, 2_i16, [10_i16, 7500_i16])
test_decode "[lower,upper] - both inclusive", "'[1.5,10.75]'::numrange", PG::Numeric.new(2_i16, 0_i16, 0_i16, 1_i16, [1_i16, 5000_i16])..PG::Numeric.new(2_i16, 0_i16, 0_i16, 2_i16, [10_i16, 7500_i16])
test_decode "empty range", "'empty'::numrange", PG::Numeric.new(1_i16, 0_i16, 0_i16, 0_i16, [0_i16])...PG::Numeric.new(1_i16, 0_i16, 0_i16, 0_i16, [0_i16])
test_decode "(-infinity,upper] - infinite lower bound", "'(,10.75]'::numrange", nil..PG::Numeric.new(2_i16, 0_i16, 0_i16, 2_i16, [10_i16, 7500_i16])
test_decode "[lower,+infinity) - infinite upper bound", "'[1.5,)'::numrange", PG::Numeric.new(2_i16, 0_i16, 0_i16, 1_i16, [1_i16, 5000_i16])...nil
test_decode "(-infinity,+infinity) - both bounds infinite", "'(,)'::numrange", nil...nil
end
end

if Helper.db_version_gte(14)
describe "multiranges" do
describe "int4multirange" do
test_decode "empty", "'{}'::int4multirange", [] of Range(Int32?, Int32?)
test_decode "single range", "'{[1,5)}'::int4multirange", [1...5]
test_decode "multiple ranges", "'{[1,3), [7,10)}'::int4multirange", [1...3, 7...10]
test_decode "with infinite bounds", "'{(,0), [10,)}'::int4multirange", [nil...0, 10...nil]
end

describe "int8multirange" do
test_decode "empty", "'{}'::int8multirange", [] of Range(Int64?, Int64?)
test_decode "single range", "'{[1,5)}'::int8multirange", [1_i64...5_i64]
test_decode "multiple ranges", "'{[1,3), [7,10)}'::int8multirange", [1_i64...3_i64, 7_i64...10_i64]
end

describe "datemultirange" do
test_decode "empty", "'{}'::datemultirange", [] of Range(Time?, Time?)
test_decode "single range", "'{[2023-01-01,2023-01-05)}'::datemultirange", [Time.utc(2023, 1, 1)...Time.utc(2023, 1, 5)]
test_decode "multiple ranges", "'{[2023-01-01,2023-01-03), [2023-01-07,2023-01-10)}'::datemultirange", [Time.utc(2023, 1, 1)...Time.utc(2023, 1, 3), Time.utc(2023, 1, 7)...Time.utc(2023, 1, 10)]
end

describe "tsmultirange" do
test_decode "empty", "'{}'::tsmultirange", [] of Range(Time?, Time?)
test_decode "single range", "'{[2023-01-01 10:30:00,2023-01-01 15:30:00)}'::tsmultirange", [Time.utc(2023, 1, 1, 10, 30, 0)...Time.utc(2023, 1, 1, 15, 30, 0)]
end

describe "tstzmultirange" do
test_decode "empty", "'{}'::tstzmultirange", [] of Range(Time?, Time?)
test_decode "single range", "'{[2023-01-01 10:30:00+00,2023-01-01 15:30:00+00)}'::tstzmultirange", [Time.utc(2023, 1, 1, 10, 30, 0)...Time.utc(2023, 1, 1, 15, 30, 0)]
end

describe "nummultirange" do
test_decode "empty", "'{}'::nummultirange", [] of Range(PG::Numeric?, PG::Numeric?)
test_decode "single range", "'{[1.5,5.75)}'::nummultirange", [PG::Numeric.new(2_i16, 0_i16, 0_i16, 1_i16, [1_i16, 5000_i16])...PG::Numeric.new(2_i16, 0_i16, 0_i16, 2_i16, [5_i16, 7500_i16])]
end
end
end
end
Loading