lib/ash/type/string.ex

defmodule Ash.Type.String do
  @constraints [
    max_length: [
      type: :non_neg_integer,
      doc: "Enforces a maximum length on the value"
    ],
    min_length: [
      type: :non_neg_integer,
      doc: "Enforces a minimum length on the value"
    ],
    match: [
      type: {:custom, __MODULE__, :match, []},
      doc: "Enforces that the string matches a passed in regex"
    ],
    trim?: [
      type: :boolean,
      doc: "Trims the value.",
      default: true
    ],
    allow_empty?: [
      type: :boolean,
      doc: "If false, the value is set to `nil` if it's empty.",
      default: false
    ]
  ]

  @moduledoc """
  Stores a string in the database.

  A built-in type that can be referenced via `:string`.

  By default, values are trimmed and empty values are set to `nil`.
  You can use the `allow_empty?` and `trim?` constraints to change these behaviors.

  ### Constraints

  #{Spark.OptionsHelpers.docs(@constraints)}
  """
  use Ash.Type

  require Ash.Expr

  @impl true
  def storage_type(_), do: :string

  def cast_atomic_update(expr, constraints) when is_binary(expr) do
    case cast_input(expr, constraints) do
      {:ok, value} -> {:atomic, value}
      {:error, other} -> {:error, other}
    end
  end

  @impl true
  def cast_atomic_update(expr, constraints) do
    # We can't support `match` currently, as we don't have a multi-target regex
    if constraints[:match] do
      {:not_atomic, "cannot use the `match` string constraint atomically"}
    else
      expr =
        if constraints[:trim?] do
          Ash.Expr.expr(string_trim(^expr))
        else
          expr
        end

      expr =
        if constraints[:allow_empty?] do
          expr
        else
          Ash.Expr.expr(
            if ^expr == "" do
              nil
            else
              ^expr
            end
          )
        end

      validated =
        case {constraints[:max_length], constraints[:min_length]} do
          {nil, nil} ->
            expr

          {max, nil} ->
            Ash.Expr.expr(
              if string_length(^expr) > ^max do
                error(
                  Ash.Error.Changes.InvalidChanges,
                  message: "length must be less than or equal to %{max}",
                  vars: %{max: max}
                )
              else
                ^expr
              end
            )

          {nil, min} ->
            Ash.Expr.expr(
              if string_length(^expr) < ^min do
                error(
                  Ash.Error.Changes.InvalidChanges,
                  message: "length must be greater than or equal to %{min}",
                  vars: %{min: min}
                )
              else
                ^expr
              end
            )

          {max, min} ->
            Ash.Expr.expr(
              cond do
                string_length(^expr) < ^min ->
                  error(
                    Ash.Error.Changes.InvalidChanges,
                    message: "length must be greater than or equal to %{min}",
                    vars: %{min: min}
                  )

                string_length(^expr) > ^max ->
                  error(
                    Ash.Error.Changes.InvalidChanges,
                    message: "length must be less than or equal to %{max}",
                    vars: %{max: max}
                  )

                true ->
                  ^expr
              end
            )
        end

      {:atomic, validated}
    end
  end

  @impl true
  def constraints, do: @constraints

  @impl true
  def generator(constraints) do
    base_generator =
      StreamData.string(
        :printable,
        Keyword.take(constraints, [:max_length, :min_length])
      )

    if constraints[:trim?] && constraints[:min_length] do
      StreamData.filter(base_generator, fn value ->
        value |> String.trim() |> String.length() |> Kernel.>=(constraints[:min_length])
      end)
    else
      base_generator
    end
  end

  def apply_constraints(nil, _), do: :ok

  def apply_constraints(value, constraints) do
    {value, errors} =
      return_value(
        Keyword.get(constraints, :allow_empty?, false),
        Keyword.get(constraints, :trim?, true),
        value,
        constraints
      )

    case errors do
      [] -> {:ok, value}
      [error] -> {:error, error}
      errors -> {:error, errors}
    end
  end

  defp return_value(false, true, value, constraints) do
    trimmed = String.trim(value)

    if trimmed == "" do
      {nil, []}
    else
      {trimmed, validate(trimmed, constraints)}
    end
  end

  defp return_value(false, false, value, constraints) do
    if String.trim(value) == "" do
      {nil, []}
    else
      {value, validate(value, constraints)}
    end
  end

  defp return_value(true, true, value, constraints) do
    trimmed = String.trim(value)
    {trimmed, validate(trimmed, constraints)}
  end

  defp return_value(true, false, value, constraints),
    do: {value, validate(value, constraints)}

  defp validate(value, constraints) do
    Enum.reduce(constraints, [], fn
      {:max_length, max_length}, errors ->
        if String.length(value) > max_length do
          [[message: "length must be less than or equal to %{max}", max: max_length] | errors]
        else
          errors
        end

      {:min_length, min_length}, errors ->
        if String.length(value) < min_length do
          [
            [message: "length must be greater than or equal to %{min}", min: min_length]
            | errors
          ]
        else
          errors
        end

      {:match, regex}, errors ->
        if String.match?(value, regex) do
          errors
        else
          [[message: "must match the pattern %{regex}", regex: inspect(regex)] | errors]
        end

      _, errors ->
        errors
    end)
  end

  @impl true
  def cast_input(%Ash.CiString{} = ci_string, constraints) do
    ci_string
    |> Ash.CiString.value()
    |> cast_input(constraints)
  end

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

  def cast_input(value, constraints) when is_atom(value) do
    cast_input(to_string(value), constraints)
  end

  def cast_input(value, _) do
    Ecto.Type.cast(:string, value)
  end

  @impl true
  def cast_stored(nil, _), do: {:ok, nil}

  def cast_stored(value, _) do
    Ecto.Type.load(:string, value)
  end

  @impl true

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

  def dump_to_native(value, _) do
    Ecto.Type.dump(:string, value)
  end

  def match(%Regex{} = regex), do: {:ok, regex}

  def match(_) do
    {:error, "Must provide a regex to match, e.g ~r/foobar/"}
  end
end