Skip to content

add support for PostgreSQL range and multirange types#304

Open
mamantoha wants to merge 13 commits into
will:masterfrom
mamantoha:range
Open

add support for PostgreSQL range and multirange types#304
mamantoha wants to merge 13 commits into
will:masterfrom
mamantoha:range

Conversation

@mamantoha

@mamantoha mamantoha commented Nov 14, 2025

Copy link
Copy Markdown

This PR adds support for PostgreSQL range and multirange types.

Features Added

  • Supports all 6 built-in range types:
    int4range, int8range, daterange, tsrange, tstzrange, numrange
  • Supports all 6 built-in multirange types on PostgreSQL 14+:
    int4multirange, int8multirange, datemultirange, tsmultirange, tstzmultirange, nummultirange
  • Decodes ranges to PG::Range(T) to preserve PostgreSQL boundary semantics:
    inclusive/exclusive bounds, empty ranges, and infinite bounds
  • Decodes multiranges to Array(PG::Range(T))
  • Supports encoding PG::Range(T) values as query parameters
  • Keeps native Crystal Range parameter encoding as a convenience for callers that do not need exclusive lower bounds

Notes

PostgreSQL continuous range types such as tsrange, tstzrange, and numrange can distinguish (a,b) from [a,b). Native Crystal Range cannot represent exclusive lower bounds, so this PR introduces PG::Range(T) for decoded values and exact parameter encoding.

Discrete range types such as int4range, int8range, and daterange are still canonicalized by PostgreSQL, for example [1,10] becomes [1,11).

Testing

  • Added decoder coverage for all supported range types and boundary combinations
  • Added encoder coverage for range and multirange query parameters
  • Added regression coverage for preserving exclusive lower bounds on continuous ranges
  • Covered empty ranges, infinite bounds, timezone handling, PostgreSQL canonicalization, and round-trip behavior

Breaking Changes

None. This is a pure addition.

Comment thread spec/pg/decoders/range_decoder_spec.cr Outdated
Comment thread spec/pg/decoders/range_decoder_spec.cr

@jgaskins jgaskins left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is great. I occasionally work on things where tstzranges would be helpful and it'd be nice to be able to use them without having to add support for them in the app.

My notes here are mostly cosmetic. The only structural things are the placement of the constants and the decode_range method.

Comment thread src/pg/decoders/range_decoder.cr Outdated
Comment thread src/pg/decoders/range_decoder.cr Outdated
Comment thread src/pg/decoders/range_decoder.cr Outdated
Comment thread src/pg/decoder.cr Outdated
Comment thread src/pg.cr
Comment thread src/pq/param.cr Outdated
mamantoha and others added 5 commits June 28, 2026 11:14
Co-authored-by: Jamie Gaskins <jgaskins@hey.com>
Co-authored-by: Jamie Gaskins <jgaskins@hey.com>
Co-authored-by: Jamie Gaskins <jgaskins@hey.com>
@mamantoha

Copy link
Copy Markdown
Author

@jgaskins thanks for the review. I addressed the remaining comments:

  • Moved the range flags and decode_range into a range-specific mixin included only by RangeDecoder and MultiRangeDecoder.
  • Removed the unused bytesize and oid parameters from decode_range.
  • Kept the Range metadata logging overload for compatibility with db 0.14.0, and added a comment explaining that it mirrors the upstream crystal-db fix until this shard can rely on that version.

@jgaskins jgaskins left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I do have one additional note below that I noticed during testing and we should probably discuss it before merging, but otherwise, I really like this.

Benchmarks look great:

Benchmark code
require "../src/pg"
require "benchmark"

pg = DB.open("postgres:///")
sql = <<-SQL
  SELECT
    '[2026-06-28T12:00:00-04, 2026-06-28T13:00:00-04)'::tstzrange AS time
  FROM generate_series(1, $1)
SQL

# Warm the prepared statement cache
pg.query_all sql, 1, as: Event

event = nil
allocations = {} of Int64 => Int64
Benchmark.bm do |x|
  {
    1,
    10,
    100,
    1_000,
    10_000,
    100_000,
    1_000_000,
    10_000_000,
  }.each do |count|
    x.report "#{count.humanize(precision: 0, significant: false)} events" do
      mem = Benchmark.memory do
        pg.query_each sql, count do |rs|
          event = Event.new(rs)
        end
      end
      allocations[count] = mem
    end
  end
end
puts "Heap allocations:"
pp allocations
  .transform_keys(&.humanize(precision: 0, significant: false))
  .transform_values(&.humanize_bytes)
pp event

struct Event
  include DB::Serializable

  @[DB::Field(converter: TSTZRangeConverter(Time, Time))]
  getter time : Range(Time, Time)
end

module TSTZRangeConverter(Begin, End)
  def self.from_rs(rs : DB::ResultSet) : Range(Begin, End)
    range = rs.read(PG::Range).to_crystal_range

    Range.new(
      range.begin.as(Begin),
      range.end.as(End),
      exclusive: range.excludes_end?,
    )
  end
end
                  user     system      total        real
1 events      0.000006   0.000011   0.000017 (  0.000054)
10 events     0.000005   0.000009   0.000014 (  0.000042)
100 events    0.000018   0.000010   0.000028 (  0.000067)
1k events     0.000122   0.000018   0.000140 (  0.000264)
10k events    0.001157   0.000117   0.001274 (  0.001776)
100k events   0.010441   0.000912   0.011353 (  0.015458)
1M events     0.078240   0.008208   0.086448 (  0.138686)
10M events    0.802205   0.085781   0.887986 (  1.319421)
Heap allocations:
{"1" => "368B",
 "10" => "384B",
 "100" => "368B",
 "1k" => "368B",
 "10k" => "368B",
 "100k" => "368B",
 "1M" => "368B",
 "10M" => "352B"}
Event(
 @time=
  2026-06-28 12:00:00-04:00[America/New_York]...2026-06-28 13:00:00-04:00[America/New_York])

The result is constant heap usage, which I'd hoped for but am still delighted to see, and sublinear CPU time, which was pleasantly surprising.

The ergonomics of going through PG::Range aren't ideal, but the correctness is more important. I think we could follow up with something like this to allow folks to avoid not only writing a converter but also having to use it in all their range-based column-mapped properties:

struct Event
  include DB::Serializable

  getter time : Range(Time, Time)
end

Comment thread src/pg/range.cr
Comment on lines +28 to +30
def to_crystal_range
::Range.new(@begin, @end, exclusive: excludes_end?)
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Silently dropping the excludes_begin? here may lead to some hard-to-pin-down bugs. I want to say this should raise in this case so folks know exactly where the problem is. That's easy for me to say as someone who only ever uses ranges that are closed on the left, though. I don't know of any use cases where the range is open on the left (and the normalization mentioned in this comment doesn't apply to tstzrange), so I'm open to alternatives.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

What would you recommend here?

  1. Make to_crystal_range raise when excludes_begin? is true.
  2. Keep the current behavior, but document clearly that to_crystal_range only preserves the upper-bound exclusivity and cannot preserve exclusive lower bounds.
  3. Remove to_crystal_range entirely and require callers to inspect/use PG::Range when exact semantics matter.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

My gut feeling is that it should raise because it can't be represented in the stdlib Range type. I do like your second option, though. It's not as disruptive, but that can also lead to it being easily overlooked.

The third option doesn't seem viable. All of the Postgres value types that don't map cleanly to stdlib types have some way of converting to a stdlib type. For example, PG::Numeric can be converted to a BigDecimal or BigRational, PG::Interval can be converted to Time::Span or Time::MonthSpan, etc. This is important because the data objects in this shard aren't full value types (there's no Time#+(PG::Interval)). They're just bags of metadata about the value we got back from Postgres.

PG::Interval is a good example of your second option. It doesn't map losslessly to either Time::Span, Time::MonthSpan, or even both combined since converting 1 calendar day to 24 monotonic hours is a lossy conversion1. But it does it because, at least at the time it was written, there wasn't a better option for that. Maybe that's the path we take for PG::Range, too. Thoughts?

Footnotes

  1. Not all days are exactly 24 hours long (example). Part of the reason I created my Duration shard and added Postgres support was to cover this gap. It maps better to the PG::Interval type than the stdlib types do. It wasn't just for Postgres, but it exists for the same reason Postgres stores intervals the way it does.

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.

3 participants