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 @impl true def storage_type(_), do: :string @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