Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ 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-alpha.1

### 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.
- `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`
Expand Down
98 changes: 98 additions & 0 deletions lib/interval.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 15 additions & 3 deletions lib/interval/behaviour.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,30 @@ 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.

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
19 changes: 19 additions & 0 deletions lib/interval/date_interval.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -20,5 +24,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
19 changes: 19 additions & 0 deletions lib/interval/date_time_interval.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -16,5 +20,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
20 changes: 20 additions & 0 deletions lib/interval/decimal_interval.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -18,5 +22,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
22 changes: 22 additions & 0 deletions lib/interval/float_interval.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,36 @@ 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}
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
18 changes: 18 additions & 0 deletions lib/interval/integer_interval.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -22,5 +26,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
3 changes: 3 additions & 0 deletions lib/interval/interval_parse_error.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
defmodule Interval.IntervalParseError do
defexception [:message]
end
11 changes: 11 additions & 0 deletions lib/interval/macro.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
Expand All @@ -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__{}
Expand Down Expand Up @@ -79,6 +81,15 @@ 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__)

if @defimpl_string_chars? do
defimpl String.Chars do
defdelegate to_string(interval), to: Interval, as: :format
end
end
end
end
end
Loading