From fc3581b652b8769886febddd88dfa7db30e79aa9 Mon Sep 17 00:00:00 2001 From: Henrik Tudborg Date: Tue, 18 Mar 2025 14:51:40 +0100 Subject: [PATCH 1/4] feature: format and parse functions to convert to/from strings --- CHANGELOG.md | 10 +++ lib/interval.ex | 98 ++++++++++++++++++++++ lib/interval/behaviour.ex | 18 +++- lib/interval/date_interval.ex | 15 ++++ lib/interval/date_time_interval.ex | 15 ++++ lib/interval/decimal_interval.ex | 16 ++++ lib/interval/float_interval.ex | 18 ++++ lib/interval/integer_interval.ex | 14 ++++ lib/interval/interval_parse_error.ex | 3 + lib/interval/macro.ex | 3 + lib/interval/naive_date_time_interval.ex | 15 ++++ test/builtin_test.exs | 62 ++++++++++++++ test/continuous_interval_property_test.exs | 6 ++ test/discrete_interval_property_test.exs | 6 ++ test/ecto_test.exs | 4 + test/interval_format_parse_test.exs | 86 +++++++++++++++++++ test/interval_test.exs | 14 ++-- 17 files changed, 391 insertions(+), 12 deletions(-) create mode 100644 lib/interval/interval_parse_error.ex create mode 100644 test/interval_format_parse_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index b6bbfca..4eea2d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 2.0.1-dev + +### Added + +- `Interval.format/1` formats an interval as a string. +- `Interval.parse/2` parses a string into an interval. +- Interval module functions `format/1` and `parse/1` that delegates to the above. +- Interval callback `point_format/1` for customizing how a point is formattet. +- Interval callback `point_parse/1` to parse a string into a point. + ## 2.0.0 No changes from `alpha.2` diff --git a/lib/interval.ex b/lib/interval.ex index 2441057..1502970 100644 --- a/lib/interval.ex +++ b/lib/interval.ex @@ -1205,10 +1205,108 @@ defmodule Interval do compare_bounds(module, a_side, Map.fetch!(a, a_side), b_side, Map.fetch!(b, b_side)) end + @doc """ + Format the interval as a string + """ + @spec format(t()) :: String.t() + def format(%module{} = a) do + if empty?(a) do + "empty" + else + pfmt = + case function_exported?(module, :point_format, 1) do + true -> &module.point_format/1 + false -> &to_string/1 + end + + left = + case a.left do + :unbounded -> "" + {:inclusive, point} -> "[#{pfmt.(point)}" + {:exclusive, point} -> "(#{pfmt.(point)}" + end + + right = + case a.right do + :unbounded -> "" + {:inclusive, point} -> "#{pfmt.(point)}]" + {:exclusive, point} -> "#{pfmt.(point)})" + end + + "#{left},#{right}" + end + end + + @doc """ + Parse a string into an interval. + """ + @spec parse(String.t(), module()) :: {:ok, t()} | {:error, reason :: any()} + def parse(string, module) do + case string do + "empty" -> + {:ok, new_empty(module)} + + string -> + case String.split(string, ",", parts: 2) do + [_] -> + {:error, :missing_comma} + + [left, right] -> + with {:ok, left} <- parse_left(left, module), + {:ok, right} <- parse_right(right, module) do + {:ok, from_endpoints(module, left, right)} + end + end + end + end + + @doc """ + Parse a string into an interval, raises `Interval.IntervalParseError` if parsing fails. + """ + @spec parse!(String.t(), module()) :: t() + def parse!(string, module) do + case parse(string, module) do + {:ok, interval} -> + interval + + {:error, reason} -> + raise Interval.IntervalParseError, + message: + "Failed to parse string into interval: #{inspect(string)} reason=#{inspect(reason)}" + end + end + ## ## Helpers ## + defp parse_left("", _), do: {:ok, :unbounded} + defp parse_left("[" <> string, module), do: parse_point(string, module, :inclusive, :left) + defp parse_left("(" <> string, module), do: parse_point(string, module, :exclusive, :left) + defp parse_left(_, _), do: {:error, {:left, :missing_bound}} + + defp parse_right(string, module) do + len = String.length(string) + + case String.slice(string, len - 1, len) do + "" -> {:ok, :unbounded} + "]" -> parse_point(String.slice(string, 0, len - 1), module, :inclusive, :right) + ")" -> parse_point(String.slice(string, 0, len - 1), module, :exclusive, :right) + _ -> {:error, {:right, :missing_bound}} + end + end + + defp parse_point(string, module, boundness, side) do + if function_exported?(module, :point_parse, 1) do + case module.point_parse(string) do + {:ok, value} -> {:ok, {boundness, value}} + :error -> {:error, {side, {:invalid_point, string}}} + end + else + {:error, {:not_implemented, {module, :point_parse, 1}}} + end + end + defp compare_bounds(_module, _, a, _, b) when a == :empty or b == :empty do # deals with empty intervals. This should be checked before calling this function raise IntervalOperationError, message: "cannot compare bounds of empty intervals" diff --git a/lib/interval/behaviour.ex b/lib/interval/behaviour.ex index 48537be..b40f6b5 100644 --- a/lib/interval/behaviour.ex +++ b/lib/interval/behaviour.ex @@ -46,12 +46,12 @@ defmodule Interval.Behaviour do @doc """ Normalize a point to a canonical form. Returns :error if the point is invalid. """ - @callback point_normalize(Interval.point()) :: :error | {:ok, Interval.point()} + @callback point_normalize(point :: Interval.point()) :: :error | {:ok, Interval.point()} @doc """ Compare two points, returning if `a == b`, `a > b` or `a < b`. """ - @callback point_compare(Interval.point(), Interval.point()) :: :eq | :gt | :lt + @callback point_compare(a :: Interval.point(), b :: Interval.point()) :: :eq | :gt | :lt @doc """ Step a discrete point `n` steps. @@ -59,5 +59,17 @@ defmodule Interval.Behaviour do If `n` is negative, the point is stepped backwards. For integers this is simply addition (`point + n`) """ - @callback point_step(Interval.point(), n :: integer()) :: Interval.point() + @callback point_step(point :: Interval.point(), n :: integer()) :: Interval.point() + + @doc """ + Return a string representation of a point for use in formatting intervals. + """ + @callback point_format(point :: Interval.point()) :: String.t() + + @doc """ + Parse a string representation of a point into a point, for use in parsing intervals. + """ + @callback point_parse(string :: String.t()) :: {:ok, Interval.point()} | :error + + @optional_callbacks point_format: 1, point_parse: 1 end diff --git a/lib/interval/date_interval.ex b/lib/interval/date_interval.ex index a47a418..7fedb9a 100644 --- a/lib/interval/date_interval.ex +++ b/lib/interval/date_interval.ex @@ -20,5 +20,20 @@ if Application.get_env(:interval, Interval.DateInterval, true) do @impl true @spec point_step(Date.t(), integer()) :: Date.t() def point_step(%Date{} = date, n) when is_integer(n), do: Date.add(date, n) + + @impl true + @spec point_format(Date.t()) :: String.t() + def point_format(point) do + Date.to_iso8601(point) + end + + @impl true + @spec point_parse(String.t()) :: {:ok, Date.t()} | :error + def point_parse(str) do + case Date.from_iso8601(str) do + {:ok, dt} -> {:ok, dt} + {:error, _} -> :error + end + end end end diff --git a/lib/interval/date_time_interval.ex b/lib/interval/date_time_interval.ex index 331788d..4c0c20e 100644 --- a/lib/interval/date_time_interval.ex +++ b/lib/interval/date_time_interval.ex @@ -16,5 +16,20 @@ if Application.get_env(:interval, Interval.DateTimeInterval, true) do @impl true @spec point_compare(DateTime.t(), DateTime.t()) :: :lt | :eq | :gt defdelegate point_compare(a, b), to: DateTime, as: :compare + + @impl true + @spec point_format(DateTime.t()) :: String.t() + def point_format(point) do + DateTime.to_iso8601(point) + end + + @impl true + @spec point_parse(String.t()) :: {:ok, DateTime.t()} | :error + def point_parse(str) do + case DateTime.from_iso8601(str) do + {:ok, dt, _offset} -> {:ok, dt} + {:error, _} -> :error + end + end end end diff --git a/lib/interval/decimal_interval.ex b/lib/interval/decimal_interval.ex index e194b01..fcf8115 100644 --- a/lib/interval/decimal_interval.ex +++ b/lib/interval/decimal_interval.ex @@ -18,5 +18,21 @@ if Application.get_env(:interval, Interval.DecimalInterval, true) and Code.ensur def point_compare(a, b) when is_struct(a, Decimal) and is_struct(b, Decimal) do Decimal.compare(a, b) end + + @impl true + @spec point_format(Decimal.t()) :: String.t() + def point_format(point) do + Decimal.to_string(point) + end + + @impl true + @spec point_parse(String.t()) :: {:ok, Decimal.t()} | :error + def point_parse(str) do + case Decimal.parse(str) do + {num, ""} -> {:ok, num} + {_num, _} -> :error + :error -> :error + end + end end end diff --git a/lib/interval/float_interval.ex b/lib/interval/float_interval.ex index f016ecd..6328bc8 100644 --- a/lib/interval/float_interval.ex +++ b/lib/interval/float_interval.ex @@ -8,14 +8,32 @@ if Application.get_env(:interval, Interval.FloatInterval, true) do use Interval.Support.EctoType, ecto_type: :floatrange end + @impl true @spec point_normalize(any()) :: {:ok, float()} | :error def point_normalize(-0.0), do: {:ok, +0.0} def point_normalize(a) when is_float(a), do: {:ok, a} def point_normalize(_), do: :error + @impl true @spec point_compare(float(), float()) :: :lt | :eq | :gt def point_compare(a, a) when is_float(a), do: :eq def point_compare(a, b) when is_float(a) and is_float(b) and a > b, do: :gt def point_compare(a, b) when is_float(a) and is_float(b) and a < b, do: :lt + + @impl true + @spec point_format(float()) :: String.t() + def point_format(point) do + Float.to_string(point) + end + + @impl true + @spec point_parse(String.t()) :: {:ok, float()} | :error + def point_parse(str) do + case Float.parse(str) do + {num, ""} -> {:ok, num} + {_num, _} -> :error + :error -> :error + end + end end end diff --git a/lib/interval/integer_interval.ex b/lib/interval/integer_interval.ex index 0ee41a6..1adde21 100644 --- a/lib/interval/integer_interval.ex +++ b/lib/interval/integer_interval.ex @@ -22,5 +22,19 @@ if Application.get_env(:interval, Interval.IntegerInterval, true) do @impl true @spec point_step(integer(), integer()) :: integer() def point_step(a, n) when is_integer(a) and is_integer(n), do: a + n + + @impl true + @spec point_format(integer()) :: String.t() + def point_format(point), do: Integer.to_string(point) + + @impl true + @spec point_parse(String.t()) :: {:ok, integer()} | :error + def point_parse(str) do + case Integer.parse(str) do + {num, ""} -> {:ok, num} + {_num, _} -> :error + :error -> :error + end + end end end diff --git a/lib/interval/interval_parse_error.ex b/lib/interval/interval_parse_error.ex new file mode 100644 index 0000000..eb9dee5 --- /dev/null +++ b/lib/interval/interval_parse_error.ex @@ -0,0 +1,3 @@ +defmodule Interval.IntervalParseError do + defexception [:message] +end diff --git a/lib/interval/macro.ex b/lib/interval/macro.ex index bb9e132..91b6d84 100644 --- a/lib/interval/macro.ex +++ b/lib/interval/macro.ex @@ -79,6 +79,9 @@ defmodule Interval.Macro do defdelegate intersection(a, b), to: Interval defdelegate partition(a, x), to: Interval defdelegate difference(a, b), to: Interval + + defdelegate format(a), to: Interval + def parse(str), do: Interval.parse(str, __MODULE__) end end end diff --git a/lib/interval/naive_date_time_interval.ex b/lib/interval/naive_date_time_interval.ex index 65a5522..a608f6f 100644 --- a/lib/interval/naive_date_time_interval.ex +++ b/lib/interval/naive_date_time_interval.ex @@ -20,5 +20,20 @@ if Application.get_env(:interval, Interval.NaiveDateTimeInterval, true) do @impl true @spec point_step(NaiveDateTime.t(), any()) :: nil def point_step(%NaiveDateTime{}, _n), do: nil + + @impl true + @spec point_format(NaiveDateTime.t()) :: String.t() + def point_format(point) do + NaiveDateTime.to_iso8601(point) + end + + @impl true + @spec point_parse(String.t()) :: {:ok, NaiveDateTime.t()} | :error + def point_parse(str) do + case NaiveDateTime.from_iso8601(str) do + {:ok, dt} -> {:ok, dt} + {:error, _} -> :error + end + end end end diff --git a/test/builtin_test.exs b/test/builtin_test.exs index 2702f5e..f1f0dc9 100644 --- a/test/builtin_test.exs +++ b/test/builtin_test.exs @@ -19,6 +19,16 @@ defmodule Interval.BuiltinTest do test "point_step/2" do assert 4 === IntegerInterval.point_step(2, 2) end + + test "point_format/1" do + assert "2" === IntegerInterval.point_format(2) + end + + test "point_parse/1" do + assert {:ok, 2} === IntegerInterval.point_parse("2") + assert :error === IntegerInterval.point_parse("2.0") + assert :error === IntegerInterval.point_parse("potato") + end end describe "Date" do @@ -36,6 +46,15 @@ defmodule Interval.BuiltinTest do assert :lt === DateInterval.point_compare(~D[2022-01-01], ~D[2022-01-02]) assert :gt === DateInterval.point_compare(~D[2022-01-02], ~D[2022-01-01]) end + + test "point_format/1" do + assert "2022-01-01" === DateInterval.point_format(~D[2022-01-01]) + end + + test "point_parse/1" do + assert {:ok, ~D[2022-01-01]} === DateInterval.point_parse("2022-01-01") + assert :error === DateInterval.point_parse("2022-01-01T00:00:00Z") + end end describe "Float" do @@ -51,6 +70,16 @@ defmodule Interval.BuiltinTest do assert {:ok, +0.0} === FloatInterval.point_normalize(-0.0) assert :error === FloatInterval.point_normalize(~D[2023-01-01]) end + + test "point_format/1" do + assert "2.0" === FloatInterval.point_format(2.0) + end + + test "point_parse/1" do + assert {:ok, 2.0} === FloatInterval.point_parse("2.0") + assert :error === FloatInterval.point_parse("2.0.0") + assert :error === FloatInterval.point_parse("potato") + end end describe "DateTime" do @@ -77,6 +106,17 @@ defmodule Interval.BuiltinTest do assert :eq === DateTimeInterval.point_compare(a, a) assert :gt === DateTimeInterval.point_compare(b, a) end + + test "point_format/1" do + assert "2022-01-01T00:00:00Z" === DateTimeInterval.point_format(~U[2022-01-01 00:00:00Z]) + end + + test "point_parse/1" do + assert {:ok, ~U[2022-01-01 00:00:00Z]} === + DateTimeInterval.point_parse("2022-01-01T00:00:00Z") + + assert :error === DateTimeInterval.point_parse("2022-01-01") + end end describe "NaiveDateTime" do @@ -102,6 +142,17 @@ defmodule Interval.BuiltinTest do assert :eq === NaiveDateTimeInterval.point_compare(a, a) assert :gt === NaiveDateTimeInterval.point_compare(b, a) end + + test "point_format/1" do + assert "2022-01-01T00:00:00" === NaiveDateTimeInterval.point_format(~N[2022-01-01 00:00:00]) + end + + test "point_parse/1" do + assert {:ok, ~N[2022-01-01 00:00:00]} === + NaiveDateTimeInterval.point_parse("2022-01-01T00:00:00") + + assert :error === NaiveDateTimeInterval.point_parse("2022-01-01") + end end describe "Decimal" do @@ -127,5 +178,16 @@ defmodule Interval.BuiltinTest do assert :eq === DecimalInterval.point_compare(Decimal.new(2), Decimal.new(2)) assert :gt === DecimalInterval.point_compare(Decimal.new(2), Decimal.new(1)) end + + test "point_format/1" do + assert "1" === DecimalInterval.point_format(Decimal.new(1)) + end + + test "point_parse/1" do + assert {:ok, Decimal.new(1)} === DecimalInterval.point_parse("1") + assert {:ok, Decimal.new("1.0")} === DecimalInterval.point_parse("1.0") + assert :error === DecimalInterval.point_parse("1.0.0") + assert :error === DecimalInterval.point_parse("potato") + end end end diff --git a/test/continuous_interval_property_test.exs b/test/continuous_interval_property_test.exs index cb0cb21..1cbbd53 100644 --- a/test/continuous_interval_property_test.exs +++ b/test/continuous_interval_property_test.exs @@ -14,6 +14,12 @@ defmodule ContinuousIntervalPropertyTest do Map.put_new(ctx, :impl, Interval.FloatInterval) end + property "format/1 -> parse/2 always results in same interval", %{impl: impl} do + check all(a <- Helper.interval(impl)) do + assert {:ok, a} === a |> Interval.format() |> Interval.parse(impl) + end + end + property "overlaps?/2 is commutative", %{impl: impl} do check all( a <- Helper.interval(impl), diff --git a/test/discrete_interval_property_test.exs b/test/discrete_interval_property_test.exs index bc59b50..a061887 100644 --- a/test/discrete_interval_property_test.exs +++ b/test/discrete_interval_property_test.exs @@ -13,6 +13,12 @@ defmodule DiscreteIntervalPropertyTest do Map.put_new(ctx, :impl, Interval.IntegerInterval) end + property "format/1 -> parse/2 always results in same interval", %{impl: impl} do + check all(a <- Helper.interval(impl)) do + assert {:ok, a} === a |> Interval.format() |> Interval.parse(impl) + end + end + property "overlaps?/2 is commutative", %{impl: impl} do check all( a <- Helper.interval(impl), diff --git a/test/ecto_test.exs b/test/ecto_test.exs index 2c03f01..05a7635 100644 --- a/test/ecto_test.exs +++ b/test/ecto_test.exs @@ -139,6 +139,10 @@ defmodule Interval.Support.EctoTypeTest do assert interval.right == {:inclusive, 2.0} end + test "EctoType.supported?/0" do + assert EctoType.supported?() + end + ## ## Helpers ## diff --git a/test/interval_format_parse_test.exs b/test/interval_format_parse_test.exs new file mode 100644 index 0000000..af72d64 --- /dev/null +++ b/test/interval_format_parse_test.exs @@ -0,0 +1,86 @@ +defmodule IntervalFormatParseTest do + use ExUnit.Case, async: true + + alias Interval.IntervalParseError + alias Interval.IntegerInterval + alias Interval.FloatInterval + + def inti(p), do: inti(p, p, "[]") + def inti(left, right, bounds \\ "[)"), do: IntegerInterval.new(left, right, bounds) + + def floati(p), do: floati(p, p, "[]") + def floati(left, right, bounds \\ "[)"), do: FloatInterval.new(left, right, bounds) + + test "format" do + assert Interval.format(inti(1, 2, "[]")) === "[1,3)" + assert Interval.format(inti(1, 2, "()")) === "empty" + assert Interval.format(inti(1, 2, "[)")) === "[1,2)" + assert Interval.format(inti(1, 2, "(]")) === "[2,3)" + assert Interval.format(inti(nil, 2, "[)")) === ",2)" + assert Interval.format(inti(1, nil, "[)")) === "[1," + + assert Interval.format(floati(:empty)) === "empty" + assert Interval.format(floati(1.0, 2.0, "[]")) === "[1.0,2.0]" + assert Interval.format(floati(1.0, 2.0, "()")) === "(1.0,2.0)" + assert Interval.format(floati(1.0, 2.0, "[)")) === "[1.0,2.0)" + assert Interval.format(floati(1.0, 2.0, "(]")) === "(1.0,2.0]" + assert Interval.format(floati(nil, 2.0, "[)")) === ",2.0)" + assert Interval.format(floati(1.0, nil, "[)")) === "[1.0," + end + + test "parse" do + assert Interval.parse("[1,2]", IntegerInterval) === {:ok, inti(1, 2, "[]")} + assert Interval.parse("(1,2)", IntegerInterval) === {:ok, inti(1, 2, "()")} + assert Interval.parse("[1,2)", IntegerInterval) === {:ok, inti(1, 2, "[)")} + assert Interval.parse("(1,2]", IntegerInterval) === {:ok, inti(1, 2, "(]")} + assert Interval.parse("empty", IntegerInterval) === {:ok, inti(:empty)} + + assert Interval.parse("[1,2]", FloatInterval) === {:ok, floati(1.0, 2.0, "[]")} + assert Interval.parse("(1,2)", FloatInterval) === {:ok, floati(1.0, 2.0, "()")} + assert Interval.parse("[1,2)", FloatInterval) === {:ok, floati(1.0, 2.0, "[)")} + assert Interval.parse("(1,2]", FloatInterval) === {:ok, floati(1.0, 2.0, "(]")} + assert Interval.parse("empty", FloatInterval) === {:ok, floati(:empty)} + + assert Interval.parse(",", FloatInterval) == {:ok, floati(nil, nil)} + + ## + # errors + ## + # no comma + assert {:error, :missing_comma} = Interval.parse("", FloatInterval) + # no point between left/right bound and comma + assert {:error, {:left, {:invalid_point, ""}}} = Interval.parse("[,1.0]", FloatInterval) + assert {:error, {:right, {:invalid_point, ""}}} = Interval.parse("[1.0,]", FloatInterval) + # no bound before/after point + assert {:error, {:left, :missing_bound}} = Interval.parse("1.0,2.0]", FloatInterval) + assert {:error, {:right, :missing_bound}} = Interval.parse("[1.0,2.0", FloatInterval) + # point_parse not implemented + assert {:error, {:not_implemented, {__MODULE__, :point_parse, 1}}} = + Interval.parse("[1.0,2.0", __MODULE__) + end + + test "parse!" do + assert Interval.parse!("[1,2]", FloatInterval) === floati(1.0, 2.0, "[]") + + assert_raise IntervalParseError, fn -> + Interval.parse!("(1,2", FloatInterval) + end + end + + defmodule MyInterval do + use Interval, type: Integer + + defdelegate point_compare(a, b), to: IntegerInterval + defdelegate point_normalize(a), to: IntegerInterval + end + + test "MyInterval.format" do + assert MyInterval.format(MyInterval.new(1, 2)) === "[1,2)" + assert MyInterval.format(MyInterval.new(1, 2, "()")) === "(1,2)" + end + + test "MyInterval.parse" do + assert {:error, {:not_implemented, {MyInterval, :point_parse, 1}}} === + MyInterval.parse("[1,2)") + end +end diff --git a/test/interval_test.exs b/test/interval_test.exs index 8e0717f..8f1f2c1 100644 --- a/test/interval_test.exs +++ b/test/interval_test.exs @@ -8,15 +8,11 @@ defmodule Interval.IntervalTest do alias DateIntervalInterval alias DateTimeIntervalInterval - defp inti(:empty), do: inti(:empty, :empty) - defp inti(p), do: inti(p, p, "[]") - defp inti(l, r), do: inti(l, r, nil) - defp inti(l, r, bounds), do: IntegerInterval.new(l, r, bounds) - - defp floati(:empty), do: floati(:empty, :empty) - defp floati(p), do: floati(p, p, "[]") - defp floati(l, r), do: floati(l, r, nil) - defp floati(l, r, bounds), do: FloatInterval.new(l, r, bounds) + def inti(p), do: inti(p, p, "[]") + def inti(left, right, bounds \\ "[)"), do: IntegerInterval.new(left, right, bounds) + + def floati(p), do: floati(p, p, "[]") + def floati(left, right, bounds \\ "[)"), do: FloatInterval.new(left, right, bounds) test "new/1" do # some normal construction From 716483c34115e1e0b5d948532fefcdf86739b4a9 Mon Sep 17 00:00:00 2001 From: Henrik Tudborg Date: Tue, 18 Mar 2025 15:03:36 +0100 Subject: [PATCH 2/4] feature: Add `String.Chars` protocol implementation for all builtin types --- CHANGELOG.md | 1 + lib/interval/macro.ex | 8 +++++++ test/builtin_test.exs | 33 +++++++++++++++++++++++++++++ test/interval_format_parse_test.exs | 3 +-- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eea2d8..9fc24aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Interval module functions `format/1` and `parse/1` that delegates to the above. - Interval callback `point_format/1` for customizing how a point is formattet. - Interval callback `point_parse/1` to parse a string into a point. +- `String.Chars` protocol implemented for all builtin types, which delegates to `format/1` ## 2.0.0 diff --git a/lib/interval/macro.ex b/lib/interval/macro.ex index 91b6d84..b21f0c2 100644 --- a/lib/interval/macro.ex +++ b/lib/interval/macro.ex @@ -6,6 +6,7 @@ defmodule Interval.Macro do def define_interval(opts) do type = Keyword.fetch!(opts, :type) discrete = Keyword.get(opts, :discrete, false) + defimpl_string_chars? = Keyword.get(opts, :to_string, true) quote do @moduledoc """ @@ -19,6 +20,7 @@ defmodule Interval.Macro do @behaviour Interval.Behaviour @discrete unquote(discrete) + @defimpl_string_chars? unquote(defimpl_string_chars?) @typedoc "An interval of point type `#{inspect(unquote(type))}`" @type t() :: %__MODULE__{} @@ -82,6 +84,12 @@ defmodule Interval.Macro do defdelegate format(a), to: Interval def parse(str), do: Interval.parse(str, __MODULE__) + + if @defimpl_string_chars? do + defimpl String.Chars do + defdelegate to_string(interval), to: Interval, as: :format + end + end end end end diff --git a/test/builtin_test.exs b/test/builtin_test.exs index f1f0dc9..41d3ac4 100644 --- a/test/builtin_test.exs +++ b/test/builtin_test.exs @@ -29,6 +29,10 @@ defmodule Interval.BuiltinTest do assert :error === IntegerInterval.point_parse("2.0") assert :error === IntegerInterval.point_parse("potato") end + + test "to_string/1" do + assert "[1,2)" === IntegerInterval.parse("[1,2)") |> unwrap!() |> to_string() + end end describe "Date" do @@ -55,6 +59,11 @@ defmodule Interval.BuiltinTest do assert {:ok, ~D[2022-01-01]} === DateInterval.point_parse("2022-01-01") assert :error === DateInterval.point_parse("2022-01-01T00:00:00Z") end + + test "to_string/1" do + assert "[2022-01-01,2022-01-02)" === + DateInterval.parse("[2022-01-01,2022-01-02)") |> unwrap!() |> to_string() + end end describe "Float" do @@ -80,6 +89,10 @@ defmodule Interval.BuiltinTest do assert :error === FloatInterval.point_parse("2.0.0") assert :error === FloatInterval.point_parse("potato") end + + test "to_string/1" do + assert "[1.0,2.0]" === FloatInterval.parse("[1.0,2.0]") |> unwrap!() |> to_string() + end end describe "DateTime" do @@ -117,6 +130,13 @@ defmodule Interval.BuiltinTest do assert :error === DateTimeInterval.point_parse("2022-01-01") end + + test "to_string/1" do + assert "[2022-01-01T00:00:00Z,2022-01-01T00:00:01Z)" === + DateTimeInterval.parse("[2022-01-01T00:00:00Z,2022-01-01T00:00:01Z)") + |> unwrap!() + |> to_string() + end end describe "NaiveDateTime" do @@ -153,6 +173,13 @@ defmodule Interval.BuiltinTest do assert :error === NaiveDateTimeInterval.point_parse("2022-01-01") end + + test "to_string/1" do + assert "[2022-01-01T00:00:00,2022-01-01T00:00:01)" === + NaiveDateTimeInterval.parse("[2022-01-01T00:00:00,2022-01-01T00:00:01)") + |> unwrap!() + |> to_string() + end end describe "Decimal" do @@ -189,5 +216,11 @@ defmodule Interval.BuiltinTest do assert :error === DecimalInterval.point_parse("1.0.0") assert :error === DecimalInterval.point_parse("potato") end + + test "to_string/1" do + assert "[1,2]" === DecimalInterval.parse("[1,2]") |> unwrap!() |> to_string() + end end + + defp unwrap!({:ok, value}), do: value end diff --git a/test/interval_format_parse_test.exs b/test/interval_format_parse_test.exs index af72d64..9337d84 100644 --- a/test/interval_format_parse_test.exs +++ b/test/interval_format_parse_test.exs @@ -68,8 +68,7 @@ defmodule IntervalFormatParseTest do end defmodule MyInterval do - use Interval, type: Integer - + use Interval, type: Integer, to_string: false defdelegate point_compare(a, b), to: IntegerInterval defdelegate point_normalize(a), to: IntegerInterval end From c677ffe449b5b0af0be3074447e1541624e27cae Mon Sep 17 00:00:00 2001 From: Henrik Tudborg Date: Tue, 18 Mar 2025 15:21:40 +0100 Subject: [PATCH 3/4] feature: Add support for Jason.Encoder protocol --- CHANGELOG.md | 1 + lib/interval/date_interval.ex | 4 ++++ lib/interval/date_time_interval.ex | 4 ++++ lib/interval/decimal_interval.ex | 4 ++++ lib/interval/float_interval.ex | 4 ++++ lib/interval/integer_interval.ex | 4 ++++ lib/interval/naive_date_time_interval.ex | 4 ++++ lib/interval/support/ecto_type.ex | 2 +- lib/interval/support/jason.ex | 29 +++++++++++++++++++++++ mix.exs | 1 + test/builtin_test.exs | 30 ++++++++++++++++++++++++ test/ecto_test.exs | 8 +++---- test/jason_test.exs | 13 ++++++++++ 13 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 lib/interval/support/jason.ex create mode 100644 test/jason_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc24aa..96dad71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Interval callback `point_format/1` for customizing how a point is formattet. - Interval callback `point_parse/1` to parse a string into a point. - `String.Chars` protocol implemented for all builtin types, which delegates to `format/1` +- `Jason.Encoder` protocl implemented for all builtin types, which delegates to `format/1` ## 2.0.0 diff --git a/lib/interval/date_interval.ex b/lib/interval/date_interval.ex index 7fedb9a..c0a16ab 100644 --- a/lib/interval/date_interval.ex +++ b/lib/interval/date_interval.ex @@ -8,6 +8,10 @@ if Application.get_env(:interval, Interval.DateInterval, true) do use Interval.Support.EctoType, ecto_type: :daterange end + if Interval.Support.Jason.supported?() do + use Interval.Support.Jason + end + @impl true @spec point_normalize(any()) :: {:ok, Date.t()} | :error def point_normalize(a) when is_struct(a, Date), do: {:ok, a} diff --git a/lib/interval/date_time_interval.ex b/lib/interval/date_time_interval.ex index 4c0c20e..56d3e3d 100644 --- a/lib/interval/date_time_interval.ex +++ b/lib/interval/date_time_interval.ex @@ -8,6 +8,10 @@ if Application.get_env(:interval, Interval.DateTimeInterval, true) do use Interval.Support.EctoType, ecto_type: :tstzrange end + if Interval.Support.Jason.supported?() do + use Interval.Support.Jason + end + @impl true @spec point_normalize(any()) :: {:ok, DateTime.t()} | :error def point_normalize(a) when is_struct(a, DateTime), do: {:ok, a} diff --git a/lib/interval/decimal_interval.ex b/lib/interval/decimal_interval.ex index fcf8115..f554f2f 100644 --- a/lib/interval/decimal_interval.ex +++ b/lib/interval/decimal_interval.ex @@ -8,6 +8,10 @@ if Application.get_env(:interval, Interval.DecimalInterval, true) and Code.ensur use Interval.Support.EctoType, ecto_type: :numrange end + if Interval.Support.Jason.supported?() do + use Interval.Support.Jason + end + @impl true @spec point_normalize(any()) :: {:ok, Decimal.t()} | :error def point_normalize(a) when is_struct(a, Decimal), do: {:ok, a} diff --git a/lib/interval/float_interval.ex b/lib/interval/float_interval.ex index 6328bc8..3c3f372 100644 --- a/lib/interval/float_interval.ex +++ b/lib/interval/float_interval.ex @@ -8,6 +8,10 @@ if Application.get_env(:interval, Interval.FloatInterval, true) do use Interval.Support.EctoType, ecto_type: :floatrange end + if Interval.Support.Jason.supported?() do + use Interval.Support.Jason + end + @impl true @spec point_normalize(any()) :: {:ok, float()} | :error def point_normalize(-0.0), do: {:ok, +0.0} diff --git a/lib/interval/integer_interval.ex b/lib/interval/integer_interval.ex index 1adde21..4cdd76d 100644 --- a/lib/interval/integer_interval.ex +++ b/lib/interval/integer_interval.ex @@ -8,6 +8,10 @@ if Application.get_env(:interval, Interval.IntegerInterval, true) do use Interval.Support.EctoType, ecto_type: :int4range end + if Interval.Support.Jason.supported?() do + use Interval.Support.Jason + end + @impl true @spec point_normalize(any()) :: {:ok, integer()} | :error def point_normalize(a) when is_integer(a), do: {:ok, a} diff --git a/lib/interval/naive_date_time_interval.ex b/lib/interval/naive_date_time_interval.ex index a608f6f..543620d 100644 --- a/lib/interval/naive_date_time_interval.ex +++ b/lib/interval/naive_date_time_interval.ex @@ -8,6 +8,10 @@ if Application.get_env(:interval, Interval.NaiveDateTimeInterval, true) do use Interval.Support.EctoType, ecto_type: :tstzrange end + if Interval.Support.Jason.supported?() do + use Interval.Support.Jason + end + @impl true @spec point_normalize(NaiveDateTime.t()) :: {:ok, NaiveDateTime.t()} | :error def point_normalize(a) when is_struct(a, NaiveDateTime), do: {:ok, a} diff --git a/lib/interval/support/ecto_type.ex b/lib/interval/support/ecto_type.ex index a78dd7c..a5724f3 100644 --- a/lib/interval/support/ecto_type.ex +++ b/lib/interval/support/ecto_type.ex @@ -4,7 +4,7 @@ defmodule Interval.Support.EctoType do ## Example - use Interval.Support.EctoTypeType, ecto_type: :numrange + use Interval.Support.EctoType, ecto_type: :numrange """ @supported? Code.ensure_loaded?(Postgrex) and Code.ensure_loaded?(Ecto) diff --git a/lib/interval/support/jason.ex b/lib/interval/support/jason.ex new file mode 100644 index 0000000..084cdcb --- /dev/null +++ b/lib/interval/support/jason.ex @@ -0,0 +1,29 @@ +defmodule Interval.Support.Jason do + @moduledoc """ + Automatically generate Jason.Encoder for intervals + + ## Example + + use Interval.Support.Jason + """ + + @supported? Code.ensure_loaded?(Jason) + + @doc """ + Returns true if Interval was compiled with support for `Jason` + """ + @spec supported?() :: unquote(@supported?) + def supported?(), do: @supported? + + if @supported? do + defmacro __using__(_opts) do + quote do + defimpl Jason.Encoder do + def encode(value, opts) do + Jason.Encode.string(Interval.format(value), opts) + end + end + end + end + end +end diff --git a/mix.exs b/mix.exs index 8f86bf8..027ac35 100644 --- a/mix.exs +++ b/mix.exs @@ -91,6 +91,7 @@ defmodule Interval.MixProject do defp deps do [ {:ecto, ">= 3.4.3 and < 4.0.0", optional: true}, + {:jason, ">= 1.0.0 and < 2.0.0", optional: true}, {:postgrex, "~> 0.14", optional: true}, {:decimal, "~> 2.0", optional: true}, {:stream_data, "~> 1.0", only: [:test, :dev], runtime: false}, diff --git a/test/builtin_test.exs b/test/builtin_test.exs index 41d3ac4..51b7668 100644 --- a/test/builtin_test.exs +++ b/test/builtin_test.exs @@ -33,6 +33,10 @@ defmodule Interval.BuiltinTest do test "to_string/1" do assert "[1,2)" === IntegerInterval.parse("[1,2)") |> unwrap!() |> to_string() end + + test "Jason.Encoder protocol" do + assert ~S{"[1,2)"} === IntegerInterval.new(1, 2) |> Jason.encode!() + end end describe "Date" do @@ -64,6 +68,11 @@ defmodule Interval.BuiltinTest do assert "[2022-01-01,2022-01-02)" === DateInterval.parse("[2022-01-01,2022-01-02)") |> unwrap!() |> to_string() end + + test "Jason.Encoder protocol" do + assert ~S{"[2022-01-01,2023-01-01)"} === + DateInterval.new(~D[2022-01-01], ~D[2023-01-01]) |> Jason.encode!() + end end describe "Float" do @@ -93,6 +102,10 @@ defmodule Interval.BuiltinTest do test "to_string/1" do assert "[1.0,2.0]" === FloatInterval.parse("[1.0,2.0]") |> unwrap!() |> to_string() end + + test "Jason.Encoder protocol" do + assert ~S{"[1.0,2.0)"} === FloatInterval.new(1.0, 2.0) |> Jason.encode!() + end end describe "DateTime" do @@ -137,6 +150,12 @@ defmodule Interval.BuiltinTest do |> unwrap!() |> to_string() end + + test "Jason.Encoder protocol" do + assert ~S{"[2022-01-01T00:00:00Z,2023-01-01T00:00:00Z)"} === + DateTimeInterval.new(~U[2022-01-01 00:00:00Z], ~U[2023-01-01 00:00:00Z]) + |> Jason.encode!() + end end describe "NaiveDateTime" do @@ -180,6 +199,12 @@ defmodule Interval.BuiltinTest do |> unwrap!() |> to_string() end + + test "Jason.Encoder protocol" do + assert ~S{"[2022-01-01T00:00:00,2023-01-01T00:00:00)"} === + NaiveDateTimeInterval.new(~N[2022-01-01 00:00:00], ~N[2023-01-01 00:00:00]) + |> Jason.encode!() + end end describe "Decimal" do @@ -220,6 +245,11 @@ defmodule Interval.BuiltinTest do test "to_string/1" do assert "[1,2]" === DecimalInterval.parse("[1,2]") |> unwrap!() |> to_string() end + + test "Jason.Encoder protocol" do + assert ~S{"[1,2)"} === + DecimalInterval.new(Decimal.new(1), Decimal.new(2)) |> Jason.encode!() + end end defp unwrap!({:ok, value}), do: value diff --git a/test/ecto_test.exs b/test/ecto_test.exs index 05a7635..1638fb0 100644 --- a/test/ecto_test.exs +++ b/test/ecto_test.exs @@ -10,10 +10,10 @@ defmodule Interval.Support.EctoTypeTest do @module Interval.IntegerInterval test "Interval.IntegerInterval implements Ecto.Type behaviour" do - assert Kernel.function_exported?(@module, :type, 0) - assert Kernel.function_exported?(@module, :cast, 1) - assert Kernel.function_exported?(@module, :load, 1) - assert Kernel.function_exported?(@module, :dump, 1) + assert function_exported?(@module, :type, 0) + assert function_exported?(@module, :cast, 1) + assert function_exported?(@module, :load, 1) + assert function_exported?(@module, :dump, 1) end test "Interval.IntegerInterval.type/0" do diff --git a/test/jason_test.exs b/test/jason_test.exs new file mode 100644 index 0000000..d2b1715 --- /dev/null +++ b/test/jason_test.exs @@ -0,0 +1,13 @@ +defmodule Interval.Support.JasonTest do + use ExUnit.Case + + @module Interval.IntegerInterval + + test "supported?" do + assert Interval.Support.Jason.supported?() == true + end + + test "Interval.IntegerInterval" do + assert ~s{"[1,2)"} === @module.new(1, 2) |> Jason.encode!() + end +end From 9e9cd3af2df73fe8a37039fc0254d9d7ec5a07f8 Mon Sep 17 00:00:00 2001 From: Henrik Tudborg Date: Tue, 18 Mar 2025 15:57:49 +0100 Subject: [PATCH 4/4] feature: rewrite EctoType to support loading and casting from strings Also removed dependency on Postgrex, since the backing type could be `string`. This needs further work, but intervals should be loadable from strings now. --- CHANGELOG.md | 8 +- lib/interval/support/ecto_type.ex | 154 ++++++++++++++------- test/{ecto_test.exs => ecto_type_test.exs} | 8 ++ 3 files changed, 117 insertions(+), 53 deletions(-) rename test/{ecto_test.exs => ecto_type_test.exs} (94%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96dad71..a2e4725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 2.0.1-dev +## 2.0.1-alpha.1 ### Added @@ -16,6 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `String.Chars` protocol implemented for all builtin types, which delegates to `format/1` - `Jason.Encoder` protocl implemented for all builtin types, which delegates to `format/1` + +### Changed + +- `Interval.Support.EctoType` has been rewritten to support loading and casting from strings. +- `Interval.Support.EctoType` no longer depends on `Postgrex`, since backing type could be `string` + ## 2.0.0 No changes from `alpha.2` diff --git a/lib/interval/support/ecto_type.ex b/lib/interval/support/ecto_type.ex index a5724f3..c352bed 100644 --- a/lib/interval/support/ecto_type.ex +++ b/lib/interval/support/ecto_type.ex @@ -7,7 +7,7 @@ defmodule Interval.Support.EctoType do use Interval.Support.EctoType, ecto_type: :numrange """ - @supported? Code.ensure_loaded?(Postgrex) and Code.ensure_loaded?(Ecto) + @supported? Code.ensure_loaded?(Ecto) @doc """ Returns if Interval was compiled with support for `Ecto.Type` @@ -17,10 +17,9 @@ defmodule Interval.Support.EctoType do if @supported? do defmacro __using__(opts) do - quote location: :keep, - bind_quoted: [ - ecto_type: Keyword.fetch!(opts, :ecto_type) - ] do + ecto_type = Keyword.fetch!(opts, :ecto_type) + + quote do use Ecto.Type alias Interval.Support.EctoType @@ -28,67 +27,118 @@ defmodule Interval.Support.EctoType do def type(), do: unquote(ecto_type) @doc false - def cast(nil), do: {:ok, nil} - def cast(%Postgrex.Range{} = r), do: load(r) - def cast(%__MODULE__{} = i), do: {:ok, i} - def cast(_), do: :error + def cast(value), do: EctoType.cast(value, __MODULE__) @doc false - def load(nil), do: {:ok, nil} - def load(%Postgrex.Range{} = r), do: {:ok, EctoType.from_postgrex_range(r, __MODULE__)} - def load(_), do: :error + def load(value), do: EctoType.load(value, __MODULE__) @doc false - def dump(nil), do: {:ok, nil} - def dump(%__MODULE__{} = i), do: {:ok, EctoType.to_postgrex_range(i, __MODULE__)} - def dump(_), do: :error + def dump(value), do: EctoType.dump(value, __MODULE__) defoverridable Ecto.Type end end + end - @doc """ - Convert a `Postgrex.Range` to a struct of type `module` - """ - def from_postgrex_range(%Postgrex.Range{} = range, module) do - bounds = - [ - if(range.lower_inclusive, do: "[", else: "("), - if(range.upper_inclusive, do: "]", else: ")") - ] - |> Enum.join() - - module.new(left: from_point(range.lower), right: from_point(range.upper), bounds: bounds) - end + @postgrex_range :"Elixir.Postgrex.Range" + @postgres_range_types [ + :int4range, + :int8range, + :numrange, + :tsrange, + :tstzrange, + :daterange + ] + + ## + # Cast + ## + def cast(nil, _module) do + {:ok, nil} + end - @doc """ - Convert an `Interval` struct to a `Postgrex.Range`. - """ - def to_postgrex_range(interval, module \\ nil) - - def to_postgrex_range(%module{left: left, right: right}, module) do - {lower, lower_inclusive} = to_point(left) - {upper, upper_inclusive} = to_point(right) - - %Postgrex.Range{ - lower: lower, - upper: upper, - lower_inclusive: lower_inclusive, - upper_inclusive: upper_inclusive - } + def cast(%module{} = struct, module) do + {:ok, struct} + end + + def cast(string, module) when is_binary(string) do + case Interval.parse(string, module) do + {:ok, interval} -> {:ok, interval} + {:error, _reason} -> :error end + end - def to_postgrex_range(%module{} = struct, nil) do - to_postgrex_range(struct, module) + def cast(%@postgrex_range{} = range, module) do + {:ok, from_postgrex_range(range, module)} + end + + ## + # Load + ## + def load(nil, _module) do + {:ok, nil} + end + + def load(%@postgrex_range{} = range, module) do + {:ok, from_postgrex_range(range, module)} + end + + def load(string, module) when is_binary(string) do + cast(string, module) + end + + ## + # Dump + ## + def dump(nil, _module) do + {:ok, nil} + end + + def dump(interval, module) do + case module.type() do + type when is_atom(type) and type in @postgres_range_types -> + {:ok, to_postgrex_range(interval, module)} + + _ -> + {:ok, Interval.format(interval)} end + end - defp to_point(:unbounded), do: {:unbound, false} - defp to_point(:empty), do: {:empty, false} - defp to_point({:inclusive, point}), do: {point, true} - defp to_point({:exclusive, point}), do: {point, false} + def from_postgrex_range(%{__struct__: :"Elixir.Postgrex.Range"} = range, module) do + bounds = + [ + if(range.lower_inclusive, do: "[", else: "("), + if(range.upper_inclusive, do: "]", else: ")") + ] + |> Enum.join() - defp from_point(:unbound), do: nil - defp from_point(:empty), do: :empty - defp from_point(point), do: point + module.new(left: from_point(range.lower), right: from_point(range.upper), bounds: bounds) end + + def to_postgrex_range(interval, module \\ nil) + + def to_postgrex_range(%module{left: left, right: right}, module) do + {lower, lower_inclusive} = to_point(left) + {upper, upper_inclusive} = to_point(right) + + struct!(:"Elixir.Postgrex.Range", + lower: lower, + upper: upper, + lower_inclusive: lower_inclusive, + upper_inclusive: upper_inclusive + ) + end + + def to_postgrex_range(%module{} = struct, nil) do + to_postgrex_range(struct, module) + end + + defp to_point(:unbounded), do: {:unbound, false} + defp to_point(:empty), do: {:empty, false} + defp to_point({:inclusive, point}), do: {point, true} + defp to_point({:exclusive, point}), do: {point, false} + + defp from_point(:unbound), do: nil + defp from_point(:empty), do: :empty + defp from_point(point), do: point end diff --git a/test/ecto_test.exs b/test/ecto_type_test.exs similarity index 94% rename from test/ecto_test.exs rename to test/ecto_type_test.exs index 1638fb0..f3abdb5 100644 --- a/test/ecto_test.exs +++ b/test/ecto_type_test.exs @@ -33,6 +33,11 @@ defmodule Interval.Support.EctoTypeTest do # When we cast something that is already an Interval: interval = @module.new(left: 1, right: 2) assert @module.cast(interval) == {:ok, interval} + + # cast from string + assert @module.cast("[1,2)") === {:ok, @module.new(1, 2, "[)")} + assert @module.cast("empty") === {:ok, @module.new(:empty, :empty)} + assert @module.cast("potato") === :error end test "Interval.IntegerInterval.load/1" do @@ -56,6 +61,9 @@ defmodule Interval.Support.EctoTypeTest do # When DB returns NULL: assert @module.load(nil) == {:ok, nil} + + # load from DB as string + assert @module.load("[1,2)") === {:ok, @module.new(1, 2, "[)")} end test "Interval.IntegerInterval.dump/1" do