• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mirego / absinthe_error_payload / f657de027a1840bcfdbbcba32d2de2b46ea4c18f-PR-37

pending completion
f657de027a1840bcfdbbcba32d2de2b46ea4c18f-PR-37

Pull #37

github

remi
Let’s try OTP 26 and 27 😰
Pull Request #37: Update supported Elixir, OTP and Ecto versions

1 of 1 new or added line in 1 file covered. (100.0%)

9 existing lines in 2 files now uncovered.

129 of 151 relevant lines covered (85.43%)

10.12 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.04
/lib/absinthe_error_payload/changeset_parser.ex
1
defmodule AbsintheErrorPayload.ChangesetParser do
2
  @moduledoc """
3
  Converts an ecto changeset into a list of validation errors structs.
4
  Currently *does not* support nested errors
5
  """
6

7
  import Ecto.Changeset, only: [traverse_errors: 2]
8
  alias AbsintheErrorPayload.ValidationMessage
9

10
  @doc """
11
  Generate a list of `AbsintheErrorPayload.ValidationMessage` structs from changeset errors
12

13
  For examples, please see the test cases in the github repo.
14
  """
15
  def extract_messages(changeset) do
16
    changeset
17
    |> reject_replaced_changes()
18
    |> traverse_errors(&construct_traversed_message/3)
19
    |> Enum.to_list()
20
    |> Enum.flat_map(&handle_nested_errors/1)
112✔
21
  end
22

23
  defp reject_replaced_changes(values) when is_list(values) do
24
    values
25
    |> Enum.map(&reject_replaced_changes/1)
26
    |> Enum.reject(&match?(%Ecto.Changeset{action: :replace}, &1))
16✔
27
  end
28

29
  defp reject_replaced_changes(%{changes: changes} = changeset) do
30
    Enum.reduce(changes, changeset, fn {key, value}, acc ->
156✔
31
      %{acc | changes: Map.put(acc.changes, key, reject_replaced_changes(value))}
108✔
32
    end)
33
  end
34

35
  defp reject_replaced_changes(value), do: value
88✔
36

37
  defp handle_nested_errors({parent_field, values}) when is_map(values) do
38
    Enum.flat_map(values, fn {field, value} ->
12✔
39
      field_with_parent = construct_field(parent_field, field)
12✔
40
      handle_nested_errors({field_with_parent, value})
12✔
41
    end)
42
  end
43

44
  defp handle_nested_errors({parent_field, values}) when is_list(values) do
45
    values
46
    |> Enum.with_index()
47
    |> Enum.flat_map(&handle_nested_error(parent_field, &1))
152✔
48
  end
49

50
  defp handle_nested_errors({_field, values}), do: values
×
51

52
  defp handle_nested_error(parent_field, {%ValidationMessage{} = value, _index}) do
148✔
53
    [%{value | field: parent_field}]
54
  end
55

56
  defp handle_nested_error(parent_field, {many_values, index}) do
57
    Enum.flat_map(many_values, fn {field, values} ->
28✔
58
      field_with_index = construct_field(parent_field, field, index: index)
24✔
59
      handle_nested_errors({field_with_index, values})
24✔
60
    end)
61
  end
62

63
  defp construct_traversed_message(_changeset, field, {message, opts}) do
64
    construct_message(field, {message, opts})
148✔
65
  end
66

67
  defp construct_field(parent_field, field, options \\ []) do
68
    :absinthe_error_payload
69
    |> Application.get_env(:field_constructor)
70
    |> apply(:error, [parent_field, field, options])
192✔
71
  end
72

73
  @doc """
74
  Generate a single `AbsintheErrorPayload.ValidationMessage` struct from a changeset.
75

76
  This method is designed to be used with `Ecto.Changeset.traverse_errors` to generate a map of structs.
77

78
  ## Examples
79

80
      error_map = Changeset.traverse_errors(fn(changeset, field, error) ->
81
        AbsintheErrorPayload.ChangesetParser.construct_message(field, error)
82
      end)
83
      error_list = Enum.flat_map(error_map, fn({_, messages}) -> messages end)
84

85
  """
86
  def construct_message(field, error_tuple)
87

88
  def construct_message(field, {message, opts}) do
89
    options = build_opts(opts)
156✔
90

91
    %ValidationMessage{
156✔
92
      code: to_code({message, opts}),
93
      field: construct_field(field, nil),
94
      key: field,
95
      template: message,
96
      message: interpolate_message({message, options}),
97
      options: to_key_value(options)
98
    }
99
  end
100

101
  defp to_key_value(opts) do
102
    Enum.map(opts, fn {key, value} ->
156✔
103
      %{
148✔
104
        key: key,
105
        value: interpolated_value_to_string(value)
106
      }
107
    end)
108
  end
109

110
  defp build_opts(opts) do
111
    opts
112
    |> Keyword.drop([:validation, :max, :is, :min, :code])
113
    |> Map.new()
156✔
114
  end
115

116
  @doc """
117
  Inserts message variables into message.
118
  Code inspired by Phoenix DataCase.on_errors/1 boilerplate.
119

120
  ## Examples
121

122
      iex> interpolate_message({"length should be between %{one} and %{two}", %{one: "1", two: "2", three: "3"}})
123
      "length should be between 1 and 2"
124
      iex> interpolate_message({"is already taken: %{fields}", %{fields: [:one, :two]}})
125
      "is already taken: one,two"
126

127
  """
128
  def interpolate_message({message, opts}) do
129
    Enum.reduce(opts, message, fn {key, value}, acc ->
172✔
130
      String.replace(acc, "%{#{key}}", interpolated_value_to_string(value))
172✔
131
    end)
132
  end
133

134
  defp interpolated_value_to_string([item | _] = value) when is_atom(item) do
135
    value
136
    |> Enum.map(&to_string(&1))
8✔
137
    |> interpolated_value_to_string()
4✔
138
  end
139

140
  defp interpolated_value_to_string(value) when is_list(value), do: Enum.join(value, ",")
36✔
141

142
  # Ecto < 3.12
143
  defp interpolated_value_to_string({:parameterized, Ecto.Enum, %{on_load: mappings}}),
UNCOV
144
    do: mappings |> Map.values() |> Enum.join(",")
×
145

146
  # Ecto >= 3.12
147
  defp interpolated_value_to_string({:parameterized, {Ecto.Enum, %{on_load: mappings}}}),
148
    do: mappings |> Map.values() |> Enum.join(",")
8✔
149

150
  defp interpolated_value_to_string(value), do: to_string(value)
276✔
151

152
  @doc """
153
  Generate unique code for each validation type.
154

155
  Expects an array of validation options such as those supplied
156
  by `Ecto.Changeset.traverse_errors/2`, with the addition of a message key containing the message string.
157
  Messages are required for several validation types to be identified.
158

159
  ## Supported
160

161
  - `:cast` - generated by `Ecto.Changeset.cast/3`
162
  - `:association` - generated by `Ecto.Changeset.assoc_constraint/3`, `Ecto.Changeset.cast_assoc/3`, `Ecto.Changeset.put_assoc/3`,  `Ecto.Changeset.cast_embed/3`, `Ecto.Changeset.put_embed/3`
163
  - `:acceptance` - generated by `Ecto.Changeset.validate_acceptance/3`
164
  - `:confirmation` - generated by `Ecto.Changeset.validate_confirmation/3`
165
  - `:length` - generated by `Ecto.Changeset.validate_length/3` when the `:is` option fails validation
166
  - `:min` - generated by `Ecto.Changeset.validate_length/3` when the `:min` option fails validation
167
  - `:max` - generated by `Ecto.Changeset.validate_length/3` when the `:max` option fails validation
168
  - `:less_than_or_equal_to` - generated by `Ecto.Changeset.validate_length/3` when the `:less_than_or_equal_to` option fails validation
169
  - `:less_than` - generated by `Ecto.Changeset.validate_length/3` when the `:less_than` option fails validation
170
  - `:greater_than_or_equal_to` - generated by `Ecto.Changeset.validate_length/3` when the `:greater_than_or_equal_to` option fails validation
171
  - `:greater_than` - generated by `Ecto.Changeset.validate_length/3` when the `:greater_than` option fails validation
172
  - `:equal_to` - generated by `Ecto.Changeset.validate_length/3` when the `:equal_to` option fails validation
173
  - `:exclusion` - generated by `Ecto.Changeset.validate_exclusion/4`
174
  - `:inclusion` - generated by `Ecto.Changeset.validate_inclusion/4`
175
  - `:required` - generated by `Ecto.Changeset.validate_required/3`
176
  - `:subset` - generated by `Ecto.Changeset.validate_subset/4`
177
  - `:unique` - generated by `Ecto.Changeset.unique_constraint/3`
178
  - `:foreign` -  generated by `Ecto.Changeset.foreign_key_constraint/3`
179
  - `:no_assoc_constraint` -  generated by `Ecto.Changeset.no_assoc_constraint/3`
180
  - `:unknown` - supplied when validation cannot be matched. This will also match any custom errors added through
181
  `Ecto.Changeset.add_error/4`, `Ecto.Changeset.validate_change/3`, and `Ecto.Changeset.validate_change/4`
182

183
  """
184
  def to_code({message, validation_options}) do
185
    validation_options
186
    |> Enum.into(%{message: message})
187
    |> validation_options_to_code()
156✔
188
  end
189

190
  defp validation_options_to_code(%{code: code}), do: code
12✔
191
  defp validation_options_to_code(%{validation: :cast}), do: :cast
4✔
192
  defp validation_options_to_code(%{validation: :required}), do: :required
40✔
193
  defp validation_options_to_code(%{validation: :format}), do: :format
16✔
194
  defp validation_options_to_code(%{validation: :inclusion}), do: :inclusion
8✔
195
  defp validation_options_to_code(%{validation: :exclusion}), do: :exclusion
4✔
196
  defp validation_options_to_code(%{validation: :subset}), do: :subset
4✔
197
  defp validation_options_to_code(%{validation: :acceptance}), do: :acceptance
4✔
198
  defp validation_options_to_code(%{validation: :confirmation}), do: :confirmation
4✔
199
  defp validation_options_to_code(%{validation: :length, kind: :is}), do: :length
16✔
200
  defp validation_options_to_code(%{validation: :length, kind: :min}), do: :min
4✔
201
  defp validation_options_to_code(%{validation: :length, kind: :max}), do: :max
4✔
202

203
  defp validation_options_to_code(%{validation: :number, message: message}) do
204
    cond do
20✔
205
      String.contains?(message, "less than or equal to") -> :less_than_or_equal_to
4✔
206
      String.contains?(message, "greater than or equal to") -> :greater_than_or_equal_to
16✔
207
      String.contains?(message, "less than") -> :less_than
12✔
208
      String.contains?(message, "greater than") -> :greater_than
8✔
209
      String.contains?(message, "equal to") -> :equal_to
4✔
210
      true -> :unknown
×
211
    end
212
  end
213

UNCOV
214
  defp validation_options_to_code(%{message: "is invalid", type: _}), do: :association
×
215

UNCOV
216
  defp validation_options_to_code(%{message: "has already been taken"}), do: :unique
×
UNCOV
217
  defp validation_options_to_code(%{message: "does not exist"}), do: :foreign
×
UNCOV
218
  defp validation_options_to_code(%{message: "is still associated with this entry"}), do: :no_assoc
×
219

220
  defp validation_options_to_code(_unknown) do
16✔
221
    :unknown
222
  end
223
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc