Skip to content

Conversation

@mamantoha
Copy link

This PR adds support for PostgreSQL range and multirange types.

Features Added

  • All 6 range types: int4range, int8range, daterange, tsrange, tstzrange, numrange
  • All 6 multirange types: int4multirange, int8multirange, datemultirange, tsmultirange, tstzmultirange, nummultirange (PostgreSQL 14+)
  • Complete boundary support: inclusive/exclusive bounds, empty ranges, infinite bounds

Testing

  • covering all range types and boundary combinations
  • Edge cases: empty ranges, infinite bounds, timezone handling, round-trip consistency

Breaking Changes

None - this is a pure addition of new functionality.


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) - 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 👍

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.

2 participants