Ruby
正規表現
RubyOnRails
Validation
Railsチュートリアル

Ruby on Rails チュートリアル 第6章 Validation・DB(SQLite)・パーシャルやヘルパーの使い方を解説

前回の続き

著者略歴

YUUKI

ポートフォリオサイト:Pooks

RailsTutorial2周目


ユーザーのモデルを作成する

第5章で作成した新規ユーザー作成のスタブページをカスタマイズ、データモデルの作成やデータ保存まで行う。

Webアプリケーションには欠かせないログイン/認証システムの実装手段は数多くあり、例えばClearanceやAuthlogicなどを使えば簡単に実装できるが、この章ではあえてRailsでゼロから作成する。

その理由としては、ゼロから作成して理解した方が、サードパーティ製品を導入することになっても容易にカスタマイズできるし、そもそも自分で認証システムを実装した経験があった方が全般的にシステムを理解できる為である。


Userモデル

Railsではデータモデルとして扱うデフォルトのデータ構造のことをModelと呼ぶ。

MVCアーキテクチャのMに当たる部分。

Railsではデータを永続化するデフォルトの解決策として、DBを使ってデータを長期化する。

Railsでは、データベースとやりとりをするActive Record(デフォルトのRailsライブラリ)という概念があるので、SQLを意識する必要が殆どない。

また、データの定義をマイグレーションと呼ばれる機能でRubyに記述する為、新たにDDLを学ぶ必要もない。

つまり、Railsではデータベースの細部をほぼ完全に隠蔽し、切り離してくれる。

Tutorialでは、SQliteをdevelopmentで使い、PostgreSQLをproductionで使う。

ゆえに、本番環境のデータの保存方法を詳細に考える必要が殆どない。


データベースの移行

4章で扱ったカスタムビルドクラスのUserではRailsの重要な部分である永続性と言う要素が欠けていた。

Rails ConsoleでUserクラスのオブジェクトを作成してもexitすれば消えてしまう。

今回は簡単に消えることのないユーザーのモデルを構築する。

まずはnameとemailという属性を持つユーザーをモデリングする。

emailを一意のユーザー名として扱う。

class User

attr_accessor :name, :email

end

attr_accessorはインスタンス変数の定義で、ここではnameemailと言うインスタンス変数を定義している。

が、これはRubyでの書き方である。

Railsはデータを保存する際にデフォルトでRDB(リレーショナルデータベース)を使う。

例えば、nameとemailと言う属性を持つユーザーを保存する場合、nameemailのカラムを持つusersテーブルを作成する。

テーブルに格納されるデータの例がこれ

image.png

出典:図 6.2: usersテーブルに含まれるデータのサンプル

対応するデータモデルはこれ

image.png

出典:図 6.3: Userのデータモデルのスケッチ

今回はrails g modelコマンドでモデルを作成する。

$ rails g model User name:string email:string

Running via Spring preloader in process 22586
invoke active_record
create db/migrate/20181217232230_create_users.rb
create app/models/user.rb
invoke test_unit
create test/models/user_test.rb
create test/fixtures/users.yml

ここで重要なのは、以下の二点。


  • モデル名は先頭が大文字の単数形(User)を用いていること

  • name属性とemail属性にデータ型がstring(文字列) のパラメータを渡している。

コントローラ生成時にはUserをusersと定義したが、モデル生成時にはUserと定義し、さらに二つの属性にオプションでstringパラメータを渡している。

ここで、generateコマンドの結果としてマイグレーションファイルが生成される。

マイグレーションは、DBの構造をインクリメンタルに変更する手段を提供する。

要求が変更された場合にデータモデルを適合させることができる為、一度設定しておけば良い。

なお、マイグレーションファイルはモデル生成スクリプトによって自動的に作られる。


[time_stamp]_create_users.rb

class CreateUsers < ActiveRecord::Migration[5.1]

def change
create_table :users do |t|
t.string :name
t.string :email

t.timestamps
end
end
end


このような記述が自動がされているが、ファイル生成時のファイル名の数値に注目。

これはファイル生成の発生日時を記録するtimestampsによって記録されている。

timestampsのお陰で全く同時にマイグレーションが生成されることが起きずに、コンフリクトを回避できる。

マイグレーション自体は、データーベースに与える変更を定義したchangeメソッドの集まり。

上記のコードでは、changeメソッドがcreate_tableというRailsメソッドを呼んで、ユーザーを保存するためのテーブルをDBに作成する。

さらに、create_tableメソッドはブロック変数を一つ持つtブロックを受け取り、

tオブジェクトを使ってnameemailカラムをDBに生成。

データ型はどちらもstring。

なお、テーブル名はクラス名と違い、複数形のusersを使っている(これはRailsの言葉の慣習である)

複数形を使う理由は、モデルは一人のユーザーを表すのに対し、

DBのテーブルは複数のユーザーから構成されるから。

さらに、t.timestampsでマジックカラム(created_atとupdated_atの両カラム)が作成される。

上記のマイグレーションによって作成された完全なデータモデルがこれ

image.png

出典:図 6.4: リスト 6.2で生成されたUserのデータモデル

ここで、マイグレーションに変更を反映する為に、migrating upを行う。

$ rails db:migrate

== 20181217232230 CreateUsers: migrating ======================================
-- create_table(:users)
-> 0.0044s
== 20181217232230 CreateUsers: migrated (0.0052s) =============================

これで、usersテーブルが作成される。

このコマンドを実行すると、db/development.sqlite3という名前のファイルが生成される。

これはSQliteデータベースの実体である。

ここで、DB Browser for SQliteというツールを使って、DBの構造を見てみる。

スクリーンショット 2018-12-18 9.03.52.png

ここにあるidは、レコードを一意に識別する為のカラムである。


演習

1:schecma.rbというDB構造の追跡ファイルとマイグレーションファイルを見比べる。


schema.rb

ActiveRecord::Schema.define(version: 20181217232230) do

create_table "users", force: :cascade do |t|
t.string "name"
t.string "email"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end

end



[timestamp]_create_users.rb

class CreateUsers < ActiveRecord::Migration[5.1]

def change
create_table :users do |t|
t.string :name
t.string :email

t.timestamps
end
end
end


ちょっとまだ一部のコードの意味がわからんw

2:マイグレーションファイルをロールバックしてみる。

$ rails db:rollback

== 20181217232230 CreateUsers: reverting ======================================
-- drop_table(:users)
-> 0.0043s
== 20181217232230 CreateUsers: reverted (0.0076s) =============================

schema.rbの中身を確認。


schema.rb

ActiveRecord::Schema.define(version: 0) do

end


中身が消えている。

これは、db:rollbackコマンドでdrop_tableコマンドを内部で呼び出し、usersテーブルを削除したため。

要はrails db:migrate実行前に戻したのでこうなる。

ちなみに、これが上手く行くのはdrop_tablecreate_tableがそれぞれ対応していることをchangeメソッドが知っているから。

この対応関係を知っているため、ロールバック用の逆方向をのマイグレーションを簡単に実現できる。

なお、カラムを削除するような不可逆なマイグレーションの場合、changeメソッドの代わりにupとdownメソッドを別々に定義する必要がある。

3:やり直す

$ rails db:migrate

schema.rbの内容が戻っていればOK。


modelファイル

ここでは作成されたuser.rbというモデルのコードを見ていく。


user.rb

class User < ApplicationRecord

end

UserクラスはApplicationRecordを継承していることがわかる。

さらに、ApplicationRecordはActiveRecord::Baseという基本クラスを継承していることになる。


演習

1:RailsConsoleでUser.newでuserクラスのオブジェクトを生成、そのオブジェクトがApplicationRecordを継承していることを確認

2:さらにApplicationRecordがActiveRecord::Baseを継承していることを確認。

>> User.new

=> #<User id: nil, name: nil, email: nil, created_at: nil, updated_at: nil>
>> User
=> User(id: integer, name: string, email: string, created_at: datetime, updated_at: datetime)
>> User.class
=> Class
>> User.superclass
=> ApplicationRecord(abstract)
>> ApplicationRecord.superclass
=> ActiveRecord::Base
>>

OK。


ユーザーオブジェクトを作成する

今回はRails Consoleをsandboxモードで起動する。

$ rails c --sandbox

今回はUserモデルを生成している。

なので、Railsコンソールの起動時にRailsの環境を自動的に読み込んだ際に、その環境にはモデルも含まれるので、新しいユーザーオブジェクトを作成する時に余分な作業を行わずに済む。

$ User.new

<User id: nil, name: nil, email: nil, created_at: nil, updated_at: nil, password_digest: nil, remember_digest: nil, admin: false, activation_digest: nil, activated: false, activated_at: nil, reset_digest: nil, reset_sent_at: nil>

User.newを引数なしで呼んだ場合、全ての属性がnilのオブジェクトを返す。

以前、オブジェクトの属性を設定するための初期化ハッシュ(name: "M"〜の部分)を引数に取るように、user_example.rbにてUserクラスの例を設計した。この設計は、同様の方法でオブジェクトを初期化するActive Recordの設計に基づいている。

user = User.new(name: "Michael Hartl", email: "mhartl@example.com")

=> #<User id: nil, name: "Michael Hartl", email: "mhartl@example.com", created_at: nil, updated_at: nil, password_digest: nil, remember_digest: nil, admin: false, activation_digest: nil, activated: false, activated_at: nil, reset_digest: nil, reset_sent_at: nil>

こうしてみると、nameとemail属性が設定されていることが分かる。

Active Recordを理解する上で、有効性(Validity)という概念も重要。

例えば、先ほどのuserオブジェクトが有効かどうか確認してみる。

確認の為にvalid?メソッドを使う。

#<User id: nil, name: "Michael Hartl", email: "mhartl@example.com", created_at: nil, updated_at: nil>

>> user.valid?
=> true

現時点でtrueが返ってくるのはuserオブジェクトが有効だから(メモリ上でオブジェクトを作成しただけ)で、

データベースにデータがあるかどうかは有効性には関係ない

DBにUserオブジェクトを保存する為には、Userオブジェクトからsaveメソッドを呼び出す。

>> user.save

(0.1ms) begin transaction
SQL (8.8ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["name", "Michael Hartl"], ["email", "mhartl@example.com"], ["created_at", "2018-12-18 23:40:49.783353"], ["updated_at", "2018-12-18 23:40:49.783353"]]
(4.6ms) commit transaction
=> true
>>

saveメソッドは成功すればtrueを、失敗すればfalseを返す。

RailsConsole上ではuser.saveに対応するSQLコマンドやその結果INSERT INTO "users"も表示するようになっている。

作成した時点でのユーザーオブジェクトはid属性、マジックカラムのcreated_at属性・updated_at属性の値がいずれもnilであったが、saveメソッド後には

#<User id: 2, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-20 01:10:31", updated_at: "2018-12-20 01:10:31">

idに2という値が代入され、マジックカラムには現在の日時が代入されている。

これを更新すると値が変化する。

さらに、Userクラスと同様、Userモデルのインスタンスはドット記法でその属性にアクセスが可能。

>> user.name

=> "Michael Hartl"
>> user.email
=> "mhartl@example.com"
>> user.updated_at
=> Thu, 20 Dec 2018 01:10:31 UTC +00:00

モデルの生成と保存を二つのステップに分けておくと何かと便利だが、

Active RecordではUser.createでモデルの生成と保存を同時に行う方法もある。

>> User.create(name: "A Nother", email: "another@example.org")

(0.1ms) SAVEPOINT active_record_1
SQL (0.2ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["name", "A Nother"], ["email", "another@example.org"], ["created_at", "2018-12-20 01:31:40.553178"], ["updated_at", "2018-12-20 01:31:40.553178"]]
(0.1ms) RELEASE SAVEPOINT active_record_1
=> #<User id: 3, name: "A Nother", email: "another@example.org", created_at: "2018-12-20 01:31:40", updated_at: "2018-12-20 01:31:40">

User.createでは、論理値のtrueかfalseを返す代わりに、

ユーザーオブジェクト自身を返す点に注目

返されたオブジェクトは、例えばfoo = User.createのように変数に代入することも可能。

destroyはcreateの逆。

>> foo = User.create(name: "Foo", email: "foo@bar.com")

(0.1ms) SAVEPOINT active_record_1
SQL (0.1ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["name", "Foo"], ["email", "foo@bar.com"], ["created_at", "2018-12-20 01:34:49.287577"], ["updated_at", "2018-12-20 01:34:49.287577"]]
(0.0ms) RELEASE SAVEPOINT active_record_1
=> #<User id: 4, name: "Foo", email: "foo@bar.com", created_at: "2018-12-20 01:34:49", updated_at: "2018-12-20 01:34:49">

>> foo.destroy
(0.1ms) SAVEPOINT active_record_1
SQL (0.1ms) DELETE FROM "users" WHERE "users"."id" = ? [["id", 4]]
(0.0ms) RELEASE SAVEPOINT active_record_1
=> #<User id: 4, name: "Foo", email: "foo@bar.com", created_at: "2018-12-20 01:34:49", updated_at: "2018-12-20 01:34:49">

createと同じように、destroyはそのオブジェクト自身を返す。

しかし、その戻り値を使ってもう一度destroyを呼ぶことはできない。

さらに、削除されたオブジェクトはまだメモリ上に残っている。

>> foo

=> #<User id: 4, name: "Foo", email: "foo@bar.com", created_at: "2018-12-20 01:34:49", updated_at: "2018-12-20 01:34:49">

ここで二つの疑問。

- オブジェクトが本当に削除されたかどうかどのようにして知ればいいのか

- 保存して削除されていないオブジェクトの場合、どうやってデータベースからユーザーを取得するのか

これらは次項で説明する。


演習

1:user.nameとuser.emailがどちらもStringクラスのインスタンスであることを確認

>> user.name.class

=> String
>> user.email.class
=> String

2:create_atとupdated_atは、どのクラスのインスタンスか

>> foo.created_at.class

=> ActiveSupport::TimeWithZone
>> foo.updated_at.class
=> ActiveSupport::TimeWithZone


ユーザーオブジェクトを検索する

まずはid1のユーザーを探してみる。

>> User.find(1)

User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-18 23:40:49", updated_at: "2018-12-18 23:40:49">

ここでは、User.findにユーザーid1を渡してオブジェクトを返している。

次に、id=4のユーザーがまだデータベースに存在するかどうか確認してみる。

>> User.find(4)

User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]]
ActiveRecord::RecordNotFound: Couldn't find User with 'id'=4
from (irb):44

foo.destroyでid=4のユーザーを削除したので、ActiveRecordはこのユーザーをデータベースの中から見つけることができなかった。

代わりに、findメソッドはexceptionを発生する。

(exception = 例外)

ActiveRecord::RecordNotFound: Couldn't find User with 'id'=4

findでActiveRecord::RecordNot ~ が返ってきてる。

これがexceptionである。例外的なイベントが発生した為 find Userできないという例外が発生した模様。

また、findメソッド以外に、Active Recordには特定の属性でユーザーを検索する方法もある。

>> User.find_by(email: "mhartl@example.com")

User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."email" = ? LIMIT ? [["email", "mhartl@example.com"], ["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-18 23:40:49", updated_at: "2018-12-18 23:40:49">

このように、Userモデルにfind_byメソッドでemail属性と値を渡すことで、この値を持ったユーザーをDBから探すことができる。

ちなみに、このメソッドはユーザーをサイトにログインさせる方法を学ぶ時に役立つらしい。

また、ユーザー数が膨大になった際に特定の属性値で検索するfind_byメソッドでは効率が悪いと危惧するかも知れないが、この問題、そしてデータベースのインデックスを使った解決策については後に取り上げる。

他にユーザーを検索するには、firstメソッドやallメソッドなどがある。

>> User.first

User Load (0.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-18 23:40:49", updated_at: "2018-12-18 23:40:49">
>> User.all
User Load (0.1ms) SELECT "users".* FROM "users" LIMIT ? [["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-18 23:40:49", updated_at: "2018-12-18 23:40:49">, #<User id: 2, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-20 01:10:31", updated_at: "2018-12-20 01:10:31">, #<User id: 3, name: "A Nother", email: "another@example.org", created_at: "2018-12-20 01:31:40", updated_at: "2018-12-20 01:31:40">]>

firstメソッドではデータベースの最初のユーザーを、

allメソッドでは全てのUserオブジェクトが返ってくる。

ここで、返ってきたオブジェクトのクラスがActiveRecord::Relationとなっている点に注目。

これは、各オブジェクトを配列として効率的にまとめてくれるクラスである


演習

1:nameを使ってユーザーオブジェクトを検索してみる。また、find_by_nameメソッドが使えるか確認。

>> User.find_by(name: "Michael Hartl")

User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."name" = ? LIMIT ? [["name", "Michael Hartl"], ["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-18 23:40:49", updated_at: "2018-12-18 23:40:49">
>> User.find_by_name("Michael Hartl")
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."name" = ? LIMIT ? [["name", "Michael Hartl"], ["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-18 23:40:49", updated_at: "2018-12-18 23:40:49">

2:User.allが配列でないことを確かめる為に、User.alで生成されるオブジェクトを調べ、ArrayクラスではなくUser::ActiveRecord_Relationクラスであることを確認する。

>> User.all.class

=> User::ActiveRecord_Relation

3:User.all.lengthで長さを求めてみる。

>> User.all.length

User Load (0.2ms) SELECT "users".* FROM "users"
=> 3

ん?lengthメソッドは文字列を返すんじゃなかったけ・・・

ということはallメソッドでは一つ一つのユーザーが文字列として認識されているということか?

さっきの演習でUser.allは配列ではないことを確かめたよね?

つまり、配列じゃない1,2,3のユーザーを3と数えたということか?

User.allはActiveRedcord_Relationクラスだから、このクラスについて詳しく調べる必要があるな・・・。

・・・このようにクラスを詳しく知らなくてもなんとなくオブジェクトをどう扱えば良いかわかる

Rubyの性質をダックタイピングと呼ぶらしい。


ユーザーオブジェクトを更新する

オブジェクトの更新方法は基本的に二つ。

1つ目

>> user = User.find(1)

User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-18 23:40:49", updated_at: "2018-12-18 23:40:49">
>> user
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-18 23:40:49", updated_at: "2018-12-18 23:40:49">
>> user.email = "mhartl@example.net"
=> "mhartl@example.net"
>> user.save
(0.1ms) SAVEPOINT active_record_1
SQL (0.1ms) UPDATE "users" SET "email" = ?, "updated_at" = ? WHERE "users"."id" = ? [["email", "mhartl@example.net"], ["updated_at", "2018-12-20 03:30:53.585378"], ["id", 1]]
(0.0ms) RELEASE SAVEPOINT active_record_1
=> true

この方法ではuser.email = "メールアドレス"で属性を個別に代入している。

そのあと、user.saveでデータベースに保存している。

なお、保存を行わずにreloadを実行すると

>> user.email

=> "mhartl@example.net"
>> user.email = "foo@bar.com"
=> "foo@bar.com"
>> user.reload.email
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> "mhartl@example.net"

データベースの情報を元にオブジェクトを再読み込みするので、変更が取り消される。

話を戻すが、user.saveを実行したことで、ユーザーが更新できた。

>> user.created_at

=> Tue, 18 Dec 2018 23:40:49 UTC +00:00
>> user.updated_at
=> Thu, 20 Dec 2018 03:30:53 UTC +00:00

saveしたことでマジックカラムの更新日時も更新されていることを確認できた。

2つ目の属性更新方法は、updated_attributesを使う

>> user.update_attributes(name: "The Dude",email: "dude@abides.org")

(0.1ms) SAVEPOINT active_record_1
SQL (0.2ms) UPDATE "users" SET "email" = ?, "updated_at" = ?, "name" = ? WHERE "users"."id" = ? [["email", "dude@abides.org"], ["updated_at", "2018-12-20 03:36:10.863098"], ["name", "The Dude"], ["id", 1]]
(0.0ms) RELEASE SAVEPOINT active_record_1
=> true
>> user.name
=> "The Dude"
>> user.email
=> "dude@abides.org"

update_attributesメソッドは属性のハッシュを受け取り、

成功時には更新と保存を続けて同時に行う。(trueを返す)

ただし、検証(valid?)に一つでも失敗すると、update_attributesの呼び出しは失敗する。

例えば、password保存の要求でバリデーションを設定していると、保存ができない時にupdate_attributesの呼び出しが失敗する。

特定の属性のみを更新したい場合は、次のようにupdate_attributesを使う。

>> user.update_attribute(:name, "EI Duderino")

(0.1ms) SAVEPOINT active_record_1
SQL (0.6ms) UPDATE "users" SET "updated_at" = ?, "name" = ? WHERE "users"."id" = ? [["updated_at", "2018-12-20 03:59:17.171879"], ["name", "EI Duderino"], ["id", 1]]
(0.1ms) RELEASE SAVEPOINT active_record_1
=> true
>> user.name
=> "EI Duderino"


演習

1:userオブジェクトへの代入を使ってname属性を更新し、saveで保存

>> user.name = "YUUKI"

=> "YUUKI"
>> user.save
(0.1ms) SAVEPOINT active_record_1
SQL (0.1ms) UPDATE "users" SET "updated_at" = ?, "name" = ? WHERE "users"."id" = ? [["updated_at", "2018-12-20 04:00:12.477014"], ["name", "YUUKI"], ["id", 1]]
(0.1ms) RELEASE SAVEPOINT active_record_1
=> true

2:update_attributesを使って、email属性を更新お呼び保存

>> user.update_attribute(:email, "yuuki@foo.com")

(0.1ms) SAVEPOINT active_record_1
SQL (0.1ms) UPDATE "users" SET "email" = ?, "updated_at" = ? WHERE "users"."id" = ? [["email", "yuuki@foo.com"], ["updated_at", "2018-12-20 04:01:56.781841"], ["id", 1]]
(0.0ms) RELEASE SAVEPOINT active_record_1
=> true

sを抜く点に注意(update_attributes付きは複数の属性値を変更する場合に使う)

(問題文にはs付きで書いてあるけど)

3:マジックカラムであるcreated_atも直接更新できることを確認する。

>> user.update_attribute(:created_at, 1.year.ago)

(0.1ms) SAVEPOINT active_record_1
SQL (0.1ms) UPDATE "users" SET "created_at" = ?, "updated_at" = ? WHERE "users"."id" = ? [["created_at", "2017-12-20 04:04:31.211527"], ["updated_at", "2018-12-20 04:04:31.212291"], ["id", 1]]
(0.0ms) RELEASE SAVEPOINT active_record_1
=> true

1.year.agoは現在の時刻から1年前の時間を算出するRails特有の時間指定方法。


ユーザーを検証する

作成したUserモデルにアクセス可能なnameとemail属性が与えられた。

(attr_accessorメソッドで:nameと:emailというインスタンス変数を定義した)

この二つに値を渡して使うのだが、現在はどんな値でも受け取れてしまう為、ある程度の制限を付ける必要がある。

例えば、nameに値を渡す際空にしてはいけないとか、メールアドレスはフォーマットに従うとか、制限を設ける。

特にメールアドレスは一意のユーザー名として扱うので、データベース内で重複が起きないようにする必要がある

Active Recordでは、Validation(検証)という機能を通して制約を課す。

ここでは


  • presence(存在性)

  • length(長さ)

  • format(フォーマット)

  • uniqueness(一意性)

のValidationを行う。


有効性を検証する

テスト駆動開発とモデルのバリデーション機能はピッタシの機能と言えるので、従来通りテストの失敗→成功といった流れで書いていく。

具体的なテスト方法は

①有効なモデルのオブジェクトを作成

②属性のうちの1つを有効でない属性に意図的に変更する

③バリデーションで失敗するかどうかテストする

念のため、最初に作成時の状態に対してもテストを書いておき、最初のモデルが有効であるかを確認する。

(こうすることで、バリデーションの実装に問題があったのか、オブジェクトそのものに問題があったのかを確認できる)

まずはUserモデルのテストw確認。


user_test.rb

require 'test_helper'

class UserTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end


最初は有効なオブジェクトに対してテストを書くため、setupメソッドを使い、その中に有効なUserオブジェクト@userを作成する。

これはインスタンス変数だが、setupメソッド内で宣言しておけば、

全てのテスト内でこのインスタンス変数が使えるようになる

したがって、valid?メソッドを使ってUserオブジェクトの有効性をテストできる。


user_test.rb

  def setup

@user = User.new(name: "Example User", email: "user@example.com")
end

test "should be valid" do
assert @user.valid? #@user.valid?がtrueを返すと成功、falseを返すと失敗
end


このテストではシンプルなassertメソッドを使ってテストする。

@userはまだバリデーションを設定しておらず、有効なので成功する。

$ rails t

1 tests, 1 assertions, 0 failures, 0 errors, 0 skips

ここで、モデルのみテストを走らせてみる。

 $ rails test:models

1 tests, 1 assertions, 0 failures, 0 errors, 0 skips

結果は同じ。なぜなら、先ほどのテストはguardの自動テストで保存したファイルのみテストを走らせたから。


演習

1:コンソールから新しく生成したuserオブジェクトか有効(valid)かどうか確認。

>> @user = User.new

=> #<User id: nil, name: nil, email: nil, created_at: nil, updated_at: nil>
>> @user.valid?
=> true

2:以前生成したuserオブジェクトも有効かどうか検証

>> User.find(1).valid?

User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> true
>> User.find(2).valid?
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
=> true

以前生成した2つのユーザー。どっちもtrue。


存在性を検証する

最も基本的なバリデーションは存在性

Presenceという名で、こういったバリデーションのメソッドをバリデーションヘルパーと言う。

例えば、今回はユーザーがデータベースに保存される前にnameとemailフィールドの両方が存在することを保証する。

(つまり、存在=true)

まずは、先ほどのUserモデルのテストファイルに、name属性の存在性に関するテストを追加する。

  test "name should be present" do

@user.name = " "
assert_not @user.valid? #@userが有効でなくなったことを確認(@userが無効なら成功、有効なら失敗)
end

@userは有効なのでテストは失敗

2 tests, 2 assertions, 1 failures, 0 errors, 0 skips

name属性の存在を検査する方法は、

validatesメソッドにpresence: trueという引数を与えること

validates :name, presence: true

presence: trueという引数は、要素が1つのオプションハッシュ。

つまり、presenceヘルパーでtrueを指定したことで、validatesにname属性の存在性を検証する値を渡している。

:nameの部分はハッシュ〜のキーだが、シンボルではないので単独での使用が可能

これでテストは成功。

2 tests, 2 assertions, 0 failures, 0 errors, 0 skips

カッコを使うとこのような表記になる

validates(:name, presence: true)

結果は同じ。

コンソールを起動して、Userモデルに検証を追加した効果を見てみる。

>> user = User.new(name: "", email: "mhartl@example.com")                                                                                                   

=> #<User id: nil, name: "", email: "mhartl@example.com", created_at: nil, updated_at: nil>
>> user.valid?
=> false

presence: trueが効いているのでfalseが返ってきた。

このように、user変数が有効かどうかをvalid?メソッドでチェックすることができる。

もし、オブジェクトが1つ以上の検証に失敗したときは、falseを返す。

また、全てのバリデーションが通った時にtrueを返す。

今回は検証が1つしかないので、どの検証が失敗したかわかる。

失敗した時に作られるerrorオブジェクトを使って確認してみる。

>> user.errors.full_messages

=> ["Name can't be blank"]

つまり、Nameは空(blank)にはできませんよと否定されている。

一応、blankメソッドで確認。

>> user.blank?

=> false

つまり、空白であるかどうか以前にUserオブジェクトにblank?が渡せない(validatesが引っ掛かるから)ってこと。

これは、先ほどのpresenceヘルパーが内部でblank?メソッドを使用している為、このような結果になる。

Userオブジェクトは有効ではないので、saveしようとしても失敗する。

>> user.save

(0.1ms) SAVEPOINT active_record_1
(0.1ms) ROLLBACK TO SAVEPOINT active_record_1
=> false

この変更を行った結果、テストは成功する。

2 tests, 2 assertions, 0 failures, 0 errors, 0 skips

email属性にもname属性と同じようなテストを書く。


user_test.rb

  test "email bhould be present" do

@user.email = " "
assert_not @user.valid?
end


user.rb

  validates :email, presence: true


テストを走らせると成功。

3 tests, 3 assertions, 0 failures, 0 errors, 0 skips


演習

1:新しいユーザーuを作成し、作成した時点では有効ではない(invalid)ことを確認する。また、理由はなぜかエラーメッセージを確認してみる。

>> u.valid?

=> false
>> u.errors.full_messages
=> ["Name can't be blank", "Email can't be blank"]

NameとEmailがblankにできない(validationが効いている)

2:u.errors.messagesを実行すると、ハッシュ形式でエラーが取得できる。

emailに関するエラーだけを抽出してみる。

>> u.errors.messages

=> {:name=>["can't be blank"], :email=>["can't be blank"]}
>> u.errors.messages[:email]
=> ["can't be blank"]

*エラーメッセージはハッシュ(配列)で格納されている為、[:email]を指定して呼び出す。


長さを検証する

存在性の次は文字列の長さを検証してみる。

今回は、ユーザーの名前の長さを制限する。

50文字を上限とし、51文字以上の名前は長すぎることを検証する。

また、メールアドレスについても問題になることがあるのでバリデーションを賭ける。

メールアドレスは255文字を上限とする。

(ほとんどのデータベースでは文字列の上限を255文字としている)

まずはテストから。


user_test.rb

  test "name should not be too long" do

@user.name = "a" * 51 #51文字の"a"を@user.nameに代入
assert_not @user.valid? #@userが有効でなくなった(nameが50文字より多い)ことを確認(@userが無効なら成功、有効なら失敗)
end

test "email should not be too long" do
@user.email = "a" * 244 + "@example.com" #244文字の"a"と"@example.com"を足し合わせた文字を@user.emailに代入
assert_not @user.valid? #@userが有効でなくなった(emailが255文字より多い)か確認(@userが無効なら成功、有効なら失敗)
end


ここで、"a"* 51というかけ算をコンソール上で行い、文字数を確認してみる。

>> "a" * 51

=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
>> ("a" * 51).length
=> 51

メールアドレスの長さに対するバリデーションも検証

>> "a" * 244 + "@example.com"

=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@example.com"
>> ("a" * 244 + "@example.com").length
=> 256 #上限が255

この時点でテストは失敗

5 tests, 5 assertions, 2 failures, 0 errors, 0 skips

これをパスさせるために、長さを検証するための検証の引数を使う必要がある。

:nameと:emailに:maximumパラメータと共に用いられる:lengthを使って長さを強制する。

class User < ApplicationRecord

validates :name, presence: true, length: { maximum: 50 }
validates :email, presence: true, length: { maximum: 255 }
end

これでテストを走らせると通る。

5 tests, 5 assertions, 0 failures, 0 errors, 0 skips


演習

1:長すぎるnameとemail属性を持ったuserオブジェクトを生成し、有効でないことを確認

>> user = User.new

=> #<User id: nil, name: nil, email: nil, created_at: nil, updated_at: nil>
>> user.name = "a" * 51
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
>> user.email = "a" * 244 + "@example.com"
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@example.com"
>> user.valid?
=> false

2:バリデーション失敗の原因をエラーメッセージで確認

>> user.errors.full_messages

=> ["Name is too long (maximum is 50 characters)", "Email is too long (maximum is 255 characters)"]

名前もEmailも文字が長い、最大値は名前が50でメールが255ですよ。と怒られている


フォーマットを検証する

email属性の場合は最大文字数と空のメールアドレスかどうかのバリデーションしか掛けていない為、

他にもメールアドレスのパターン(user@example.comなどの)に合っているかどうかも確認してみる。

最初に、有効なメールアドレスと無効なメールアドレスのコレクションに対するテストを行う。

コレクションを作る方法として、文字列の配列を簡単に作れる%w[]という便利なテクニックを使う。

>> %w[foo bar baz]

=> ["foo", "bar", "baz"]
>> addresses = %w[USER@foo.COM THE_US-ER@foo.bar.org first.last@foo.jp]
=> ["USER@foo.COM", "THE_US-ER@foo.bar.org", "first.last@foo.jp"]
>> addresses.each do |address|
?> puts address
>> end
USER@foo.COM
THE_US-ER@foo.bar.org
first.last@foo.jp

eachメソッドを使ってaddresses配列の各要素を繰り返し取り出した。

(これは第4章で学んだブロックの繰り返しテクニック)

メールアドレスのバリデーションは扱いが難しく、エラーが発生しやすい。

なので、有効なメールアドレスと無効なメールアドレスを用意し、バリデーション内のエラーを検知していく。

例: "user@example,com" → ✖︎ "user@example.com" → ●


user_test.rb

  #メールアドレスのフォーマットに対するテスト

test "email validation should accept valid addresses" do
valid_addresses = %w[user@example.com USER@foo.COM A_US-ER@foo.bar.org
first.last@foo.jp alice+bob@baz.cn]
#5つのアドレスを配列で指定
valid_addresses.each do |valid_address| #それぞれの要素をブロックvalid_addressに繰り返し代入。一個ずつ検証する。
@user.email = valid_address #@user.emailにブロックを代入
assert @user.valid?, "#{valid_address.inspect} should be valid" #@userが通ったらtrue、通らなかったらfalse。さらに、第二引数でどのメールアドレスで失敗したかエラーメッセージを追加
end
end


ここで重要なのがeachメソッドとブロックの中身。

各メールアドレスを一個ずつ順にテストしている。

さらに、assertメソッドの第二引数に注目。

assert @user.valid?, "#{valid_address.inspect} should be valid"

この文では、詳細な文字列を調べるためにinspectメソッドを調べている。

この引数により、どのメールアドレスで失敗したかエラーメッセージが追加される。

次に、無効なメールアドレス(無効性(invalidity))の検証を行う。

例としては、.,となっていたり、@がないメールアドレスで失敗するかどうかを検証する。


user_test.rb

  test "email validation should reject invalid addresses" do

invalid_addresses = %w[user@example,com user_at_foo.org user.name@example.
foo@bar_baz.com foo@bar+baz.com]
#配列で5つのアドレス指定
invalid_addresses.each do |invalid_address| #それぞれの要素をブロックinvalid_addressに繰り返し代入。1つずつ検証。
@user.email = invalid_address #@user.emailにブロックを代入
assert_not @user.valid?, "#{invalid_address.inspect} should be invalid" #@userが有効なら失敗、無効なら成功。第二引数で失敗したメールアドレスをそのまま文字列として表示
end
end

この時点でテストは失敗。

7 tests, 11 assertions, 1 failures, 0 errors, 0 skips

なぜなら@userが有効だから。(メールアドレスのvalidationは空か255文字以上しか検証していないから)

メールアドレスのフォーマットを検証するためには、formatを使う。


user.rb

validates :email, format: { with: /<regular expression>/ }


ここで引数にregular expressionという正規表現を取っている。

今回はこのような正規表現を使う。

VALID_EMAIL_REGEX = /¥A[¥w+¥-.]+@[a-z¥d¥-.]+¥.[a-z]+¥z/i

Rubularを使って、上記の正規表現で通るメールアドレスを確認してみる。

スクリーンショット 2018-12-23 5.16.18.png

枠で囲んであるアドレスは正規表現に合致しているが、

囲まれてないアドレスは合致していない。

これを実際にvalidationで使ってみる。


user.rb

  validates :name,  presence: true, length: { maximum: 50 }

VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX }

ここでテストが通ることを確認。

7 tests, 15 assertions, 0 failures, 0 errors, 0 skips


演習

1:有効なメールアドレスと無効なメールアドレスを確認

スクリーンショット 2018-12-23 6.53.01.png

2:foo@bar..comのようなドットの連続を誤りを検知できない。このメールアドレスを無効なメールアドレスリストに追加し、テストが失敗することを確認。

    invalid_addresses = %w[user@example,com user_at_foo.org user.name@example. 

foo@bar_baz.com foo@bar+baz.com foo@bar..com]

$ rails test:models
7 tests, 16 assertions, 1 failures, 0 errors, 0 skips

さらに、下記のような複雑な正規表現を使ってテストがパスすることを確認。


user.rb

VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i


7 tests, 16 assertions, 0 failures, 0 errors, 0 skips

3:foo@bar..comがRubularで失敗するかどうか、正規表現を使って確認。

スクリーンショット 2018-12-23 7.00.16.png


一意性を検証する

重複したメールアドレスに制限を掛ける為、validatesメソッドの:uniqueオプションを使う。

ここで注意点としては、一意性のテストを書く際は、必ずレコードをデータベースに登録する必要がある。

その為、まずは重複したメールアドレスからテストしていく。


user_test.rb

  test "email addresses should be unique" do

duplicate_user = @user.dup #@userを複製する
@user.save #@userをデータベースに保存
assert_not duplicate_user.valid? #複製したduplicate_userが有効なら失敗、無効なら成功
end

重複したユーザーを作成する為、dupメソッドを使っている。

@userを保存した後では、複製されたユーザーのメールアドレスが存在する。assert_notを使っている為、@user複製が有効だと失敗する。

 test_email_addresses_should_be_unique#UserTest (0.05s)

Expected true to be nil or false
test/models/user_test.rb:56:in `block in <class:UserTest>'

8/8: [==================================================================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.05117s
8 tests, 17 assertions, 1 failures, 0 errors, 0 skips

テストをパスさせるため、emailバリデーションにuniqueness: trueというオプションを追加。


user.rb

  validates :name,  presence: true, length: { maximum: 50 }

VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: true #メールアドレスに「一意性がtrue」というオプションを追加

テストがパスする。

8 tests, 17 assertions, 0 failures, 0 errors, 0 skips

これでOK・・・ではなく、通常、メールアドレスの文字列は大文字小文字の区別がないため、どちらの場合も検証しなければならない。

つまり、テストに同一メールアドレス文字が大文字の場合の検証を追記する必要がある。


user_test.rb

    def setup

@user = User.new(name: "Example User", email: "user@example.com")
end

test "email addresses should be unique" do
duplicate_user = @user.dup #@userを複製する
duplicate_user.email = @user.email.upcase #複製したduplicate_userのメールアドレス欄の文字列を大文字にする
@user.save #@userをデータベースに保存
assert_not duplicate_user.valid? #@userの複製が有効なら失敗、無効なら成功
end


一度Rails Console で確認。

 $ rails c --sandbox

Running via Spring preloader in process 15721
Loading development environment in sandbox (Rails 5.1.6)
Any modifications you make will be rolled back on exit
>> user = User.create(name: "Example User", email: "user@example.com")
=> #<User id: 3, name: "Example User", email: "user@example.com", created_at: "2018-12-22 22:56:54", updated_at: "2018-12-22 22:56:54">
>> user.email.upcase
=> "USER@EXAMPLE.COM"
>> duplicate_user = user.dup
=> #<User id: nil, name: "Example User", email: "user@example.com", created_at: nil, updated_at: nil>
>> duplicate_user.email = user.email.upcase
=> "USER@EXAMPLE.COM"
>> duplicate_user.valid?
User Exists (0.2ms) SELECT 1 AS one FROM "users" WHERE "users"."email" = ? LIMIT ? [["email", "USER@EXAMPLE.COM"], ["LIMIT", 1]]
=> true

ここで複製したユーザーのメールアドレスを大文字にしたもののvalidationがtrueになっている

>> duplicate_user

=> #<User id: nil, name: "Example User", email: "USER@EXAMPLE.COM", created_at: nil, updated_at: nil>
>> user
=> #<User id: 3, name: "Example User", email: "user@example.com", create

↑大文字小文字で区別はされているが同じメールアドレスが複製可能となっている

これは本来ならばvalid?でfalseにしなければならない。

ということで、:uniqueness:case_sensitiveオプションを渡してみる。


user.rb

uniqueness: { case_sensitive: false }                       #大文字小文字を区別しない(false)に設定する このオプションには通常のuniquenessはtrueと判断する。


ここで重要なのは、このオプションには一意性の検証も含まれる。

つまり、追記でuniqueness: trueとする必要がない

これでテストがパスする。

8 tests, 17 assertions, 0 failures, 0 errors, 0 skips

ようやく・・・完成。。。と思ったら、まだあるらしい。

どうも、まだActive Recordはデータベースのレベルでは一意性を保証できていないんだとか。

具体的にどういうことか、というと、このままビルドしてアプリケーションをローンチしてしまうと、

ユーザーが会員登録する場合、登録申請ボタンを素早く2回クリックした場合、リクエストが二つ連続で送信され、同一のメールアドレスを持つユーザーレコードが(一意性の検証を行なっているにも関わらず)作成されてしまう。

RailsのWebサイトでは、トラフィックが多い時にこのような問題が発生する模様。

実はこの問題も簡単に解決できる模様。

その方法は、データベースレベルでも一意性を強制すること

具体的には、データベース上のemailのカラムにインデックス(index)を追加し、そのインデックスが一意であるようにすれば解決する(らしい)

また、データベース上のユーザー探索で、findメソッドを使って探す場合、今のままでは先頭から順にユーザーを一人ずつ探していくことになる。そうなると、例えばユーザーが1000人登録されている場合、1000番目のユーザーを探し当てるには1000回もサーチしなければならない。

これはデータベースの世界では全表スキャン(Full-table Scan)として知られており、

DBへの負担やリクエストの回数など考えても極めてよくない。

なので、その点からもemailのカラムにインデックスを追加すれば、割り当てたインデックスを管理する表の中から探し当てればいい訳で、アルゴリズムの計算量が比較的少なくて済む。

Userのemailカラムにインデックスを与えてfindで探す場合、ハッシュ表探索法でサーチしてる認識でいいのかな?

(一回で探し当てるって書いてあったのでアルゴリズム計算量O(1)だと勝手にそう解釈したけど)

emailカラムにインデックスを追加するためには、migrationジェネレーターを使ってマイグレーションを直接作成する必要がある。

$ rails g migration add_index_to_users_email

Running via Spring preloader in process 19811
invoke active_record
create db/migrate/20181223003323_add_index_to_users_email.rb

生成されたファイルに、add_indexというメソッドを使ってusersテーブルのemailカラムに、一意性を強制する為のunique: trueオプションを渡す。


[timestamp]_add_index_to_users_email.rb

class AddIndexToUsersEmail < ActiveRecord::Migration[5.1]

def change
add_index :users, :email, unique: true
end
end

設定したらデータベースに反映

$ rails db:migrate

この時点ではテストDB用のサンプルデータが含まれているfixtures内で一意性の制限が保たれていないため、テスとは失敗する。

今まではgenerateコマンドでfixtureが自動的に生成されていたが、

今回はmigrationコマンドを使用した為生成されていない。


users.yml

# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html

one:
name:
MyString
email: MyString

two:
name:
MyString
email: MyString


このメールアドレスが一意になっていない為、失敗するのだが、これまでテストが通っていたのはバリデーションを中継していない為である。

今回は、fixtureは削除しておく


users.yml

#8章まで使わない


これでテストはパスする。

しかし、メールアドレスの一意性を保証するにはあと一つやらなければならないことがある。

それは、いくつかのDBアダプタが、常に大文字小文字を区別するインデックスを使っているとは限らないから。

例えばFoo@ExAMPle.Comfoo@example.comが別々の文字列と解釈してしまうケースがある。

これらの文字列は同一であると解釈されるべきなので、

今回はデータベースに保存される直前に全ての文字列を小文字に変換する

これを実装するために、Active Recordのcallbackメソッドを利用する。

これはある特定の時点で呼び出されるメソッド。今回はオブジェクトが保存される前に実行したいため

before_saveというコールバックを使う。


user.rb

before_save { self.email = email.downcase }                                    #オブジェクトが保存される前に、インスタンス変数(email)自身に、小文字のemailの値を代入。


befre_saveコールバックに{}内のブロックを渡してユーザーのメールアドレスを設定している。

現在の値をStringクラスのdowncaseメソッドを使って、小文字にしたメールアドレスに変換しているため、

大文字小文字が混同する心配がなくなる。

また、上記のコードは


user.rb

before_save { self.email = self.email.downcase }


と書くこともできるが、Userモデルの中では右式のselfを省略できる。

(ここのselfは現在のユーザーを指す)

左側のselfは省略することはできないので注意。

これで、先程の問題点をクリアにできた。


  • 二度目のレコード保存のリクエストは一意性の制約によって拒否

  • emailにインデックス属性を追加したことで、サーチ効率の向上

  • 大文字小文字混同のメールアドレスをDBsave前に小文字に変換し一意性を保証


演習

1:メールアドレスを小文字にするテストを書く


user_test.rb

  test "email addresses should be saved as lower-case" do

mixed_case_email = "Foo@ExAMPle.Com"
@user.email = mixed_case_email
@user.save
assert_equal mixed_case_email.downcase, @user.reload.email #第一引数で@userのEmailを小文字に変換、第二引数でDBからEmail(大文字小文字混同のemail)を再読み込み、この二つが同一であればtrueを返す
end

この状態でテストを走らせると成功し、Userモデルのbefore_actionをコメントアウトすると失敗することを確認。


user.rb

  #before_save { self.email = email.downcase }


        Expected: "foo@example.com"

Actual: "Foo@ExAMPle.Com"
test/models/user_test.rb:64:in `block in <class:UserTest>'

9/9: [==================================================================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.06880s
9 tests, 18 assertions, 1 failures, 0 errors, 0 skips

2:破壊的メソッドを使い、selfを使わずにemailの文字列を小文字に変換


user.rb

before_save { email.downcase! } 


テストがパスすればOK


セキュアなパスワードを追加する

セキュアなパスワードという手法では

各ユーザーにパスワードとパスワードの確認を入力させ、それをハッシュ化したものをデータベースに保存する

ハッシュ化されたパスワードは、第8章のログイン機構でユーザーを認証する際に利用する。

ユーザーの認証は

1:パスワードの送信

2:ハッシュ化

3:データベース内のハッシュ化された値と比較

という手順で進める。

ここでの3のハッシュ値はユーザーが新規会員登録時に送信したパスワードをハッシュ化したハッシュ値を使う為、

会員登録時には比較は行わない(ログインではない為)

生のパスワードを平文と言うが、平文のままDBに保存することは危険な為、必ずハッシュ化させておく必要がある。

仮にパスワードの内容が盗まれても、パスワードの安全性が保たれる。


ハッシュ化されたパスワード

セキュアなパスワードの実装はhas_secure_passwordメソッドの呼び出しだけで殆ど完成してしまう。

class User < ApplicationRecord

has_secure_password

このメソッドによって


  • ハッシュ化したパスワードをDB内のpassword_digest属性に保存できる


  • passwordpassword_confirmationが使えるようになり、存在性と値が一致するかどうかのバリデーションも追加される

  • authenticateメソッドが使えるようになる

has_secure_password機能を使えるようにするには、モデル内にpassword_digestという属性を追加する必要がある。

今回はUserモデルで使うので、usersのデータモデルは以下のようになる。

image.png

出典:図 6.8: Userモデルにpassword_digest属性を追加する

データモデルを上記のようにするため、まずはpassword_digestカラム用のマイグレーションを生成する。

$ rails g migration add_passwrod_digest_to_users password_digest:string

Running via Spring preloader in process 29699
invoke active_record
create db/migrate/20181223043336_add_passwrod_digest_to_users.rb

生成されたファイルを確認。


[timestamp]_add_password_digest_to_users.rb

class AddPasswrodDigestToUsers < ActiveRecord::Migration[5.1]

def change
add_column :users, :password_digest, :string
end
end

ファイル名の末尾にto_usersを入れたことで自動的に:usersが入り、password_digestの引数を与えた値も入っている。

データベースにマイグレーションを反映させる為に、rails db:migrate実行。

rails db:migrate

ハッシュ関数の利用にはbcryptgemを追加する。

gem 'bcrypt',       '3.1.12'

bundle installを実行

bundle install


ユーザーがセキュアなパスワードを持っている

Userモデルにhas_secure_passwordが使えるようになったので、早速使用してみる。


user.rb

has_secure_password


ここでテストを走らせると失敗する。その理由は、has_secureを書いたことによりpassword属性とpassword_confirmation属性に対してのバリデーションが自動で追加されていることによる。

テストをパスさせる為に、以下のキーと値を追加する。


user_test.rb

  def setup

@user = User.new(name: "Example User", email: "user@example.com",
password: "foobar", password_confirmation: "foobar")
end

テストを走らせる。

9 tests, 18 assertions, 0 failures, 0 errors, 0 skips


演習

userオブジェクトを作成し、有効な名前とメールアドレスを与えてもvalid?で失敗する理由

>> user = User.new(name: "yuuki", email: "yuuki@foo.com")                                                                                                      

=> #<User id: nil, name: "yuuki", email: "yuuki@foo.com", created_at: nil, updated_at: nil, password_digest: nil>
>> user.valid?
User Exists (0.2ms) SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) LIMIT ? [["email", "yuuki@foo.com"], ["LIMIT", 1]]
=> false
>> user.errors.full_messages
=> ["Password can't be blank"]


パスワードの最小文字数

パスワードを簡単に当てられないようにする為、パスワードの最小文字数を設定する。

今回は以下の二つのバリデーションを設定する。


  • パスワードが空でないこと

  • パスワードの最小文字数を6文字にする

まずはテストから書いていく


user_test.rb

  test "password should be present (nonblank)" do                               #passwordとpassword_confirmationが空かどうか検証

@user.password = @user.password_confirmation = " " * 6 #二つの属性に空白文字を6個代入
assert_not @user.valid? #@userが有効なら失敗、無効なら成功
end

test "password should have a minimum length" do #passwordとpassword_confirmationが最低6文字以上あるかどうか検証
@user.password = @user.password_confirmation = "a" * 5 #二つの属性に"a"をを5個代入
assert_not @user.valid? #@userが有効なら失敗、無効なら成功
end


ここで注目したいのは


user_test.rb

@user.password = @user.password_confirmation = "a" * 5 


の部分。=が二つ付いている、このような構文を多重代入(multiple Assignment)と呼ぶ。

上記の文では@user.password@user.password_confirmation"aaaaa"を代入している。

ちなみに、has_secure_passwordメソッドの存在性のバリデーションは、

新規にレコードが追加された時だけしか適用されず、例えばレコードのユーザー名を空白の文字で埋めて更新しようとすると、バリデーションが適用されずに更新されてしまう

この時点でテストはもちろん失敗。

テストをパスさせる為に、パスワードが空でないこと、最低6文字以上のバリデーションを掛ける。


user.rb

validates :password, presence: true,length: { minimum: 6 }                    #passwordの文字列が空でなく、6文字以上ならtrue


テストがパスする。

10 tests, 14 assertions, 0 failures, 0 errors, 0 skips


演習

有効な名前とメールアドレスでも、パスワードが短すぎるとuserオブジェクトがfalseになることを確かめ、エラーメッセージを確認。

$ rails c --sandbox

>> user = User.new(name: "yuuki", email: "yuuki@foo.com", password: "asdf")
=> #<User id: nil, name: "yuuki", email: "yuuki@foo.com", created_at: nil, updated_at: nil, password_digest: "$2a$10$/JBZ/iqkeaQRdRx8N/NSW.BA23YaZPo7OzllMIeNPvR...">
>> user.valid?
User Exists (0.2ms) SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) LIMIT ? [["email", "yuuki@foo.com"], ["LIMIT", 1]]
=> false
>> user.errors.full_messages
=> ["Password is too short (minimum is 6 characters)"]

パスワードが短い、最低でも6文字以上の文字列にせよ、とエラーがでている。


ユーザーの作成と認証

ここでhas_secure_passwordやauthenticateメソッドの効果を確かめるために、

Rails Consoleを使い、実際にデータベースにユーザーを作成してみる。

User.create(name: "Michael Hartl", email: "mhartl@example.com",

?> password: "foobar", password_confirmation: "foobar")
(0.1ms) begin transaction
User Exists (0.1ms) SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) LIMIT ? [["email", "mhartl@example.com"], ["LIMIT", 1]]
SQL (4.8ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at", "password_digest") VALUES (?, ?, ?, ?, ?) [["name", "Michael Hartl"], ["email", "mhartl@example.com"], ["created_at", "2018-12-23 20:59:49.589763"], ["updated_at", "2018-12-23 20:59:49.589763"], ["password_digest", "$2a$10$W5uH1QYvbuqcARBxVnyDE.hVIHNuGMB4DW67hCv7Y/pgqTJ3XAkUS"]]
(13.5ms) commit transaction
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-23 20:59:49", updated_at: "2018-12-23 20:59:49", password_digest: "$2a$10$W5uH1QYvbuqcARBxVnyDE.hVIHNuGMB4DW67hCv7Y/p...">

実際にデータベースに保存されたかどうか SQLiteでusersテーブルを確認。

スクリーンショット 2018-12-24 6.08.26.png

コンソールで作成したユーザーをサーチしてみる

>> user = User.find_by(email: "mhartl@example.com")

User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."email" = ? LIMIT ? [["email", "mhartl@example.com"], ["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-23 20:59:49", updated_at: "2018-12-23 20:59:49", password_digest: "$2a$10$W5uH1QYvbuqcARBxVnyDE.hVIHNuGMB4DW67hCv7Y/p...">
>> user.password_digest
=> "$2a$10$W5uH1QYvbuqcARBxVnyDE.hVIHNuGMB4DW67hCv7Y/pgqTJ3XAkUS"

foobarという文字列がハッシュ化されているのが分かる。

これは、bcryptを使って生成しているので、この文字列から元々のパスワードを導出することはほぼ不可能。

さらに、has_secure_passwordをUserモデルに追加したことで、authenticateメソッドが使えるようになっている。

>> user.authenticate("not_the_right_password")

=> false
>> user.authenticate("foobaz")
=> false
>> user.authenticate("foobar")
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2018-12-23 20:59:49", updated_at: "2018-12-23 20:59:49", password_digest: "$2a$10$W5uH1QYvbuqcARBxVnyDE.hVIHNuGMB4DW67hCv7Y/p...">

引数に間違ったパスワードを与えると、falseを返し、正しいパスワードを与えると、ユーザーオブジェクトを返していることが分かる。

後にauthenticateメソッドを使ってユーザーを認証しログイン機構を作る。

なお、authenticateは返す論理値はtrueであることは確認しておきたい。

>> !!user.authenticate("foobar")

=> true

この性質を利用すると、user.authenticateを活用できる。


演習

コンソールを再起動し、userオブジェクトを検索して名前を新しい文字列に変えてみる。

>> user.name = "YUUKI"

=> "YUUKI"
>> user.save
(0.1ms) begin transaction
User Exists (0.2ms) SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND ("users"."id" != ?) LIMIT ? [["email", "mhartl@example.com"], ["id", 1], ["LIMIT", 1]]
(0.1ms) rollback transaction
=> false
>> user.errors.full_messages
=> ["Password can't be blank", "Password is too short (minimum is 6 characters)"]
>> user.password
=> nil

保存できない理由は、passwordに値が入っていないから。会員制のサイトでユーザー名を更新する時にパスワードの入力が求められるのに入力せずに更新しようとしたらエラーになるあの仕組みだと思われるw

今度はupdate_attributesメソッドを使って

nameとpasswordの値を同時に更新してみる。

>> user.update_attributes(name: "YUUKI", password: "foobaka")                                                                                                  

(0.1ms) begin transaction
User Exists (0.2ms) SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND ("users"."id" != ?) LIMIT ? [["email", "mhartl@example.com"], ["id", 1], ["LIMIT", 1]]
SQL (6.3ms) UPDATE "users" SET "name" = ?, "password_digest" = ?, "updated_at" = ? WHERE "users"."id" = ? [["name", "YUUKI"], ["password_digest", "$2a$10$AxSaG6sZMlG/lLCye8qiDeqLEBuYPsltBx5glmWhpU.d2sAwjOrsW"], ["updated_at", "2018-12-23 21:35:02.139347"], ["id", 1]]
(8.7ms) commit transaction
=> true
>> user
=> #<User id: 1, name: "YUUKI", email: "mhartl@example.com", created_at: "2018-12-23 20:59:49", updated_at: "2018-12-23 21:35:02", password_digest: "$2a$10$AxSaG6sZMlG/lLCye8qiDeqLEBuYPsltBx5glmWhpU....">
>>

できた。


Git・デプロイ

とりあえずテスト・コミット

$ rails t

$ git add -A
$ git commit -m "Make a basic User model (including secure passwords)"

次にmasterブランチにマージ。その後プッシュ

$ git checkout master

$ git merge modeling-users
$ git push

Herokuにpush後、本番環境でマイグレーションを走らせる為にheroku run コマンドを打つ。

$ rails t

$ git push heroku
$ heroku run rails db:migrate

うまくいったかどうか、本番環境のコンソールに接続し確認できる

$ heroku run rails console --sandbox

User.create(name: "Michael Hartl", email: "michael@example.com",
password: "foobar", password_confirmation: "foobar")

作成できた。

第7章へ


単語集


  • ブラックボックス

内部構造が明らかになっていないが何となく使っている、ような状態のこと。

中身がわかっていないにも拘らず、プログラムの動作だけを確認するテスト手法に「ブラックボックステスト」というものもある。


  • Active Record

データオブジェクトの作成/保存/検索のためのメソッドをもったRailsライブラリ。

これらのメソッドを使う上で、関係データベースで使うSQLを意識する必要が殆どない。


  • DDL

データ定義言語。DBのTableに命令する言語のことであり、主な命令にcreate tableがある。


  • マイグレーション

Active Recordの機能の一つ。

マイグレーション用の言語DSLを使用し、テーブルの変更を簡単に記述できる。


  • RDB

リレーショナルデータベースの略。列(カラム)と行(レコード)で作られ、データ型が定義されておりなおかつ他の表とリレーションシップ(関連線)があるデータベースのこと。完全に正規化されたデータベースのことである。


  • コンフリクト

データやファイルが同じタイミングで実行されることで動作が不安定になったりすること。

DB関連でよく使われる。


  • create_table

DBにテーブルを新規作成するRails特有のメソッド。


  • timestamps

Railsの特別なメソッドで、created_atとupdated_atという二つのマジックカラムを作成する。

これらは、あるユーザーが作成または更新された時に、その時刻を自動的に記録するタイムスタンプのこと。

データ型はdatetimeとなる。


  • integer

データ型の一つで、整数(小数点以下がない数)を扱う時に使われる。


  • ロールバック(rollback)

トランザクション(DBに対するひとまとまりの処理)を開始して何らかの障害が発生した場合、直前のチェックポイントの状態まで戻す。

逆の言葉にロールフォワードがある。


  • sandbox

Rails Consoleをデータベースに反映せずに行うモードのこと。使い方は

rails c --sandbox

なお、データベースへの変更を取り消してくれる証拠に

Any modifications you make will be rolled back on exit

このようなメッセージが表示される。

これを略すると「ここで行った全ての変更は終了時にロールバックされます」


  • find

データベースからオブジェクトを見つけるメソッド。引数に条件を指定してデータベースから検索する。

User.find(1) # id1のユーザーをユーザーモデルの中から探し出す。


  • exception

例外という意味。Railsではプログラムの実行時に何か例外的なイベントが発生したことを示すために使われる。


  • ActiveRecord::Relation

各オブジェクトを配列として効率的にまとめてくれるクラスのこと。


  • SQLクエリ

DBMSに対するSQL命令のこと。


  • setup

各テストが走る直前に実行される特殊なメソッド。


  • assert

指定した要素の真偽を問うメソッド。

trueなら成功、falseなら失敗を返す。


  • presence

渡された属性が存在することを検証する。

つまり、値がnilや空文字でないことを確認している。

- オプションハッシュ

Railsヘルパーの引数の中で、オプションで何らかの属性と値を指定することができるRailsの機能。

link_to "sample_app", "#", id: "logo" #第三引数でid属性に"logo"と指定することで、cssでidlogoを指定できている。


  • errors.full_messages

エラーのメッセージをフルに返してくれるメソッド。


  • collection

全部のデータにアクションを使用する。


  • 正規表現

いくつかの文字列を一つの形式で表現するための表現方法。

いくつかの文字列に対して下記のような特殊文字を与えて使う。特殊文字はメタ文字とも呼ばれる。

. ^ $ [ ] * + ? | ( )

参考:サルでもわかる正規表現入門


  • format

withオプションで与えられた正規表現と属性の値がマッチするかどうかをテストすることによって、バリデーションを行う。

使い方

validates :legacy_code, format: { with: 正規表現(/\A[a-zA-Z]+\z/) }

上記のwithオプションで与えられた正規表現の値に合致すればtrue、合致しなければfalse


  • 定数

変数の更新できないバージョン。定数を定義する際は、先頭の文字を大文字にする。

例:

Test = 4 * 5

puts Test

他言語では定数は値を上書きできないが、rubyの場合は警告メッセージは出るが上書きできる。

>> TEST = 5

=> 5
>> TEST = 10
(irb):6: warning: already initialized constant TEST
(irb):5: warning: previous definition of TEST was here
=> 10

値を上書きされたくなければ、定数をmoduleに格納し、module及び定数をfreezeする。

>> module TEST

>> TOWN = "TOKYO".freeze
>> end
=> "TOKYO"
>> TEST.freeze
=> TEST
>> TEST::TOWN = "aa"
RuntimeError: can't modify frozen Module
from (irb):9
>> TEST::TOWN.downcase!
RuntimeError: can'
t modify frozen String
from (irb):10:in `downcase!'
from (irb):10


  • dup

同じ属性を持つデータを重複するためのメソッド。


  • fixture

テスト実行前に初期データを投入するディレクトリのこと。拡張子はyml


  • コールバック

ある特定の地点で呼び出されるメソッド。書き方は

before_save {オブジェクトが保存される前に行う処理を書く}

様々なコールバックを使って、オブジェクトを作成したり、更新したり、削除したりできる。


  • ハッシュ化

ハッシュ関数を用いて、不可逆(元に戻せない)なハッシュ値に変換する方法。


  • add_column

カラムを追記するメソッド。


  • authenticate

引数に渡された文字列をハッシュ化した値と、データベース内にあるpassword_digestのカラム値を検証する。