├── config ├── dev.exs ├── config.exs └── test.exs ├── .formatter.exs ├── test ├── support │ ├── repo.ex │ ├── models │ │ ├── address.ex │ │ ├── user_config.ex │ │ ├── note.ex │ │ ├── comment.ex │ │ ├── account.ex │ │ ├── article.ex │ │ ├── user.ex │ │ └── appointment.ex │ └── migrations │ │ └── 20000101000000_create_tables.exs ├── test_helper.exs └── changeset_helpers_test.exs ├── .gitignore ├── mix.exs ├── mix.lock ├── README.md ├── LICENSE └── lib └── changeset_helpers.ex /config/dev.exs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "#{Mix.env()}.exs" 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/support/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule ChangesetHelpers.Repo do 2 | use Ecto.Repo, 3 | otp_app: :changeset_helpers, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | {:ok, _} = ChangesetHelpers.Repo.start_link() 2 | 3 | ExUnit.start() 4 | 5 | Ecto.Adapters.SQL.Sandbox.mode(ChangesetHelpers.Repo, :manual) 6 | -------------------------------------------------------------------------------- /test/support/models/address.ex: -------------------------------------------------------------------------------- 1 | defmodule ChangesetHelpers.Address do 2 | use Ecto.Schema 3 | 4 | schema "addresses" do 5 | field(:street, :string) 6 | field(:city, :string) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/support/models/user_config.ex: -------------------------------------------------------------------------------- 1 | defmodule ChangesetHelpers.UserConfig do 2 | use Ecto.Schema 3 | 4 | schema "users_configs" do 5 | belongs_to(:address, ChangesetHelpers.Address) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/support/models/note.ex: -------------------------------------------------------------------------------- 1 | defmodule ChangesetHelpers.Note do 2 | use Ecto.Schema 3 | 4 | schema "notes" do 5 | field(:text, :string) 6 | belongs_to(:user, ChangesetHelpers.User) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/support/models/comment.ex: -------------------------------------------------------------------------------- 1 | defmodule ChangesetHelpers.Comment do 2 | use Ecto.Schema 3 | 4 | schema "comments" do 5 | field(:body, :string) 6 | belongs_to(:article, ChangesetHelpers.Article) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/support/models/account.ex: -------------------------------------------------------------------------------- 1 | defmodule ChangesetHelpers.Account do 2 | use Ecto.Schema 3 | 4 | schema "accounts" do 5 | field(:email, :string) 6 | field(:mobile, :string) 7 | belongs_to(:user, ChangesetHelpers.User) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/support/models/article.ex: -------------------------------------------------------------------------------- 1 | defmodule ChangesetHelpers.Article do 2 | use Ecto.Schema 3 | 4 | schema "articles" do 5 | field(:title, :string) 6 | belongs_to(:user, ChangesetHelpers.User) 7 | has_many(:comments, ChangesetHelpers.Comment) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/support/models/user.ex: -------------------------------------------------------------------------------- 1 | defmodule ChangesetHelpers.User do 2 | use Ecto.Schema 3 | 4 | schema "users" do 5 | field(:name, :string) 6 | has_many(:articles, ChangesetHelpers.Article) 7 | has_many(:notes, ChangesetHelpers.Note) 8 | belongs_to(:user_config, ChangesetHelpers.UserConfig) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :logger, level: :warn 4 | 5 | config :changeset_helpers, 6 | ecto_repos: [ChangesetHelpers.Repo] 7 | 8 | config :changeset_helpers, ChangesetHelpers.Repo, 9 | username: "postgres", 10 | password: "postgres", 11 | database: "changeset_helpers", 12 | hostname: "localhost", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | priv: "test/support" 15 | -------------------------------------------------------------------------------- /test/support/models/appointment.ex: -------------------------------------------------------------------------------- 1 | defmodule ChangesetHelpers.Appointment do 2 | use Ecto.Schema 3 | 4 | schema "appointment" do 5 | field(:start_time, :time) 6 | field(:end_time, :time) 7 | 8 | field(:start_date, :date) 9 | field(:end_date, :date) 10 | 11 | field(:attendees, :integer) 12 | field(:max_attendees, :integer) 13 | 14 | field(:int1, :integer) 15 | field(:int2, :integer) 16 | 17 | field(:foo, :integer) 18 | field(:bar, :integer) 19 | 20 | field(:days_of_week, {:array, :integer}) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ IDEA 2 | /.idea/ 3 | *.iml 4 | 5 | .lh 6 | 7 | # The directory Mix will write compiled artifacts to. 8 | /_build/ 9 | 10 | # If you run "mix test --cover", coverage assets end up here. 11 | /cover/ 12 | 13 | # The directory Mix downloads your dependencies sources to. 14 | /deps/ 15 | 16 | # Where third-party dependencies like ExDoc output generated docs. 17 | /doc/ 18 | 19 | # Ignore .fetch files in case you like to edit your project deps locally. 20 | /.fetch 21 | 22 | # If the VM crashes, it generates a dump, let's ignore it too. 23 | erl_crash.dump 24 | 25 | # Also ignore archive artifacts (built via "mix archive.build"). 26 | *.ez 27 | 28 | # Ignore package tarball (built via "mix hex.build"). 29 | changeset_helpers-*.tar 30 | -------------------------------------------------------------------------------- /test/support/migrations/20000101000000_create_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule ChangesetHelpers.CreateTables do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:addresses) do 6 | add(:street, :string) 7 | add(:city, :string) 8 | end 9 | 10 | create table(:users_configs) do 11 | add(:name, :string) 12 | add(:address_id, references(:addresses)) 13 | end 14 | 15 | create table(:users) do 16 | add(:name, :string) 17 | add(:users_config_id, references(:users_configs)) 18 | end 19 | 20 | create table(:accounts) do 21 | add(:name, :string) 22 | add(:user_id, references(:users)) 23 | end 24 | 25 | create table(:articles) do 26 | add(:title, :string) 27 | add(:user_id, references(:users)) 28 | end 29 | 30 | create table(:notes) do 31 | add(:text, :string) 32 | add(:user_id, references(:users)) 33 | end 34 | 35 | create table(:comments) do 36 | add(:body, :string) 37 | add(:article_id, references(:articles)) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ChangesetHelpers.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.23.0" 5 | 6 | def project do 7 | [ 8 | app: :changeset_helpers, 9 | elixir: "~> 1.10", 10 | deps: deps(), 11 | aliases: aliases(), 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | 14 | # Hex 15 | version: @version, 16 | package: package(), 17 | description: "Functions to help working with nested changesets and associations", 18 | 19 | # ExDoc 20 | name: "Changeset Helpers", 21 | source_url: "https://github.com/mathieuprog/changeset_helpers", 22 | docs: docs() 23 | ] 24 | end 25 | 26 | def application do 27 | [ 28 | extra_applications: [:logger] 29 | ] 30 | end 31 | 32 | defp deps do 33 | [ 34 | {:ecto, "~> 3.11"}, 35 | {:ecto_sql, "~> 3.11", only: :test}, 36 | {:postgrex, "~> 0.19", only: :test}, 37 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 38 | ] 39 | end 40 | 41 | defp aliases do 42 | [ 43 | test: [ 44 | "ecto.create --quiet", 45 | "ecto.rollback --all", 46 | "ecto.migrate", 47 | "test" 48 | ] 49 | ] 50 | end 51 | 52 | defp elixirc_paths(:test), do: ["lib", "test/support"] 53 | defp elixirc_paths(_), do: ["lib"] 54 | 55 | defp package do 56 | [ 57 | licenses: ["Apache-2.0"], 58 | maintainers: ["Mathieu Decaffmeyer"], 59 | links: %{"GitHub" => "https://github.com/mathieuprog/changeset_helpers"} 60 | ] 61 | end 62 | 63 | defp docs do 64 | [ 65 | main: "readme", 66 | extras: ["README.md"], 67 | source_ref: "v#{@version}" 68 | ] 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 3 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 5 | "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, 6 | "ecto_sql": {:hex, :ecto_sql, "3.11.3", "4eb7348ff8101fbc4e6bbc5a4404a24fecbe73a3372d16569526b0cf34ebc195", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e5f36e3d736b99c7fee3e631333b8394ade4bafe9d96d35669fca2d81c2be928"}, 7 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 8 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 9 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 10 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 11 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 12 | "postgrex": {:hex, :postgrex, "0.19.0", "f7d50e50cb42e0a185f5b9a6095125a9ab7e4abccfbe2ab820ab9aa92b71dbab", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "dba2d2a0a8637defbf2307e8629cb2526388ba7348f67d04ec77a5d6a72ecfae"}, 13 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 14 | } 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChangesetHelpers 2 | 3 | This library provides a set of helper functions to work with Ecto Changesets. 4 | 5 | ### `validate_comparison(changeset, field1, operator, field2_or_value, opts)` 6 | 7 | Validates the result of the comparison of 8 | * two fields (where at least one is a change) or 9 | * a change and a value, where the value is an integer, a `Date`, a `Time`, 10 | a `DateTime` or a `NaiveDateTime`. 11 | 12 | ```elixir 13 | validate_comparison(changeset, :start_time, :lt, :end_time) 14 | 15 | assert [start_time: {"must be less than 10:00:00", [validation: :comparison]}] = changeset.errors 16 | assert [start_time: :comparison, end_time: :comparison] = changeset.validations 17 | ``` 18 | 19 | ```elixir 20 | validate_comparison(appointment_changeset, :end_time, :lt, ~T[21:00:00]) 21 | 22 | assert [end_time: {"must be less than 21:00:00", [validation: :comparison]}] = changeset.errors 23 | assert [end_time: :comparison] = changeset.validations 24 | ``` 25 | 26 | ### `validate_not_changed(changeset, field)` 27 | 28 | Checks if the specified attribute is present in the changeset's parameters. If the attribute is present, an error is added to the changeset's `:errors` key. 29 | 30 | ```elixir 31 | changes = %{foo: 1, bar: 2} 32 | 33 | changeset = 34 | %Appointment{} 35 | |> cast(changes, [:foo]) 36 | |> validate_not_changed([:bar]) 37 | |> validate_not_changed([:baz, :qux]) 38 | 39 | refute changeset.valid? 40 | assert [bar: {"cannot be changed", [validation: :not_present]}] = changeset.errors 41 | ``` 42 | 43 | ### `validate_list(changeset, field, validation_fun, validation_fun_args)` 44 | 45 | Validates a list of values using the given validator. 46 | 47 | ```elixir 48 | changeset = 49 | %Appointment{} 50 | |> Appointment.changeset(%{days_of_week: [1, 3, 8]}) 51 | |> validate_list(:days_of_week, &Ecto.Changeset.validate_inclusion/3, [1..7]) 52 | 53 | assert [days_of_week: {"is invalid", [validation: :list, index: 2, validator: :validate_inclusion]}] = changeset.errors 54 | assert [days_of_week: {:list, [validator: :validate_inclusion]}] = changeset.validations 55 | ``` 56 | 57 | As the validator function is from the `Ecto.Changeset` module, you may just write `:validate_inclusion`. 58 | 59 | ### `put_assoc(changeset, keys, value)` 60 | 61 | Puts the given nested association in the changeset through a given list of field names. 62 | 63 | ```elixir 64 | ChangesetHelpers.put_assoc(account_changeset, [:user, :config, :address], address_changeset) 65 | ``` 66 | 67 | Instead of giving a Changeset or a schema as the third argument, a function may also be given in order to modify the 68 | nested changeset in one go. 69 | 70 | ```elixir 71 | ChangesetHelpers.put_assoc(account_changeset, [:user, :articles], 72 | &(Enum.concat(&1, [%Article{} |> Ecto.Changeset.change()]))) 73 | ``` 74 | 75 | In the code above, we change a new empty Article, and add the changeset into the articles association (typically done when we want to add a new 76 | row of form inputs to add an entity into a form handling a nested collection of entities). 77 | 78 | ### `put_assoc(changeset, keys, index, value)` 79 | 80 | Puts the given nested association in the changeset through a given list of field names, at the given index. 81 | 82 | ### `change_assoc(struct_or_changeset, keys, changes \\ %{})` 83 | 84 | Returns the nested association in a changeset. This function will first look into the changes and then fails back on 85 | data wrapped in a changeset. 86 | 87 | Changes may be added to the given changeset through the third argument. 88 | 89 | A tuple is returned containing the modified root changeset and the changeset of the association. 90 | 91 | ```elixir 92 | {account_changeset, address_changeset} = 93 | change_assoc(account_changeset, [:user, :user_config, :address], %{street: "Foo street"}) 94 | ``` 95 | 96 | ### `change_assoc(struct_or_changeset, keys, index, changes \\ %{})` 97 | 98 | Returns the nested association in a changeset at the given index. 99 | 100 | ### `update_assoc_changes(changeset, keys, changes)` 101 | 102 | Adds changes to the changed association changesets. 103 | 104 | ### `fetch_field(changeset, keys)` 105 | 106 | Fetches the given nested field from changes or from the data. 107 | 108 | ```elixir 109 | {:changes, street} = 110 | ChangesetHelpers.fetch_field(account_changeset, [:user, :config, :address, :street]) 111 | ``` 112 | 113 | ### `fetch_field!(changeset, keys)` 114 | 115 | Same as `fetch_field/2` but returns the value or raises if the given nested key was not found. 116 | 117 | ```elixir 118 | street = ChangesetHelpers.fetch_field!(account_changeset, [:user, :config, :address, :street]) 119 | ``` 120 | 121 | ### `fetch_change(changeset, keys)` 122 | 123 | Fetches the given nested field from changes or from the data. 124 | 125 | ```elixir 126 | {:ok, street} = 127 | ChangesetHelpers.fetch_change(account_changeset, [:user, :config, :address, :street]) 128 | ``` 129 | 130 | ### `fetch_change!(changeset, keys)` 131 | 132 | Same as `fetch_change/2` but returns the value or raises if the given nested key was not found. 133 | 134 | ```elixir 135 | street = ChangesetHelpers.fetch_change!(account_changeset, [:user, :config, :address, :street]) 136 | ``` 137 | 138 | ### `has_change?(changeset, keys)` 139 | 140 | ```elixir 141 | ChangesetHelpers.has_change?(account_changeset, [:user, :config, :address, :street]) 142 | ``` 143 | 144 | ### `diff_field(changeset1, changeset2, keys)` 145 | 146 | This function allows checking if a given field is different in two changesets. 147 | 148 | ```elixir 149 | {street_changed, street1, street2} = 150 | diff_field(account_changeset, new_account_changeset, [:user, :user_config, :address, :street]) 151 | ``` 152 | 153 | ### `add_error(changeset, keys, message, extra \\ [])` 154 | 155 | Adds an error to the nested changeset. 156 | 157 | ```elixir 158 | ChangesetHelpers.add_error(account_changeset, [:user, :articles, :error_key], "Some error") 159 | ``` 160 | 161 | ### `field_fails_validation?(changeset, field, validations)` 162 | 163 | Checks whether a field as the given validation error key. 164 | 165 | ```elixir 166 | ChangesetHelpers.field_fails_validation?(changeset, :email, :unsafe_unique) 167 | ``` 168 | 169 | ### `field_violates_constraint?(changeset, field, constraints)` 170 | 171 | Checks whether a field as the given constraint error key. 172 | 173 | ```elixir 174 | ChangesetHelpers.field_violates_constraint?(changeset, :email, :unique) 175 | ``` 176 | 177 | ### `validate_changes(changeset, fields, meta, validator)` 178 | 179 | Works like `Ecto.Changeset.validate_change/3` but may receive multiple fields. 180 | 181 | ## Installation 182 | 183 | Add `changeset_helpers` for Elixir as a dependency in your `mix.exs` file: 184 | 185 | ```elixir 186 | def deps do 187 | [ 188 | {:changeset_helpers, "~> 0.23"} 189 | ] 190 | end 191 | ``` 192 | 193 | ## HexDocs 194 | 195 | HexDocs documentation can be found at [https://hexdocs.pm/changeset_helpers](https://hexdocs.pm/changeset_helpers). 196 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2023 Mathieu Decaffmeyer 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | -------------------------------------------------------------------------------- /test/changeset_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ChangesetHelpersTest do 2 | use ExUnit.Case 3 | doctest ChangesetHelpers 4 | 5 | alias ChangesetHelpers.{Account, Address, Appointment, Article, User, UserConfig} 6 | import Ecto.Changeset 7 | import ChangesetHelpers 8 | 9 | setup do 10 | account_changeset = 11 | change( 12 | %Account{}, 13 | %{ 14 | email: "john@example.net", 15 | mobile: "0434123456", 16 | user: %{ 17 | name: "John", 18 | articles: [ 19 | %{title: "Article 1", comments: [%{body: "Comment 1"}, %{body: "Comment 2"}]}, 20 | %{title: "Article 2", comments: [%{body: "Comment 1"}, %{body: "Comment 2"}]} 21 | ], 22 | user_config: %{ 23 | address: %{street: "A street"} 24 | } 25 | } 26 | } 27 | ) 28 | 29 | [account_changeset: account_changeset] 30 | end 31 | 32 | test "validate_not_changed/2" do 33 | changes = %{start_time: ~T[11:00:00], end_time: ~T[12:00:00]} 34 | 35 | changeset = 36 | %Appointment{} 37 | |> cast(changes, [:start_time]) 38 | |> validate_not_changed([:end_time, :foo]) 39 | 40 | refute changeset.valid? 41 | assert [end_time: {"cannot be changed", [validation: :not_present]}] = changeset.errors 42 | 43 | changeset = 44 | %Appointment{} 45 | |> change(changes) 46 | |> validate_not_changed(:end_time) 47 | 48 | refute changeset.valid? 49 | assert [end_time: {"cannot be changed", [validation: :not_present]}] = changeset.errors 50 | 51 | changes = %{start_time: ~T[11:00:00]} 52 | 53 | changeset = 54 | %Appointment{} 55 | |> cast(changes, [:start_time]) 56 | |> validate_not_changed([:end_time, :foo]) 57 | 58 | assert changeset.valid? 59 | assert [] == changeset.errors 60 | 61 | changeset = 62 | %Appointment{} 63 | |> change(changes) 64 | |> validate_not_changed(:end_time) 65 | 66 | assert changeset.valid? 67 | assert [] == changeset.errors 68 | end 69 | 70 | test "validate_list/5" do 71 | appointment_changeset = change(%Appointment{}, %{days_of_week: [1, 3, 8]}) 72 | 73 | changeset = validate_list(appointment_changeset, :days_of_week, &Ecto.Changeset.validate_inclusion/3, [1..7]) 74 | 75 | assert [days_of_week: {"is invalid", [validation: :list, index: 2, validator: :validate_inclusion]}] = changeset.errors 76 | assert [days_of_week: {:list, [validator: :validate_inclusion]}] = changeset.validations 77 | 78 | changeset = validate_list(appointment_changeset, :days_of_week, :validate_inclusion, [1..7]) 79 | 80 | assert [days_of_week: {"is invalid", [validation: :list, index: 2, validator: :validate_inclusion]}] = changeset.errors 81 | assert [days_of_week: {:list, [validator: :validate_inclusion]}] = changeset.validations 82 | 83 | appointment_changeset = change(%Appointment{}, %{days_of_week: [1, 3, 5]}) 84 | 85 | changeset = validate_list(appointment_changeset, :days_of_week, &Ecto.Changeset.validate_inclusion/3, [1..7]) 86 | 87 | assert [] = changeset.errors 88 | assert [days_of_week: {:list, [validator: :validate_inclusion]}] = changeset.validations 89 | 90 | appointment_changeset = change(%Appointment{}, %{}) 91 | 92 | changeset = validate_list(appointment_changeset, :days_of_week, &Ecto.Changeset.validate_inclusion/3, [1..7]) 93 | 94 | assert [] = changeset.errors 95 | assert [days_of_week: {:list, [validator: :validate_inclusion]}] = changeset.validations 96 | 97 | appointment_changeset = change(%Appointment{}, %{days_of_week: []}) 98 | 99 | changeset = validate_list(appointment_changeset, :days_of_week, &Ecto.Changeset.validate_inclusion/3, [1..7]) 100 | 101 | assert [] = changeset.errors 102 | assert [days_of_week: {:list, [validator: :validate_inclusion]}] = changeset.validations 103 | end 104 | 105 | test "validate_comparison/5" do 106 | appointment_changeset = 107 | change( 108 | %Appointment{start_time: ~T[11:00:00]}, 109 | %{ 110 | end_time: ~T[10:00:00], 111 | start_date: ~D[2021-06-11], 112 | end_date: ~D[2021-06-11], 113 | attendees: 5, 114 | max_attendees: 3, 115 | int2: 1 116 | } 117 | ) 118 | 119 | changeset = validate_comparison(appointment_changeset, :start_time, :lt, :end_time) 120 | 121 | assert [start_time: {"must be less than 10:00:00", [validation: :comparison]}] = changeset.errors 122 | assert [start_time: :comparison, end_time: :comparison] = changeset.validations 123 | 124 | changeset = validate_comparison(appointment_changeset, :start_time, :lt, :end_time, message: "foo") 125 | 126 | assert [start_time: {"foo", [validation: :comparison]}] = changeset.errors 127 | assert [start_time: :comparison, end_time: :comparison] = changeset.validations 128 | 129 | changeset = validate_comparison(appointment_changeset, :start_time, :lt, :end_time, error_on_field: :end_time) 130 | 131 | assert [end_time: {"must be greater than 11:00:00", [validation: :comparison]}] = changeset.errors 132 | assert [start_time: :comparison, end_time: :comparison] = changeset.validations 133 | 134 | changeset = validate_comparison(appointment_changeset, :end_time, :gt, ~T[14:00:00]) 135 | 136 | assert [end_time: {"must be greater than 14:00:00", [validation: :comparison]}] = changeset.errors 137 | assert [end_time: :comparison] = changeset.validations 138 | 139 | changeset = validate_comparison(appointment_changeset, :end_time, :gt, ~T[08:00:00]) 140 | 141 | assert [] = changeset.errors 142 | assert [end_time: :comparison] = changeset.validations 143 | 144 | changeset = validate_comparison(appointment_changeset, :attendees, :le, :max_attendees) 145 | 146 | assert [attendees: {"must be less than or equal to 3", [validation: :comparison]}] = changeset.errors 147 | assert [attendees: :comparison, max_attendees: :comparison] = changeset.validations 148 | 149 | changeset = validate_comparison(appointment_changeset, :foo, :le, :bar) 150 | 151 | assert [] = changeset.errors 152 | assert [foo: :comparison, bar: :comparison] = changeset.validations 153 | 154 | changeset = validate_comparison(appointment_changeset, :int1, :le, :int2) 155 | 156 | assert [] = changeset.errors 157 | assert [int1: :comparison, int2: :comparison] = changeset.validations 158 | end 159 | 160 | test "validate_changes/4", context do 161 | account_changeset = context[:account_changeset] 162 | 163 | account_changeset1 = 164 | account_changeset 165 | |> validate_change(:email, :custom, fn _, _ -> 166 | [{:email, {"changeset error", [validation: :custom, foo: :bar]}}] 167 | end) 168 | 169 | assert [email: {"changeset error", [validation: :custom, foo: :bar]}] == account_changeset1.errors 170 | assert [email: :custom] == account_changeset1.validations 171 | 172 | account_changeset2 = 173 | account_changeset 174 | |> validate_changes([:email], :custom, fn arg -> 175 | assert [email: _] = arg 176 | [{:email, {"changeset error", [validation: :custom, foo: :bar]}}] 177 | end) 178 | 179 | assert [email: {"changeset error", [validation: :custom, foo: :bar]}] == account_changeset2.errors 180 | assert [email: :custom] == account_changeset2.validations 181 | 182 | account_changeset = change(%Account{email: "john@example.net"}, %{mobile: "0434123456"}) 183 | 184 | account_changeset3 = 185 | account_changeset 186 | |> validate_changes([:email, :mobile], :custom, fn arg -> 187 | assert [email: _, mobile: _] = arg 188 | [{:email, {"changeset error", [validation: :custom, foo: :bar]}}] 189 | end) 190 | 191 | assert [email: {"changeset error", [validation: :custom, foo: :bar]}] == account_changeset3.errors 192 | assert [email: :custom, mobile: :custom] == account_changeset3.validations 193 | end 194 | 195 | test "field_violates_constraint?/3", context do 196 | account_changeset = context[:account_changeset] 197 | 198 | changeset = 199 | account_changeset 200 | |> unique_constraint(:email) 201 | 202 | refute field_violates_constraint?(changeset, :email, :unique) 203 | end 204 | 205 | test "field_fails_validation?/3", context do 206 | account_changeset = context[:account_changeset] 207 | 208 | changeset = 209 | account_changeset 210 | |> validate_length(:email, min: 200) 211 | 212 | assert field_fails_validation?(changeset, :email, :length) 213 | 214 | changeset = 215 | account_changeset 216 | |> put_change(:email, "") 217 | |> validate_required([:email]) 218 | |> validate_length(:email, min: 200) 219 | 220 | assert field_fails_validation?(changeset, :email, :required) 221 | 222 | # `:email` is blank, validation error is `:required` 223 | changeset = 224 | account_changeset 225 | |> put_change(:email, "") 226 | |> validate_required([:email]) 227 | |> validate_length(:email, min: 200) 228 | 229 | refute field_fails_validation?(changeset, :email, :length) 230 | 231 | # `:foo` field doesn't exist 232 | assert_raise ArgumentError, "unknown field `:foo`", fn -> 233 | account_changeset 234 | |> field_fails_validation?(:foo, :length) 235 | end 236 | 237 | # no validation `:length` for `:email` 238 | assert_raise ArgumentError, "unknown validation `:length` for field `:email`", fn -> 239 | account_changeset 240 | |> field_fails_validation?(:email, :length) 241 | end 242 | 243 | changeset = 244 | account_changeset 245 | |> validate_required([:email]) 246 | |> validate_length(:mobile, min: 2) 247 | |> validate_length(:email, min: 3) 248 | 249 | refute field_fails_validation?(changeset, :email, [:length, :required]) 250 | refute field_fails_validation?(changeset, :mobile, :length) 251 | 252 | # `:email` length is invalid but `:mobile` length is valid 253 | changeset = 254 | account_changeset 255 | |> validate_required([:email]) 256 | |> validate_length(:mobile, min: 2) 257 | |> validate_length(:email, min: 200) 258 | 259 | assert field_fails_validation?(changeset, :email, [:required, :length]) 260 | refute field_fails_validation?(changeset, :mobile, :length) 261 | 262 | changeset = 263 | account_changeset 264 | |> validate_required([:email]) 265 | |> validate_length(:mobile, min: 100) 266 | |> validate_length(:email, min: 3) 267 | 268 | refute field_fails_validation?(changeset, :email, [:length, :required]) 269 | assert field_fails_validation?(changeset, :mobile, :length) 270 | 271 | changeset = 272 | account_changeset 273 | |> put_change(:email, "123") 274 | |> validate_required([:email]) 275 | |> validate_length(:email, min: 200) 276 | 277 | assert field_fails_validation?(changeset, :email, [:length, :required]) 278 | 279 | # raise format and not length (raise in order of validations) 280 | changeset = 281 | account_changeset 282 | |> put_change(:email, "123") 283 | |> validate_required([:email]) 284 | |> validate_format(:email, ~r/@/) 285 | |> validate_length(:email, min: 200) 286 | 287 | assert field_fails_validation?(changeset, :email, [:length, :format]) 288 | assert field_fails_validation?(changeset, :email, :length) 289 | assert field_fails_validation?(changeset, :email, :format) 290 | refute field_fails_validation?(changeset, :email, :required) 291 | 292 | changeset = 293 | account_changeset 294 | |> put_change(:email, "123") 295 | |> validate_required([:email]) 296 | |> validate_length(:email, min: 200) 297 | 298 | assert field_fails_validation?(changeset, :email, [:length, :required]) 299 | 300 | changeset = 301 | account_changeset 302 | |> put_change(:email, "") 303 | |> validate_required([:email]) 304 | |> validate_length(:email, min: 3) 305 | 306 | assert field_fails_validation?(changeset, :email, [:length, :required]) 307 | 308 | # custom validation 309 | changeset = 310 | account_changeset 311 | |> validate_change(:email, {:custom, []}, fn _, _ -> 312 | [{:email, {"changeset error", [validation: :custom]}}] 313 | end) 314 | 315 | assert field_fails_validation?(changeset, :email, :custom) 316 | end 317 | 318 | test "change_assoc", context do 319 | account_changeset = context[:account_changeset] 320 | 321 | {_, [article_changeset | _]} = change_assoc(account_changeset, [:user, :articles]) 322 | 323 | assert %Ecto.Changeset{data: %Article{}} = article_changeset 324 | 325 | account = apply_changes(account_changeset) 326 | 327 | {_, [article_changeset | _]} = change_assoc(account, [:user, :articles]) 328 | 329 | assert %Ecto.Changeset{data: %Article{}} = article_changeset 330 | 331 | {_, address_changeset} = 332 | change_assoc(account, [:user, :user_config, :address], %{street: "Foo street"}) 333 | 334 | assert %Ecto.Changeset{data: %Address{}, changes: %{street: "Foo street"}} = address_changeset 335 | 336 | {_, [article_changeset | _], article_changeset} = 337 | change_assoc(account, [:user, :articles], 0, %{title: "Article X"}) 338 | 339 | assert %Ecto.Changeset{data: %Article{}, changes: %{title: "Article X"}} = article_changeset 340 | 341 | {_, [article1, article2_changeset], article2_changeset} = 342 | change_assoc(account, [:user, :articles], 1, %{title: "Article Y"}) 343 | 344 | assert "Article 1" = Ecto.Changeset.fetch_field!(Ecto.Changeset.change(article1), :title) 345 | assert %Ecto.Changeset{data: %Article{}, changes: %{title: "Article Y"}} = article2_changeset 346 | end 347 | 348 | test "change_assoc with NotLoaded assoc" do 349 | account = %Account{user: %User{}} 350 | 351 | assert {_, []} = change_assoc(account, [:user, :articles]) 352 | 353 | account = %Account{} 354 | 355 | assert {_, []} = change_assoc(account, [:user, :articles]) 356 | end 357 | 358 | test "put_assoc", context do 359 | account_changeset = context[:account_changeset] 360 | 361 | address_changeset = change(%Address{}, %{street: "Another street"}) 362 | 363 | account_changeset = 364 | ChangesetHelpers.put_assoc( 365 | account_changeset, 366 | [:user, :user_config, :address], 367 | address_changeset 368 | ) 369 | 370 | assert "Another street" = 371 | Ecto.Changeset.fetch_field!(account_changeset, :user) 372 | |> Map.fetch!(:user_config) 373 | |> Map.fetch!(:address) 374 | |> Map.fetch!(:street) 375 | 376 | account_changeset = 377 | ChangesetHelpers.put_assoc( 378 | account_changeset, 379 | [:user, :user_config, :address], 380 | &change(&1, %{street: "Foo street"}) 381 | ) 382 | 383 | assert "Foo street" = 384 | Ecto.Changeset.fetch_field!(account_changeset, :user) 385 | |> Map.fetch!(:user_config) 386 | |> Map.fetch!(:address) 387 | |> Map.fetch!(:street) 388 | 389 | article_changeset = change(%Article{}, %{title: "Article X"}) 390 | 391 | account_changeset = 392 | ChangesetHelpers.put_assoc( 393 | account_changeset, 394 | [:user, :articles], 395 | 1, 396 | article_changeset 397 | ) 398 | 399 | assert "Article 1" = 400 | Ecto.Changeset.fetch_field!(account_changeset, :user) 401 | |> Map.fetch!(:articles) 402 | |> Enum.at(0) 403 | |> Map.fetch!(:title) 404 | 405 | assert "Article X" = 406 | Ecto.Changeset.fetch_field!(account_changeset, :user) 407 | |> Map.fetch!(:articles) 408 | |> Enum.at(1) 409 | |> Map.fetch!(:title) 410 | 411 | account_changeset = 412 | ChangesetHelpers.put_assoc( 413 | account_changeset, 414 | [:user, :articles], 415 | 1, 416 | fn _ -> article_changeset end 417 | ) 418 | 419 | assert "Article 1" = 420 | Ecto.Changeset.fetch_field!(account_changeset, :user) 421 | |> Map.fetch!(:articles) 422 | |> Enum.at(0) 423 | |> Map.fetch!(:title) 424 | 425 | assert "Article X" = 426 | Ecto.Changeset.fetch_field!(account_changeset, :user) 427 | |> Map.fetch!(:articles) 428 | |> Enum.at(1) 429 | |> Map.fetch!(:title) 430 | end 431 | 432 | test "fetch_field", context do 433 | account_changeset = context[:account_changeset] 434 | 435 | assert {:changes, "A street"} = ChangesetHelpers.fetch_field(account_changeset, [:user, :user_config, :address, :street]) 436 | assert "A street" = ChangesetHelpers.fetch_field!(account_changeset, [:user, :user_config, :address, :street]) 437 | 438 | user_changeset = change(%User{name: "John", user_config: %UserConfig{address: %Address{street: "John's street"}}}) 439 | account_changeset = change(%Account{}, %{user: user_changeset}) 440 | 441 | assert {:data, "John"} = ChangesetHelpers.fetch_field(account_changeset, [:user, :name]) 442 | assert {:data, "John's street"} = ChangesetHelpers.fetch_field(account_changeset, [:user, :user_config, :address, :street]) 443 | end 444 | 445 | test "fetch_change", context do 446 | account_changeset = context[:account_changeset] 447 | 448 | assert {:ok, "A street"} = ChangesetHelpers.fetch_change(account_changeset, [:user, :user_config, :address, :street]) 449 | assert "A street" = ChangesetHelpers.fetch_change!(account_changeset, [:user, :user_config, :address, :street]) 450 | assert :error = ChangesetHelpers.fetch_change(account_changeset, [:user, :user_config, :address, :city]) 451 | assert :error = ChangesetHelpers.fetch_change(account_changeset, [:user, :dummy]) 452 | end 453 | 454 | test "has_change?", context do 455 | account_changeset = context[:account_changeset] 456 | 457 | assert ChangesetHelpers.has_change?(account_changeset, [:user, :articles, :comments]) 458 | assert ChangesetHelpers.has_change?(account_changeset, [:user, :articles, :comments, :body]) 459 | refute ChangesetHelpers.has_change?(account_changeset, [:user, :notes]) 460 | assert ChangesetHelpers.has_change?(account_changeset, :user) 461 | end 462 | 463 | test "update_assoc_changes", context do 464 | account_changeset = context[:account_changeset] 465 | 466 | notes_changes = %{text: "Lorem ipsum"} 467 | 468 | account_changeset = 469 | ChangesetHelpers.update_assoc_changes( 470 | account_changeset, 471 | [:user, :notes], 472 | notes_changes 473 | ) 474 | 475 | assert [] != 476 | Ecto.Changeset.fetch_field!(account_changeset, :user) 477 | |> Map.fetch!(:notes) 478 | 479 | account_changeset = 480 | ChangesetHelpers.put_assoc( 481 | account_changeset, 482 | [:user, :notes], 483 | fn changeset -> Enum.map(changeset, &Map.merge(&1, notes_changes)) end 484 | ) 485 | 486 | assert [] = 487 | Ecto.Changeset.fetch_field!(account_changeset, :user) 488 | |> Map.fetch!(:notes) 489 | 490 | changes = %{title: "Lorem ipsum 1"} 491 | 492 | account_changeset = 493 | ChangesetHelpers.update_assoc_changes( 494 | account_changeset, 495 | [:user, :articles], 496 | changes 497 | ) 498 | 499 | assert [article1, article2] = 500 | Ecto.Changeset.fetch_field!(account_changeset, :user) 501 | |> Map.fetch!(:articles) 502 | 503 | assert article1.title == "Lorem ipsum 1" 504 | assert article2.title == "Lorem ipsum 1" 505 | 506 | changes = %{title: "Lorem ipsum 2"} 507 | 508 | account_changeset = 509 | ChangesetHelpers.put_assoc( 510 | account_changeset, 511 | [:user, :articles], 512 | fn changeset -> Enum.map(changeset, &Ecto.Changeset.change(&1, changes)) end 513 | ) 514 | 515 | assert [article1, article2] = 516 | Ecto.Changeset.fetch_field!(account_changeset, :user) 517 | |> Map.fetch!(:articles) 518 | 519 | assert article1.title == "Lorem ipsum 2" 520 | assert article2.title == "Lorem ipsum 2" 521 | 522 | # TODO: support nested associations in associations 523 | # comments_changes = %{body: "Lorem ipsum comment"} 524 | 525 | # account_changeset = 526 | # ChangesetHelpers.put_assoc( 527 | # account_changeset, 528 | # [:user, :articles, :comments], 529 | # fn changeset -> Enum.map(changeset, &Ecto.Changeset.change(&1, comments_changes)) end 530 | # ) 531 | end 532 | 533 | test "diff_field", context do 534 | account_changeset = context[:account_changeset] 535 | 536 | {_, address_changeset} = 537 | change_assoc(account_changeset, [:user, :user_config, :address], %{street: "Another street"}) 538 | 539 | new_account_changeset = 540 | ChangesetHelpers.put_assoc( 541 | account_changeset, 542 | [:user, :user_config, :address], 543 | address_changeset 544 | ) 545 | 546 | {street_changed, street1, street2} = 547 | diff_field(account_changeset, new_account_changeset, [ 548 | :user, 549 | :user_config, 550 | :address, 551 | :street 552 | ]) 553 | 554 | assert {true, "A street", "Another street"} = {street_changed, street1, street2} 555 | end 556 | 557 | test "add_error", context do 558 | account_changeset = context[:account_changeset] 559 | 560 | account_changeset = ChangesetHelpers.add_error(account_changeset, [:user, :articles, :error_key], "Some error") 561 | 562 | refute account_changeset.valid? 563 | 564 | {_, [article_changeset | _]} = change_assoc(account_changeset, [:user, :articles]) 565 | 566 | refute account_changeset.valid? 567 | assert [error_key: {"Some error", []}] = article_changeset.errors 568 | end 569 | end 570 | -------------------------------------------------------------------------------- /lib/changeset_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ChangesetHelpers do 2 | @moduledoc ~S""" 3 | Provides a set of helpers to work with Changesets. 4 | """ 5 | 6 | @doc ~S""" 7 | Validates the result of the comparison of 8 | * two fields (where at least one is a change) or 9 | * a change and a value, where the value is an integer, a `Date`, a `Time`, 10 | a `DateTime` or a `NaiveDateTime`. 11 | 12 | ## Options 13 | 14 | * `:error_on_field` - specifies on which field to add the error, defaults to the first field 15 | * `:message` - a customized message on failure 16 | """ 17 | def validate_comparison(changeset, field1, operator, field2_or_value, opts \\ []) 18 | 19 | def validate_comparison(%Ecto.Changeset{} = changeset, field1, operator, field2, opts) when is_atom(field2) do 20 | error_on_field = Keyword.get(opts, :error_on_field, field1) 21 | 22 | operator = operator_abbr(operator) 23 | 24 | validate_changes changeset, [field1, field2], :comparison, fn [{_, value1}, {_, value2}] -> 25 | if value1 == nil || value2 == nil do 26 | [] 27 | else 28 | valid? = 29 | case compare(value1, value2) do 30 | :eq -> 31 | operator in [:eq, :ge, :le] 32 | 33 | :lt -> 34 | operator in [:ne, :lt, :le] 35 | 36 | :gt -> 37 | operator in [:ne, :gt, :ge] 38 | end 39 | 40 | message = 41 | if error_on_field == field1 do 42 | Keyword.get(opts, :message, comparison_error_message(operator, value2)) 43 | else 44 | reverse_operator = 45 | case operator do 46 | :lt -> :gt 47 | :gt -> :lt 48 | :le -> :ge 49 | :ge -> :le 50 | _ -> operator 51 | end 52 | Keyword.get(opts, :message, comparison_error_message(reverse_operator, value1)) 53 | end 54 | 55 | if valid?, 56 | do: [], 57 | else: [{error_on_field, {message, [validation: :comparison]}}] 58 | end 59 | end 60 | end 61 | 62 | def validate_comparison(%Ecto.Changeset{} = changeset, field, operator, value, opts) do 63 | operator = operator_abbr(operator) 64 | 65 | Ecto.Changeset.validate_change changeset, field, :comparison, fn _, field_value -> 66 | valid? = 67 | case compare(field_value, value) do 68 | :eq -> 69 | operator in [:eq, :ge, :le] 70 | 71 | :lt -> 72 | operator in [:ne, :lt, :le] 73 | 74 | :gt -> 75 | operator in [:ne, :gt, :ge] 76 | end 77 | 78 | message = Keyword.get(opts, :message, comparison_error_message(operator, value)) 79 | 80 | if valid?, 81 | do: [], 82 | else: [{field, {message, [validation: :comparison]}}] 83 | end 84 | end 85 | 86 | @doc ~S""" 87 | `validate_not_changed/3` checks if the specified attribute is present in the changeset's parameters. 88 | If the attribute is present, an error is added to the changeset's `:errors` key. 89 | """ 90 | def validate_not_changed(changeset, field_or_fields) do 91 | fields = List.wrap(field_or_fields) 92 | 93 | Enum.reduce(fields, changeset, fn field, changeset -> 94 | present? = !!(Ecto.Changeset.get_change(changeset, field) || changeset.params[to_string(field)]) 95 | 96 | if present? do 97 | Ecto.Changeset.add_error(changeset, field, "cannot be changed", validation: :not_present) 98 | else 99 | changeset 100 | end 101 | end) 102 | end 103 | 104 | defp operator_abbr(:eq), do: :eq 105 | defp operator_abbr(:ne), do: :ne 106 | defp operator_abbr(:gt), do: :gt 107 | defp operator_abbr(:ge), do: :ge 108 | defp operator_abbr(:lt), do: :lt 109 | defp operator_abbr(:le), do: :le 110 | 111 | defp operator_abbr(:equal_to), do: :eq 112 | defp operator_abbr(:not_equal_to), do: :ne 113 | defp operator_abbr(:greater_than), do: :gt 114 | defp operator_abbr(:greater_than_or_equal_to), do: :ge 115 | defp operator_abbr(:less_than), do: :lt 116 | defp operator_abbr(:less_than_or_equal_to), do: :le 117 | 118 | defp comparison_error_message(:eq, value), do: "must be equal to #{to_string value}" 119 | defp comparison_error_message(:ne, value), do: "must be not equal to #{to_string value}" 120 | defp comparison_error_message(:gt, value), do: "must be greater than #{to_string value}" 121 | defp comparison_error_message(:ge, value), do: "must be greater than or equal to #{to_string value}" 122 | defp comparison_error_message(:lt, value), do: "must be less than #{to_string value}" 123 | defp comparison_error_message(:le, value), do: "must be less than or equal to #{to_string value}" 124 | 125 | defp compare(%Time{} = time1, %Time{} = time2), do: Time.compare(time1, time2) 126 | defp compare(%Date{} = date1, %Date{} = date2), do: Date.compare(date1, date2) 127 | defp compare(%DateTime{} = dt1, %DateTime{} = dt2), do: DateTime.compare(dt1, dt2) 128 | defp compare(%NaiveDateTime{} = dt1, %NaiveDateTime{} = dt2), do: NaiveDateTime.compare(dt1, dt2) 129 | 130 | defp compare(number1, number2) when is_number(number1) and is_number(number2) do 131 | cond do 132 | number1 == number2 -> :eq 133 | number1 < number2 -> :lt 134 | true -> :gt 135 | end 136 | end 137 | 138 | @doc ~S""" 139 | Validates a list of values using the given validator. 140 | 141 | ```elixir 142 | changeset = 143 | %Appointment{} 144 | |> Appointment.changeset(%{days_of_week: [1, 3, 8]}) 145 | |> validate_list(:days_of_week, &Ecto.Changeset.validate_inclusion/3, [1..7]) 146 | 147 | assert [days_of_week: {"is invalid", [validation: :list, index: 2, validator: :validate_inclusion]}] = changeset.errors 148 | assert [days_of_week: {:list, [validator: :validate_inclusion]}] = changeset.validations 149 | ``` 150 | """ 151 | def validate_list(changeset, field, validation_fun, validation_fun_args) do 152 | {validation_fun, validation_fun_name} = 153 | if is_atom(validation_fun) do 154 | {capture_function(validation_fun, length(validation_fun_args) + 2), validation_fun} 155 | # + 2 because we must pass the changeset and the field name 156 | else 157 | {:name, validation_fun_name} = Function.info(validation_fun, :name) 158 | {validation_fun, validation_fun_name} 159 | end 160 | 161 | values = Ecto.Changeset.get_change(changeset, field) 162 | 163 | changeset = 164 | if values == nil || values == [] do 165 | changeset 166 | else 167 | ecto_type = type(hd(values)) 168 | 169 | {errors, index} = 170 | Enum.reduce_while(values, {[], -1}, fn value, {_errors, index} -> 171 | data = %{} 172 | types = %{field => ecto_type} 173 | params = %{field => value} 174 | 175 | changeset = Ecto.Changeset.cast({data, types}, params, Map.keys(types)) 176 | changeset = apply(validation_fun, [changeset, field | validation_fun_args]) 177 | 178 | if match?(%Ecto.Changeset{valid?: false}, changeset) do 179 | {:halt, {changeset.errors, index + 1}} 180 | else 181 | {:cont, {[], index + 1}} 182 | end 183 | end) 184 | 185 | case errors do 186 | [] -> 187 | changeset 188 | 189 | [{_field, {message, _meta}}] -> 190 | Ecto.Changeset.add_error(changeset, field, message, validation: :list, index: index, validator: validation_fun_name) 191 | end 192 | end 193 | 194 | %{changeset | validations: [{field, {:list, validator: validation_fun_name}} | changeset.validations]} 195 | end 196 | 197 | defp capture_function(fun_name, args_count), do: Function.capture(Ecto.Changeset, fun_name, args_count) 198 | 199 | defp type(%Time{}), do: :time 200 | defp type(%Date{}), do: :date 201 | defp type(%DateTime{}), do: :utc_datetime 202 | defp type(%NaiveDateTime{}), do: :naive_datetime 203 | defp type(integer) when is_integer(integer), do: :integer 204 | defp type(float) when is_float(float), do: :float 205 | defp type(string) when is_binary(string), do: :string 206 | 207 | @doc ~S""" 208 | Works like `Ecto.Changeset.validate_change/3` but may receive multiple fields. 209 | 210 | The `validator` function receives as argument a keyword list, where the keys are the field 211 | names and the values are the change for this field, or the data.any() 212 | 213 | If one of the fields is `nil`, the `validator` function is not invoked. 214 | """ 215 | def validate_changes(changeset, fields, meta, validator) when is_list(fields) do 216 | fields_values = Enum.map(fields, &{&1, Ecto.Changeset.fetch_field!(changeset, &1)}) 217 | 218 | changeset = 219 | cond do 220 | # none of the values are nil 221 | Enum.all?(fields_values, fn {_, value} -> value != nil end) -> 222 | errors = validator.(fields_values) 223 | 224 | if errors do 225 | Enum.reduce(errors, changeset, fn 226 | {field, {msg, meta}}, changeset -> 227 | Ecto.Changeset.add_error(changeset, field, msg, meta) 228 | 229 | {field, msg}, changeset -> 230 | Ecto.Changeset.add_error(changeset, field, msg) 231 | end) 232 | else 233 | changeset 234 | end 235 | 236 | true -> 237 | changeset 238 | end 239 | 240 | validations = Enum.map(fields, &{&1, meta}) 241 | 242 | %{changeset | validations: validations ++ changeset.validations} 243 | end 244 | 245 | def field_fails_validation?(changeset, field, validations) do 246 | validations = List.wrap(validations) 247 | ensure_field_exists!(changeset, field) 248 | ensure_validations_exist!(changeset, field, validations) 249 | 250 | do_field_fails_validation?(changeset, field, validations) 251 | end 252 | 253 | defp do_field_fails_validation?(%Ecto.Changeset{valid?: true}, _, _), do: false 254 | 255 | defp do_field_fails_validation?(changeset, field, validations) do 256 | errors = get_errors_for_field(changeset, field) 257 | 258 | validations = List.wrap(validations) 259 | 260 | Enum.any?(errors, fn {_key, {_message, meta}} -> 261 | Enum.member?(validations, meta[:validation]) 262 | end) 263 | end 264 | 265 | def field_violates_constraint?(changeset, field, constraints) do 266 | constraints = List.wrap(constraints) 267 | ensure_field_exists!(changeset, field) 268 | ensure_constraints_exist!(changeset, field, constraints) 269 | 270 | do_field_violates_constraint?(changeset, field, constraints) 271 | end 272 | 273 | defp do_field_violates_constraint?(%Ecto.Changeset{valid?: true}, _, _), do: false 274 | 275 | defp do_field_violates_constraint?(changeset, field, constraints) do 276 | errors = get_errors_for_field(changeset, field) 277 | 278 | constraints = List.wrap(constraints) 279 | 280 | Enum.any?(errors, fn {_key, {_message, meta}} -> 281 | Enum.member?(constraints, meta[:constraint]) 282 | end) 283 | end 284 | 285 | defp get_errors_for_field(%Ecto.Changeset{errors: errors}, field) do 286 | Enum.filter(errors, fn {key, _} -> key == field end) 287 | end 288 | 289 | defp ensure_field_exists!(%Ecto.Changeset{types: types}, field) do 290 | unless Map.has_key?(types, field) do 291 | raise ArgumentError, "unknown field `#{inspect field}`" 292 | end 293 | end 294 | 295 | defp ensure_validations_exist!(%Ecto.Changeset{} = changeset, field, validations) do 296 | required? = Enum.member?(changeset.required, field) 297 | 298 | all_validations = 299 | Ecto.Changeset.validations(changeset) 300 | |> Enum.filter(fn {f, _} -> field == f end) 301 | |> Enum.map(fn 302 | {_, validation_tuple} when is_tuple(validation_tuple) -> 303 | elem(validation_tuple, 0) 304 | 305 | {_, validation} when is_atom(validation) -> 306 | validation 307 | end) 308 | 309 | all_validations = 310 | if required? do 311 | [:required] ++ all_validations 312 | else 313 | all_validations 314 | end 315 | 316 | unknown_validations = validations -- all_validations 317 | 318 | if unknown_validations != [] do 319 | [validation | _] = unknown_validations 320 | raise ArgumentError, "unknown validation `#{inspect validation}` for field `#{inspect field}`" 321 | end 322 | end 323 | 324 | defp ensure_constraints_exist!(%Ecto.Changeset{} = changeset, field, constraints) do 325 | all_constraints = 326 | Ecto.Changeset.constraints(changeset) 327 | |> Enum.filter(fn %{field: f} -> field == f end) 328 | |> Enum.map(fn %{error_type: error_type} -> error_type end) 329 | 330 | unknown_constraints = constraints -- all_constraints 331 | 332 | if unknown_constraints != [] do 333 | [constraint | _] = unknown_constraints 334 | raise ArgumentError, "unknown constraint `#{inspect constraint}` for field `#{inspect field}`" 335 | end 336 | end 337 | 338 | def update_assoc_changes(%Ecto.Changeset{} = changeset, keys, changes) when is_map(changes) do 339 | if has_change?(changeset, keys) do 340 | put_assoc(changeset, keys, fn changeset -> Enum.map(changeset, &Ecto.Changeset.change(&1, changes)) end) 341 | else 342 | changeset 343 | end 344 | end 345 | 346 | def has_change?(%Ecto.Changeset{} = changeset, keys) do 347 | do_has_change?(changeset, List.wrap(keys)) 348 | end 349 | 350 | def do_has_change?(%Ecto.Changeset{} = changeset, [key | []]) do 351 | Ecto.Changeset.fetch_change(changeset, key) != :error 352 | end 353 | 354 | def do_has_change?(%Ecto.Changeset{} = changeset, [key | tail_keys]) do 355 | case Map.get(changeset.changes, key) do 356 | nil -> 357 | false 358 | 359 | %Ecto.Changeset{} = changeset -> 360 | do_has_change?(changeset, tail_keys) 361 | 362 | changesets when is_list(changesets) -> 363 | Enum.any?(changesets, &do_has_change?(&1, tail_keys)) 364 | end 365 | end 366 | 367 | def change_assoc(struct_or_changeset, keys) do 368 | change_assoc(struct_or_changeset, keys, %{}) 369 | end 370 | 371 | @doc ~S""" 372 | Returns the nested association in a changeset. This function will first look into the changes and then fails back on 373 | data wrapped in a changeset. 374 | 375 | Changes may be added to the given changeset through the third argument. 376 | 377 | A tuple is returned containing the root changeset, and the changeset of the association. 378 | 379 | ```elixir 380 | {account_changeset, address_changeset} = 381 | change_assoc(account_changeset, [:user, :config, :address], %{street: "Foo street"}) 382 | ``` 383 | """ 384 | def change_assoc(struct_or_changeset, keys, changes) when is_map(changes) do 385 | keys = List.wrap(keys) 386 | 387 | changed_assoc = do_change_assoc(struct_or_changeset, keys, changes) 388 | 389 | { 390 | put_assoc(struct_or_changeset |> Ecto.Changeset.change(), keys, changed_assoc), 391 | changed_assoc 392 | } 393 | end 394 | 395 | def change_assoc(struct_or_changeset, keys, index) when is_integer(index) do 396 | change_assoc(struct_or_changeset, keys, index, %{}) 397 | end 398 | 399 | @doc ~S""" 400 | Returns the nested association in a changeset at the given index. 401 | 402 | A tuple is returned containing the root changeset, the changesets of the association and the changeset at the 403 | specified index. 404 | 405 | See `change_assoc(struct_or_changeset, keys, changes)`. 406 | ``` 407 | """ 408 | def change_assoc(struct_or_changeset, keys, index, changes) when is_integer(index) and is_map(changes) do 409 | keys = List.wrap(keys) 410 | 411 | changed_assoc = do_change_assoc(struct_or_changeset, keys, index, changes) 412 | 413 | { 414 | put_assoc(struct_or_changeset |> Ecto.Changeset.change(), keys, changed_assoc), 415 | changed_assoc, 416 | Enum.at(changed_assoc, index) 417 | } 418 | end 419 | 420 | defp do_change_assoc(changeset, keys, index \\ nil, changes) 421 | 422 | defp do_change_assoc(%Ecto.Changeset{} = changeset, [key | []], nil, changes) do 423 | Map.get(changeset.changes, key, Map.fetch!(changeset.data, key) |> load!(changeset.data)) 424 | |> do_change_assoc(changes) 425 | end 426 | 427 | defp do_change_assoc(%Ecto.Changeset{} = changeset, [key | []], index, changes) do 428 | Map.get(changeset.changes, key, Map.fetch!(changeset.data, key) |> load!(changeset.data)) 429 | |> List.update_at(index, &(do_change_assoc(&1, changes))) 430 | end 431 | 432 | defp do_change_assoc(%{__meta__: _} = schema, [key | []], nil, changes) do 433 | Map.fetch!(schema, key) 434 | |> load!(schema) 435 | |> do_change_assoc(changes) 436 | end 437 | 438 | defp do_change_assoc(%{__meta__: _} = schema, [key | []], index, changes) do 439 | Map.fetch!(schema, key) 440 | |> load!(schema) 441 | |> List.update_at(index, &(do_change_assoc(&1, changes))) 442 | end 443 | 444 | defp do_change_assoc(%Ecto.Changeset{} = changeset, [key | tail_keys], index, changes) do 445 | Map.get(changeset.changes, key, Map.fetch!(changeset.data, key) |> load!(changeset.data)) 446 | |> do_change_assoc(tail_keys, index, changes) 447 | end 448 | 449 | defp do_change_assoc(%{__meta__: _} = schema, [key | tail_keys], index, changes) do 450 | Map.fetch!(schema, key) 451 | |> load!(schema) 452 | |> do_change_assoc(tail_keys, index, changes) 453 | end 454 | 455 | defp do_change_assoc([], _changes), do: [] 456 | 457 | defp do_change_assoc([%{__meta__: _} = schema | tail], changes) do 458 | [Ecto.Changeset.change(schema, changes) | do_change_assoc(tail, changes)] 459 | end 460 | 461 | defp do_change_assoc([%Ecto.Changeset{} = changeset | tail], changes) do 462 | [Ecto.Changeset.change(changeset, changes) | do_change_assoc(tail, changes)] 463 | end 464 | 465 | defp do_change_assoc(%{__meta__: _} = schema, changes) do 466 | Ecto.Changeset.change(schema, changes) 467 | end 468 | 469 | defp do_change_assoc(%Ecto.Changeset{} = changeset, changes) do 470 | Ecto.Changeset.change(changeset, changes) 471 | end 472 | 473 | @doc ~S""" 474 | Puts the given nested association in the changeset through a given list of field names. 475 | 476 | ```elixir 477 | ChangesetHelpers.put_assoc(account_changeset, [:user, :config, :address], address_changeset) 478 | ``` 479 | 480 | Instead of giving a Changeset or a schema as the third argument, a function may also be given receiving the nested 481 | Changeset(s) to be updated as argument. 482 | 483 | ```elixir 484 | ChangesetHelpers.put_assoc(account_changeset, [:user, :articles], 485 | &(Enum.concat(&1, [%Article{} |> Ecto.Changeset.change()]))) 486 | ``` 487 | 488 | In the code above, we add a new empty Article to the articles association (typically done when we want to add a new 489 | article to a form). 490 | """ 491 | def put_assoc(changeset, keys, value) do 492 | do_put_assoc(changeset, List.wrap(keys), value) 493 | end 494 | 495 | @doc ~S""" 496 | Puts the given nested association in the changeset at the given index. 497 | 498 | See `put_assoc(changeset, keys, value)`. 499 | """ 500 | def put_assoc(changeset, keys, index, value) do 501 | do_put_assoc(changeset, List.wrap(keys), index, value) 502 | end 503 | 504 | defp do_put_assoc(changeset, keys, index \\ nil, value_or_fun) 505 | 506 | defp do_put_assoc(changeset, [key | []], nil, fun) when is_function(fun) do 507 | Ecto.Changeset.put_assoc(changeset, key, fun.(do_change_assoc(changeset, [key], %{}))) 508 | end 509 | 510 | defp do_put_assoc(changeset, [key | []], nil, value) do 511 | Ecto.Changeset.put_assoc(changeset, key, value) 512 | end 513 | 514 | defp do_put_assoc(changeset, [key | []], index, fun) when is_function(fun) do 515 | nested_changesets = do_change_assoc(changeset, [key], %{}) 516 | nested_changesets = List.update_at(nested_changesets, index, &(fun.(&1))) 517 | 518 | Ecto.Changeset.put_assoc(changeset, key, nested_changesets) 519 | end 520 | 521 | defp do_put_assoc(changeset, [key | []], index, value) do 522 | nested_changesets = 523 | do_change_assoc(changeset, [key], %{}) 524 | |> List.replace_at(index, value) 525 | 526 | Ecto.Changeset.put_assoc(changeset, key, nested_changesets) 527 | end 528 | 529 | defp do_put_assoc(changeset, [key | tail_keys], index, value_or_fun) do 530 | Ecto.Changeset.put_change( 531 | changeset, 532 | key, 533 | do_put_assoc(do_change_assoc(changeset, [key], %{}), tail_keys, index, value_or_fun) 534 | ) 535 | end 536 | 537 | @doc ~S""" 538 | Fetches the given nested field from changes or from the data. 539 | 540 | While `fetch_change/2` only looks at the current changes to retrieve a value, this function looks at the changes and 541 | then falls back on the data, finally returning `:error` if no value is available. 542 | 543 | For relations, these functions will return the changeset original data with changes applied. To retrieve raw 544 | changesets, please use `fetch_change/2`. 545 | 546 | ```elixir 547 | {:changes, street} = 548 | ChangesetHelpers.fetch_field(account_changeset, [:user, :config, :address, :street]) 549 | ``` 550 | """ 551 | def fetch_field(%Ecto.Changeset{} = changeset, [key | []]) do 552 | Ecto.Changeset.fetch_field(changeset, key) 553 | end 554 | 555 | def fetch_field(%Ecto.Changeset{} = changeset, [key | tail_keys]) do 556 | Map.get(changeset.changes, key, Map.fetch!(changeset.data, key) |> load!(changeset.data)) 557 | |> Ecto.Changeset.change() 558 | |> fetch_field(tail_keys) 559 | end 560 | 561 | @doc ~S""" 562 | Same as `fetch_field/2` but returns the value or raises if the given nested key was not found. 563 | 564 | ```elixir 565 | street = ChangesetHelpers.fetch_field!(account_changeset, [:user, :config, :address, :street]) 566 | ``` 567 | """ 568 | def fetch_field!(%Ecto.Changeset{} = changeset, keys) do 569 | case fetch_field(changeset, keys) do 570 | {_, value} -> 571 | value 572 | 573 | :error -> 574 | raise KeyError, key: keys, term: changeset.data 575 | end 576 | end 577 | 578 | @doc ~S""" 579 | Fetches a nested change from the given changeset. 580 | 581 | This function only looks at the `:changes` field of the given `changeset` and returns `{:ok, value}` if the change is 582 | present or `:error` if it's not. 583 | 584 | ```elixir 585 | {:ok, street} = 586 | ChangesetHelpers.fetch_change(account_changeset, [:user, :config, :address, :street]) 587 | ``` 588 | """ 589 | def fetch_change(%Ecto.Changeset{} = changeset, [key | []]) do 590 | Ecto.Changeset.fetch_change(changeset, key) 591 | end 592 | 593 | def fetch_change(%Ecto.Changeset{} = changeset, [key | tail_keys]) do 594 | case Map.get(changeset.changes, key) do 595 | nil -> 596 | :error 597 | 598 | changeset -> 599 | fetch_change(changeset, tail_keys) 600 | end 601 | end 602 | 603 | @doc ~S""" 604 | Same as `fetch_change/2` but returns the value or raises if the given nested key was not found. 605 | 606 | ```elixir 607 | street = ChangesetHelpers.fetch_change!(account_changeset, [:user, :config, :address, :street]) 608 | ``` 609 | """ 610 | def fetch_change!(%Ecto.Changeset{} = changeset, keys) do 611 | case fetch_change(changeset, keys) do 612 | {:ok, value} -> 613 | value 614 | 615 | :error -> 616 | raise KeyError, key: keys, term: changeset.changes 617 | end 618 | end 619 | 620 | @doc ~S""" 621 | This function allows checking if a given field is different between two changesets. 622 | 623 | ```elixir 624 | {street_changed, street1, street2} = 625 | diff_field(account_changeset, new_account_changeset, [:user, :config, :address, :street]) 626 | ``` 627 | """ 628 | def diff_field(%Ecto.Changeset{} = changeset1, %Ecto.Changeset{} = changeset2, keys) do 629 | field1 = fetch_field!(changeset1, keys) 630 | field2 = fetch_field!(changeset2, keys) 631 | 632 | {field1 != field2, field1, field2} 633 | end 634 | 635 | @doc ~S""" 636 | Adds an error to the nested changeset. 637 | 638 | ```elixir 639 | account_changeset = 640 | ChangesetHelpers.add_error(account_changeset, [:user, :articles, :error_key], "Some error") 641 | ``` 642 | """ 643 | def add_error(%Ecto.Changeset{} = changeset, keys, message, extra \\ []) do 644 | reversed_keys = keys |> Enum.reverse() 645 | last_key = hd(reversed_keys) 646 | keys_without_last = reversed_keys |> tl() |> Enum.reverse() 647 | 648 | {_, nested_changes} = change_assoc(changeset, keys_without_last) 649 | 650 | nested_changes = do_add_error(nested_changes, last_key, message, extra) 651 | 652 | ChangesetHelpers.put_assoc(changeset, keys_without_last, nested_changes) 653 | end 654 | 655 | defp do_add_error(nested_changes, key, message, extra) when is_list(nested_changes) do 656 | Enum.map(nested_changes, &(Ecto.Changeset.add_error(&1, key, message, extra))) 657 | end 658 | 659 | defp do_add_error(nested_changes, key, message, extra) do 660 | Ecto.Changeset.add_error(nested_changes, key, message, extra) 661 | end 662 | 663 | defp load!(%Ecto.Association.NotLoaded{} = not_loaded, %{__meta__: %{state: :built}}) do 664 | case cardinality_to_empty(not_loaded.__cardinality__) do 665 | nil -> 666 | Ecto.build_assoc(struct(not_loaded.__owner__), not_loaded.__field__) 667 | 668 | [] -> 669 | [] 670 | end 671 | end 672 | 673 | defp load!(%Ecto.Association.NotLoaded{__field__: field}, struct) do 674 | raise "attempting to change association `#{field}` " <> 675 | "from `#{inspect struct.__struct__}` that was not loaded. Please preload your " <> 676 | "associations before manipulating them through changesets" 677 | end 678 | 679 | defp load!(loaded, _struct) do 680 | loaded 681 | end 682 | 683 | defp cardinality_to_empty(:one), do: nil 684 | defp cardinality_to_empty(:many), do: [] 685 | end 686 | --------------------------------------------------------------------------------