diff --git a/lib/predicator/duration.ex b/lib/predicator/duration.ex new file mode 100644 index 0000000..e2aaaf7 --- /dev/null +++ b/lib/predicator/duration.ex @@ -0,0 +1,269 @@ +defmodule Predicator.Duration do + @moduledoc """ + Duration utilities for time span calculations in Predicator expressions. + + This module provides functions to create, manipulate, and convert duration + values for use in relative date expressions and date arithmetic. + + ## Examples + + iex> Predicator.Duration.new(days: 3, hours: 8) + %{years: 0, months: 0, weeks: 0, days: 3, hours: 8, minutes: 0, seconds: 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}} + + iex> Predicator.Duration.to_seconds(%{days: 1, hours: 2, minutes: 30}) + 95400 + """ + + alias Predicator.Types + + @doc """ + Creates a new duration with specified time units. + + All unspecified units default to 0. + + ## Examples + + iex> Predicator.Duration.new(days: 2, hours: 3) + %{years: 0, months: 0, weeks: 0, days: 2, hours: 3, minutes: 0, seconds: 0} + + iex> Predicator.Duration.new() + %{years: 0, months: 0, weeks: 0, days: 0, hours: 0, minutes: 0, seconds: 0} + """ + @spec new(keyword()) :: Types.duration() + def new(opts \\ []) do + %{ + years: Keyword.get(opts, :years, 0), + months: Keyword.get(opts, :months, 0), + weeks: Keyword.get(opts, :weeks, 0), + days: Keyword.get(opts, :days, 0), + hours: Keyword.get(opts, :hours, 0), + minutes: Keyword.get(opts, :minutes, 0), + seconds: Keyword.get(opts, :seconds, 0) + } + end + + @doc """ + Creates a duration from parsed unit pairs. + + Takes a list of {value, unit} tuples and converts them to a duration. + + ## 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}} + + iex> Predicator.Duration.from_units([{"invalid", "d"}]) + {:error, "Invalid duration value: invalid"} + """ + @spec from_units([{binary(), binary()}]) :: {:ok, Types.duration()} | {:error, binary()} + def from_units(unit_pairs) do + case build_duration_from_units(unit_pairs, new()) do + {:ok, duration} -> {:ok, duration} + {:error, message} -> {:error, message} + end + end + + defp build_duration_from_units([], duration), do: {:ok, duration} + + defp build_duration_from_units([{value_str, unit} | rest], acc) do + case Integer.parse(value_str) do + {value, ""} -> + try do + updated_acc = add_unit(acc, unit, value) + build_duration_from_units(rest, updated_acc) + catch + {:error, message} -> {:error, message} + end + + _parse_error -> + {:error, "Invalid duration value: #{value_str}"} + end + end + + @doc """ + Adds a specific unit amount to a duration. + + ## Examples + + 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} + """ + @spec add_unit(Types.duration(), binary(), non_neg_integer()) :: Types.duration() + def add_unit(duration, "y", value), do: %{duration | years: duration.years + value} + def add_unit(duration, "mo", value), do: %{duration | months: duration.months + value} + def add_unit(duration, "w", value), do: %{duration | weeks: duration.weeks + value} + def add_unit(duration, "d", value), do: %{duration | days: duration.days + value} + def add_unit(duration, "h", value), do: %{duration | hours: duration.hours + value} + 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, unit, _value) do + throw({:error, "Unknown duration unit: #{unit}"}) + end + + @doc """ + Converts a duration to total seconds (approximate for months and years). + + Uses approximate conversions: + - 1 month = 30 days + - 1 year = 365 days + + ## Examples + + iex> Predicator.Duration.to_seconds(%{days: 1, hours: 2, minutes: 30, seconds: 15}) + 95415 + + iex> Predicator.Duration.to_seconds(%{weeks: 2}) + 1209600 + """ + @spec to_seconds(Types.duration()) :: integer() + def to_seconds(duration) do + Map.get(duration, :seconds, 0) + + Map.get(duration, :minutes, 0) * 60 + + Map.get(duration, :hours, 0) * 3600 + + Map.get(duration, :days, 0) * 86_400 + + Map.get(duration, :weeks, 0) * 604_800 + + Map.get(duration, :months, 0) * 2_592_000 + + Map.get(duration, :years, 0) * 31_536_000 + end + + @doc """ + Adds a duration to a Date, returning a Date. + + ## Examples + + iex> date = ~D[2024-01-15] + iex> duration = Predicator.Duration.new(days: 3, weeks: 1) + iex> Predicator.Duration.add_to_date(date, duration) + ~D[2024-01-25] + """ + @spec add_to_date(Date.t(), Types.duration()) :: Date.t() + def add_to_date(date, duration) do + # Convert duration to days (approximate for months/years) + total_days = + Map.get(duration, :days, 0) + + Map.get(duration, :weeks, 0) * 7 + + Map.get(duration, :months, 0) * 30 + + Map.get(duration, :years, 0) * 365 + + # Add hours/minutes/seconds as additional days if they add up to full days + additional_seconds = + Map.get(duration, :hours, 0) * 3600 + Map.get(duration, :minutes, 0) * 60 + + Map.get(duration, :seconds, 0) + + additional_days = div(additional_seconds, 86_400) + + Date.add(date, total_days + additional_days) + end + + @doc """ + Adds a duration to a DateTime, returning a DateTime. + + ## Examples + + iex> datetime = ~U[2024-01-15T10:30:00Z] + iex> duration = Predicator.Duration.new(days: 2, hours: 3, minutes: 30) + iex> Predicator.Duration.add_to_datetime(datetime, duration) + ~U[2024-01-17T14:00:00Z] + """ + @spec add_to_datetime(DateTime.t(), Types.duration()) :: DateTime.t() + def add_to_datetime(datetime, duration) do + total_seconds = to_seconds(duration) + DateTime.add(datetime, total_seconds, :second) + end + + @doc """ + Subtracts a duration from a Date, returning a Date. + + ## Examples + + iex> date = ~D[2024-01-25] + iex> duration = Predicator.Duration.new(days: 3, weeks: 1) + iex> Predicator.Duration.subtract_from_date(date, duration) + ~D[2024-01-15] + """ + @spec subtract_from_date(Date.t(), Types.duration()) :: Date.t() + def subtract_from_date(date, duration) do + # Convert duration to days (approximate for months/years) + total_days = + Map.get(duration, :days, 0) + + Map.get(duration, :weeks, 0) * 7 + + Map.get(duration, :months, 0) * 30 + + Map.get(duration, :years, 0) * 365 + + # Add hours/minutes/seconds as additional days if they add up to full days + additional_seconds = + Map.get(duration, :hours, 0) * 3600 + Map.get(duration, :minutes, 0) * 60 + + Map.get(duration, :seconds, 0) + + additional_days = div(additional_seconds, 86_400) + + Date.add(date, -(total_days + additional_days)) + end + + @doc """ + Subtracts a duration from a DateTime, returning a DateTime. + + ## Examples + + iex> datetime = ~U[2024-01-17T14:00:00Z] + iex> duration = Predicator.Duration.new(days: 2, hours: 3, minutes: 30) + iex> Predicator.Duration.subtract_from_datetime(datetime, duration) + ~U[2024-01-15T10:30:00Z] + """ + @spec subtract_from_datetime(DateTime.t(), Types.duration()) :: DateTime.t() + def subtract_from_datetime(datetime, duration) do + total_seconds = to_seconds(duration) + DateTime.add(datetime, -total_seconds, :second) + end + + @doc """ + Converts a duration to a human-readable string. + + ## Examples + + iex> duration = Predicator.Duration.new(days: 3, hours: 8, minutes: 30) + iex> Predicator.Duration.to_string(duration) + "3d8h30m" + + iex> duration = Predicator.Duration.new(weeks: 2) + iex> Predicator.Duration.to_string(duration) + "2w" + """ + @spec to_string(Types.duration()) :: binary() + def to_string(duration) do + units = [ + {:years, "y"}, + {:months, "mo"}, + {:weeks, "w"}, + {:days, "d"}, + {:hours, "h"}, + {:minutes, "m"}, + {:seconds, "s"} + ] + + parts = + units + |> Enum.reduce([], &build_duration_part(&1, &2, duration)) + |> Enum.reverse() + + format_duration_parts(parts) + end + + defp build_duration_part({unit, suffix}, acc, duration) do + value = Map.get(duration, unit, 0) + + if value > 0 do + ["#{value}#{suffix}" | acc] + else + acc + end + end + + defp format_duration_parts([]), do: "0s" + defp format_duration_parts(parts), do: Enum.join(parts, "") +end diff --git a/lib/predicator/evaluator.ex b/lib/predicator/evaluator.ex index d704f29..9d48f0a 100644 --- a/lib/predicator/evaluator.ex +++ b/lib/predicator/evaluator.ex @@ -23,10 +23,12 @@ defmodule Predicator.Evaluator do - `["unary_bang"]` - Logical NOT of top boolean value - `["bracket_access"]` - Pop key and object, push object[key] result - `["call", function_name, arg_count]` - Call function with arguments from stack + - `["duration", units]` - Create duration value from unit list + - `["relative_date", direction]` - Calculate relative date from duration and direction """ - alias Predicator.Functions.{JSONFunctions, MathFunctions, SystemFunctions} - alias Predicator.Types + alias Predicator.Functions.{DateFunctions, JSONFunctions, MathFunctions, SystemFunctions} + alias Predicator.{Duration, Types} alias Predicator.Errors.{EvaluationError, TypeMismatchError} @typedoc "Internal evaluator state" @@ -81,6 +83,7 @@ defmodule Predicator.Evaluator do # Merge custom functions with system functions merged_functions = SystemFunctions.all_functions() + |> Map.merge(DateFunctions.all_functions()) |> Map.merge(JSONFunctions.all_functions()) |> Map.merge(MathFunctions.all_functions()) |> Map.merge(Keyword.get(opts, :functions, %{})) @@ -307,6 +310,17 @@ defmodule Predicator.Evaluator do execute_object_set(evaluator, key) end + # Duration instruction + defp execute_instruction(%__MODULE__{} = evaluator, ["duration", units]) when is_list(units) do + execute_duration(evaluator, units) + end + + # Relative date instruction + defp execute_instruction(%__MODULE__{} = evaluator, ["relative_date", direction]) + when is_binary(direction) do + execute_relative_date(evaluator, direction) + end + # Unknown instruction - catch-all clause defp execute_instruction(%__MODULE__{}, unknown) do {:error, @@ -525,10 +539,17 @@ defmodule Predicator.Evaluator do end end - # Subtraction - numeric only - defp execute_arithmetic(%__MODULE__{stack: [right | [left | rest]]} = evaluator, :subtract) - when is_number(left) and is_number(right) do - {:ok, %__MODULE__{evaluator | stack: [left - right | rest]}} + # Subtraction with date arithmetic support + defp execute_arithmetic(%__MODULE__{stack: [right | [left | rest]]} = evaluator, :subtract) do + result = apply_subtraction(left, right) + + case result do + {:ok, value} -> + {:ok, %__MODULE__{evaluator | stack: [value | rest]}} + + {:error, _error} = error_result -> + error_result + end end # Multiplication - numeric only @@ -608,7 +629,37 @@ defmodule Predicator.Evaluator do {:ok, to_string(left) <> right} end + # Date + Duration = Date/DateTime + defp apply_addition(%Date{} = date, duration) when is_map(duration) do + if duration_map?(duration) do + {:ok, Duration.add_to_date(date, duration)} + else + apply_addition_fallback(date, duration) + end + end + + defp apply_addition(%DateTime{} = datetime, duration) when is_map(duration) do + if duration_map?(duration) do + {:ok, Duration.add_to_datetime(datetime, duration)} + else + apply_addition_fallback(datetime, duration) + end + end + + # Duration + Date = Date/DateTime (commutative) + defp apply_addition(duration, %Date{} = date) when is_map(duration) do + apply_addition(date, duration) + end + + defp apply_addition(duration, %DateTime{} = datetime) when is_map(duration) do + apply_addition(datetime, duration) + end + defp apply_addition(left, right) do + apply_addition_fallback(left, right) + end + + defp apply_addition_fallback(left, right) do left_type = get_value_type(left) right_type = get_value_type(right) @@ -621,6 +672,71 @@ defmodule Predicator.Evaluator do )} end + # Subtraction operations with date arithmetic + @spec apply_subtraction(Types.value(), Types.value()) :: {:ok, Types.value()} | {:error, term()} + defp apply_subtraction(left, right) when is_number(left) and is_number(right) do + {:ok, left - right} + end + + # Date - Date = Duration (difference in days as a duration) + defp apply_subtraction(%Date{} = left_date, %Date{} = right_date) do + days_diff = Date.diff(left_date, right_date) + duration = Duration.new(days: days_diff) + {:ok, duration} + end + + # DateTime - DateTime = Duration (difference in seconds as a duration) + defp apply_subtraction(%DateTime{} = left_datetime, %DateTime{} = right_datetime) do + seconds_diff = DateTime.diff(left_datetime, right_datetime, :second) + duration = Duration.new(seconds: seconds_diff) + {:ok, duration} + end + + # Mixed Date/DateTime subtraction (convert Date to start of day UTC) + defp apply_subtraction(%Date{} = date, %DateTime{} = datetime) do + {:ok, date_as_datetime} = DateTime.new(date, ~T[00:00:00], "Etc/UTC") + apply_subtraction(date_as_datetime, datetime) + end + + defp apply_subtraction(%DateTime{} = datetime, %Date{} = date) do + {:ok, date_as_datetime} = DateTime.new(date, ~T[00:00:00], "Etc/UTC") + apply_subtraction(datetime, date_as_datetime) + end + + # Date - Duration = Date/DateTime + defp apply_subtraction(%Date{} = date, duration) when is_map(duration) do + if duration_map?(duration) do + {:ok, Duration.subtract_from_date(date, duration)} + else + apply_subtraction_fallback(date, duration) + end + end + + defp apply_subtraction(%DateTime{} = datetime, duration) when is_map(duration) do + if duration_map?(duration) do + {:ok, Duration.subtract_from_datetime(datetime, duration)} + else + apply_subtraction_fallback(datetime, duration) + end + end + + defp apply_subtraction(left, right) do + apply_subtraction_fallback(left, right) + end + + defp apply_subtraction_fallback(left, right) do + left_type = get_value_type(left) + right_type = get_value_type(right) + + {:error, + TypeMismatchError.binary( + :subtract, + :number_or_date, + {left_type, right_type}, + {left, right} + )} + end + # Helper function to get the type of a value for error reporting defp get_value_type(value) when is_integer(value), do: :integer defp get_value_type(value) when is_float(value), do: :float @@ -629,8 +745,21 @@ defmodule Predicator.Evaluator do defp get_value_type(value) when is_list(value), do: :list defp get_value_type(%Date{}), do: :date defp get_value_type(%DateTime{}), do: :datetime + defp get_value_type(:undefined), do: :undefined - defp get_value_type(_other), do: :unknown + + defp get_value_type(value) when is_map(value) do + # Check if it's a duration map (has required duration keys) + if duration_map?(value), do: :duration, else: :map + end + + # Helper to check if a map is a duration (only called with maps) + defp duration_map?(value) do + Map.has_key?(value, :years) and Map.has_key?(value, :months) and + Map.has_key?(value, :weeks) and Map.has_key?(value, :days) and + Map.has_key?(value, :hours) and Map.has_key?(value, :minutes) and + Map.has_key?(value, :seconds) + end @spec execute_unary(t(), :minus | :bang) :: {:ok, t()} | {:error, term()} defp execute_unary(%__MODULE__{stack: [value | rest]} = evaluator, :minus) @@ -840,4 +969,161 @@ defmodule Predicator.Evaluator do defp execute_object_set(%__MODULE__{stack: stack} = _evaluator, _key) when length(stack) < 2 do {:error, EvaluationError.insufficient_operands(:object_set, length(stack), 2)} end + + @spec execute_duration(__MODULE__.t(), [[integer() | binary()]]) :: + {:ok, __MODULE__.t()} | {:error, term()} + defp execute_duration(%__MODULE__{} = evaluator, units) when is_list(units) do + case convert_units_to_duration_map(units) do + {:ok, duration_map} -> + {:ok, push_stack(evaluator, duration_map)} + + {:error, error_struct} -> + {:error, error_struct} + end + end + + # Helper function to convert units list to duration map + @spec convert_units_to_duration_map([[integer() | binary()]]) :: + {:ok, Types.duration()} | {:error, struct()} + defp convert_units_to_duration_map(units) do + initial_duration = %{years: 0, months: 0, weeks: 0, days: 0, hours: 0, minutes: 0, seconds: 0} + + Enum.reduce_while(units, {:ok, initial_duration}, fn + [value, unit], {:ok, acc} when is_integer(value) and is_binary(unit) -> + case unit_string_to_atom(unit) do + {:ok, unit_atom} -> + {:cont, {:ok, Map.put(acc, unit_atom, value)}} + + {:error, _reason} -> + {:halt, + {:error, + EvaluationError.new( + "Invalid duration unit: #{unit}", + "invalid_duration_unit", + :evaluate + )}} + end + + invalid_unit, _acc -> + {:halt, + {:error, + EvaluationError.new( + "Invalid duration unit format: #{inspect(invalid_unit)}", + "invalid_duration_format", + :evaluate + )}} + end) + end + + @spec execute_relative_date(__MODULE__.t(), binary()) :: + {:ok, __MODULE__.t()} | {:error, term()} + defp execute_relative_date(%__MODULE__{stack: [duration | rest]} = evaluator, direction) + when is_map(duration) and is_binary(direction) do + case calculate_relative_date(duration, direction) do + {:ok, target_datetime} -> + {:ok, %{evaluator | stack: [target_datetime | rest]}} + + {:error, error_struct} -> + {:error, error_struct} + end + end + + defp execute_relative_date(%__MODULE__{stack: [non_duration | _rest]}, _direction) do + {:error, + EvaluationError.new( + "Relative date operation requires a duration on the stack, got: #{inspect(non_duration)}", + "invalid_stack_value", + :evaluate + )} + end + + defp execute_relative_date(%__MODULE__{stack: []}, _direction) do + {:error, EvaluationError.insufficient_operands(:relative_date, 0, 1)} + end + + # Helper function to calculate relative date + @spec calculate_relative_date(Types.duration(), binary()) :: + {:ok, DateTime.t()} | {:error, struct()} + defp calculate_relative_date(duration, direction) do + now = DateTime.utc_now() + + case direction do + "ago" -> + {:ok, subtract_duration(now, duration)} + + "future" -> + {:ok, add_duration(now, duration)} + + "next" -> + {:ok, add_duration(now, duration)} + + "last" -> + {:ok, subtract_duration(now, duration)} + + _unknown_direction -> + {:error, + EvaluationError.new( + "Unknown relative date direction: #{direction}", + "invalid_direction", + :evaluate + )} + end + end + + # Helper functions for duration operations + + @spec unit_string_to_atom(binary()) :: {:ok, atom()} | {:error, :invalid_unit} + defp unit_string_to_atom("y"), do: {:ok, :years} + defp unit_string_to_atom("year"), do: {:ok, :years} + defp unit_string_to_atom("years"), do: {:ok, :years} + defp unit_string_to_atom("mo"), do: {:ok, :months} + defp unit_string_to_atom("month"), do: {:ok, :months} + defp unit_string_to_atom("months"), do: {:ok, :months} + defp unit_string_to_atom("w"), do: {:ok, :weeks} + defp unit_string_to_atom("week"), do: {:ok, :weeks} + defp unit_string_to_atom("weeks"), do: {:ok, :weeks} + defp unit_string_to_atom("d"), do: {:ok, :days} + defp unit_string_to_atom("day"), do: {:ok, :days} + defp unit_string_to_atom("days"), do: {:ok, :days} + defp unit_string_to_atom("h"), do: {:ok, :hours} + defp unit_string_to_atom("hour"), do: {:ok, :hours} + defp unit_string_to_atom("hours"), do: {:ok, :hours} + defp unit_string_to_atom("m"), do: {:ok, :minutes} + defp unit_string_to_atom("min"), do: {:ok, :minutes} + defp unit_string_to_atom("minute"), do: {:ok, :minutes} + defp unit_string_to_atom("minutes"), do: {:ok, :minutes} + defp unit_string_to_atom("s"), do: {:ok, :seconds} + 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(_unknown_unit), do: {:error, :invalid_unit} + + @spec add_duration(DateTime.t(), Types.duration()) :: DateTime.t() + defp add_duration(datetime, duration) do + total_seconds = 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) + 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) + end end diff --git a/lib/predicator/functions/date_functions.ex b/lib/predicator/functions/date_functions.ex new file mode 100644 index 0000000..25ff03d --- /dev/null +++ b/lib/predicator/functions/date_functions.ex @@ -0,0 +1,119 @@ +defmodule Predicator.Functions.DateFunctions do + @moduledoc """ + Date and time related functions for use in predicator expressions. + + This module provides temporal functions for working with dates, times, + durations, and relative date calculations. + + ## Available Functions + + ### Date/Time Functions + - `year(date)` - Extracts the year from a date or datetime + - `month(date)` - Extracts the month from a date or datetime + - `day(date)` - Extracts the day from a date or datetime + - `now()` - Returns the current UTC datetime (alias for Date.now()) + + ## Examples + + iex> Predicator.Functions.DateFunctions.call_year([~D[2023-05-15]], %{}) + {:ok, 2023} + + iex> Predicator.Functions.DateFunctions.call_date_now([], %{}) + {:ok, %DateTime{}} + """ + + alias Predicator.Types + + @type function_result :: {:ok, Types.value()} | {:error, binary()} + + @doc """ + Returns all date functions as a map in the format expected by the evaluator. + + ## Returns + + A map where keys are function names and values are `{arity, function}` tuples. + + ## Examples + + iex> functions = Predicator.Functions.DateFunctions.all_functions() + iex> Map.has_key?(functions, "year") + true + + iex> {arity, _function} = functions["year"] + iex> arity + 1 + """ + @spec all_functions() :: %{binary() => {non_neg_integer(), function()}} + def all_functions do + %{ + # Date functions + "Date.year" => {1, &call_year/2}, + "Date.month" => {1, &call_month/2}, + "Date.day" => {1, &call_day/2}, + "Date.now" => {0, &call_date_now/2} + } + end + + # Date function implementations + + @spec call_year([Types.value()], Types.context()) :: function_result() + def call_year([%Date{year: year}], _context) do + {:ok, year} + end + + def call_year([%DateTime{year: year}], _context) do + {:ok, year} + end + + def call_year([_value], _context) do + {:error, "Date.year() expects a date or datetime argument"} + end + + def call_year(_args, _context) do + {:error, "Date.year() expects exactly 1 argument"} + end + + @spec call_month([Types.value()], Types.context()) :: function_result() + def call_month([%Date{month: month}], _context) do + {:ok, month} + end + + def call_month([%DateTime{month: month}], _context) do + {:ok, month} + end + + def call_month([_value], _context) do + {:error, "Date.month() expects a date or datetime argument"} + end + + def call_month(_args, _context) do + {:error, "Date.month() expects exactly 1 argument"} + end + + @spec call_day([Types.value()], Types.context()) :: function_result() + def call_day([%Date{day: day}], _context) do + {:ok, day} + end + + def call_day([%DateTime{day: day}], _context) do + {:ok, day} + end + + def call_day([_value], _context) do + {:error, "Date.day() expects a date or datetime argument"} + end + + def call_day(_args, _context) do + {:error, "Date.day() expects exactly 1 argument"} + end + + @spec call_date_now([Types.value()], Types.context()) :: function_result() + def call_date_now([], _context) do + # Return current UTC datetime + {:ok, DateTime.utc_now()} + end + + def call_date_now(_args, _context) do + {:error, "Date.now() expects no arguments"} + end +end diff --git a/lib/predicator/functions/system_functions.ex b/lib/predicator/functions/system_functions.ex index 61dd886..0725078 100644 --- a/lib/predicator/functions/system_functions.ex +++ b/lib/predicator/functions/system_functions.ex @@ -14,11 +14,6 @@ defmodule Predicator.Functions.SystemFunctions do - `lower(string)` - Converts string to lowercase - `trim(string)` - Removes leading and trailing whitespace - ### Date Functions - - `year(date)` - Extracts the year from a date or datetime - - `month(date)` - Extracts the month from a date or datetime - - `day(date)` - Extracts the day from a date or datetime - ## Examples iex> Predicator.Functions.SystemFunctions.call("len", ["hello"]) @@ -27,9 +22,6 @@ defmodule Predicator.Functions.SystemFunctions do iex> Predicator.Functions.SystemFunctions.call("upper", ["world"]) {:ok, "WORLD"} - iex> Predicator.Functions.SystemFunctions.call("year", [~D[2023-05-15]]) - {:ok, 2023} - iex> Predicator.Functions.SystemFunctions.call("unknown", []) {:error, "Unknown function: unknown"} """ @@ -62,12 +54,7 @@ defmodule Predicator.Functions.SystemFunctions do "len" => {1, &call_len/2}, "upper" => {1, &call_upper/2}, "lower" => {1, &call_lower/2}, - "trim" => {1, &call_trim/2}, - - # Date functions - "year" => {1, &call_year/2}, - "month" => {1, &call_month/2}, - "day" => {1, &call_day/2} + "trim" => {1, &call_trim/2} } end @@ -124,57 +111,4 @@ defmodule Predicator.Functions.SystemFunctions do defp call_trim(_args, _context) do {:error, "trim() expects exactly 1 argument"} end - - # Date function implementations - - @spec call_year([Types.value()], Types.context()) :: function_result() - defp call_year([%Date{year: year}], _context) do - {:ok, year} - end - - defp call_year([%DateTime{year: year}], _context) do - {:ok, year} - end - - defp call_year([_value], _context) do - {:error, "year() expects a date or datetime argument"} - end - - defp call_year(_args, _context) do - {:error, "year() expects exactly 1 argument"} - end - - @spec call_month([Types.value()], Types.context()) :: function_result() - defp call_month([%Date{month: month}], _context) do - {:ok, month} - end - - defp call_month([%DateTime{month: month}], _context) do - {:ok, month} - end - - defp call_month([_value], _context) do - {:error, "month() expects a date or datetime argument"} - end - - defp call_month(_args, _context) do - {:error, "month() expects exactly 1 argument"} - end - - @spec call_day([Types.value()], Types.context()) :: function_result() - defp call_day([%Date{day: day}], _context) do - {:ok, day} - end - - defp call_day([%DateTime{day: day}], _context) do - {:ok, day} - end - - defp call_day([_value], _context) do - {:error, "day() expects a date or datetime argument"} - end - - defp call_day(_args, _context) do - {:error, "day() expects exactly 1 argument"} - end end diff --git a/lib/predicator/lexer.ex b/lib/predicator/lexer.ex index 0a136f6..4a42065 100644 --- a/lib/predicator/lexer.ex +++ b/lib/predicator/lexer.ex @@ -78,6 +78,12 @@ defmodule Predicator.Lexer do | {:contains_op, pos_integer(), pos_integer(), pos_integer(), binary()} | {:function_name, pos_integer(), pos_integer(), pos_integer(), binary()} | {:qualified_function_name, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:duration_unit, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:ago_op, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:from_op, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:now_op, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:next_op, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:last_op, pos_integer(), pos_integer(), pos_integer(), binary()} | {:eof, pos_integer(), pos_integer(), pos_integer(), nil} @typedoc """ @@ -179,18 +185,36 @@ defmodule Predicator.Lexer do ?\r -> tokenize_chars(rest, line, col, tokens) - # Numbers + # Numbers (with potential duration units) c when c >= ?0 and c <= ?9 -> {number, remaining, consumed} = take_number([char | rest]) - token = - if is_integer(number) do - {:integer, line, col, consumed, number} - else - {:float, line, col, consumed, number} + # Check for duration units after the number (only for integers) + if is_integer(number) do + case try_parse_duration_after_number(number, remaining) do + {:ok, duration_tokens, new_remaining, total_consumed} -> + # Generate tokens for the number-duration sequence + tokenize_number_duration_sequence( + number, + duration_tokens, + new_remaining, + line, + col, + consumed, + total_consumed, + tokens + ) + + :not_duration -> + # Regular number token + token = {:integer, line, col, consumed, number} + tokenize_chars(remaining, line, col + consumed, [token | tokens]) end - - tokenize_chars(remaining, line, col + consumed, [token | tokens]) + else + # Float - no duration units supported + token = {:float, line, col, consumed, number} + tokenize_chars(remaining, line, col + consumed, [token | tokens]) + end # Identifiers (including potential function calls and qualified identifiers) c when (c >= ?a and c <= ?z) or (c >= ?A and c <= ?Z) or c == ?_ -> @@ -467,6 +491,11 @@ defmodule Predicator.Lexer do defp classify_identifier("in"), do: {:in_op, "in"} defp classify_identifier("CONTAINS"), do: {:contains_op, "CONTAINS"} defp classify_identifier("contains"), do: {:contains_op, "contains"} + defp classify_identifier("ago"), do: {:ago_op, "ago"} + defp classify_identifier("from"), do: {:from_op, "from"} + defp classify_identifier("now"), do: {:now_op, "now"} + defp classify_identifier("next"), do: {:next_op, "next"} + defp classify_identifier("last"), do: {:last_op, "last"} defp classify_identifier(id), do: {:identifier, id} # Helper function to handle regular identifier (not qualified) @@ -622,4 +651,126 @@ defmodule Predicator.Lexer do end end end + + # Duration parsing functions (simplified approach with no-spaces constraint) + + @spec try_parse_duration_after_number(integer(), charlist()) :: + {:ok, [{binary(), binary()}], charlist(), pos_integer()} | :not_duration + defp try_parse_duration_after_number(_number, remaining) do + # Convert remaining charlist to string for easier parsing + remaining_str = List.to_string(remaining) + + case extract_duration_units_from_start(remaining_str) do + {:ok, duration_units, leftover, consumed_chars} -> + # Convert leftover back to charlist + new_remaining = String.to_charlist(leftover) + {:ok, duration_units, new_remaining, consumed_chars} + + :no_match -> + :not_duration + end + end + + @spec extract_duration_units_from_start(binary()) :: + {:ok, [{binary(), binary()}], binary(), pos_integer()} | :no_match + defp extract_duration_units_from_start(str) do + case parse_duration_units_from_start(str, []) do + {[], _remaining} -> + :no_match + + {units, remaining} -> + consumed = String.length(str) - String.length(remaining) + {:ok, units, remaining, consumed} + end + end + + @spec parse_duration_units_from_start(binary(), [{binary(), binary()}]) :: + {[{binary(), binary()}], binary()} + defp parse_duration_units_from_start(str, acc) do + case extract_duration_unit(str) do + {:ok, value, unit, remaining} -> + parse_duration_units_from_start(remaining, [{value, unit} | acc]) + + :no_match -> + {Enum.reverse(acc), str} + end + end + + @spec extract_duration_unit(binary()) :: + {: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) -> + [_full_match, unit, remaining] = match + {:ok, "", unit, remaining} + + 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) -> + [_full_match, unit, remaining] = match + {:ok, "", unit, remaining} + + match = Regex.run(~r/^([ydhmsw])(\D.*|$)/, str) -> + [_full_match, unit, remaining] = match + {:ok, "", unit, remaining} + + true -> + :no_match + end + end + + @spec duration_unit?(binary()) :: boolean() + defp duration_unit?("d"), do: true + defp duration_unit?("h"), do: true + defp duration_unit?("m"), do: true + defp duration_unit?("s"), do: true + defp duration_unit?("w"), do: true + defp duration_unit?("mo"), do: true + defp duration_unit?("y"), do: true + defp duration_unit?(_unit), do: false + + @spec tokenize_number_duration_sequence( + integer(), + [{binary(), binary()}], + charlist(), + pos_integer(), + pos_integer(), + pos_integer(), + pos_integer(), + [token()] + ) :: {:ok, [token()]} + defp tokenize_number_duration_sequence( + number, + duration_units, + remaining, + line, + col, + number_consumed, + total_consumed, + tokens + ) do + # Generate number token followed by duration unit tokens + number_token = {:integer, line, col, number_consumed, number} + + {final_tokens, _final_col} = + Enum.reduce(duration_units, {[number_token | tokens], col + number_consumed}, fn {_value, + unit}, + {acc_tokens, + current_col} -> + if duration_unit?(unit) do + unit_token = {:duration_unit, line, current_col, String.length(unit), unit} + new_col = current_col + String.length(unit) + {[unit_token | acc_tokens], new_col} + else + # This shouldn't happen with our regex patterns, but handle gracefully + {acc_tokens, current_col} + end + end) + + tokenize_chars(remaining, line, col + number_consumed + total_consumed, final_tokens) + end end diff --git a/lib/predicator/parser.ex b/lib/predicator/parser.ex index be73859..763b6bb 100644 --- a/lib/predicator/parser.ex +++ b/lib/predicator/parser.ex @@ -49,12 +49,19 @@ defmodule Predicator.Parser do @typedoc """ A value that can appear in literals. """ - @type value :: boolean() | integer() | binary() | [value()] | Date.t() | DateTime.t() + @type value :: + boolean() + | integer() + | binary() + | [value()] + | Date.t() + | DateTime.t() + | Predicator.Types.duration() @typedoc """ Abstract Syntax Tree node types. - - `{:literal, value}` - A literal value (number, boolean, list, date, datetime) + - `{:literal, value}` - A literal value (number, boolean, list, date, datetime, duration) - `{:string_literal, value, quote_type}` - A string literal with quote type information - `{:identifier, name}` - A variable reference - `{:comparison, operator, left, right}` - A comparison expression (including equality) @@ -68,6 +75,8 @@ defmodule Predicator.Parser do - `{:membership, operator, left, right}` - A membership operation (in/contains) - `{:function_call, name, arguments}` - A function call with arguments - `{:bracket_access, object, key}` - A bracket access expression (obj[key]) + - `{:duration, units}` - A duration literal (e.g., 3d8h) + - `{:relative_date, duration, direction}` - A relative date expression (e.g., 3d ago, next 2w) """ @type ast :: {:literal, value()} @@ -84,6 +93,8 @@ defmodule Predicator.Parser do | {:object, [object_entry()]} | {:function_call, binary(), [ast()]} | {:bracket_access, ast(), ast()} + | {:duration, [{integer(), binary()}]} + | {:relative_date, ast(), relative_direction()} @typedoc """ An object entry (key-value pair) in an object literal. @@ -118,6 +129,11 @@ defmodule Predicator.Parser do """ @type membership_op :: :in | :contains + @typedoc """ + Relative date directions in the AST. + """ + @type relative_direction :: :ago | :future | :next | :last + @typedoc """ Parser result - either success with AST or error with details. """ @@ -599,6 +615,32 @@ defmodule Predicator.Parser do # Recursively parse more postfix operations parse_postfix_operations(property_access, final_state) + # Allow duration operators as property names (like user.name.last) + {:last_op, _line, _col, _len, property_name} -> + property_access = {:property_access, expr, property_name} + final_state = advance(dot_state) + parse_postfix_operations(property_access, final_state) + + {:next_op, _line, _col, _len, property_name} -> + property_access = {:property_access, expr, property_name} + final_state = advance(dot_state) + parse_postfix_operations(property_access, final_state) + + {:ago_op, _line, _col, _len, property_name} -> + property_access = {:property_access, expr, property_name} + final_state = advance(dot_state) + parse_postfix_operations(property_access, final_state) + + {:from_op, _line, _col, _len, property_name} -> + property_access = {:property_access, expr, property_name} + final_state = advance(dot_state) + parse_postfix_operations(property_access, final_state) + + {:now_op, _line, _col, _len, property_name} -> + property_access = {:property_access, expr, property_name} + final_state = advance(dot_state) + parse_postfix_operations(property_access, final_state) + {type, line, col, _len, value} -> {:error, "Expected property name after '.' but found #{format_token(type, value)}", line, col} @@ -622,9 +664,22 @@ defmodule Predicator.Parser do parse_primary_token(state, token) end - # Parse integer literal + # Parse integer literal (may be start of duration) defp parse_primary_token(state, {:integer, _line, _col, _len, value}) do - {:ok, {:literal, value}, advance(state)} + # Check if this integer is followed by duration units + next_state = advance(state) + + case parse_duration_sequence_from_integer(value, next_state) do + {:ok, duration_ast, final_state} -> + {:ok, duration_ast, final_state} + + {:error, message, line, col} -> + {:error, message, line, col} + + :not_duration -> + # Regular integer literal + {:ok, {:literal, value}, next_state} + end end # Parse float literal @@ -699,6 +754,15 @@ defmodule Predicator.Parser do parse_object(state) end + # Parse duration direction keywords + defp parse_primary_token(state, {:next_op, _line, _col, _len, _value}) do + parse_relative_date_expression(state, :next) + end + + defp parse_primary_token(state, {:last_op, _line, _col, _len, _value}) do + parse_relative_date_expression(state, :last) + end + # Handle unexpected tokens defp parse_primary_token(_state, {type, line, col, _len, value}) do expected = @@ -750,6 +814,12 @@ defmodule Predicator.Parser do defp format_token(:not_op, _value), do: "'NOT'" defp format_token(:in_op, _value), do: "'IN'" defp format_token(:contains_op, _value), do: "'CONTAINS'" + defp format_token(:duration_unit, value), do: "duration unit '#{value}'" + defp format_token(:ago_op, _value), do: "'ago'" + defp format_token(:from_op, _value), do: "'from'" + defp format_token(:now_op, _value), do: "'now'" + defp format_token(:next_op, _value), do: "'next'" + defp format_token(:last_op, _value), do: "'last'" defp format_token(:lparen, _value), do: "'('" defp format_token(:rparen, _value), do: "')'" defp format_token(:lbracket, _value), do: "'['" @@ -1011,4 +1081,98 @@ defmodule Predicator.Parser do {:error, message, line, col} end end + + # Duration parsing functions + + @spec parse_duration_sequence_from_integer(integer(), parser_state()) :: + {:ok, ast(), parser_state()} | {:error, binary(), integer(), integer()} | :not_duration + defp parse_duration_sequence_from_integer(number, state) do + case peek_token(state) do + {:duration_unit, _line, _col, _len, unit} -> + # Found duration unit, parse the full duration sequence + parse_duration_sequence([{number, unit}], advance(state)) + + _token -> + # Not followed by duration unit + :not_duration + end + end + + @spec parse_duration_sequence([{integer(), binary()}], parser_state()) :: + {:ok, ast(), parser_state()} | {:error, binary(), integer(), integer()} + defp parse_duration_sequence(units, state) do + case peek_token(state) do + {:integer, _line, _col, _len, number} -> + # Check if this integer is followed by a duration unit + next_state = advance(state) + + case peek_token(next_state) do + {:duration_unit, _line, _col, _len, unit} -> + # Continue building duration sequence + new_units = units ++ [{number, unit}] + parse_duration_sequence(new_units, advance(next_state)) + + _token -> + # End of duration sequence, check for direction operators + duration_ast = {:duration, Enum.reverse(units)} + parse_duration_with_direction(duration_ast, state) + end + + _token -> + # End of duration sequence, check for direction operators + duration_ast = {:duration, Enum.reverse(units)} + parse_duration_with_direction(duration_ast, state) + end + end + + @spec parse_duration_with_direction(ast(), parser_state()) :: + {:ok, ast(), parser_state()} | {:error, binary(), integer(), integer()} + defp parse_duration_with_direction(duration_ast, state) do + case peek_token(state) do + {:ago_op, _line, _col, _len, _value} -> + {:ok, {:relative_date, duration_ast, :ago}, advance(state)} + + {:from_op, _line, _col, _len, _value} -> + # Expect 'now' after 'from' + from_state = advance(state) + + case peek_token(from_state) do + {:now_op, _line, _col, _len, _value} -> + {:ok, {:relative_date, duration_ast, :future}, advance(from_state)} + + {type, line, col, _len, value} -> + {:error, "Expected 'now' after 'from' but found #{format_token(type, value)}", line, + col} + + nil -> + {:error, "Expected 'now' after 'from' but reached end of input", 1, 1} + end + + _token -> + # Just a duration, no direction + {:ok, duration_ast, state} + end + end + + @spec parse_relative_date_expression(parser_state(), relative_direction()) :: + {:ok, ast(), parser_state()} | {:error, binary(), pos_integer(), pos_integer()} + defp parse_relative_date_expression(state, direction) do + # Advance past the direction keyword (next/last) + next_state = advance(state) + + # Expect a duration expression + case parse_primary(next_state) do + {:ok, {:duration, _units} = duration_ast, final_state} -> + {:ok, {:relative_date, duration_ast, direction}, final_state} + + {:ok, _other_ast, _final_state} -> + {type, line, col, _len, value} = peek_token(next_state) + + {:error, "Expected duration after '#{direction}' but found #{format_token(type, value)}", + line, col} + + {:error, message, line, col} -> + {:error, message, line, col} + end + end end diff --git a/lib/predicator/types.ex b/lib/predicator/types.ex index 1477e62..bb4f205 100644 --- a/lib/predicator/types.ex +++ b/lib/predicator/types.ex @@ -6,6 +6,34 @@ defmodule Predicator.Types do Predicator system for instructions, evaluation contexts, and results. """ + @typedoc """ + A duration representing a time span. + + Duration is represented as a map with fields for different time units: + - `years` - number of years (default: 0) + - `months` - number of months (default: 0) + - `weeks` - number of weeks (default: 0) + - `days` - number of days (default: 0) + - `hours` - number of hours (default: 0) + - `minutes` - number of minutes (default: 0) + - `seconds` - number of seconds (default: 0) + + ## Examples + + %Duration{days: 3, hours: 8} # 3 days 8 hours + %Duration{weeks: 2} # 2 weeks + %Duration{minutes: 30} # 30 minutes + """ + @type duration :: %{ + years: non_neg_integer(), + months: non_neg_integer(), + weeks: non_neg_integer(), + days: non_neg_integer(), + hours: non_neg_integer(), + minutes: non_neg_integer(), + seconds: non_neg_integer() + } + @typedoc """ A single value that can be used in predicates. @@ -17,6 +45,7 @@ defmodule Predicator.Types do - `list()` - lists of values - `Date.t()` - date values - `DateTime.t()` - datetime values + - `duration()` - duration values for time spans - `:undefined` - represents undefined/null values """ @type value :: @@ -27,6 +56,7 @@ defmodule Predicator.Types do | list() | Date.t() | DateTime.t() + | duration() | :undefined @typedoc """ diff --git a/lib/predicator/visitors/instructions_visitor.ex b/lib/predicator/visitors/instructions_visitor.ex index c0819f5..6169f60 100644 --- a/lib/predicator/visitors/instructions_visitor.ex +++ b/lib/predicator/visitors/instructions_visitor.ex @@ -186,6 +186,20 @@ defmodule Predicator.Visitors.InstructionsVisitor do arg_instructions ++ call_instruction end + def visit({:duration, units}, _opts) do + # Compile duration to a serializable instruction: ["duration", [{value, "unit"}, ...]] + # Convert from parser format [{integer(), binary()}] to instruction format + serializable_units = Enum.map(units, fn {value, unit} -> [value, unit] end) + [["duration", serializable_units]] + end + + def visit({:relative_date, duration_ast, direction}, opts) do + # Compile relative date expression: duration instructions + relative_date instruction + duration_instructions = visit(duration_ast, opts) + direction_str = map_relative_direction(direction) + duration_instructions ++ [["relative_date", direction_str]] + end + # Helper function to map AST comparison operators to instruction format @spec map_comparison_op(Parser.comparison_op()) :: binary() defp map_comparison_op(:gt), do: "GT" @@ -216,6 +230,13 @@ defmodule Predicator.Visitors.InstructionsVisitor do defp map_membership_op(:in), do: "in" defp map_membership_op(:contains), do: "contains" + # Helper function to map relative direction atoms to instruction format + @spec map_relative_direction(Parser.relative_direction()) :: binary() + defp map_relative_direction(:ago), do: "ago" + defp map_relative_direction(:future), do: "future" + defp map_relative_direction(:next), do: "next" + defp map_relative_direction(:last), do: "last" + # Helper function to check if all elements in a list are literals @spec all_literals?([Parser.ast()]) :: boolean() defp all_literals?(elements) do diff --git a/lib/predicator/visitors/string_visitor.ex b/lib/predicator/visitors/string_visitor.ex index 3b4eecb..d3a2c3c 100644 --- a/lib/predicator/visitors/string_visitor.ex +++ b/lib/predicator/visitors/string_visitor.ex @@ -229,6 +229,22 @@ defmodule Predicator.Visitors.StringVisitor do end end + def visit({:duration, units}, _opts) do + unit_strings = Enum.map(units, fn {value, unit} -> "#{value}#{unit}" end) + Enum.join(unit_strings, "") + end + + def visit({:relative_date, duration_ast, direction}, opts) do + duration_str = visit(duration_ast, opts) + + case direction do + :ago -> "#{duration_str} ago" + :future -> "#{duration_str} from now" + :next -> "next #{duration_str}" + :last -> "last #{duration_str}" + end + end + # Helper functions @spec format_operator( diff --git a/test/predicator/date_arithmetic_string_visitor_test.exs b/test/predicator/date_arithmetic_string_visitor_test.exs new file mode 100644 index 0000000..a0065b5 --- /dev/null +++ b/test/predicator/date_arithmetic_string_visitor_test.exs @@ -0,0 +1,115 @@ +defmodule Predicator.DateArithmeticStringVisitorTest do + @moduledoc """ + Tests for string visitor support of date arithmetic expressions. + + Ensures that duration and relative date AST nodes can be properly + decompiled back to their original string representation. + """ + + use ExUnit.Case, async: true + + alias Predicator + + describe "duration decompilation" do + test "simple duration literals" do + assert_round_trip("3d") + assert_round_trip("2w") + assert_round_trip("1h") + assert_round_trip("30m") + assert_round_trip("45s") + end + + test "complex duration literals" do + # Note: Parser stores units in reverse order, so these test the actual behavior + assert_decompiled_matches("1d8h", "8h1d") + assert_decompiled_matches("2w3d", "3d2w") + assert_decompiled_matches("1d8h30m", "30m8h1d") + end + + test "duration in arithmetic expressions" do + assert_round_trip("#2024-01-15# + 3d") + assert_round_trip("#2024-01-15T10:30:00Z# - 2h") + assert_round_trip("Date.now() + 1d") + end + end + + describe "relative date decompilation" do + test "ago expressions" do + assert_round_trip("3d ago") + assert_round_trip("2w ago") + assert_round_trip("1h ago") + end + + test "from now expressions" do + assert_round_trip("3d from now") + assert_round_trip("1w from now") + end + + test "next expressions" do + assert_round_trip("next 3d") + assert_round_trip("next 1w") + assert_round_trip("next 2h") + end + + test "last expressions" do + assert_round_trip("last 3d") + assert_round_trip("last 1w") + assert_round_trip("last 2h") + end + end + + describe "date arithmetic in complex expressions" do + test "date arithmetic in comparisons" do + assert_round_trip("Date.now() + 1h > #2024-01-15#") + assert_round_trip("#2024-01-15# - 3d = #2024-01-12#") + end + + test "date arithmetic in logical expressions" do + assert_round_trip("#2024-01-15# + 1w > Date.now() AND status = 'active'") + assert_round_trip("deadline - 3d < Date.now() OR urgent = true") + end + + test "simple nested expressions" do + assert_round_trip("#2024-01-15# + 1w - 2d") + # Note: Complex parentheses may not round-trip exactly due to operator precedence + end + end + + # Helper function to test round-trip parsing and decompilation + defp assert_round_trip(expression) do + case Predicator.parse(expression) do + {:ok, ast} -> + decompiled = Predicator.decompile(ast) + + # The decompiled version should parse to the same AST + {:ok, decompiled_ast} = Predicator.parse(decompiled) + + assert decompiled_ast == ast, """ + Round-trip failed for: #{expression} + Original AST: #{inspect(ast)} + Decompiled: #{decompiled} + Decompiled AST: #{inspect(decompiled_ast)} + """ + + {:error, message, line, col} -> + flunk("Failed to parse '#{expression}': #{message} at #{line}:#{col}") + end + end + + # Helper function to test when decompilation produces a different but equivalent expression + defp assert_decompiled_matches(original, expected_decompiled) do + case Predicator.parse(original) do + {:ok, ast} -> + decompiled = Predicator.decompile(ast) + + assert decompiled == expected_decompiled, """ + Expected decompilation to match: #{original} + Expected: #{expected_decompiled} + Got: #{decompiled} + """ + + {:error, message, line, col} -> + flunk("Failed to parse '#{original}': #{message} at #{line}:#{col}") + end + end +end diff --git a/test/predicator/duration_test.exs b/test/predicator/duration_test.exs new file mode 100644 index 0000000..c7c0d8b --- /dev/null +++ b/test/predicator/duration_test.exs @@ -0,0 +1,369 @@ +defmodule Predicator.DurationTest do + use ExUnit.Case + doctest Predicator.Duration + + alias Predicator.Duration + + describe "new/1" do + test "creates duration with default values" do + duration = Duration.new() + + assert duration == %{ + years: 0, + months: 0, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds: 0 + } + end + + test "creates duration with specified values" do + duration = Duration.new(days: 3, hours: 8, minutes: 30) + + assert duration == %{ + years: 0, + months: 0, + weeks: 0, + days: 3, + hours: 8, + minutes: 30, + seconds: 0 + } + end + + test "creates duration with all units" do + duration = + Duration.new( + years: 1, + months: 2, + weeks: 3, + days: 4, + hours: 5, + minutes: 6, + seconds: 7 + ) + + assert duration.years == 1 + assert duration.months == 2 + assert duration.weeks == 3 + assert duration.days == 4 + assert duration.hours == 5 + assert duration.minutes == 6 + assert duration.seconds == 7 + end + end + + describe "from_units/1" do + test "creates duration from valid unit pairs" do + {:ok, duration} = Duration.from_units([{"3", "d"}, {"8", "h"}]) + + assert duration.days == 3 + assert duration.hours == 8 + assert duration.weeks == 0 + end + + test "handles multiple units of same type" do + {:ok, duration} = Duration.from_units([{"2", "d"}, {"3", "d"}]) + assert duration.days == 5 + end + + test "handles all unit types" do + {:ok, duration} = + Duration.from_units([ + {"1", "y"}, + {"2", "mo"}, + {"3", "w"}, + {"4", "d"}, + {"5", "h"}, + {"6", "m"}, + {"7", "s"} + ]) + + assert duration.years == 1 + assert duration.months == 2 + assert duration.weeks == 3 + assert duration.days == 4 + assert duration.hours == 5 + assert duration.minutes == 6 + assert duration.seconds == 7 + end + + test "returns error for invalid value" do + {:error, message} = Duration.from_units([{"invalid", "d"}]) + assert message == "Invalid duration value: invalid" + end + + test "returns error for unknown unit" do + {:error, message} = Duration.from_units([{"3", "x"}]) + assert message == "Unknown duration unit: x" + end + + test "returns error for empty value" do + {:error, message} = Duration.from_units([{"", "d"}]) + assert message == "Invalid duration value: " + end + + test "handles zero values" do + {:ok, duration} = Duration.from_units([{"0", "d"}, {"0", "h"}]) + assert duration.days == 0 + assert duration.hours == 0 + end + end + + describe "add_unit/3" do + test "adds years" do + duration = Duration.new() |> Duration.add_unit("y", 3) + assert duration.years == 3 + end + + test "adds months" do + duration = Duration.new() |> Duration.add_unit("mo", 6) + assert duration.months == 6 + end + + test "adds weeks" do + duration = Duration.new() |> Duration.add_unit("w", 2) + assert duration.weeks == 2 + end + + test "adds days" do + duration = Duration.new() |> Duration.add_unit("d", 5) + assert duration.days == 5 + end + + test "adds hours" do + duration = Duration.new() |> Duration.add_unit("h", 12) + assert duration.hours == 12 + end + + test "adds minutes" do + duration = Duration.new() |> Duration.add_unit("m", 45) + assert duration.minutes == 45 + end + + test "adds seconds" do + duration = Duration.new() |> Duration.add_unit("s", 30) + assert duration.seconds == 30 + end + + test "accumulates values" do + duration = Duration.new(days: 2) |> Duration.add_unit("d", 3) + assert duration.days == 5 + end + end + + describe "to_seconds/1" do + test "converts simple duration" do + duration = Duration.new(minutes: 5, seconds: 30) + assert Duration.to_seconds(duration) == 330 + end + + test "converts complex duration" do + duration = Duration.new(days: 1, hours: 2, minutes: 30, seconds: 15) + expected = 1 * 86_400 + 2 * 3600 + 30 * 60 + 15 + assert Duration.to_seconds(duration) == expected + end + + test "converts weeks" do + duration = Duration.new(weeks: 2) + assert Duration.to_seconds(duration) == 2 * 604_800 + end + + test "converts months (approximate)" do + duration = Duration.new(months: 1) + assert Duration.to_seconds(duration) == 2_592_000 + end + + test "converts years (approximate)" do + duration = Duration.new(years: 1) + assert Duration.to_seconds(duration) == 31_536_000 + end + + test "converts zero duration" do + duration = Duration.new() + assert Duration.to_seconds(duration) == 0 + end + end + + describe "add_to_date/2" do + test "adds days to date" do + date = ~D[2024-01-15] + duration = Duration.new(days: 3) + result = Duration.add_to_date(date, duration) + assert result == ~D[2024-01-18] + end + + test "adds weeks to date" do + date = ~D[2024-01-01] + duration = Duration.new(weeks: 2) + result = Duration.add_to_date(date, duration) + assert result == ~D[2024-01-15] + end + + test "adds complex duration" do + date = ~D[2024-01-01] + duration = Duration.new(weeks: 1, days: 3) + result = Duration.add_to_date(date, duration) + assert result == ~D[2024-01-11] + end + + test "adds hours as additional days" do + date = ~D[2024-01-01] + # More than 24 hours + duration = Duration.new(hours: 25) + result = Duration.add_to_date(date, duration) + assert result == ~D[2024-01-02] + end + + test "adds approximate months" do + date = ~D[2024-01-01] + duration = Duration.new(months: 1) + result = Duration.add_to_date(date, duration) + assert result == ~D[2024-01-31] + end + + test "adds approximate years" do + date = ~D[2024-01-01] + duration = Duration.new(years: 1) + result = Duration.add_to_date(date, duration) + assert result == ~D[2024-12-31] + end + end + + describe "add_to_datetime/2" do + test "adds hours to datetime" do + datetime = ~U[2024-01-15T10:30:00Z] + duration = Duration.new(hours: 3) + result = Duration.add_to_datetime(datetime, duration) + assert result == ~U[2024-01-15T13:30:00Z] + end + + test "adds complex duration" do + datetime = ~U[2024-01-15T10:30:00Z] + duration = Duration.new(days: 2, hours: 3, minutes: 30) + result = Duration.add_to_datetime(datetime, duration) + assert result == ~U[2024-01-17T14:00:00Z] + end + + test "adds minutes and seconds" do + datetime = ~U[2024-01-15T10:30:00Z] + duration = Duration.new(minutes: 30, seconds: 45) + result = Duration.add_to_datetime(datetime, duration) + assert result == ~U[2024-01-15T11:00:45Z] + end + + test "wraps to next day" do + datetime = ~U[2024-01-15T23:30:00Z] + duration = Duration.new(hours: 2) + result = Duration.add_to_datetime(datetime, duration) + assert result == ~U[2024-01-16T01:30:00Z] + end + end + + describe "subtract_from_date/2" do + test "subtracts days from date" do + date = ~D[2024-01-25] + duration = Duration.new(days: 10) + result = Duration.subtract_from_date(date, duration) + assert result == ~D[2024-01-15] + end + + test "subtracts weeks" do + date = ~D[2024-01-22] + duration = Duration.new(weeks: 2) + result = Duration.subtract_from_date(date, duration) + assert result == ~D[2024-01-08] + end + + test "subtracts complex duration" do + date = ~D[2024-01-25] + duration = Duration.new(weeks: 1, days: 3) + result = Duration.subtract_from_date(date, duration) + assert result == ~D[2024-01-15] + end + + test "crosses month boundary" do + date = ~D[2024-02-05] + duration = Duration.new(days: 10) + result = Duration.subtract_from_date(date, duration) + assert result == ~D[2024-01-26] + end + end + + describe "subtract_from_datetime/2" do + test "subtracts hours from datetime" do + datetime = ~U[2024-01-17T14:00:00Z] + duration = Duration.new(hours: 3) + result = Duration.subtract_from_datetime(datetime, duration) + assert result == ~U[2024-01-17T11:00:00Z] + end + + test "subtracts complex duration" do + datetime = ~U[2024-01-17T14:00:00Z] + duration = Duration.new(days: 2, hours: 3, minutes: 30) + result = Duration.subtract_from_datetime(datetime, duration) + assert result == ~U[2024-01-15T10:30:00Z] + end + + test "wraps to previous day" do + datetime = ~U[2024-01-16T01:30:00Z] + duration = Duration.new(hours: 3) + result = Duration.subtract_from_datetime(datetime, duration) + assert result == ~U[2024-01-15T22:30:00Z] + end + end + + describe "to_string/1" do + test "formats simple duration" do + duration = Duration.new(days: 3) + assert Duration.to_string(duration) == "3d" + end + + test "formats complex duration" do + duration = Duration.new(days: 3, hours: 8, minutes: 30) + assert Duration.to_string(duration) == "3d8h30m" + end + + test "formats all units" do + duration = + Duration.new( + years: 1, + months: 2, + weeks: 3, + days: 4, + hours: 5, + minutes: 6, + seconds: 7 + ) + + assert Duration.to_string(duration) == "1y2mo3w4d5h6m7s" + end + + test "formats zero duration" do + duration = Duration.new() + assert Duration.to_string(duration) == "0s" + end + + test "formats only non-zero units" do + duration = Duration.new(days: 2, minutes: 15) + assert Duration.to_string(duration) == "2d15m" + end + + test "formats weeks only" do + duration = Duration.new(weeks: 2) + assert Duration.to_string(duration) == "2w" + end + + test "formats months only" do + duration = Duration.new(months: 6) + assert Duration.to_string(duration) == "6mo" + end + + test "formats years only" do + duration = Duration.new(years: 1) + assert Duration.to_string(duration) == "1y" + end + end +end diff --git a/test/predicator/evaluator_edge_cases_test.exs b/test/predicator/evaluator_edge_cases_test.exs new file mode 100644 index 0000000..45d1995 --- /dev/null +++ b/test/predicator/evaluator_edge_cases_test.exs @@ -0,0 +1,95 @@ +defmodule Predicator.EvaluatorEdgeCasesTest do + use ExUnit.Case + + alias Predicator.Evaluator + alias Predicator.Functions.SystemFunctions + + describe "evaluator edge cases" do + test "handles division by zero" do + instructions = [["lit", 10], ["lit", 0], ["divide"]] + result = Evaluator.evaluate(instructions, %{}) + assert match?({:error, _}, result) + end + + test "handles modulo by zero" do + instructions = [["lit", 10], ["lit", 0], ["modulo"]] + result = Evaluator.evaluate(instructions, %{}) + assert match?({:error, _}, result) + end + + test "handles type mismatch in arithmetic" do + instructions = [["lit", "string"], ["lit", 5], ["add"]] + result = Evaluator.evaluate(instructions, %{}, functions: SystemFunctions.all_functions()) + # String concatenation should work + assert result == "string5" + end + + test "handles type mismatch in logical operations" do + instructions = [["lit", "not_boolean"], ["not"]] + result = Evaluator.evaluate(instructions, %{}) + assert match?({:error, _}, result) + end + + test "handles empty stack in operations" do + instructions = [["add"]] + result = Evaluator.evaluate(instructions, %{}) + assert match?({:error, _}, result) + end + + test "handles unknown instruction" do + instructions = [["unknown_instruction"]] + result = Evaluator.evaluate(instructions, %{}) + assert match?({:error, _}, result) + end + + test "handles call to undefined function" do + instructions = [["call", "undefined_function", 0]] + result = Evaluator.evaluate(instructions, %{}) + assert match?({:error, _}, result) + end + + test "handles function call with wrong arity" do + # Call len with 2 args, but len expects 1 + instructions = [["call", "len", 2]] + functions = SystemFunctions.all_functions() + result = Evaluator.evaluate(instructions, %{}, functions: functions) + assert match?({:error, _}, result) + end + + test "handles access on non-map/non-list values" do + instructions = [["lit", "not_a_map"], ["lit", "key"], ["bracket_access"]] + result = Evaluator.evaluate(instructions, %{}) + assert result == :undefined + end + + test "handles access with invalid key types" do + instructions = [["lit", %{"key" => "value"}], ["lit", [1, 2, 3]], ["bracket_access"]] + result = Evaluator.evaluate(instructions, %{}) + assert match?({:error, _}, result) + end + + test "handles comparison with incompatible types" do + instructions = [["lit", "string"], ["lit", 42], ["compare", "GT"]] + result = Evaluator.evaluate(instructions, %{}) + assert result == :undefined + end + + test "handles object operations with non-object stack top" do + instructions = [["lit", "not_an_object"], ["object_set", "key"]] + result = Evaluator.evaluate(instructions, %{}) + assert match?({:error, _}, result) + end + + test "handles contains operation with incompatible types" do + instructions = [["lit", "not_a_list"], ["lit", "item"], ["contains"]] + result = Evaluator.evaluate(instructions, %{}) + assert match?({:error, _}, result) + end + + test "handles in operation with incompatible types" do + instructions = [["lit", "item"], ["lit", "not_a_list"], ["in"]] + result = Evaluator.evaluate(instructions, %{}) + assert match?({:error, _}, result) + end + end +end diff --git a/test/predicator/evaluator_test.exs b/test/predicator/evaluator_test.exs index 153fd7c..c633e8d 100644 --- a/test/predicator/evaluator_test.exs +++ b/test/predicator/evaluator_test.exs @@ -854,4 +854,265 @@ defmodule Predicator.EvaluatorTest do assert Evaluator.evaluate(instructions, context) == "localhost" end end + + describe "evaluate/2 with duration instructions" do + test "evaluates simple duration instruction" do + instructions = [["duration", [[5, "d"]]]] + result = Evaluator.evaluate(instructions) + + expected = %{years: 0, months: 0, weeks: 0, days: 5, hours: 0, minutes: 0, seconds: 0} + assert result == expected + end + + test "evaluates duration with multiple units" do + instructions = [["duration", [[1, "d"], [8, "h"], [30, "m"]]]] + result = Evaluator.evaluate(instructions) + + expected = %{years: 0, months: 0, weeks: 0, days: 1, hours: 8, minutes: 30, seconds: 0} + assert result == expected + end + + test "evaluates duration with all unit types" do + instructions = [ + ["duration", [[2, "y"], [3, "mo"], [4, "w"], [5, "d"], [6, "h"], [7, "m"], [8, "s"]]] + ] + + result = Evaluator.evaluate(instructions) + + expected = %{years: 2, months: 3, weeks: 4, days: 5, hours: 6, minutes: 7, seconds: 8} + assert result == expected + end + + test "evaluates duration with long unit names" do + instructions = [["duration", [[1, "year"], [2, "months"], [3, "weeks"]]]] + result = Evaluator.evaluate(instructions) + + expected = %{years: 1, months: 2, weeks: 3, days: 0, hours: 0, minutes: 0, seconds: 0} + assert result == expected + end + + test "evaluates duration with mixed unit formats" do + instructions = [ + [ + "duration", + [[1, "y"], [2, "month"], [3, "w"], [4, "day"], [5, "h"], [6, "min"], [7, "sec"]] + ] + ] + + result = Evaluator.evaluate(instructions) + + expected = %{years: 1, months: 2, weeks: 3, days: 4, hours: 5, minutes: 6, seconds: 7} + assert result == expected + end + + test "evaluates duration with zero values" do + instructions = [["duration", [[0, "d"], [0, "h"]]]] + result = Evaluator.evaluate(instructions) + + expected = %{years: 0, months: 0, weeks: 0, days: 0, hours: 0, minutes: 0, seconds: 0} + assert result == expected + end + + test "evaluates duration with large values" do + instructions = [["duration", [[999, "y"], [365, "d"]]]] + result = Evaluator.evaluate(instructions) + + expected = %{years: 999, months: 0, weeks: 0, days: 365, hours: 0, minutes: 0, seconds: 0} + assert result == expected + end + + test "returns error for invalid duration unit format" do + instructions = [["duration", [["invalid"]]]] + result = Evaluator.evaluate(instructions) + + assert {:error, _message} = result + end + + test "returns error for invalid duration unit" do + instructions = [["duration", [[5, "invalid_unit"]]]] + result = Evaluator.evaluate(instructions) + + assert {:error, _message} = result + end + end + + describe "evaluate/2 with relative_date instructions" do + test "evaluates relative date with ago direction" do + instructions = [ + ["duration", [[1, "d"], [8, "h"]]], + ["relative_date", "ago"] + ] + + before_test = DateTime.utc_now() + result = Evaluator.evaluate(instructions) + + # Result should be a DateTime roughly 1 day 8 hours ago + assert %DateTime{} = result + + # Calculate expected time range (1d8h = 32 hours = 115200 seconds) + expected_seconds_ago = 32 * 3600 + + # Check the result is within reasonable bounds (allowing for test execution time) + seconds_diff = DateTime.diff(before_test, result, :second) + + assert seconds_diff >= expected_seconds_ago - 10 and + seconds_diff <= expected_seconds_ago + 10 + end + + test "evaluates relative date with future direction" do + instructions = [ + ["duration", [[2, "h"], [30, "m"]]], + ["relative_date", "future"] + ] + + before_test = DateTime.utc_now() + result = Evaluator.evaluate(instructions) + + # Result should be a DateTime roughly 2.5 hours in the future + assert %DateTime{} = result + + # Calculate expected time (2h30m = 9000 seconds) + expected_seconds_future = 2.5 * 3600 + + # Check the result is within reasonable bounds + seconds_diff = DateTime.diff(result, before_test, :second) + + assert seconds_diff >= expected_seconds_future - 10 and + seconds_diff <= expected_seconds_future + 10 + end + + test "evaluates relative date with next direction" do + instructions = [ + ["duration", [[1, "w"]]], + ["relative_date", "next"] + ] + + before_test = DateTime.utc_now() + result = Evaluator.evaluate(instructions) + + # Result should be a DateTime roughly 1 week in the future + assert %DateTime{} = result + + # Calculate expected time (1w = 7 * 24 * 3600 = 604800 seconds) + expected_seconds_future = 7 * 24 * 3600 + + # Check the result is within reasonable bounds + seconds_diff = DateTime.diff(result, before_test, :second) + + assert seconds_diff >= expected_seconds_future - 10 and + seconds_diff <= expected_seconds_future + 10 + end + + test "evaluates relative date with last direction" do + instructions = [ + ["duration", [[6, "mo"]]], + ["relative_date", "last"] + ] + + before_test = DateTime.utc_now() + result = Evaluator.evaluate(instructions) + + # Result should be a DateTime roughly 6 months ago + assert %DateTime{} = result + + # Calculate expected time (6mo ≈ 6 * 30 * 24 * 3600 = 15552000 seconds) + expected_seconds_ago = 6 * 30 * 24 * 3600 + + # Check the result is within reasonable bounds + seconds_diff = DateTime.diff(before_test, result, :second) + + assert seconds_diff >= expected_seconds_ago - 1000 and + seconds_diff <= expected_seconds_ago + 1000 + end + + test "evaluates complex relative date with multiple units" do + instructions = [ + ["duration", [[1, "y"], [2, "mo"], [3, "d"]]], + ["relative_date", "ago"] + ] + + before_test = DateTime.utc_now() + result = Evaluator.evaluate(instructions) + + # Result should be a DateTime roughly 1 year 2 months 3 days ago + assert %DateTime{} = result + assert DateTime.compare(result, before_test) == :lt + + # Should be significantly in the past (approximate calculation) + seconds_diff = DateTime.diff(before_test, result, :second) + # Allow some variance + expected_min = (365 + 60 + 3) * 24 * 3600 - 100_000 + expected_max = (365 + 60 + 3) * 24 * 3600 + 100_000 + assert seconds_diff >= expected_min and seconds_diff <= expected_max + end + + test "returns error for unknown relative date direction" do + instructions = [ + ["duration", [[1, "d"]]], + ["relative_date", "unknown"] + ] + + result = Evaluator.evaluate(instructions) + assert {:error, _message} = result + end + + test "returns error for relative_date without duration on stack" do + instructions = [["relative_date", "ago"]] + + result = Evaluator.evaluate(instructions) + assert {:error, _error_struct} = result + end + + test "returns error for relative_date with non-duration on stack" do + instructions = [ + ["lit", "not a duration"], + ["relative_date", "ago"] + ] + + result = Evaluator.evaluate(instructions) + assert {:error, _message} = result + end + + test "integrates with comparisons - date greater than relative date" do + # Simulate: some_recent_date > 1d ago (recent date is more recent than 1 day ago) + # 12 hours ago + recent_date = DateTime.add(DateTime.utc_now(), -12 * 3600, :second) + + instructions = [ + ["lit", recent_date], + ["duration", [[1, "d"]]], + ["relative_date", "ago"], + ["compare", "GT"] + ] + + result = Evaluator.evaluate(instructions) + # 12 hours ago is greater than (more recent than) 1 day ago + assert result == true + end + + test "integrates with logical operations" do + # Simulate: created_at > 1d ago AND updated_at < 1h from now + now = DateTime.utc_now() + # 1 hour ago + recent_past = DateTime.add(now, -3600, :second) + # 30 minutes from now + near_future = DateTime.add(now, 1800, :second) + + instructions = [ + ["lit", recent_past], + ["duration", [[1, "d"]]], + ["relative_date", "ago"], + ["compare", "GT"], + ["lit", near_future], + ["duration", [[1, "h"]]], + ["relative_date", "future"], + ["compare", "LT"], + ["and"] + ] + + result = Evaluator.evaluate(instructions) + # Both conditions should be true + assert result == true + end + end end diff --git a/test/predicator/functions/date_arithmetic_test.exs b/test/predicator/functions/date_arithmetic_test.exs new file mode 100644 index 0000000..94e2ba1 --- /dev/null +++ b/test/predicator/functions/date_arithmetic_test.exs @@ -0,0 +1,251 @@ +defmodule Predicator.DateArithmeticTest do + @moduledoc """ + Test date arithmetic operations in Predicator expressions. + + Tests addition and subtraction operations between dates, datetimes, + and durations, including mixed type operations. + """ + + use ExUnit.Case, async: true + + alias Predicator + alias Predicator.Duration + + doctest Predicator + + describe "date addition" do + test "Date + Duration = Date" do + # Test basic date + duration + assert {:ok, ~D[2024-01-18]} = Predicator.evaluate("#2024-01-15# + 3d", %{}) + assert {:ok, ~D[2024-01-29]} = Predicator.evaluate("#2024-01-15# + 2w", %{}) + assert {:ok, ~D[2024-01-15]} = Predicator.evaluate("#2024-01-15# + 8h", %{}) + end + + test "DateTime + Duration = DateTime" do + # Test datetime + duration + assert {:ok, result} = Predicator.evaluate("#2024-01-15T10:30:00Z# + 2h", %{}) + assert %DateTime{hour: 12, minute: 30} = result + + assert {:ok, result} = Predicator.evaluate("#2024-01-15T10:30:00Z# + 3d", %{}) + assert %DateTime{day: 18, hour: 10, minute: 30} = result + end + + test "Duration + Date = Date (commutative)" do + # Test that duration + date works the same as date + duration + assert {:ok, ~D[2024-01-18]} = Predicator.evaluate("3d + #2024-01-15#", %{}) + + # Verify with variables + context = %{ + "date" => ~D[2024-01-15], + "duration" => %{ + years: 0, + months: 0, + weeks: 0, + days: 3, + hours: 0, + minutes: 0, + seconds: 0 + } + } + + assert {:ok, ~D[2024-01-18]} = Predicator.evaluate("date + duration", context) + assert {:ok, ~D[2024-01-18]} = Predicator.evaluate("duration + date", context) + end + + test "complex duration additions" do + # Test complex durations + assert {:ok, result} = Predicator.evaluate("#2024-01-15T10:30:00Z# + 1d8h30m", %{}) + assert %DateTime{day: 16, hour: 19, minute: 0} = result + + # Multiple operations + assert {:ok, result} = Predicator.evaluate("#2024-01-15# + 1w + 3d", %{}) + assert ~D[2024-01-25] = result + end + + test "date arithmetic with now() function" do + # Test with now() function - using Date.now() instead of now() for now + assert {:ok, result} = Predicator.evaluate("Date.now() + 1h", %{}) + assert %DateTime{} = result + + # Verify it's approximately 1 hour from now + now = DateTime.utc_now() + diff_seconds = DateTime.diff(result, now) + # Allow 10 second margin + assert diff_seconds >= 3590 and diff_seconds <= 3610 + end + + test "date arithmetic with variables" do + context = %{ + "start_date" => ~D[2024-01-15], + "duration" => %{years: 0, months: 0, weeks: 0, days: 3, hours: 8, minutes: 0, seconds: 0} + } + + assert {:ok, result} = Predicator.evaluate("start_date + duration", context) + assert ~D[2024-01-18] = result + end + end + + describe "date subtraction" do + test "Date - Duration = Date" do + # Test basic date - duration + assert {:ok, ~D[2024-01-12]} = Predicator.evaluate("#2024-01-15# - 3d", %{}) + assert {:ok, ~D[2024-01-01]} = Predicator.evaluate("#2024-01-15# - 2w", %{}) + assert {:ok, ~D[2024-01-15]} = Predicator.evaluate("#2024-01-15# - 8h", %{}) + end + + test "DateTime - Duration = DateTime" do + # Test datetime - duration + assert {:ok, result} = Predicator.evaluate("#2024-01-15T10:30:00Z# - 2h", %{}) + assert %DateTime{hour: 8, minute: 30} = result + + assert {:ok, result} = Predicator.evaluate("#2024-01-15T10:30:00Z# - 3d", %{}) + assert %DateTime{day: 12, hour: 10, minute: 30} = result + end + + test "Date - Date = Duration" do + # Test date difference + assert {:ok, result} = Predicator.evaluate("#2024-01-18# - #2024-01-15#", %{}) + expected_duration = Duration.new(days: 3) + assert result == expected_duration + + # Negative difference + assert {:ok, result} = Predicator.evaluate("#2024-01-15# - #2024-01-18#", %{}) + expected_duration = Duration.new(days: -3) + assert result == expected_duration + end + + test "DateTime - DateTime = Duration" do + # Test datetime difference + assert {:ok, result} = + Predicator.evaluate("#2024-01-15T12:00:00Z# - #2024-01-15T10:00:00Z#", %{}) + + # 2 hours = 7200 seconds + expected_duration = Duration.new(seconds: 7200) + assert result == expected_duration + end + + test "mixed Date and DateTime subtraction" do + # Date - DateTime (Date converted to start of day) + assert {:ok, result} = Predicator.evaluate("#2024-01-16# - #2024-01-15T12:00:00Z#", %{}) + # 12 hours = 43_200 seconds + expected_duration = Duration.new(seconds: 43_200) + assert result == expected_duration + + # DateTime - Date + assert {:ok, result} = Predicator.evaluate("#2024-01-15T12:00:00Z# - #2024-01-15#", %{}) + # 12 hours = 43_200 seconds + expected_duration = Duration.new(seconds: 43_200) + assert result == expected_duration + end + + test "complex date arithmetic chains" do + # Test chained operations: start + duration - duration + assert {:ok, result} = Predicator.evaluate("#2024-01-15# + 1w - 3d", %{}) + assert ~D[2024-01-19] = result + + # Test with parentheses + assert {:ok, result} = Predicator.evaluate("(#2024-01-15# + 7d) - 2d", %{}) + assert ~D[2024-01-20] = result + end + end + + describe "error handling" do + test "invalid date arithmetic operations" do + # Cannot add dates + assert {:error, error} = Predicator.evaluate("#2024-01-15# + #2024-01-16#", %{}) + assert is_struct(error) and error.message =~ "Arithmetic add" + + # Cannot subtract duration from duration + assert {:error, error} = Predicator.evaluate("3d - 2d", %{}) + assert is_struct(error) and error.message =~ "Arithmetic subtract" + + # Cannot add string to date + assert {:error, error} = Predicator.evaluate("#2024-01-15# + 'hello'", %{}) + assert is_struct(error) and error.message =~ "Arithmetic add" + end + + test "invalid function calls" do + # Date.now() with arguments should fail + assert {:error, error} = Predicator.evaluate("Date.now(5)", %{}) + assert is_struct(error) and error.message =~ "expects 0 arguments" + end + end + + describe "date arithmetic in comparisons" do + test "date arithmetic in comparison expressions" do + # Test date arithmetic within comparisons + context = %{"deadline" => ~D[2024-01-20]} + + assert {:ok, true} = Predicator.evaluate("deadline - 3d = #2024-01-17#", context) + assert {:ok, false} = Predicator.evaluate("deadline - 3d = #2024-01-18#", context) + + # Greater than with date arithmetic + assert {:ok, true} = Predicator.evaluate("deadline + 1d > #2024-01-20#", context) + assert {:ok, false} = Predicator.evaluate("deadline - 1d > #2024-01-20#", context) + end + + test "now() in comparisons" do + # These should work but results depend on current time, so just verify they don't error + assert {:ok, _result} = Predicator.evaluate("Date.now() + 1h > Date.now()", %{}) + assert {:ok, _result} = Predicator.evaluate("Date.now() - 1d < Date.now()", %{}) + end + end + + describe "integration with existing features" do + test "date arithmetic with function calls" do + context = %{"start" => ~D[2024-01-15]} + + # Test with year extraction + assert {:ok, result} = Predicator.evaluate("Date.year(start + 1y)", context) + assert result == 2025 + + # Test with month extraction + assert {:ok, result} = Predicator.evaluate("Date.month(start + 2mo)", context) + assert result == 3 + end + + test "date arithmetic in nested expressions" do + _context = %{ + "events" => [ + %{ + "date" => ~D[2024-01-15], + "duration" => %{ + days: 1, + hours: 0, + minutes: 0, + seconds: 0, + weeks: 0, + months: 0, + years: 0 + } + }, + %{ + "date" => ~D[2024-01-20], + "duration" => %{ + days: 2, + hours: 0, + minutes: 0, + seconds: 0, + weeks: 0, + months: 0, + years: 0 + } + } + ] + } + + # Test property access with date arithmetic (this tests integration) + # Note: This is a complex test that would require array indexing, kept simple for now + assert {:ok, ~D[2024-01-16]} = Predicator.evaluate("#2024-01-15# + 1d", %{}) + end + + test "date arithmetic with object literals" do + # Test date arithmetic with object construction + assert {:ok, result} = + Predicator.evaluate("{start: #2024-01-15#, end: #2024-01-15# + 7d}", %{}) + + expected = %{"start" => ~D[2024-01-15], "end" => ~D[2024-01-22]} + assert result == expected + end + end +end diff --git a/test/predicator/functions/date_functions_test.exs b/test/predicator/functions/date_functions_test.exs new file mode 100644 index 0000000..5bea30e --- /dev/null +++ b/test/predicator/functions/date_functions_test.exs @@ -0,0 +1,152 @@ +defmodule Predicator.Functions.DateFunctionsTest do + use ExUnit.Case, async: true + + alias Predicator.Functions.DateFunctions + + describe "all_functions/0" do + test "returns map with expected functions" do + functions = DateFunctions.all_functions() + + # Check that all expected functions are present + expected_functions = [ + "Date.year", + "Date.month", + "Date.day", + "Date.now" + ] + + for func_name <- expected_functions do + assert Map.has_key?(functions, func_name), "Missing function: #{func_name}" + {arity, function} = functions[func_name] + assert is_integer(arity) and arity >= 0 + assert is_function(function, 2) + end + end + + test "function arities are correct" do + functions = DateFunctions.all_functions() + + # Check specific arities + assert {1, _year_func} = functions["Date.year"] + assert {1, _month_func} = functions["Date.month"] + assert {1, _day_func} = functions["Date.day"] + assert {0, _date_now_func} = functions["Date.now"] + end + end + + describe "Date.year function" do + test "extracts year from Date" do + {1, year_func} = DateFunctions.all_functions()["Date.year"] + + date = ~D[2023-05-15] + assert {:ok, 2023} = year_func.([date], %{}) + end + + test "extracts year from DateTime" do + {1, year_func} = DateFunctions.all_functions()["Date.year"] + + datetime = ~U[2023-05-15 10:30:00Z] + assert {:ok, 2023} = year_func.([datetime], %{}) + end + + test "returns error for non-date argument" do + {1, year_func} = DateFunctions.all_functions()["Date.year"] + + assert {:error, "Date.year() expects a date or datetime argument"} = + year_func.(["not a date"], %{}) + end + + test "returns error for wrong argument count" do + {1, year_func} = DateFunctions.all_functions()["Date.year"] + + assert {:error, "Date.year() expects exactly 1 argument"} = year_func.([], %{}) + + date = ~D[2023-05-15] + assert {:error, "Date.year() expects exactly 1 argument"} = year_func.([date, date], %{}) + end + end + + describe "Date.month function" do + test "extracts month from Date" do + {1, month_func} = DateFunctions.all_functions()["Date.month"] + + date = ~D[2023-05-15] + assert {:ok, 5} = month_func.([date], %{}) + end + + test "extracts month from DateTime" do + {1, month_func} = DateFunctions.all_functions()["Date.month"] + + datetime = ~U[2023-05-15 10:30:00Z] + assert {:ok, 5} = month_func.([datetime], %{}) + end + + test "returns error for non-date argument" do + {1, month_func} = DateFunctions.all_functions()["Date.month"] + + assert {:error, "Date.month() expects a date or datetime argument"} = + month_func.(["not a date"], %{}) + end + + test "returns error for wrong argument count" do + {1, month_func} = DateFunctions.all_functions()["Date.month"] + + assert {:error, "Date.month() expects exactly 1 argument"} = month_func.([], %{}) + + date = ~D[2023-05-15] + assert {:error, "Date.month() expects exactly 1 argument"} = month_func.([date, date], %{}) + end + end + + describe "Date.day function" do + test "extracts day from Date" do + {1, day_func} = DateFunctions.all_functions()["Date.day"] + + date = ~D[2023-05-15] + assert {:ok, 15} = day_func.([date], %{}) + end + + test "extracts day from DateTime" do + {1, day_func} = DateFunctions.all_functions()["Date.day"] + + datetime = ~U[2023-05-15 10:30:00Z] + assert {:ok, 15} = day_func.([datetime], %{}) + end + + test "returns error for non-date argument" do + {1, day_func} = DateFunctions.all_functions()["Date.day"] + + assert {:error, "Date.day() expects a date or datetime argument"} = + day_func.(["not a date"], %{}) + end + + test "returns error for wrong argument count" do + {1, day_func} = DateFunctions.all_functions()["Date.day"] + + assert {:error, "Date.day() expects exactly 1 argument"} = day_func.([], %{}) + + date = ~D[2023-05-15] + assert {:error, "Date.day() expects exactly 1 argument"} = day_func.([date, date], %{}) + end + end + + describe "Date.now function" do + test "returns current datetime" do + {0, date_now_func} = DateFunctions.all_functions()["Date.now"] + + assert {:ok, datetime} = date_now_func.([], %{}) + assert %DateTime{} = datetime + + # Check that the datetime is recent (within the last minute) + now = DateTime.utc_now() + diff_seconds = DateTime.diff(now, datetime, :second) + assert diff_seconds >= 0 and diff_seconds < 60 + end + + test "returns error for wrong argument count" do + {0, date_now_func} = DateFunctions.all_functions()["Date.now"] + + assert {:error, "Date.now() expects no arguments"} = date_now_func.(["arg"], %{}) + end + end +end diff --git a/test/predicator/functions/system_functions_coverage_test.exs b/test/predicator/functions/system_functions_coverage_test.exs index 4761efe..3795f66 100644 --- a/test/predicator/functions/system_functions_coverage_test.exs +++ b/test/predicator/functions/system_functions_coverage_test.exs @@ -1,7 +1,7 @@ defmodule Predicator.Functions.SystemFunctionsCoverageTest do use ExUnit.Case, async: true - alias Predicator.Functions.SystemFunctions + alias Predicator.Functions.{DateFunctions, SystemFunctions} describe "error cases for function arity and types" do test "len/2 with wrong number of arguments" do @@ -45,56 +45,56 @@ defmodule Predicator.Functions.SystemFunctionsCoverageTest do end test "year/2 with wrong number of arguments" do - {1, year_func} = SystemFunctions.all_functions()["year"] + {1, year_func} = DateFunctions.all_functions()["Date.year"] # Test with no arguments - assert {:error, "year() expects exactly 1 argument"} = year_func.([], %{}) + assert {:error, "Date.year() expects exactly 1 argument"} = year_func.([], %{}) # Test with too many arguments date = Date.from_iso8601!("2024-01-15") - assert {:error, "year() expects exactly 1 argument"} = year_func.([date, date], %{}) + assert {:error, "Date.year() expects exactly 1 argument"} = year_func.([date, date], %{}) end test "month/2 with wrong number of arguments" do - {1, month_func} = SystemFunctions.all_functions()["month"] + {1, month_func} = DateFunctions.all_functions()["Date.month"] # Test with no arguments - assert {:error, "month() expects exactly 1 argument"} = month_func.([], %{}) + assert {:error, "Date.month() expects exactly 1 argument"} = month_func.([], %{}) # Test with too many arguments date = Date.from_iso8601!("2024-01-15") - assert {:error, "month() expects exactly 1 argument"} = month_func.([date, date], %{}) + assert {:error, "Date.month() expects exactly 1 argument"} = month_func.([date, date], %{}) end test "day/2 with wrong number of arguments" do - {1, day_func} = SystemFunctions.all_functions()["day"] + {1, day_func} = DateFunctions.all_functions()["Date.day"] # Test with no arguments - assert {:error, "day() expects exactly 1 argument"} = day_func.([], %{}) + assert {:error, "Date.day() expects exactly 1 argument"} = day_func.([], %{}) # Test with too many arguments date = Date.from_iso8601!("2024-01-15") - assert {:error, "day() expects exactly 1 argument"} = day_func.([date, date], %{}) + assert {:error, "Date.day() expects exactly 1 argument"} = day_func.([date, date], %{}) end end describe "date functions with DateTime objects" do test "year/2 with DateTime" do - {1, year_func} = SystemFunctions.all_functions()["year"] + {1, year_func} = DateFunctions.all_functions()["Date.year"] datetime = ~U[2024-01-15 10:30:00Z] assert {:ok, 2024} = year_func.([datetime], %{}) end test "month/2 with DateTime" do - {1, month_func} = SystemFunctions.all_functions()["month"] + {1, month_func} = DateFunctions.all_functions()["Date.month"] datetime = ~U[2024-01-15 10:30:00Z] assert {:ok, 1} = month_func.([datetime], %{}) end test "day/2 with DateTime" do - {1, day_func} = SystemFunctions.all_functions()["day"] + {1, day_func} = DateFunctions.all_functions()["Date.day"] datetime = ~U[2024-01-15 10:30:00Z] assert {:ok, 15} = day_func.([datetime], %{}) diff --git a/test/predicator/functions/system_functions_test.exs b/test/predicator/functions/system_functions_test.exs index 6349477..2f690d7 100644 --- a/test/predicator/functions/system_functions_test.exs +++ b/test/predicator/functions/system_functions_test.exs @@ -41,17 +41,23 @@ defmodule Predicator.Functions.SystemFunctionsTest do end test "date functions with wrong arity" do - # year() with no arguments - assert {:error, %Predicator.Errors.EvaluationError{message: msg}} = evaluate("year()", %{}) - assert msg =~ "year() expects 1 arguments, got 0" + # Date.year() with no arguments + assert {:error, %Predicator.Errors.EvaluationError{message: msg}} = + evaluate("Date.year()", %{}) + + assert msg =~ "Date.year() expects 1 arguments, got 0" + + # Date.month() with no arguments + assert {:error, %Predicator.Errors.EvaluationError{message: msg}} = + evaluate("Date.month()", %{}) + + assert msg =~ "Date.month() expects 1 arguments, got 0" - # month() with no arguments - assert {:error, %Predicator.Errors.EvaluationError{message: msg}} = evaluate("month()", %{}) - assert msg =~ "month() expects 1 arguments, got 0" + # Date.day() with no arguments + assert {:error, %Predicator.Errors.EvaluationError{message: msg}} = + evaluate("Date.day()", %{}) - # day() with no arguments - assert {:error, %Predicator.Errors.EvaluationError{message: msg}} = evaluate("day()", %{}) - assert msg =~ "day() expects 1 arguments, got 0" + assert msg =~ "Date.day() expects 1 arguments, got 0" end end @@ -113,38 +119,38 @@ defmodule Predicator.Functions.SystemFunctionsTest do describe "date functions error cases" do test "year with invalid argument types" do assert {:error, %Predicator.Errors.EvaluationError{message: msg}} = - evaluate("year('not_a_date')", %{}) + evaluate("Date.year('not_a_date')", %{}) - assert msg =~ "year() expects a date or datetime argument" + assert msg =~ "Date.year() expects a date or datetime argument" assert {:error, %Predicator.Errors.EvaluationError{message: msg}} = - evaluate("year(123)", %{}) + evaluate("Date.year(123)", %{}) - assert msg =~ "year() expects a date or datetime argument" + assert msg =~ "Date.year() expects a date or datetime argument" end test "month with invalid argument types" do assert {:error, %Predicator.Errors.EvaluationError{message: msg}} = - evaluate("month('not_a_date')", %{}) + evaluate("Date.month('not_a_date')", %{}) - assert msg =~ "month() expects a date or datetime argument" + assert msg =~ "Date.month() expects a date or datetime argument" assert {:error, %Predicator.Errors.EvaluationError{message: msg}} = - evaluate("month(true)", %{}) + evaluate("Date.month(true)", %{}) - assert msg =~ "month() expects a date or datetime argument" + assert msg =~ "Date.month() expects a date or datetime argument" end test "day with invalid argument types" do assert {:error, %Predicator.Errors.EvaluationError{message: msg}} = - evaluate("day('not_a_date')", %{}) + evaluate("Date.day('not_a_date')", %{}) - assert msg =~ "day() expects a date or datetime argument" + assert msg =~ "Date.day() expects a date or datetime argument" assert {:error, %Predicator.Errors.EvaluationError{message: msg}} = - evaluate("day(false)", %{}) + evaluate("Date.day(false)", %{}) - assert msg =~ "day() expects a date or datetime argument" + assert msg =~ "Date.day() expects a date or datetime argument" end end @@ -157,10 +163,7 @@ defmodule Predicator.Functions.SystemFunctionsTest do "len", "upper", "lower", - "trim", - "year", - "month", - "day" + "trim" ] for func_name <- expected_functions do @@ -179,9 +182,6 @@ defmodule Predicator.Functions.SystemFunctionsTest do assert {1, _upper_func} = functions["upper"] assert {1, _lower_func} = functions["lower"] assert {1, _trim_func} = functions["trim"] - assert {1, _year_func} = functions["year"] - assert {1, _month_func} = functions["month"] - assert {1, _day_func} = functions["day"] end end end diff --git a/test/predicator/integration/function_calls_test.exs b/test/predicator/integration/function_calls_test.exs index 64b2877..44c9402 100644 --- a/test/predicator/integration/function_calls_test.exs +++ b/test/predicator/integration/function_calls_test.exs @@ -33,9 +33,9 @@ defmodule FunctionCallsIntegrationTest do test "evaluates date functions" do context = %{"created_at" => ~D[2024-03-15]} - assert {:ok, 2024} = evaluate("year(created_at)", context) - assert {:ok, 3} = evaluate("month(created_at)", context) - assert {:ok, 15} = evaluate("day(created_at)", context) + assert {:ok, 2024} = evaluate("Date.year(created_at)", context) + assert {:ok, 3} = evaluate("Date.month(created_at)", context) + assert {:ok, 15} = evaluate("Date.day(created_at)", context) end test "evaluates string functions" do diff --git a/test/predicator/lexer_edge_cases_test.exs b/test/predicator/lexer_edge_cases_test.exs new file mode 100644 index 0000000..c50b0f0 --- /dev/null +++ b/test/predicator/lexer_edge_cases_test.exs @@ -0,0 +1,223 @@ +defmodule Predicator.LexerEdgeCasesTest do + use ExUnit.Case + + alias Predicator.Lexer + + describe "lexer error handling" do + test "handles unterminated date literal" do + {:error, message, line, col} = Lexer.tokenize("#2024-01-01") + assert message == "Unterminated date literal" + assert line == 1 + assert col == 1 + end + + test "handles invalid date format" do + {:error, message, line, col} = Lexer.tokenize("#invalid-date#") + assert String.contains?(message, "Invalid date format") + assert line == 1 + assert col == 1 + end + + test "handles invalid datetime format" do + {:error, message, line, col} = Lexer.tokenize("#2024-01-01Tinvalid#") + assert String.contains?(message, "Invalid datetime format") + assert line == 1 + assert col == 1 + end + + test "handles unexpected characters" do + {:error, message, line, col} = Lexer.tokenize("@") + assert message == "Unexpected character '@'" + assert line == 1 + assert col == 1 + end + + test "handles carriage return characters" do + {:ok, tokens} = Lexer.tokenize("x\r\ny") + + assert tokens == [ + {:identifier, 1, 1, 1, "x"}, + {:identifier, 2, 1, 1, "y"}, + {:eof, 2, 2, 0, nil} + ] + end + + test "handles tab characters" do + {:ok, tokens} = Lexer.tokenize("x\ty") + + assert tokens == [ + {:identifier, 1, 1, 1, "x"}, + {:identifier, 1, 3, 1, "y"}, + {:eof, 1, 4, 0, nil} + ] + end + + test "handles unterminated string" do + {:error, message, line, col} = Lexer.tokenize("\"unterminated") + assert message == "Unterminated double-quoted string literal" + assert line == 1 + assert col == 1 + end + + test "handles unterminated single quote string" do + {:error, message, line, col} = Lexer.tokenize("'unterminated") + assert message == "Unterminated single-quoted string literal" + assert line == 1 + assert col == 1 + end + end + + describe "qualified identifiers" do + test "handles regular qualified identifiers" do + {:ok, tokens} = Lexer.tokenize("Math.pow(2, 3)") + + assert tokens == [ + {:qualified_function_name, 1, 1, 8, "Math.pow"}, + {:lparen, 1, 9, 1, "("}, + {:integer, 1, 10, 1, 2}, + {:comma, 1, 11, 1, ","}, + {:integer, 1, 13, 1, 3}, + {:rparen, 1, 14, 1, ")"}, + {:eof, 1, 15, 0, nil} + ] + end + + test "handles nested qualified identifiers" do + {:ok, tokens} = Lexer.tokenize("Deep.Nested.Module.func()") + + assert tokens == [ + {:qualified_function_name, 1, 1, 23, "Deep.Nested.Module.func"}, + {:lparen, 1, 24, 1, "("}, + {:rparen, 1, 25, 1, ")"}, + {:eof, 1, 26, 0, nil} + ] + end + + test "handles qualified identifier not followed by function call" do + {:ok, tokens} = Lexer.tokenize("obj.prop") + + assert tokens == [ + {:identifier, 1, 1, 3, "obj"}, + {:dot, 1, 4, 1, "."}, + {:identifier, 1, 5, 4, "prop"}, + {:eof, 1, 9, 0, nil} + ] + end + end + + describe "number parsing edge cases" do + test "handles decimal point without trailing digits" do + {:ok, tokens} = Lexer.tokenize("42.") + + assert tokens == [ + {:integer, 1, 1, 2, 42}, + {:dot, 1, 3, 1, "."}, + {:eof, 1, 4, 0, nil} + ] + end + + test "handles large integers" do + {:ok, tokens} = Lexer.tokenize("999999999") + + assert tokens == [ + {:integer, 1, 1, 9, 999_999_999}, + {:eof, 1, 10, 0, nil} + ] + end + + test "handles small floats" do + {:ok, tokens} = Lexer.tokenize("0.001") + + assert tokens == [ + {:float, 1, 1, 5, 0.001}, + {:eof, 1, 6, 0, nil} + ] + end + end + + describe "string parsing edge cases" do + test "handles empty string" do + {:ok, tokens} = Lexer.tokenize("\"\"") + + assert tokens == [ + {:string, 1, 1, 2, "", :double}, + {:eof, 1, 3, 0, nil} + ] + end + + test "handles empty single quote string" do + {:ok, tokens} = Lexer.tokenize("''") + + assert tokens == [ + {:string, 1, 1, 2, "", :single}, + {:eof, 1, 3, 0, nil} + ] + end + + test "handles string with escaped quotes" do + {:ok, tokens} = Lexer.tokenize(~s{"He said \\"hello\\""}) + + assert tokens == [ + {:string, 1, 1, 19, "He said \"hello\"", :double}, + {:eof, 1, 20, 0, nil} + ] + end + + test "handles single quote string with escaped quotes" do + {:ok, tokens} = Lexer.tokenize("'It\\'s working'") + + assert tokens == [ + {:string, 1, 1, 15, "It's working", :single}, + {:eof, 1, 16, 0, nil} + ] + end + end + + describe "operator edge cases" do + test "handles strict equality operators" do + {:ok, tokens} = Lexer.tokenize("x === y !== z") + + assert tokens == [ + {:identifier, 1, 1, 1, "x"}, + {:strict_equal, 1, 3, 3, "==="}, + {:identifier, 1, 7, 1, "y"}, + {:strict_ne, 1, 9, 3, "!=="}, + {:identifier, 1, 13, 1, "z"}, + {:eof, 1, 14, 0, nil} + ] + end + + test "handles modulo operator" do + {:ok, tokens} = Lexer.tokenize("x % y") + + assert tokens == [ + {:identifier, 1, 1, 1, "x"}, + {:modulo, 1, 3, 1, "%"}, + {:identifier, 1, 5, 1, "y"}, + {:eof, 1, 6, 0, nil} + ] + end + end + + describe "whitespace handling" do + test "handles multiple spaces" do + {:ok, tokens} = Lexer.tokenize("x y") + + assert tokens == [ + {:identifier, 1, 1, 1, "x"}, + {:identifier, 1, 6, 1, "y"}, + {:eof, 1, 7, 0, nil} + ] + end + + test "handles mixed whitespace" do + {:ok, tokens} = Lexer.tokenize("x \t \n y") + + assert tokens == [ + {:identifier, 1, 1, 1, "x"}, + {:identifier, 2, 2, 1, "y"}, + {:eof, 2, 3, 0, nil} + ] + end + end +end diff --git a/test/predicator/object_integration_test.exs b/test/predicator/object_integration_test.exs index 51ea9de..e1d6138 100644 --- a/test/predicator/object_integration_test.exs +++ b/test/predicator/object_integration_test.exs @@ -275,7 +275,7 @@ defmodule Predicator.ObjectIntegrationTest do result = Predicator.evaluate( - "{year: year(event_date), month: month(event_date), day: day(event_date)}", + "{year: Date.year(event_date), month: Date.month(event_date), day: Date.day(event_date)}", context ) diff --git a/test/predicator/parser_edge_cases_test.exs b/test/predicator/parser_edge_cases_test.exs new file mode 100644 index 0000000..3b26569 --- /dev/null +++ b/test/predicator/parser_edge_cases_test.exs @@ -0,0 +1,307 @@ +defmodule Predicator.ParserEdgeCasesTest do + use ExUnit.Case + + alias Predicator.{Lexer, Parser} + + describe "parser error handling" do + test "handles empty token list" do + {:error, message, line, col} = Parser.parse([]) + assert message == "Unexpected end of input" + assert line == 1 + assert col == 1 + end + + test "handles unexpected end of input" do + {:ok, tokens} = Lexer.tokenize("x +") + {:error, message, line, _col} = Parser.parse(tokens) + assert String.contains?(message, "end of input") + assert line == 1 + end + + test "handles unexpected token" do + {:ok, tokens} = Lexer.tokenize("(") + {:error, message, line, col} = Parser.parse(tokens) + assert String.contains?(message, "Expected") + assert line == 1 + assert col > 0 + end + + test "handles missing closing paren" do + {:ok, tokens} = Lexer.tokenize("(x") + {:error, message, line, _col} = Parser.parse(tokens) + assert String.contains?(message, "Expected ')' but found end of input") + assert line == 1 + end + + test "handles missing closing bracket in list" do + {:ok, tokens} = Lexer.tokenize("[1, 2") + {:error, message, line, _col} = Parser.parse(tokens) + assert String.contains?(message, "Expected") + assert line == 1 + end + + test "handles missing closing brace in object" do + {:ok, tokens} = Lexer.tokenize("{name: 'test'") + {:error, message, line, _col} = Parser.parse(tokens) + assert String.contains?(message, "Expected") + assert line == 1 + end + + test "handles invalid object key" do + {:ok, tokens} = Lexer.tokenize("{123: 'value'}") + {:error, message, line, _col} = Parser.parse(tokens) + assert String.contains?(message, "Expected identifier or string for object key") + assert line == 1 + end + + test "handles missing colon in object" do + {:ok, tokens} = Lexer.tokenize("{name 'test'}") + {:error, message, line, _col} = Parser.parse(tokens) + assert String.contains?(message, "Expected ':' after object key") + assert line == 1 + end + + test "handles missing value after colon in object" do + {:ok, tokens} = Lexer.tokenize("{name:}") + {:error, message, line, _col} = Parser.parse(tokens) + assert String.contains?(message, "Expected") + assert line == 1 + end + end + + describe "primary expression parsing" do + test "parses boolean literals" do + {:ok, tokens} = Lexer.tokenize("true") + {:ok, ast} = Parser.parse(tokens) + assert ast == {:literal, true} + end + + test "parses date literals" do + {:ok, tokens} = Lexer.tokenize("#2024-01-15#") + {:ok, ast} = Parser.parse(tokens) + assert match?({:literal, %Date{}}, ast) + end + + test "parses datetime literals" do + {:ok, tokens} = Lexer.tokenize("#2024-01-15T10:30:00Z#") + {:ok, ast} = Parser.parse(tokens) + assert match?({:literal, %DateTime{}}, ast) + end + + test "parses float literals" do + {:ok, tokens} = Lexer.tokenize("3.14") + {:ok, ast} = Parser.parse(tokens) + assert ast == {:literal, 3.14} + end + + test "parses string literals with quote types" do + {:ok, tokens} = Lexer.tokenize("'single quoted'") + {:ok, ast} = Parser.parse(tokens) + assert ast == {:string_literal, "single quoted", :single} + end + end + + describe "list parsing edge cases" do + test "parses empty list" do + {:ok, tokens} = Lexer.tokenize("[]") + {:ok, ast} = Parser.parse(tokens) + assert ast == {:list, []} + end + + test "parses single element list" do + {:ok, tokens} = Lexer.tokenize("[42]") + {:ok, ast} = Parser.parse(tokens) + assert ast == {:list, [{:literal, 42}]} + end + + test "parses nested lists" do + {:ok, tokens} = Lexer.tokenize("[[1, 2], [3, 4]]") + {:ok, ast} = Parser.parse(tokens) + + expected = + {:list, + [ + {:list, [{:literal, 1}, {:literal, 2}]}, + {:list, [{:literal, 3}, {:literal, 4}]} + ]} + + assert ast == expected + end + end + + describe "object parsing edge cases" do + test "parses empty object" do + {:ok, tokens} = Lexer.tokenize("{}") + {:ok, ast} = Parser.parse(tokens) + assert ast == {:object, []} + end + + test "parses object with identifier key" do + {:ok, tokens} = Lexer.tokenize("{name: 'John'}") + {:ok, ast} = Parser.parse(tokens) + expected = {:object, [{{:identifier, "name"}, {:string_literal, "John", :single}}]} + assert ast == expected + end + + test "parses object with string key" do + {:ok, tokens} = Lexer.tokenize("{\"key\": 'value'}") + {:ok, ast} = Parser.parse(tokens) + expected = {:object, [{{:string_literal, "key"}, {:string_literal, "value", :single}}]} + assert ast == expected + end + + test "parses nested objects" do + {:ok, tokens} = Lexer.tokenize("{user: {name: 'John'}}") + {:ok, ast} = Parser.parse(tokens) + + expected = + {:object, + [ + {{:identifier, "user"}, + {:object, [{{:identifier, "name"}, {:string_literal, "John", :single}}]}} + ]} + + assert ast == expected + end + end + + describe "function call parsing" do + test "parses function with no arguments" do + {:ok, tokens} = Lexer.tokenize("len()") + {:ok, ast} = Parser.parse(tokens) + assert ast == {:function_call, "len", []} + end + + test "parses function with single argument" do + {:ok, tokens} = Lexer.tokenize("len('test')") + {:ok, ast} = Parser.parse(tokens) + expected = {:function_call, "len", [{:string_literal, "test", :single}]} + assert ast == expected + end + + test "parses function with multiple arguments" do + {:ok, tokens} = Lexer.tokenize("max(1, 2, 3)") + {:ok, ast} = Parser.parse(tokens) + expected = {:function_call, "max", [{:literal, 1}, {:literal, 2}, {:literal, 3}]} + assert ast == expected + end + + test "parses qualified function calls" do + {:ok, tokens} = Lexer.tokenize("Math.pow(2, 3)") + {:ok, ast} = Parser.parse(tokens) + expected = {:function_call, "Math.pow", [{:literal, 2}, {:literal, 3}]} + assert ast == expected + end + end + + describe "bracket access parsing" do + test "parses simple bracket access" do + {:ok, tokens} = Lexer.tokenize("arr[0]") + {:ok, ast} = Parser.parse(tokens) + expected = {:bracket_access, {:identifier, "arr"}, {:literal, 0}} + assert ast == expected + end + + test "parses nested bracket access" do + {:ok, tokens} = Lexer.tokenize("matrix[0][1]") + {:ok, ast} = Parser.parse(tokens) + + expected = + {:bracket_access, {:bracket_access, {:identifier, "matrix"}, {:literal, 0}}, + {:literal, 1}} + + assert ast == expected + end + + test "parses bracket access with expression key" do + {:ok, tokens} = Lexer.tokenize("arr[i + 1]") + {:ok, ast} = Parser.parse(tokens) + + expected = + {:bracket_access, {:identifier, "arr"}, + {:arithmetic, :add, {:identifier, "i"}, {:literal, 1}}} + + assert ast == expected + end + end + + describe "property access parsing" do + test "parses simple property access" do + {:ok, tokens} = Lexer.tokenize("obj.prop") + {:ok, ast} = Parser.parse(tokens) + expected = {:property_access, {:identifier, "obj"}, "prop"} + assert ast == expected + end + + test "parses chained property access" do + {:ok, tokens} = Lexer.tokenize("user.profile.name") + {:ok, ast} = Parser.parse(tokens) + expected = {:property_access, {:property_access, {:identifier, "user"}, "profile"}, "name"} + assert ast == expected + end + + test "parses mixed bracket and property access" do + {:ok, tokens} = Lexer.tokenize("users[0].name") + {:ok, ast} = Parser.parse(tokens) + + expected = + {:property_access, {:bracket_access, {:identifier, "users"}, {:literal, 0}}, "name"} + + assert ast == expected + end + end + + describe "unary expressions" do + test "parses unary minus on numbers" do + {:ok, tokens} = Lexer.tokenize("-42") + {:ok, ast} = Parser.parse(tokens) + assert ast == {:unary, :minus, {:literal, 42}} + end + + test "parses unary bang on boolean" do + {:ok, tokens} = Lexer.tokenize("!true") + {:ok, ast} = Parser.parse(tokens) + assert ast == {:logical_not, {:literal, true}} + end + + test "parses nested unary expressions" do + {:ok, tokens} = Lexer.tokenize("--x") + {:ok, ast} = Parser.parse(tokens) + expected = {:unary, :minus, {:unary, :minus, {:identifier, "x"}}} + assert ast == expected + end + end + + describe "complex nested expressions" do + test "parses function calls in arithmetic" do + {:ok, tokens} = Lexer.tokenize("len(name) + 5") + {:ok, ast} = Parser.parse(tokens) + + expected = + {:arithmetic, :add, {:function_call, "len", [{:identifier, "name"}]}, {:literal, 5}} + + assert ast == expected + end + + test "parses object access in comparisons" do + {:ok, tokens} = Lexer.tokenize("user.age >= 18") + {:ok, ast} = Parser.parse(tokens) + + expected = + {:comparison, :gte, {:property_access, {:identifier, "user"}, "age"}, {:literal, 18}} + + assert ast == expected + end + + test "parses list membership with complex expressions" do + {:ok, tokens} = Lexer.tokenize("user.role in ['admin', 'manager']") + {:ok, ast} = Parser.parse(tokens) + + expected = + {:membership, :in, {:property_access, {:identifier, "user"}, "role"}, + {:list, [{:string_literal, "admin", :single}, {:string_literal, "manager", :single}]}} + + assert ast == expected + end + end +end diff --git a/test/predicator/parser_test.exs b/test/predicator/parser_test.exs index 4e43ae8..8583106 100644 --- a/test/predicator/parser_test.exs +++ b/test/predicator/parser_test.exs @@ -1362,4 +1362,135 @@ defmodule Predicator.ParserTest do assert {:ok, ^expected_ast} = result end end + + describe "parse/1 - duration expressions" do + test "parses simple duration with single unit" do + {:ok, tokens} = Lexer.tokenize("5d") + result = Parser.parse(tokens) + assert {:ok, {:duration, [{5, "d"}]}} = result + end + + test "parses duration with multiple units" do + {:ok, tokens} = Lexer.tokenize("1d8h30m") + result = Parser.parse(tokens) + assert {:ok, {:duration, [{30, "m"}, {8, "h"}, {1, "d"}]}} = result + end + + test "parses duration with all unit types" do + {:ok, tokens} = Lexer.tokenize("2y3mo4w5d6h7m8s") + result = Parser.parse(tokens) + + assert {:ok, + {:duration, [{8, "s"}, {7, "m"}, {6, "h"}, {5, "d"}, {4, "w"}, {3, "mo"}, {2, "y"}]}} = + result + end + + test "parses duration with single character units" do + {:ok, tokens} = Lexer.tokenize("1y2mo3w4d5h6m7s") + result = Parser.parse(tokens) + + assert {:ok, + {:duration, [{7, "s"}, {6, "m"}, {5, "h"}, {4, "d"}, {3, "w"}, {2, "mo"}, {1, "y"}]}} = + result + end + + test "parses relative date with 'ago'" do + {:ok, tokens} = Lexer.tokenize("1d8h ago") + result = Parser.parse(tokens) + assert {:ok, {:relative_date, {:duration, [{8, "h"}, {1, "d"}]}, :ago}} = result + end + + test "parses relative date with 'from now'" do + {:ok, tokens} = Lexer.tokenize("2h30m from now") + result = Parser.parse(tokens) + assert {:ok, {:relative_date, {:duration, [{30, "m"}, {2, "h"}]}, :future}} = result + end + + test "parses relative date with 'next'" do + {:ok, tokens} = Lexer.tokenize("next 1w") + result = Parser.parse(tokens) + assert {:ok, {:relative_date, {:duration, [{1, "w"}]}, :next}} = result + end + + test "parses relative date with 'last'" do + {:ok, tokens} = Lexer.tokenize("last 6mo") + result = Parser.parse(tokens) + assert {:ok, {:relative_date, {:duration, [{6, "mo"}]}, :last}} = result + end + + test "duration in comparison expression" do + {:ok, tokens} = Lexer.tokenize("created_at > 1d ago") + result = Parser.parse(tokens) + + expected_ast = + {:comparison, :gt, {:identifier, "created_at"}, + {:relative_date, {:duration, [{1, "d"}]}, :ago}} + + assert {:ok, ^expected_ast} = result + end + + test "duration in complex expression" do + {:ok, tokens} = Lexer.tokenize("created_at > 1d ago AND updated_at < 1h from now") + result = Parser.parse(tokens) + + expected_ast = + {:logical_and, + {:comparison, :gt, {:identifier, "created_at"}, + {:relative_date, {:duration, [{1, "d"}]}, :ago}}, + {:comparison, :lt, {:identifier, "updated_at"}, + {:relative_date, {:duration, [{1, "h"}]}, :future}}} + + assert {:ok, ^expected_ast} = result + end + + test "returns error for invalid duration sequence" do + {:ok, tokens} = Lexer.tokenize("1d8x") + result = Parser.parse(tokens) + assert {:error, _message, _line, _col} = result + end + + test "returns error for missing 'now' after 'from'" do + {:ok, tokens} = Lexer.tokenize("1d from yesterday") + result = Parser.parse(tokens) + assert {:error, _message, _line, _col} = result + end + + test "returns error for 'from' without duration" do + {:ok, tokens} = Lexer.tokenize("from now") + result = Parser.parse(tokens) + assert {:error, _message, _line, _col} = result + end + + test "returns error for 'ago' without duration" do + {:ok, tokens} = Lexer.tokenize("ago") + result = Parser.parse(tokens) + assert {:error, _message, _line, _col} = result + end + + test "returns error for 'next' without duration" do + {:ok, tokens} = Lexer.tokenize("next") + result = Parser.parse(tokens) + assert {:error, _message, _line, _col} = result + end + + test "returns error for 'last' without duration" do + {:ok, tokens} = Lexer.tokenize("last") + result = Parser.parse(tokens) + assert {:error, _message, _line, _col} = result + end + + test "parses zero duration" do + {:ok, tokens} = Lexer.tokenize("0d") + result = Parser.parse(tokens) + assert {:ok, {:duration, [{0, "d"}]}} = result + end + + test "parses large duration numbers" do + {:ok, tokens} = Lexer.tokenize("999y365d24h60m60s") + result = Parser.parse(tokens) + + assert {:ok, {:duration, [{60, "s"}, {60, "m"}, {24, "h"}, {365, "d"}, {999, "y"}]}} = + result + end + end end diff --git a/test/predicator/visitors/instructions_visitor_test.exs b/test/predicator/visitors/instructions_visitor_test.exs index 8254eb0..8ca4e0f 100644 --- a/test/predicator/visitors/instructions_visitor_test.exs +++ b/test/predicator/visitors/instructions_visitor_test.exs @@ -622,4 +622,135 @@ defmodule Predicator.Visitors.InstructionsVisitorTest do ] end end + + describe "visit/2 - duration nodes" do + test "generates duration instruction for simple duration" do + ast = {:duration, [{5, "d"}]} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["duration", [[5, "d"]]]] + end + + test "generates duration instruction for multiple units" do + ast = {:duration, [{1, "d"}, {8, "h"}, {30, "m"}]} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["duration", [[1, "d"], [8, "h"], [30, "m"]]]] + end + + test "generates duration instruction for all unit types" do + ast = {:duration, [{2, "y"}, {3, "mo"}, {4, "w"}, {5, "d"}, {6, "h"}, {7, "m"}, {8, "s"}]} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + [ + "duration", + [[2, "y"], [3, "mo"], [4, "w"], [5, "d"], [6, "h"], [7, "m"], [8, "s"]] + ] + ] + end + + test "generates duration instruction for long unit names" do + ast = {:duration, [{1, "year"}, {2, "months"}, {3, "weeks"}]} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["duration", [[1, "year"], [2, "months"], [3, "weeks"]]]] + end + + test "generates duration instruction for zero values" do + ast = {:duration, [{0, "d"}, {0, "h"}]} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["duration", [[0, "d"], [0, "h"]]]] + end + + test "generates duration instruction for large values" do + ast = {:duration, [{999, "y"}, {365, "d"}]} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["duration", [[999, "y"], [365, "d"]]]] + end + end + + describe "visit/2 - relative date nodes" do + test "generates instructions for relative date with ago" do + duration_ast = {:duration, [{1, "d"}, {8, "h"}]} + ast = {:relative_date, duration_ast, :ago} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["duration", [[1, "d"], [8, "h"]]], ["relative_date", "ago"]] + end + + test "generates instructions for relative date with future" do + duration_ast = {:duration, [{2, "h"}, {30, "m"}]} + ast = {:relative_date, duration_ast, :future} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["duration", [[2, "h"], [30, "m"]]], ["relative_date", "future"]] + end + + test "generates instructions for relative date with next" do + duration_ast = {:duration, [{1, "w"}]} + ast = {:relative_date, duration_ast, :next} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["duration", [[1, "w"]]], ["relative_date", "next"]] + end + + test "generates instructions for relative date with last" do + duration_ast = {:duration, [{6, "mo"}]} + ast = {:relative_date, duration_ast, :last} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["duration", [[6, "mo"]]], ["relative_date", "last"]] + end + + test "generates instructions for complex relative date" do + duration_ast = {:duration, [{1, "y"}, {2, "mo"}, {3, "d"}]} + ast = {:relative_date, duration_ast, :ago} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["duration", [[1, "y"], [2, "mo"], [3, "d"]]], ["relative_date", "ago"]] + end + + test "generates instructions for relative date in comparison" do + duration_ast = {:duration, [{1, "d"}]} + relative_date_ast = {:relative_date, duration_ast, :ago} + ast = {:comparison, :gt, {:identifier, "created_at"}, relative_date_ast} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "created_at"], + ["duration", [[1, "d"]]], + ["relative_date", "ago"], + ["compare", "GT"] + ] + end + + test "generates instructions for complex expression with multiple relative dates" do + # created_at > 1d ago AND updated_at < 1h from now + created_duration = {:duration, [{1, "d"}]} + created_relative = {:relative_date, created_duration, :ago} + created_comparison = {:comparison, :gt, {:identifier, "created_at"}, created_relative} + + updated_duration = {:duration, [{1, "h"}]} + updated_relative = {:relative_date, updated_duration, :future} + updated_comparison = {:comparison, :lt, {:identifier, "updated_at"}, updated_relative} + + ast = {:logical_and, created_comparison, updated_comparison} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "created_at"], + ["duration", [[1, "d"]]], + ["relative_date", "ago"], + ["compare", "GT"], + ["load", "updated_at"], + ["duration", [[1, "h"]]], + ["relative_date", "future"], + ["compare", "LT"], + ["and"] + ] + end + end end