add support for PostgreSQL range and multirange types#304
Conversation
jgaskins
left a comment
There was a problem hiding this comment.
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.
Co-authored-by: Jamie Gaskins <jgaskins@hey.com>
Co-authored-by: Jamie Gaskins <jgaskins@hey.com>
Co-authored-by: Jamie Gaskins <jgaskins@hey.com>
|
@jgaskins thanks for the review. I addressed the remaining comments:
|
jgaskins
left a comment
There was a problem hiding this comment.
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| def to_crystal_range | ||
| ::Range.new(@begin, @end, exclusive: excludes_end?) | ||
| end |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
What would you recommend here?
- Make
to_crystal_rangeraise whenexcludes_begin?is true. - Keep the current behavior, but document clearly that
to_crystal_rangeonly preserves the upper-bound exclusivity and cannot preserve exclusive lower bounds. - Remove
to_crystal_rangeentirely and require callers to inspect/usePG::Rangewhen exact semantics matter.
There was a problem hiding this comment.
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
-
Not all days are exactly 24 hours long (example). Part of the reason I created my
Durationshard and added Postgres support was to cover this gap. It maps better to thePG::Intervaltype 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. ↩
This PR adds support for PostgreSQL range and multirange types.
Features Added
int4range,int8range,daterange,tsrange,tstzrange,numrangeint4multirange,int8multirange,datemultirange,tsmultirange,tstzmultirange,nummultirangePG::Range(T)to preserve PostgreSQL boundary semantics:inclusive/exclusive bounds, empty ranges, and infinite bounds
Array(PG::Range(T))PG::Range(T)values as query parametersRangeparameter encoding as a convenience for callers that do not need exclusive lower boundsNotes
PostgreSQL continuous range types such as
tsrange,tstzrange, andnumrangecan distinguish(a,b)from[a,b). Native CrystalRangecannot represent exclusive lower bounds, so this PR introducesPG::Range(T)for decoded values and exact parameter encoding.Discrete range types such as
int4range,int8range, anddaterangeare still canonicalized by PostgreSQL, for example[1,10]becomes[1,11).Testing
Breaking Changes
None. This is a pure addition.