Differences in Model Validation and Saving in Rails and Ecto
Rails is a popular Ruby Web Application Framework
Ecto is a DSL (Domain Specific Language) for database adaptors in Elixir.
Rails has a number of sub-frameworks embedded in it. Each one prefixed with “Active,” for example: ActiveSupport. Here our concern is with how models (since rails is an MVC Framework) integrate with our database or datastore. The relevant frameworks are ActiveModel and ActiveRecord.
Phoenix is Rails’ counterpart in the Elixir world, however using Ecto without Phoenix is commonplace whereas using Rails gems without Rails is rare.
Both Ecto and Rails have a concept of a Model as well as ways to initialize, validate, and save those models. A model is, simply put, a representation of the data object in terms native to the programming language/framework rather than the datastore’s. For our purposes, this means Ruby or Elixir rather than SQL.
In Rails, models are objects. Each model class has an initialize (new) method and each instance has valid?
and save
methods. A typical flow would be initializing a model, updating its attributes, then finally: saving it.
In Ecto, instead of initializing a model as an object, we deal with changesets. To create an instance of a model, first we generate a changeset, then validate it, then save it.
Rails example:
irb(main):004:0> u = User.new
irb(main):005:0> u.name = 'Johnny Knuckles'
=> "Johnny Knuckles"
irb(main):006:0> u.email = 'example@example.com'
=> "example@example.com"
irb(main):007:0> u.save
(0.2ms) BEGIN
User Exists (15.2ms) SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2 [["email", "example@example.com"], ["LIMIT", 1]]
(0.3ms) ROLLBACK
=> false
irb(main):008:0> u.errors
=> #<ActiveModel::Errors:0x00007ffdeb3c0758 @base=#, @messages={:password=>["can't be blank", "is too short (minimum is 6 characters)"]}, @details={:password=>[{:error=>:blank}, {:error=>:blank}, {:error=>:too_short, :count=>6}]}>
irb(main):009:0> u.password = 'password'
=> "password"
irb(main):010:0> u.save
(0.3ms) BEGIN
User Exists (0.6ms) SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2 [["email", "example@example.com"], ["LIMIT", 1]]
SQL (0.9ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at", "password_digest") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["name", "Johnny Knuckles"], ["email", "example@example.com"], ["created_at", "2018-01-30 18:12:06.919501"], ["updated_at", "2018-01-30 18:12:06.919501"], ["password_digest", "$2a$10$cvkjsdJLqVcowDevITULW.Y3WWM5xqHI3z6axd1jH3oUbXO9sKdzG"]]
(2.2ms) COMMIT
=> true
The difference between the two approaches is largely functional. While in Rails, attributes are modified directly on an instance; in Ecto, attributes are modified differently.
Ecto example:
iex(1)> alias PhoenixTutorial.Repo
PhoenixTutorial.Repo
iex(2)> alias PhoenixTutorial.User
PhoenixTutorial.User
iex(3)> User.changeset(%User{}, %{ name: "Johnny Knuckles", email: "example@example.com"}) |> Repo.insert! ** (Ecto.InvalidChangesetError) could not perform insert because changeset is invalid.
Applied changes
%{email: "example@example.com", name: "Johnny Knuckles"}
Params
%{"email" => "example@example.com", "name" => "Johnny Knuckles"}
Errors
%{password: [{"can't be blank", [validation: :required]}]}
Changeset
#Ecto.Changeset<action: :insert,
changes: %{email: "example@example.com", name: "Johnny Knuckles"},
errors: [password: {"can't be blank", [validation: :required]}],
data: #PhoenixTutorial.User, valid?: false>
(ecto) lib/ecto/repo/schema.ex:128: Ecto.Repo.Schema.insert!/4
iex(3)> User.changeset(%User{}, %{ name: "Johnny Knuckles", email: "example@example.com", password: "password"}) |> Repo.insert!
[debug] QUERY OK db=5.6ms
INSERT INTO "users" ("email","name","password_hash","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5) RETURNING "id" ["example@example.com", "Johnny Knuckles", "$2b$12$9yvtX56n5pd.Lrg1hfSJKO50vXEF6pvJ12ggr/bPzgWntpqz4/BCK", {{2018, 1, 31}, {20, 24, 34, 858344}}, {{2018, 1, 31}, {20, 24, 34, 858353}}]
%PhoenixTutorial.User{meta: #Ecto.Schema.Metadata,
email: "example@example.com", id: 1,
inserted_at: ~N[2018-01-31 20:24:34.858344], name: "Johnny Knuckles",
password: "password",
password_hash: "$2b$12$9yvtX56n5pd.Lrg1hfSJKO50vXEF6pvJ12ggr/bPzgWntpqz4/BCK",
updated_at: ~N[2018-01-31 20:24:34.858353]}
As you may have noticed in the Ecto example, we are creating changesets instead of applying changes directly to the model instance like we did in Rails.
iex(3)> changeset = User.changeset(%User{}, %{name: "Johnny Knuckles", email: "example@example.com"})
#Ecto.Changeset<action: nil,
changes: %{email: "example@example.com", name: "Johnny Knuckles"},
errors: [password: {"can't be blank", [validation: :required]}],
data: #PhoenixTutorial.User, valid?: false>
Changesets are an extra level of abstraction between changes and the modification of the database. A changeset is, like it sounds, a set of changes. However, we are able to do validations on the changeset itself, instead of an instance of the model. Like model instances however, changesets have knowledge of validations. Unlike model instances though, validations act as functions that take in a changeset and return another changeset. It is possible, therefore, to have much more intricate chains of functions. For instance, we can check whether a password is valid then hash it before saving it all in our validation chain. One can imagine much more complex setups as well since we can shoehorn arbitrary code into the validation process instead of trying to do complex transformations beforehand or juggling model instances.