diff --git a/CHANGELOG.md b/CHANGELOG.md index fa5cf10..b3a0132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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]}) diff --git a/lib/predicator/duration.ex b/lib/predicator/duration.ex index e2aaaf7..44a1112 100644 --- a/lib/predicator/duration.ex +++ b/lib/predicator/duration.ex @@ -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 @@ -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 @@ -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 @@ -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"} @@ -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} @@ -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 @@ -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. @@ -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) @@ -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) @@ -243,7 +284,8 @@ defmodule Predicator.Duration do {:days, "d"}, {:hours, "h"}, {:minutes, "m"}, - {:seconds, "s"} + {:seconds, "s"}, + {:milliseconds, "ms"} ] parts = diff --git a/lib/predicator/evaluator.ex b/lib/predicator/evaluator.ex index 9d48f0a..08638ba 100644 --- a/lib/predicator/evaluator.ex +++ b/lib/predicator/evaluator.ex @@ -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 diff --git a/lib/predicator/lexer.ex b/lib/predicator/lexer.ex index 4a42065..d5e366c 100644 --- a/lib/predicator/lexer.ex +++ b/lib/predicator/lexer.ex @@ -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} @@ -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 diff --git a/lib/predicator/types.ex b/lib/predicator/types.ex index bb4f205..b2d834b 100644 --- a/lib/predicator/types.ex +++ b/lib/predicator/types.ex @@ -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 """ diff --git a/test/predicator/duration_test.exs b/test/predicator/duration_test.exs index c7c0d8b..c44c284 100644 --- a/test/predicator/duration_test.exs +++ b/test/predicator/duration_test.exs @@ -15,7 +15,8 @@ defmodule Predicator.DurationTest do days: 0, hours: 0, minutes: 0, - seconds: 0 + seconds: 0, + milliseconds: 0 } end @@ -29,7 +30,8 @@ defmodule Predicator.DurationTest do days: 3, hours: 8, minutes: 30, - seconds: 0 + seconds: 0, + milliseconds: 0 } end @@ -42,7 +44,8 @@ defmodule Predicator.DurationTest do days: 4, hours: 5, minutes: 6, - seconds: 7 + seconds: 7, + milliseconds: 123 ) assert duration.years == 1 @@ -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 @@ -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