Ash.Error.Query.InvalidFilterValue when updating with custom Ash.Type

Hello,

I am having an error hard to debug, I don’t understand what is going on.

I have create an Ash.Type for Postgrex.Interval:

defmodule CELP.Types.Interval do
  use Ash.Type
  alias Postgrex.Interval

  # Returns the underlying storage type (the underlying type of the ecto type of the ash type)
  @impl Ash.Type
  def storage_type(_), do: :interval

  # Casts input (e.g. unknown) data to an instance of the type, or errors
  # Cast the data into something used at runtime. It's like `cast` in Ecto Types.
  @impl Ash.Type
  @spec cast_input(nil | integer() | String.t(), term()) ::
          {:ok, nil | Postgrex.Interval.t()} | {:error, String.t()}
  def cast_input(nil, _), do: {:ok, nil}

  def cast_input(value, _) when is_integer(value) do
    {:ok, minutes_to_interval(value)}
  end

  def cast_input(value, _) when is_binary(value) do
    case Integer.parse(value) do
      {minutes, _} -> {:ok, minutes_to_interval(minutes)}
      :error -> {:error, "invalid_interval. Input must be a valid integer in string format."}
    end
  end

  def cast_input(_, _) do
    {:error, "invalid_interval. Input must be an integer or a string representing an integer."}
  end

  # Casts a value from the data store to an instance of the type, or errors
  # This is like the load in Ecto types.
  @impl Ash.Type
  @spec cast_stored(nil | Postgrex.Interval.t(), term()) ::
          {:ok, nil | integer()} | {:error, String.t()}
  def cast_stored(nil, _), do: {:ok, nil}

  def cast_stored(%Interval{} = interval, _) do
    {:ok, interval_to_minutes(interval)}
  end

  def cast_stored(_, _),
    do: {:error, "invalid_stored_value. Expected a Postgrex.Interval struct."}

  # Casts a value from the Elixir type to a value that the data store can persist
  # This is like the dump in Ecto types. We have the value casted used in `cast_input`.
  @impl Ash.Type
  @spec dump_to_native(nil | Postgrex.Interval.t(), term()) ::
          {:ok, nil | Postgrex.Interval.t()} | {:error, String.t()}
  def dump_to_native(nil, _), do: {:ok, nil}

  def dump_to_native(%Interval{} = interval, _), do: {:ok, interval_to_minutes(interval)}

  def dump_to_native(_, _),
    do: {:error, "invalid_native_value. Expected a Postgrex.Interval struct."}

  # Helper function to convert minutes to Postgrex.Interval
  @spec minutes_to_interval(integer()) :: Postgrex.Interval.t()
  defp minutes_to_interval(minutes) do
    total_seconds = minutes * 60
    days = div(total_seconds, 86_400)
    remaining_seconds = rem(total_seconds, 86_400)

    %Interval{
      # Approximate months as 30-day units
      months: div(days, 30),
      # Remaining days after extracting months
      days: rem(days, 30),
      # Remaining seconds within the day
      secs: remaining_seconds
    }
  end

  # Converts a Postgrex.Interval to total minutes
  @spec interval_to_minutes(Postgrex.Interval.t()) :: integer() | {:error, String.t()}
  def interval_to_minutes(%Postgrex.Interval{months: months, days: days, secs: secs}) do
    # Assuming each month has 30 days
    total_days = months * 30 + days
    total_seconds = total_days * 86_400 + secs
    # Convert seconds to minutes
    div(total_seconds, 60)
  end

  def interval_to_minutes(_), do: {:error, "Invalid input. Expected a Postgrex.Interval struct."}

  defimpl String.Chars, for: [Postgrex.Interval] do
    import Kernel, except: [to_string: 1]

    def to_string(%{:months => months, :days => days, :secs => secs}) do
      m =
        if months === 0 do
          ""
        else
          " #{months} months"
        end

      d =
        if days === 0 do
          ""
        else
          " #{days} days"
        end

      s =
        if secs === 0 do
          ""
        else
          " #{secs} seconds"
        end

      if months === 0 and days === 0 and secs === 0 do
        "<None>"
      else
        "Every#{m}#{d}#{s}"
      end
    end
  end
end

I have added the attribute duration using this new Type:

  attributes do
    uuid_primary_key :id

    attribute :day_of_week, :integer do
      description "The day of the week. 1 is Monday, 7 is Sunday"
      allow_nil? false
      public? true
    end

    attribute :time, :time do
      allow_nil? false
      public? true
    end

    attribute :duration, CELP.Types.Interval do
      allow_nil? false
      public? true
    end

    timestamps()
  end

The update action is very straightforward:

    update :update do
      primary? true

      accept [
        :day_of_week,
        :time,
        :duration,
        :worker_id
      ]
    end

I am trying to update the value using AshPhoenix.Form.submit and I get and error. Just in case I have tried to update the instance using this code:

    Bookings.update_slot_template(
      socket.assigns.slot_template,
      slot_template_params["day_of_week"],
      slot_template_params["time"],
      slot_template_params["duration"],
      slot_template_params["worker_id"]
    )

But I always get this error:

Slot template params: %{
  "day_of_week" => "1",
  "duration" => "1",
  "time" => "07:00:00",
  "worker_id" => "ac908816-2a00-4e37-a0cc-6610dc3b9da5"
}
Updated slot template: {:error,
 %Ash.Error.Invalid{
   bread_crumbs: ["Returned from bulk query update: CELP.Bookings.SlotTemplate.update"], 
   changeset: "#Changeset<>", 
   errors: [
     %Ash.Error.Query.InvalidFilterValue{
       message: nil,
       value: %Postgrex.Interval{months: 0, days: 0, secs: 60, microsecs: 0},
       context: #Ecto.Changeset<action: nil, changes: %{}, errors: [],
        data: #CELP.Bookings.SlotTemplate<>, valid?: true, ...>,
       splode: Ash.Error,
       bread_crumbs: ["Returned from bulk query update: CELP.Bookings.SlotTemplate.update"],
       vars: [],
       path: [],
       stacktrace: #Splode.Stacktrace<>,
       class: :invalid
     }
   ]
 }}

I don’t get it. I have tried everything with the Type. But I don’t know why it’s filtering by the interval in this case.

What am I missing?

Hmm… hard to say at first glance. Please reproduce in a project I can run or in ash_postgres test suite and I will take a look.

1 Like

My guess is that the main issue is that your type doesn’t accept an instance of itself in cast_input. Try handling the case where input is a Postgrex.Interval.t()

1 Like

@zachdaniel you are awesome! That was precisely the problem.

Thanks a lot; I had spent a long time on it and couldn’t figure out what was happening.

I attach the custom duration type, it might help someone in the future.

defmodule CELP.Types.Interval do
  @moduledoc """
  An Ash type that handles PostgreSQL intervals, primarily used for time durations.

  This type can handle:
  - Integer input (interpreted as minutes)
  - String input (parseable as minutes)
  - Postgrex.Interval structs

  All values are internally stored as PostgreSQL intervals but exposed as minutes in the API.
  """

  use Ash.Type
  alias Postgrex.Interval

  @type minutes :: non_neg_integer()
  @type interval :: Postgrex.Interval.t()

  # Returns the underlying storage type (the underlying type of the ecto type of the ash type)
  @impl Ash.Type
  def storage_type(_), do: :interval

  # Casts input (e.g. unknown) data to an instance of the type, or errors
  # Cast the data into something used at runtime. It's like `cast` in Ecto Types.
  @impl Ash.Type
  @spec cast_input(nil | minutes() | String.t() | interval(), term()) ::
          {:ok, nil | interval()} | {:error, String.t()}

  def cast_input(nil, _), do: {:ok, nil}

  def cast_input(value, _) when is_integer(value) and value >= 0 do
    {:ok, minutes_to_interval(value)}
  end

  def cast_input(%Interval{} = interval, _) do
    {:ok, interval}
  end

  def cast_input(value, _) when is_binary(value) do
    case Integer.parse(value) do
      {minutes, _} -> {:ok, minutes_to_interval(minutes)}
      :error -> {:error, "Invalid interval: the input must be a valid number of minutes"}
    end
  end

  def cast_input(_, _) do
    {:error, "Invalid interval: expected either minutes as a number or a PostgreSQL interval"}
  end

  # Casts a value from the data store to an instance of the type, or errors
  # This is like the load in Ecto types.
  @impl Ash.Type
  @spec cast_stored(nil | Postgrex.Interval.t(), term()) ::
          {:ok, nil | integer()} | {:error, String.t()}
  def cast_stored(nil, _), do: {:ok, nil}

  def cast_stored(%Interval{} = interval, _) do
    {:ok, interval_to_minutes(interval)}
  end

  def cast_stored(_, _),
    do: {:error, "Invalid stored value: expected a PostgreSQL interval"}

  # Casts a value from the Elixir type to a value that the data store can persist
  # This is like the dump in Ecto types. We have the value casted used in `cast_input`.
  @impl Ash.Type
  @spec dump_to_native(nil | Postgrex.Interval.t(), term()) ::
          {:ok, nil | Postgrex.Interval.t()} | {:error, String.t()}
  def dump_to_native(nil, _), do: {:ok, nil}

  def dump_to_native(%Interval{} = interval, _), do: {:ok, interval}

  def dump_to_native(_, _),
    do: {:error, "Invalid value: expected a PostgreSQL interval"}

  @doc """
  Converts a duration in minutes to a PostgreSQL interval.

  The conversion is done by:
  1. Converting minutes to total seconds
  2. Extracting complete days from seconds
  3. Converting days to months (using 30-day month approximation)
  4. Calculating remaining days and seconds

  ## Parameters
    * `minutes` - Duration in minutes to convert

  ## Returns
    * `Postgrex.Interval` struct representing the duration
  """
  @spec minutes_to_interval(minutes()) :: interval()
  def minutes_to_interval(minutes) do
    total_seconds = minutes * 60
    days = div(total_seconds, 86_400)
    remaining_seconds = rem(total_seconds, 86_400)

    %Interval{
      # Approximate months as 30-day units
      months: div(days, 30),
      # Remaining days after extracting months
      days: rem(days, 30),
      # Remaining seconds within the day
      secs: remaining_seconds
    }
  end

  @doc """
  Converts a PostgreSQL interval to minutes.

  The conversion assumes 30-day months and:
  1. Converts months to days (months * 30)
  2. Adds explicit days
  3. Converts total days to seconds
  4. Adds explicit seconds
  5. Converts total seconds to minutes

  ## Parameters
    * `interval` - A Postgrex.Interval struct to convert

  ## Returns
    * Number of minutes as an integer
    * `{:error, reason}` if input is not a valid interval
  """
  @spec interval_to_minutes(interval()) :: minutes() | {:error, String.t()}
  def interval_to_minutes(%Postgrex.Interval{months: months, days: days, secs: secs}) do
    # Assuming each month has 30 days
    total_days = months * 30 + days
    total_seconds = total_days * 86_400 + secs
    # Convert seconds to minutes
    div(total_seconds, 60)
  end

  def interval_to_minutes(_), do: {:error, "Invalid input: expected a PostgreSQL interval"}

  defimpl String.Chars, for: [Postgrex.Interval] do
    import Kernel, except: [to_string: 1]

    def to_string(%{months: 0, days: 0, secs: 0}), do: "<None>"

    def to_string(%{months: months, days: days, secs: secs}) do
      parts =
        [
          if(months > 0, do: "#{months} months"),
          if(days > 0, do: "#{days} days"),
          if(secs > 0, do: "#{secs} seconds")
        ]
        |> Enum.reject(&is_nil/1)
        |> Enum.join(" ")

      "Every #{parts}"
    end
  end
end