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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Grammar additions: `duration` and `relative_date` productions
- Full pipeline support (lexer, parser, compiler, evaluator, string visitor) with tests

#### Examples:
#### Examples

```elixir
Predicator.evaluate("created_at > 3d ago", %{"created_at" => ~U[2024-01-20 00:00:00Z]})
Expand Down
58 changes: 50 additions & 8 deletions lib/predicator/duration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ defmodule Predicator.Duration do
## Examples

iex> Predicator.Duration.new(days: 3, hours: 8)
%{years: 0, months: 0, weeks: 0, days: 3, hours: 8, minutes: 0, seconds: 0}
%{years: 0, months: 0, weeks: 0, days: 3, hours: 8, minutes: 0, seconds: 0, milliseconds: 0}

iex> Predicator.Duration.from_units([{"3", "d"}, {"8", "h"}])
{:ok, %{years: 0, months: 0, weeks: 0, days: 3, hours: 8, minutes: 0, seconds: 0}}
{:ok, %{years: 0, months: 0, weeks: 0, days: 3, hours: 8, minutes: 0, seconds: 0, milliseconds: 0}}

iex> Predicator.Duration.to_seconds(%{days: 1, hours: 2, minutes: 30})
95400
Expand All @@ -27,10 +27,10 @@ defmodule Predicator.Duration do
## Examples

iex> Predicator.Duration.new(days: 2, hours: 3)
%{years: 0, months: 0, weeks: 0, days: 2, hours: 3, minutes: 0, seconds: 0}
%{years: 0, months: 0, weeks: 0, days: 2, hours: 3, minutes: 0, seconds: 0, milliseconds: 0}

iex> Predicator.Duration.new()
%{years: 0, months: 0, weeks: 0, days: 0, hours: 0, minutes: 0, seconds: 0}
%{years: 0, months: 0, weeks: 0, days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 0}
"""
@spec new(keyword()) :: Types.duration()
def new(opts \\ []) do
Expand All @@ -41,7 +41,8 @@ defmodule Predicator.Duration do
days: Keyword.get(opts, :days, 0),
hours: Keyword.get(opts, :hours, 0),
minutes: Keyword.get(opts, :minutes, 0),
seconds: Keyword.get(opts, :seconds, 0)
seconds: Keyword.get(opts, :seconds, 0),
milliseconds: Keyword.get(opts, :milliseconds, 0)
}
end

Expand All @@ -53,7 +54,7 @@ defmodule Predicator.Duration do
## Examples

iex> Predicator.Duration.from_units([{"3", "d"}, {"8", "h"}])
{:ok, %{years: 0, months: 0, weeks: 0, days: 3, hours: 8, minutes: 0, seconds: 0}}
{:ok, %{years: 0, months: 0, weeks: 0, days: 3, hours: 8, minutes: 0, seconds: 0, milliseconds: 0}}

iex> Predicator.Duration.from_units([{"invalid", "d"}])
{:error, "Invalid duration value: invalid"}
Expand Down Expand Up @@ -90,7 +91,7 @@ defmodule Predicator.Duration do

iex> duration = Predicator.Duration.new(days: 1)
iex> Predicator.Duration.add_unit(duration, "h", 3)
%{years: 0, months: 0, weeks: 0, days: 1, hours: 3, minutes: 0, seconds: 0}
%{years: 0, months: 0, weeks: 0, days: 1, hours: 3, minutes: 0, seconds: 0, milliseconds: 0}
"""
@spec add_unit(Types.duration(), binary(), non_neg_integer()) :: Types.duration()
def add_unit(duration, "y", value), do: %{duration | years: duration.years + value}
Expand All @@ -101,6 +102,9 @@ defmodule Predicator.Duration do
def add_unit(duration, "m", value), do: %{duration | minutes: duration.minutes + value}
def add_unit(duration, "s", value), do: %{duration | seconds: duration.seconds + value}

def add_unit(duration, "ms", value),
do: %{duration | milliseconds: duration.milliseconds + value}

def add_unit(_duration, unit, _value) do
throw({:error, "Unknown duration unit: #{unit}"})
end
Expand Down Expand Up @@ -131,6 +135,33 @@ defmodule Predicator.Duration do
Map.get(duration, :years, 0) * 31_536_000
end

@doc """
Converts a duration to total milliseconds (approximate for months and years).

Uses approximate conversions:
- 1 month = 30 days
- 1 year = 365 days

## Examples

iex> Predicator.Duration.to_milliseconds(%{seconds: 1, milliseconds: 500})
1500

iex> Predicator.Duration.to_milliseconds(%{minutes: 1, seconds: 30, milliseconds: 250})
90250
"""
@spec to_milliseconds(Types.duration()) :: integer()
def to_milliseconds(duration) do
Map.get(duration, :milliseconds, 0) +
Map.get(duration, :seconds, 0) * 1_000 +
Map.get(duration, :minutes, 0) * 60_000 +
Map.get(duration, :hours, 0) * 3_600_000 +
Map.get(duration, :days, 0) * 86_400_000 +
Map.get(duration, :weeks, 0) * 604_800_000 +
Map.get(duration, :months, 0) * 2_592_000_000 +
Map.get(duration, :years, 0) * 31_536_000_000
end

@doc """
Adds a duration to a Date, returning a Date.

Expand Down Expand Up @@ -171,6 +202,11 @@ defmodule Predicator.Duration do
~U[2024-01-17T14:00:00Z]
"""
@spec add_to_datetime(DateTime.t(), Types.duration()) :: DateTime.t()
def add_to_datetime(datetime, %{milliseconds: ms} = duration) when ms > 0 do
total_ms = to_milliseconds(duration)
DateTime.add(datetime, total_ms, :millisecond)
end

def add_to_datetime(datetime, duration) do
total_seconds = to_seconds(duration)
DateTime.add(datetime, total_seconds, :second)
Expand Down Expand Up @@ -216,6 +252,11 @@ defmodule Predicator.Duration do
~U[2024-01-15T10:30:00Z]
"""
@spec subtract_from_datetime(DateTime.t(), Types.duration()) :: DateTime.t()
def subtract_from_datetime(datetime, %{milliseconds: ms} = duration) when ms > 0 do
total_ms = to_milliseconds(duration)
DateTime.add(datetime, -total_ms, :millisecond)
end

def subtract_from_datetime(datetime, duration) do
total_seconds = to_seconds(duration)
DateTime.add(datetime, -total_seconds, :second)
Expand Down Expand Up @@ -243,7 +284,8 @@ defmodule Predicator.Duration do
{:days, "d"},
{:hours, "h"},
{:minutes, "m"},
{:seconds, "s"}
{:seconds, "s"},
{:milliseconds, "ms"}
]

parts =
Expand Down
34 changes: 15 additions & 19 deletions lib/predicator/evaluator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1096,34 +1096,30 @@ defmodule Predicator.Evaluator do
defp unit_string_to_atom("sec"), do: {:ok, :seconds}
defp unit_string_to_atom("second"), do: {:ok, :seconds}
defp unit_string_to_atom("seconds"), do: {:ok, :seconds}
defp unit_string_to_atom("ms"), do: {:ok, :milliseconds}
defp unit_string_to_atom("millisecond"), do: {:ok, :milliseconds}
defp unit_string_to_atom("milliseconds"), do: {:ok, :milliseconds}
defp unit_string_to_atom(_unknown_unit), do: {:error, :invalid_unit}

@spec add_duration(DateTime.t(), Types.duration()) :: DateTime.t()
defp add_duration(datetime, %{milliseconds: ms} = duration) when ms > 0 do
total_ms = Predicator.Duration.to_milliseconds(duration)
DateTime.add(datetime, total_ms, :millisecond)
end

defp add_duration(datetime, duration) do
total_seconds = duration_to_seconds(duration)
total_seconds = Predicator.Duration.to_seconds(duration)
DateTime.add(datetime, total_seconds, :second)
end

@spec subtract_duration(DateTime.t(), Types.duration()) :: DateTime.t()
defp subtract_duration(datetime, duration) do
total_seconds = duration_to_seconds(duration)
DateTime.add(datetime, -total_seconds, :second)
defp subtract_duration(datetime, %{milliseconds: ms} = duration) when ms > 0 do
total_ms = Predicator.Duration.to_milliseconds(duration)
DateTime.add(datetime, -total_ms, :millisecond)
end

# Helper function to convert duration to total seconds
@spec duration_to_seconds(Types.duration()) :: integer()
defp duration_to_seconds(duration) do
# Calculate total seconds for all time units (weeks, days, hours, minutes, seconds)
# For years and months, we'll approximate using days for now
years_in_days = Map.get(duration, :years, 0) * 365
months_in_days = Map.get(duration, :months, 0) * 30

years_in_days * 24 * 3600 +
months_in_days * 24 * 3600 +
Map.get(duration, :weeks, 0) * 7 * 24 * 3600 +
Map.get(duration, :days, 0) * 24 * 3600 +
Map.get(duration, :hours, 0) * 3600 +
Map.get(duration, :minutes, 0) * 60 +
Map.get(duration, :seconds, 0)
defp subtract_duration(datetime, duration) do
total_seconds = Predicator.Duration.to_seconds(duration)
DateTime.add(datetime, -total_seconds, :second)
end
end
11 changes: 7 additions & 4 deletions lib/predicator/lexer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -700,20 +700,22 @@ defmodule Predicator.Lexer do
{:ok, binary(), binary(), binary()} | :no_match
defp extract_duration_unit(str) do
cond do
# Match unit followed by digits (for sequences like "d8h")
match = Regex.run(~r/^(mo)(\d.*)/, str) ->
# Match multi-character units first (ms, mo) followed by digits
match = Regex.run(~r/^(ms|mo)(\d.*)/, str) ->
[_full_match, unit, remaining] = match
{:ok, "", unit, remaining}

# Match single-character units followed by digits
match = Regex.run(~r/^([ydhmsw])(\d.*)/, str) ->
[_full_match, unit, remaining] = match
{:ok, "", unit, remaining}

# Match unit at end or followed by non-digits (for cases like "d" or "d ago")
match = Regex.run(~r/^(mo)(\D.*|$)/, str) ->
# Match multi-character units at end or followed by non-digits (ms, mo)
match = Regex.run(~r/^(ms|mo)(\D.*|$)/, str) ->
[_full_match, unit, remaining] = match
{:ok, "", unit, remaining}

# Match single-character units at end or followed by non-digits
match = Regex.run(~r/^([ydhmsw])(\D.*|$)/, str) ->
[_full_match, unit, remaining] = match
{:ok, "", unit, remaining}
Expand All @@ -728,6 +730,7 @@ defmodule Predicator.Lexer do
defp duration_unit?("h"), do: true
defp duration_unit?("m"), do: true
defp duration_unit?("s"), do: true
defp duration_unit?("ms"), do: true
defp duration_unit?("w"), do: true
defp duration_unit?("mo"), do: true
defp duration_unit?("y"), do: true
Expand Down
3 changes: 2 additions & 1 deletion lib/predicator/types.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ defmodule Predicator.Types do
days: non_neg_integer(),
hours: non_neg_integer(),
minutes: non_neg_integer(),
seconds: non_neg_integer()
seconds: non_neg_integer(),
milliseconds: non_neg_integer()
}

@typedoc """
Expand Down
129 changes: 126 additions & 3 deletions test/predicator/duration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ defmodule Predicator.DurationTest do
days: 0,
hours: 0,
minutes: 0,
seconds: 0
seconds: 0,
milliseconds: 0
}
end

Expand All @@ -29,7 +30,8 @@ defmodule Predicator.DurationTest do
days: 3,
hours: 8,
minutes: 30,
seconds: 0
seconds: 0,
milliseconds: 0
}
end

Expand All @@ -42,7 +44,8 @@ defmodule Predicator.DurationTest do
days: 4,
hours: 5,
minutes: 6,
seconds: 7
seconds: 7,
milliseconds: 123
)

assert duration.years == 1
Expand All @@ -52,6 +55,7 @@ defmodule Predicator.DurationTest do
assert duration.hours == 5
assert duration.minutes == 6
assert duration.seconds == 7
assert duration.milliseconds == 123
end
end

Expand Down Expand Up @@ -365,5 +369,124 @@ defmodule Predicator.DurationTest do
duration = Duration.new(years: 1)
assert Duration.to_string(duration) == "1y"
end

test "formats milliseconds" do
duration = Duration.new(milliseconds: 500)
assert Duration.to_string(duration) == "500ms"
end

test "formats complex duration with milliseconds" do
duration = Duration.new(seconds: 30, milliseconds: 250)
assert Duration.to_string(duration) == "30s250ms"
end
end

describe "milliseconds support" do
test "creates duration with milliseconds only" do
duration = Duration.new(milliseconds: 500)
assert duration.milliseconds == 500
end

test "adds milliseconds unit" do
duration = Duration.new() |> Duration.add_unit("ms", 750)
assert duration.milliseconds == 750
end

test "accumulates millisecond values" do
duration = Duration.new(milliseconds: 200) |> Duration.add_unit("ms", 300)
assert duration.milliseconds == 500
end

test "from_units handles milliseconds" do
{:ok, duration} = Duration.from_units([{"500", "ms"}])
assert duration.milliseconds == 500
end

test "from_units handles mixed units with milliseconds" do
{:ok, duration} = Duration.from_units([{"1", "s"}, {"500", "ms"}])
assert duration.seconds == 1
assert duration.milliseconds == 500
end
end

describe "to_milliseconds/1" do
test "converts simple milliseconds" do
duration = Duration.new(milliseconds: 500)
assert Duration.to_milliseconds(duration) == 500
end

test "converts seconds to milliseconds" do
duration = Duration.new(seconds: 2)
assert Duration.to_milliseconds(duration) == 2000
end

test "converts mixed seconds and milliseconds" do
duration = Duration.new(seconds: 1, milliseconds: 500)
assert Duration.to_milliseconds(duration) == 1500
end

test "converts minutes to milliseconds" do
duration = Duration.new(minutes: 1, seconds: 30, milliseconds: 250)
expected = 1 * 60_000 + 30 * 1_000 + 250
assert Duration.to_milliseconds(duration) == expected
end

test "converts hours to milliseconds" do
duration = Duration.new(hours: 1)
assert Duration.to_milliseconds(duration) == 3_600_000
end

test "converts days to milliseconds" do
duration = Duration.new(days: 1)
assert Duration.to_milliseconds(duration) == 86_400_000
end

test "converts zero duration" do
duration = Duration.new()
assert Duration.to_milliseconds(duration) == 0
end

test "converts complex duration to milliseconds" do
duration = Duration.new(hours: 1, minutes: 30, seconds: 45, milliseconds: 123)
expected = 1 * 3_600_000 + 30 * 60_000 + 45 * 1_000 + 123
assert Duration.to_milliseconds(duration) == expected
end
end

describe "datetime operations with milliseconds" do
test "add_to_datetime uses millisecond precision when milliseconds present" do
datetime = ~U[2024-01-15T10:30:00.000Z]
duration = Duration.new(seconds: 1, milliseconds: 500)
result = Duration.add_to_datetime(datetime, duration)
assert result == ~U[2024-01-15T10:30:01.500Z]
end

test "add_to_datetime uses second precision when no milliseconds" do
datetime = ~U[2024-01-15T10:30:00.000Z]
duration = Duration.new(seconds: 5)
result = Duration.add_to_datetime(datetime, duration)
assert result == ~U[2024-01-15T10:30:05.000Z]
end

test "subtract_from_datetime uses millisecond precision when milliseconds present" do
datetime = ~U[2024-01-15T10:30:02.750Z]
duration = Duration.new(seconds: 1, milliseconds: 250)
result = Duration.subtract_from_datetime(datetime, duration)
assert result == ~U[2024-01-15T10:30:01.500Z]
end

test "subtract_from_datetime uses second precision when no milliseconds" do
datetime = ~U[2024-01-15T10:30:05.000Z]
duration = Duration.new(seconds: 2)
result = Duration.subtract_from_datetime(datetime, duration)
assert result == ~U[2024-01-15T10:30:03.000Z]
end

test "millisecond precision with complex durations" do
datetime = ~U[2024-01-15T10:30:00.000Z]
duration = Duration.new(minutes: 1, seconds: 30, milliseconds: 750)
result = Duration.add_to_datetime(datetime, duration)
assert result == ~U[2024-01-15T10:31:30.750Z]
end
end
end