Skip to content
Draft
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
38 changes: 30 additions & 8 deletions lib/plox/date_scale.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ defmodule Plox.DateScale do

This struct implements the `Plox.Scale` protocol.

`Plox.Scale.values/2` returns a `t:Date.Range.t/0` enumerable:
`Plox.Scale.values/2` returns a `t:Date.Range.t/0` enumerable and accepts
`step` and `start` options:

iex> scale = Plox.DateScale.new(Date.range(~D[2020-01-01], ~D[2020-01-10], 1))
iex> scale |> Plox.Scale.values(%{step: 2}) |> Enum.to_list()
Expand All @@ -14,6 +15,10 @@ defmodule Plox.DateScale do
iex> scale |> Plox.Scale.values(%{step: 3}) |> Enum.to_list()
[~D[2020-01-10], ~D[2020-01-07], ~D[2020-01-04], ~D[2020-01-01]]

iex> scale = Plox.DateScale.new(Date.range(~D[2020-01-01], ~D[2020-01-10], 1))
iex> scale |> Plox.Scale.values(%{start: ~D[2020-01-07]}) |> Enum.to_list()
[~D[2020-01-07], ~D[2020-01-08], ~D[2020-01-09], ~D[2020-01-10]]

`Plox.Scale.convert_to_range/3` returns a number in the given range:

iex> scale = Plox.DateScale.new(Date.range(~D[2020-01-01], ~D[2020-01-09], 1))
Expand Down Expand Up @@ -67,15 +72,32 @@ defmodule Plox.DateScale do
Returns a `t:Date.Range.t/0` of all `t:Date.t/0` values in the scale,
stepping by the given interval.

Accepts a `step` option as a number of days. The default step is 1 day.
Raises if given `step` is not a positive integer greater than 1.
Maintains the direction of the original range (positive or negative).

## Options

* `:start` - The starting date for generating values. Must be a `t:Date.t/0`
within the scale's domain. Defaults to the first date in the scale's range.

* `:step` - The number of days between each value. Must be a positive integer.
Defaults to `1`.
"""
def values(scale, opts) do
case Map.fetch(opts, :step) do
:error -> scale.range
{:ok, step} when step < 1 -> raise ArgumentError, message: "Step must be a positive integer"
{:ok, step} when scale.range.step > 0 -> %{scale.range | step: step}
{:ok, step} when scale.range.step < 0 -> %{scale.range | step: -step}
first_value = Map.get(opts, :start, scale.range.first)
step = Map.get(opts, :step, 1)

unless first_value in scale.range do
raise ArgumentError, message: "DateScale: start value must be within the range of the scale"
end

if step < 1 do
raise ArgumentError, message: "DateScale: step must be a positive integer"
end

if scale.range.step > 0 do
Date.range(first_value, scale.range.last, step)
else
Date.range(first_value, scale.range.last, -step)
end
end

Expand Down
52 changes: 41 additions & 11 deletions lib/plox/date_time_scale.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ defmodule Plox.DateTimeScale do

This struct implements the `Plox.Scale` protocol.

`Plox.Scale.values/2` returns a list of all datetime values:
`Plox.Scale.values/2` returns a list of all datetime values and accepts
`step` and `start` options:

iex> scale = Plox.DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:03:00])
iex> Plox.Scale.values(scale)
Expand All @@ -14,6 +15,10 @@ defmodule Plox.DateTimeScale do
iex> Plox.Scale.values(scale, %{step: {1, :day}})
[~U[2019-01-01 00:00:00Z], ~U[2019-01-02 00:00:00Z], ~U[2019-01-03 00:00:00Z]]

iex> scale = Plox.DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:03:00])
iex> Plox.Scale.values(scale, %{start: ~N[2019-01-01 00:01:00]})
[~N[2019-01-01 00:01:00], ~N[2019-01-01 00:02:00], ~N[2019-01-01 00:03:00]]

`Plox.Scale.convert_to_range/3` returns a number in the given range:

iex> scale = Plox.DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-03 00:00:00])
Expand Down Expand Up @@ -64,8 +69,14 @@ defmodule Plox.DateTimeScale do
Returns a list of all `DateTime` or `NaiveDateTime` values in the scale,
stepping by the given interval.

Accepts a `:step` option, which can be a number of seconds, minutes, hours,
or days. The default step is 60 seconds.
## Options

* `:start` - The starting datetime value. Must be included in the scale.
Defaults to the first value in the scale.

* `:step` - The step interval. Can be a non-zero integer number of seconds,
or a tuple of `{integer, :second | :minute | :hour | :day}`.
Defaults to `{60, :second}`.
"""
def values(%{first: %DateTime{time_zone: tz}} = scale, %{step: {step_days, :day}}) when tz != "Etc/UTC" do
scale.first
Expand All @@ -80,24 +91,43 @@ defmodule Plox.DateTimeScale do
end

def values(%{first: %date_time_module{}} = scale, opts) do
first_value = Map.get(opts, :start, scale.first)

if date_time_module.compare(first_value, scale.first) == :lt or
date_time_module.compare(first_value, scale.last) == :gt do
raise ArgumentError, message: "DateTimeScale: start value must be within the range of the scale"
end

step_seconds =
case Map.get(opts, :step, {60, :second}) do
seconds when is_integer(seconds) -> seconds
{seconds, :second} -> seconds
{minutes, :minute} -> minutes * 60
{hours, :hour} -> hours * 3600
{days, :day} -> days * 86_400
seconds when is_integer(seconds) and seconds > 0 ->
seconds

{seconds, :second} ->
seconds

{minutes, :minute} ->
minutes * 60

{hours, :hour} ->
hours * 3600

{days, :day} ->
days * 86_400

_ ->
raise ArgumentError,
message:
"DateTimeScale: step must be a non-zero integer or a {integer, :second | :minute | :hour | :day} tuple"
end

if date_time_module == DateTime and scale.first.time_zone != "Etc/UTC" and
step_seconds > 3600 do
Logger.warning(fn ->
"DateTimeScale: steps greater than an hour in seconds for non UTC DateTimes are not safe to use because of DST shifts"
"DateTimeScale: steps greater than an hour for non UTC DateTimes are not safe to use because of DST shifts"
end)
end

first_value = Map.get(opts, :start, scale.first)

total_seconds = date_time_module.diff(scale.last, first_value)
ticks = trunc(total_seconds / step_seconds)

Expand Down
39 changes: 34 additions & 5 deletions lib/plox/number_scale.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ defmodule Plox.NumberScale do

This struct implements the `Plox.Scale` protocol.

`Plox.Scale.values/2` returns an enumerable of the numerical values in the scale:
`Plox.Scale.values/2` returns an enumerable of the numerical values in the scale
and accepts `start` and `ticks` options:

iex> scale = Plox.NumberScale.new(0, 10)
iex> Plox.Scale.values(scale)
Expand All @@ -18,6 +19,10 @@ defmodule Plox.NumberScale do
iex> Plox.Scale.values(scale, %{ticks: 6})
[10.0, 8.0, 6.0, 4.0, 2.0, 0.0]

iex> scale = Plox.NumberScale.new(0, 10)
iex> Plox.Scale.values(scale, %{start: 5})
[5.0, 5.5, 6.0, 6.5, 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0]

`Plox.Scale.convert_to_range/3` returns a number in the given range:

iex> scale = Plox.NumberScale.new(0, 10)
Expand Down Expand Up @@ -69,20 +74,44 @@ defmodule Plox.NumberScale do
dynamically calculated based on `first`, `last`, and `ticks`.

Raises if `ticks` is less than `2` (default is `11`).

## Options

* `:start` - The starting value. Must be a number within the scale's domain.
Defaults to the first value in the scale.

* `:ticks` - The number of scale values to return. Must be at least `2`.
Defaults to `11`.
"""
def values(scale, opts) do
first_value =
case Map.get(opts, :start) do
nil -> scale.first
value when is_number(value) -> Decimal.from_float(value / 1.0)
value -> raise ArgumentError, message: "NumberScale: start must be a number, got #{inspect(value)}"
end

minimum = if scale.backwards?, do: scale.last, else: scale.first
maximum = if scale.backwards?, do: scale.first, else: scale.last

if Decimal.lt?(first_value, minimum) or Decimal.gt?(first_value, maximum) do
raise ArgumentError,
message: "NumberScale: start value #{Decimal.to_float(first_value)} must be within the scale range"
end

ticks = Map.get(opts, :ticks, 11)

if ticks < 2 do
raise ArgumentError, message: "Invalid ticks count `#{ticks}`, must be at least 2"
case ticks do
n when is_integer(n) and n >= 2 -> n
_ -> raise ArgumentError, message: "NumberScale: ticks must be an integer >= 2"
end

step = scale.last |> Decimal.sub(scale.first) |> Decimal.div(ticks - 1)
step = scale.last |> Decimal.sub(first_value) |> Decimal.div(ticks - 1)

# we don't compute the last value because it could include rounding errors
# carried through each step, instead we just append `scale.last`
0..(ticks - 2)
|> Enum.map_reduce(scale.first, fn _i, acc -> {acc, Decimal.add(acc, step)} end)
|> Enum.map_reduce(first_value, fn _i, acc -> {acc, Decimal.add(acc, step)} end)
|> elem(0)
|> Kernel.++([scale.last])
|> Enum.map(&Decimal.to_float/1)
Expand Down
40 changes: 37 additions & 3 deletions test/plox/date_scale_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ defmodule Plox.DateScaleTest do
end

describe "values/2" do
test "with default step (1d)" do
test "with default step (1d) and start" do
scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-03]))
assert %Date.Range{first: ~D[2019-01-01], last: ~D[2019-01-03], step: 1} = Scale.values(scale)
assert Scale.values(scale) == Date.range(~D[2019-01-01], ~D[2019-01-03], 1)
end

test "with positive custom step in days" do
Expand All @@ -51,13 +51,47 @@ defmodule Plox.DateScaleTest do
assert Scale.values(scale, %{step: 2}) == Date.range(~D[2019-01-10], ~D[2019-01-01], -2)
end

test "raises an error with invalid step" do
test "raises an error with zero step" do
scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-10]))

assert_raise ArgumentError, fn ->
Scale.values(scale, %{step: 0})
end
end

test "raises an error with negative step" do
scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-10]))

assert_raise ArgumentError, fn ->
Scale.values(scale, %{step: -1})
end
end

test "with custom start" do
scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05]))
assert Scale.values(scale, %{start: ~D[2019-01-03]}) == Date.range(~D[2019-01-03], ~D[2019-01-05])
end

test "raises an error with start before range" do
scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05]))

assert_raise ArgumentError, fn ->
Scale.values(scale, %{start: ~D[2018-12-31]})
end
end

test "raises an error with start after range" do
scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-05]))

assert_raise ArgumentError, fn ->
Scale.values(scale, %{start: ~D[2019-01-06]})
end
end

test "with custom step and start" do
scale = DateScale.new(Date.range(~D[2019-01-01], ~D[2019-01-10]))
assert Scale.values(scale, %{step: 3, start: ~D[2019-01-02]}) == Date.range(~D[2019-01-02], ~D[2019-01-10], 3)
end
end

describe "convert_to_range/3" do
Expand Down
73 changes: 72 additions & 1 deletion test/plox/date_time_scale_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ defmodule Plox.DateTimeScaleTest do
end

describe "values/2" do
test "with default step (60s)" do
test "with default step (60s) and start" do
scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00])

assert Scale.values(scale) == [
Expand All @@ -55,6 +55,16 @@ defmodule Plox.DateTimeScaleTest do
]
end

test "with valid step in total seconds" do
scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00])

assert Scale.values(scale, %{step: 60}) == [
~N[2019-01-01 00:00:00],
~N[2019-01-01 00:01:00],
~N[2019-01-01 00:02:00]
]
end

test "with valid step in days" do
scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-03 00:00:00])

Expand Down Expand Up @@ -94,6 +104,67 @@ defmodule Plox.DateTimeScaleTest do
~N[2019-01-01 00:00:03]
]
end

test "raises an error with zero step" do
scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00])

assert_raise ArgumentError, fn ->
Scale.values(scale, %{step: 0})
end
end

test "raises an error with negative step" do
scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00])

assert_raise ArgumentError, fn ->
Scale.values(scale, %{step: -60})
end
end

test "raises an error with invalid step" do
scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00])

assert_raise ArgumentError, fn ->
Scale.values(scale, %{step: {-500, :invalid}})
end
end

test "with custom start" do
scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00])

assert Scale.values(scale, %{start: ~N[2019-01-01 00:01:00]}) == [
~N[2019-01-01 00:01:00],
~N[2019-01-01 00:02:00]
]
end

test "raises an error with start before range" do
scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00])

assert_raise ArgumentError, fn ->
Scale.values(scale, %{start: ~N[2018-12-31 23:59:59]})
end
end

test "raises an error with start after range" do
scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:02:00])

assert_raise ArgumentError, fn ->
Scale.values(scale, %{start: ~N[2019-01-01 00:03:00]})
end
end

test "with custom step and start" do
scale = DateTimeScale.new(~N[2019-01-01 00:00:00], ~N[2019-01-01 00:10:00])

assert Scale.values(scale, %{step: {2, :minute}, start: ~N[2019-01-01 00:01:00]}) == [
~N[2019-01-01 00:01:00],
~N[2019-01-01 00:03:00],
~N[2019-01-01 00:05:00],
~N[2019-01-01 00:07:00],
~N[2019-01-01 00:09:00]
]
end
end

describe "convert_to_range/3" do
Expand Down
Loading