diff --git a/README.md b/README.md index 51793d66..20c3b39d 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ Since it uses protocol version 3, older versions probably also work but are not - regtype - geo types: point, box, path, lseg, polygon, circle, line - array types: int8, int4, int2, float8, float4, bool, text, numeric, timestamptz, date, timestamp +- range: int4range, int8range, daterange, tsrange, tstzrange, numrange - interval (2) 1: A note on numeric: In Postgres this type has arbitrary precision. In this diff --git a/spec/pg/decoders/range_decoder_spec.cr b/spec/pg/decoders/range_decoder_spec.cr new file mode 100644 index 00000000..cf08e7e1 --- /dev/null +++ b/spec/pg/decoders/range_decoder_spec.cr @@ -0,0 +1,34 @@ +require "../../spec_helper" + +describe PG::Decoders do + # empty ranges + test_decode "int4range ", "'(5, 5)'::int4range", 0..0 + test_decode "int4range ", "'[5, 5)'::int4range", 0..0 + test_decode "int8range ", "'(5, 5)'::int8range", 0_i64..0_i64 + test_decode "int8range ", "'[5, 5)'::int8range", 0_i64..0_i64 + test_decode "daterange ", "'(2015-02-03, 2015-02-03)'::daterange", (Time.utc(1970, 1, 1)..Time.utc(1970, 1, 1)) + + # inclusive/exclusive boundaries + test_decode "int4range ", "'[4, 8]'::int4range", 4...9 + test_decode "int4range ", "'[4, 8)'::int4range", 4...8 + test_decode "int4range ", "'(4, 8]'::int4range", 5...9 + test_decode "int4range ", "'(4, 8)'::int4range", 5...8 + + # TODO: + # how to deal with Nil, without making every Range Range(T | Nil, T | Nil) + # + # infinity + # test_decode "int4range ", "'(10,]'::int4range", nil..10 + # test_decode "int4range ", "'(,10]'::int4range", 10..nil + # test_decode "int4range ", "'(,]'::int4range", nil..nil + + # numrange + lower = PG::Numeric.new(ndigits: 1, weight: 0, sign: PG::Numeric::Sign::Pos.value, dscale: 0, digits: [1] of Int16) + upper = PG::Numeric.new(ndigits: 1, weight: 0, sign: PG::Numeric::Sign::Pos.value, dscale: 0, digits: [3] of Int16) + test_decode "numrange ", "'[1, 3)'::numrange", lower...upper + + # date/ts + test_decode "daterange ", "'[2015-02-03, 2015-02-04)'::daterange", (Time.utc(2015, 2, 3)...Time.utc(2015, 2, 4)) + test_decode "tstzrange ", "'[2015-02-03 16:15:13-01, 2015-02-03 16:15:14-01)'::tstzrange", (Time.utc(2015, 2, 3, 17, 15, 13)...Time.utc(2015, 2, 3, 17, 15, 14)) + test_decode "tsrange ", "'[2015-02-03 16:15:13, 2015-02-03 16:15:14)'::tsrange", (Time.utc(2015, 2, 3, 16, 15, 13)...Time.utc(2015, 2, 3, 16, 15, 14)) +end diff --git a/src/pg/decoders/range_decoder.cr b/src/pg/decoders/range_decoder.cr new file mode 100644 index 00000000..1e295b16 --- /dev/null +++ b/src/pg/decoders/range_decoder.cr @@ -0,0 +1,120 @@ +require "../numeric" + +module PG + module Decoders + struct RangeDecoder(T) + include Decoder + # Decoder to use for boundaries => Range oids + DECODERS_TO_OID = { + "Int32" => [3904], + "Int64" => [3926], + "Time" => [ + 3912, + 3910, + 3908, + ], + "Numeric" => [3906], + } + + # Range OID => OID of upper/lower boundary + OIDS_TO_SUBOIDS = { + 3904 => 23, # int4range + 3926 => 20, # int8range + 3912 => 1082, # daterange + 3910 => 1114, # tstzrange + 3908 => 1114, # tsrange, + 3906 => 1700, # numrange + } + + getter oids : Array(Int32) + + # See https://github.com/postgres/postgres/blob/5cbfce562f7cd2aab0cdc4694ce298ec3567930e/src/include/utils/rangetypes.h#L36 + FLAG_EMPTY = 0b00000001 + FLAG_LOWER_INCLUSIVE = 0b00000010 + FLAG_UPPER_INCLUSIVE = 0b00000100 + FLAG_LOWER_INFINITY = 0b00001000 + FLAG_UPPER_INFINITY = 0b00010000 + + def initialize(@oids : Array(Int32)) + end + + {% for key, value in DECODERS_TO_OID %} + private def decode_boundary(io, oid, infinity, type : {{ key.id }}.class ) + if infinity + \{% if T.nilable? %} + nil + \{% else %} + raise PG::RuntimeError.new("Boundary is infinite but #{T} is not nilable") + \{% end %} + else + bytesize = read_i32(io) + suboid = OIDS_TO_SUBOIDS[oid] + Decoders::{{ key.id }}Decoder.new.decode(io, bytesize, suboid) + end + end + + PG::Decoders.register_decoder RangeDecoder({{ key.id }}).new({{ value }}) + {% end %} + + private def empty_range(type : Int32.class) + Range.new(0_i32, 0_i32) + end + + private def empty_range(type : Int64.class) + Range.new(0_i64, 0_i64) + end + + private def empty_range(type : Time.class) + Range.new(Time.unix(0), Time.unix(0)) + end + + private def empty_range(type : PG::Numeric.class) + value = PG::Numeric.new(ndigits: 1, weight: 0, sign: PG::Numeric::Sign::Pos.value, dscale: 0, digits: [0] of Int16) + + Range.new(value, value) + end + + def decode(io, bytesize, oid) + header = decode_range_header(io) + + if header.empty + empty_range(T) + else + lower = decode_boundary(io, oid, header.lower_infinity, T) + upper = decode_boundary(io, oid, header.upper_infinity, T) + Range.new(lower, upper, !header.upper_inclusive) + end + end + + def type + Range(T, T) + end + + def decode_range_header(io) + # + # For discrete types postgres normalizes inclusive/exclusive to + # [a, b) + # (Inclusive lower, exclusive upper) and therefore we do not see FLAG_UPPER_INCLUSIVE + # If lower and/or upper infinity is set, we will represent this with + # beginless/endless Range. + # + flags = io.read_byte.not_nil! + + RangeHeader.new( + empty: (FLAG_EMPTY & flags) != 0, + lower_inclusive: (FLAG_LOWER_INCLUSIVE & flags) != 0, + lower_infinity: (FLAG_LOWER_INFINITY & flags) != 0, + upper_inclusive: (FLAG_UPPER_INCLUSIVE & flags) != 0, + upper_infinity: (FLAG_UPPER_INFINITY & flags) != 0 + ) + end + end + + record RangeHeader, + empty : Bool, + lower_inclusive : Bool, + lower_infinity : Bool, + upper_inclusive : Bool, + upper_infinity : Bool + end +end