Skip to content

Commit

Permalink
Merge pull request #15 from circles-learning-labs/add_multiranges
Browse files Browse the repository at this point in the history
Add support for multirange types
  • Loading branch information
vforgione authored Apr 10, 2024
2 parents 9d4bbc6 + d170fac commit 4522d44
Show file tree
Hide file tree
Showing 11 changed files with 231 additions and 8 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# PgRanges

PostgreSQL range types for Ecto.
PostgreSQL range and multirange types for Ecto.

PgRanges provides a simple wrapper around `Postgrex.Range` so that you can
create schemas with range type fields and use the native range type in
PgRanges provides a simple wrapper around `Postgrex.Range` and `Postrex.Multirange`
so that you can create schemas with range type fields and use the native range type in
migrations.

```elixir
Expand All @@ -14,6 +14,7 @@ defmodule MyApp.Employee do
schema "employees" do
field :name, :string
field :employed_dates, DateRange
field :scheduled_meetings, DateMultiRange
end
end

Expand All @@ -24,6 +25,7 @@ defmodule MyApp.Repo.Migrations.CreateEmployees do
create table(:employees) do
add :name, :string
add :employed_dates, :daterange
add :scheduled_meetings, :datemultirange
end
end
end
Expand Down
77 changes: 77 additions & 0 deletions lib/pg_multiranges.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
defmodule PgMultiranges do
@moduledoc """
PgMultiranges provides a simple wrapper around `Postgrex.Multirange`.
The behaviour is an extension of `PgRanges`, with each multi type implemented
as a list of the base type. For example, `PgRanges.Int4Multirange` takes a list of
`PgRanges.Int4Range` structs as its constructor argument.
"""

@callback new([map()]) :: any
@callback new([{any, any, any}]) :: any
@callback from_postgrex(any) :: any
@callback to_postgrex(any) :: any

@optional_callbacks [
new: 1,
from_postgrex: 1,
to_postgrex: 1
]

@doc false

defmacro __using__(opts) do
subtype = opts[:subtype]

if subtype == nil do
raise ArgumentError, "#{inspect(__MODULE__)} must be used with the :subtype option"
end

subtype_opts = Keyword.drop(opts, [:subtype])

quote location: :keep, bind_quoted: [subtype: subtype, opts: subtype_opts] do
alias Postgrex.Multirange

use Ecto.Type
@behaviour PgMultiranges
@before_compile PgRanges

@type t :: %__MODULE__{ranges: [unquote(subtype).t()]}

defstruct [:ranges]

@spec new([map()]) :: __MODULE__.t()
def new(ranges, opts \\ []) do
fields = Enum.map(ranges, &unquote(subtype).new(&1.lower, &1.upper, opts))
struct!(__MODULE__, %{ranges: fields})
end

@doc false
@spec from_postgrex(Multirange.t()) :: __MODULE__.t()
def from_postgrex(%Multirange{ranges: ranges}),
do: struct!(__MODULE__, %{ranges: Enum.map(ranges, &unquote(subtype).from_postgrex/1)})

@doc false
@spec to_postgrex(__MODULE__.t()) :: Multirange.t()
def to_postgrex(%__MODULE__{ranges: ranges}),
do: struct!(Multirange, %{ranges: Enum.map(ranges, &unquote(subtype).to_postgrex/1)})

@doc false
def cast(nil), do: {:ok, nil}
def cast(%Multirange{} = range), do: {:ok, from_postgrex(range)}
def cast(%__MODULE__{} = range), do: {:ok, range}
def cast(_), do: :error

@doc false
def load(nil), do: {:ok, nil}
def load(%Multirange{} = range), do: {:ok, from_postgrex(range)}
def load(_), do: :error

@doc false
def dump(nil), do: {:ok, nil}
def dump(%__MODULE__{} = range), do: {:ok, to_postgrex(range)}
def dump(_), do: :error

@doc false
end
end
end
7 changes: 7 additions & 0 deletions lib/pg_ranges/datemultirange.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defmodule PgRanges.DateMultirange do
use PgMultiranges, subtype: PgRanges.DateRange

@impl true
@spec type() :: :datemultirange
def type, do: :datemultirange
end
7 changes: 7 additions & 0 deletions lib/pg_ranges/int4multirange.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defmodule PgRanges.Int4Multirange do
use PgMultiranges, subtype: PgRanges.Int4Range

@impl true
@spec type() :: :int4multirange
def type, do: :int4multirange
end
7 changes: 7 additions & 0 deletions lib/pg_ranges/int8multirange.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defmodule PgRanges.Int8Multirange do
use PgMultiranges, subtype: PgRanges.Int8Range

@impl true
@spec type() :: :int8multirange
def type, do: :int8multirange
end
7 changes: 7 additions & 0 deletions lib/pg_ranges/nummultirange.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defmodule PgRanges.NumMultirange do
use PgMultiranges, subtype: PgRanges.NumRange

@impl true
@spec type() :: :nummultirange
def type, do: :nummultirange
end
7 changes: 7 additions & 0 deletions lib/pg_ranges/tsmultirange.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defmodule PgRanges.TsMultirange do
use PgMultiranges, subtype: PgRanges.TsRange

@impl true
@spec type() :: :tsmultirange
def type, do: :tsmultirange
end
7 changes: 7 additions & 0 deletions lib/pg_ranges/tstzmultirange.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defmodule PgRanges.TstzMultirange do
use PgMultiranges, subtype: PgRanges.TstzRange

@impl true
@spec type() :: :tstzmultirange
def type, do: :tstzmultirange
end
94 changes: 91 additions & 3 deletions test/pg_ranges_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ defmodule PgRanges.PgRangesTest do
TstzRange,
Int4Range,
Int8Range,
NumRange
NumRange,
DateMultirange,
TsMultirange,
TstzMultirange,
Int4Multirange,
Int8Multirange,
NumMultirange
}

setup do
Expand All @@ -28,18 +34,41 @@ defmodule PgRanges.PgRangesTest do
int8_range = Int8Range.new(0, 1_000_000_000)
num_range = NumRange.new(0, 9.9, upper_inclusive: true)

now = DateTime.utc_now()

date_multi = DateMultirange.new([date_range, DateRange.new(~D[2018-04-23], ~D[2018-04-24])])

ts_multi =
TsMultirange.new([ts_range, TsRange.new(~N[2018-04-23 15:00:00], ~N[2018-04-24 01:00:00])])

tstz_multirange =
TstzMultirange.new([
TstzRange.new(now, DateTime.add(now, 1, :hour)),
TstzRange.new(DateTime.add(now, 2, :hour), DateTime.add(now, 3, :hour))
])

int4_multi = Int4Multirange.new([int4_range, Int4Range.new(11, 20)])
int8_multi = Int8Multirange.new([int8_range, Int8Range.new(1_000_000_001, 2_000_000_000)])
num_multi = NumMultirange.new([num_range, NumRange.new(10.0, 19.9, upper_inclusive: true)])

{:ok, m} =
Model.changeset(%Model{}, %{
date: date_range,
ts: ts_range,
tstz: tstz_range,
int4: int4_range,
int8: int8_range,
num: num_range
num: num_range,
datemulti: date_multi,
tsmulti: ts_multi,
tstzmulti: tstz_multirange,
int4multi: int4_multi,
int8multi: int8_multi,
nummulti: num_multi
})
|> Repo.insert()

{:ok, model: m}
{:ok, model: m, now: now}
end

test "querying" do
Expand All @@ -50,4 +79,63 @@ defmodule PgRanges.PgRangesTest do

assert length(models) == 1
end

test "querying tstzrange" do
tzdb = Calendar.get_time_zone_database()

inside =
DateTime.from_naive!(~N[2018-04-21 15:30:00], "America/Chicago")
|> DateTime.shift_zone!("Etc/UTC", tzdb)

outside =
DateTime.from_naive!(~N[2018-04-21 14:30:00], "America/Chicago")
|> DateTime.shift_zone!("Etc/UTC", tzdb)

models =
Repo.all(
from(m in Model, where: fragment("? @> ?::timestamp with time zone", m.tstz, ^inside))
)

assert length(models) == 1

models =
Repo.all(
from(m in Model, where: fragment("? @> ?::timestamp with time zone", m.tstz, ^outside))
)

assert length(models) == 0
end

test "querying tstzmultirange", %{now: now} do
inside1 = DateTime.add(now, 30, :minute)
inside2 = DateTime.add(now, 150, :minute)
outside = DateTime.add(now, 500, :minute)

models =
Repo.all(
from(m in Model,
where: fragment("? @> ?::timestamp with time zone", m.tstzmulti, ^inside1)
)
)

assert length(models) == 1

models =
Repo.all(
from(m in Model,
where: fragment("? @> ?::timestamp with time zone", m.tstzmulti, ^inside2)
)
)

assert length(models) == 1

models =
Repo.all(
from(m in Model,
where: fragment("? @> ?::timestamp with time zone", m.tstzmulti, ^outside)
)
)

assert length(models) == 0
end
end
9 changes: 8 additions & 1 deletion test/support/base_case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ defmodule PgRanges.BaseCase do
tstz tstzrange DEFAULT NULL,
int4 int4range DEFAULT NULL,
int8 int8range DEFAULT NULL,
num numrange DEFAULT NULL
num numrange DEFAULT NULL,
datemulti datemultirange DEFAULT NULL,
tsmulti tsmultirange DEFAULT NULL,
tstzmulti tstzmultirange DEFAULT NULL,
int4multi int4multirange DEFAULT NULL,
int8multi int8multirange DEFAULT NULL,
nummulti nummultirange DEFAULT NULL
) ;
""")

Expand Down
9 changes: 8 additions & 1 deletion test/support/model.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ defmodule PgRanges.Model do
field :int4, PgRanges.Int4Range, default: nil
field :int8, PgRanges.Int8Range, default: nil
field :num, PgRanges.NumRange, default: nil

field :datemulti, PgRanges.DateMultirange, default: nil
field :tsmulti, PgRanges.TsMultirange, default: nil
field :tstzmulti, PgRanges.TstzMultirange, default: nil
field :int4multi, PgRanges.Int4Multirange, default: nil
field :int8multi, PgRanges.Int8Multirange, default: nil
field :nummulti, PgRanges.NumMultirange, default: nil
end

@attrs ~w| date ts tstz int4 int8 num |a
@attrs ~w| date ts tstz int4 int8 num datemulti tsmulti tstzmulti int4multi int8multi nummulti |a

@doc false
def changeset(model, params) do
Expand Down

0 comments on commit 4522d44

Please sign in to comment.