企业🤖AI智能体构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
# 6.2 用户数据验证 [6.1 节](#user-model)创建的用户模型现在已经有了可以使用的 `name` 和 `email` 属性,不过功能还很简单:任何字符串(包括空字符串)都可以使用。名字和电子邮件地址的格式显然要复杂一些。例如,`name` 不应该是空的,`email` 应该符合特定的格式。而且,我们要把电子邮件地址当成用户名用来登录,那么在数据库中就不能重复出现。 总之,`name` 和 `email` 不是什么字符串都可以使用的,我们要对它们可使用的值做个限制。Active Record 通过数据验证实现这种限制([2.3.2 节](chapter2.html#putting-the-micro-in-microposts)简单提到过)。本节,我们会介绍几种常用的数据验证:存在性、长度、格式和唯一性。[6.3.2 节](#user-has-secure-password)还会介绍另一种常用的数据验证——二次确认。[7.3 节](chapter7.html#unsuccessful-signups)会看到,如果提交了不合要求的数据,数据验证会显示一些很有用的错误消息。 ## 6.2.1 有效性测试 [旁注 3.3](chapter3.html#aside-when-to-test)说过,TDD 并不适用所有情况,但是模型验证是使用 TDD 的绝佳时机。如果不先编写失败测试,再想办法让它通过,我们很难确定验证是否实现了我们希望实现的功能。 我们采用的方法是,先得到一个有效的模型对象,然后把属性改为无效值,以此确认这个对象是无效的。以防万一,我们先编写一个测试,确认模型对象一开始是有效的。这样,如果验证测试失败了,我们才知道的确事出有因(而不是因为一开始对象是无效的)。 [代码清单 6.1](#listing-generate-user-model) 中的命令生成了一个用来测试用户模型的测试文件,现在这个文件中还没什么内容,如[代码清单 6.4](#listing-default-user-test) 所示。 ##### 代码清单 6.4:还没什么内容的用户模型测试文件 test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase # test "the truth" do # assert true # end end ``` 为了测试有效的对象,我们要在特殊的 `setup` 方法中创建一个有效的用户对象 `@user`。[3.6 节](chapter3.html#mostly-static-pages-exercises)的练习中提到过,`setup` 方法会在每个测试方法运行前执行。因为 `@user` 是实例变量,所以自动可在所有测试方法中使用,而且我们可以使用 `valid?` 方法检查它是否有效。测试如[代码清单 6.5](#listing-valid-user-test) 所示。 ##### 代码清单 6.5:测试用户对象一开始是有效的 GREEN test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com") end test "should be valid" do assert @user.valid? end end ``` [代码清单 6.5](#listing-valid-user-test) 使用简单的 `assert` 方法,如果 `@user.valid?` 返回 `true`,测试就能通过;返回 `false`,测试则会失败。 因为用户模型现在还没有任何验证,所有这个测试可以通过: ##### 代码清单 6.6:**GREEN** ``` $ bundle exec rake test:models ``` 这里,我们使用 `rake test:models`,只运行模型测试(和 [5.3.4 节](chapter5.html#layout-link-tests)的 `rake test:integration` 对比一下)。 ## 6.2.2 存在性验证 存在性验证算是最基本的验证了,只是检查指定的属性是否存在。本节我们会确保用户存入数据库之前,`name` 和 `email` 字段都有值。[7.3.3 节](chapter7.html#signup-error-messages)会介绍如何把这个限制应用到创建用户的注册表单中。 我们要先在[代码清单 6.5](#listing-valid-user-test) 的基础上再编写一个测试,检查 `name` 属性是否存在。如[代码清单 6.7](#listing-name-presence-test) 所示,我们只需把 `@user` 的 `name` 属性设为空字符串(包含几个空格的字符串),然后使用 `assert_not` 方法确认得到的用户对象是无效的。 ##### 代码清单 6.7:测试 `name` 属性的验证措施 RED test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com") end test "should be valid" do assert @user.valid? end test "name should be present" do @user.name = " " assert_not @user.valid? end end ``` 现在,模型测试应该失败: ##### 代码清单 6.8:**RED** ``` $ bundle exec rake test:models ``` 我们在[2.5 节](chapter2.html#a-toy-app-exercises)中见过,`name` 属性的存在性验证使用 `validates` 方法,而且其参数为 `presence: true`,如[代码清单 6.9](#listing-validates-presence-of-name) 所示。`presence: true` 是只有一个元素的可选哈希参数,[4.3.4 节](chapter4.html#css-revisited)说过,如果方法的最后一个参数是哈希,可以省略花括号。([5.1.1 节](chapter5.html#site-navigation)说过,Rails 经常使用哈希做参数。) ##### 代码清单 6.9:添加 `name` 属性存在性验证 GREEN app/models/user.rb ``` class User < ActiveRecord::Base validates :name, presence: true end ``` [代码清单 6.9](#listing-validates-presence-of-name) 中的代码看起来可能有点儿神奇,其实 `validates` 就是个方法。加入括号后,可以写成: ``` class User < ActiveRecord::Base validates(:name, presence: true) end ``` 打开控制台,看一下在用户模型中加入验证后有什么效果:[[10](#fn-10)] ``` $ rails console --sandbox >> user = User.new(name: "", email: "mhartl@example.com") >> user.valid? => false ``` 这里我们使用 `valid?` 方法检查 `user` 变量的有效性,如果有一个或多个验证失败,返回值为 `false`,如果所有验证都能通过,返回 `true`。现在只有一个验证,所以我们知道是哪一个失败,不过看一下失败时生成的 `errors` 对象还是很有用的: ``` >> user.errors.full_messages => ["Name can't be blank"] ``` (错误消息暗示,Rails 使用 [4.4.3 节](chapter4.html#modifying-built-in-classes)介绍的 `blank?` 方法验证存在性。) 因为用户无效,如果尝试把它保存到数据库中,操作会失败: ``` >> user.save => false ``` 加入验证后,[代码清单 6.7](#listing-name-presence-test) 中的测试应该可以通过了: ##### 代码清单 6.10:**GREEN** ``` $ bundle exec rake test:models ``` 按照[代码清单 6.7](#listing-name-presence-test) 的方式,再编写一个检查 `email` 属性存在性的测试就简单了,如[代码清单 6.11](#listing-email-presence-test) 所示。让这个测试通过的应用代码如[代码清单 6.12](#listing-validates-presence-of-email) 所示。 ##### 代码清单 6.11:测试 `email` 属性的验证措施 RED test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com") end test "should be valid" do assert @user.valid? end test "name should be present" do @user.name = "" assert_not @user.valid? end test "email should be present" do @user.email = " " assert_not @user.valid? end end ``` ##### 代码清单 6.12:添加 `email` 属性存在性验证 GREEN app/models/user.rb ``` class User < ActiveRecord::Base validates :name, presence: true validates :email, presence: true end ``` 现在,存在性验证都添加了,测试组件应该可以通过了: ##### 代码清单 6.13:**GREEN** ``` $ bundle exec rake test ``` ## 6.2.3 长度验证 我们已经对用户模型可接受的数据做了一些限制,现在必须为用户提供一个名字,不过我们应该做进一步限制,因为用户的名字会在演示应用中显示,所以最好限制它的长度。有了前一节的基础,这一步就简单了。 没有科学的方法确定最大长度是多少,我们就使用 50 作为长度的上限吧,所以要验证 51 个字符超长了。而且,用户的电子邮件地址可能会超过字符串的最大长度限制,这个最大值在很多数据库中都是 255——这种情况虽然很少发生,但也有发生的可能。因为下一节的格式验证无法实现这种限制,所以我们要在这一节实现。测试如[代码清单 6.14](#listing-length-validation-test) 所示。 ##### 代码清单 6.14:测试 `name` 属性的长度验证 RED test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com") end . . . test "name should not be too long" do @user.name = "a" * 51 assert_not @user.valid? end test "email should not be too long" do @user.email = "a" * 244 + "@example.com" assert_not @user.valid? end end ``` 为了方便,我们使用字符串连乘生成了一个有 51 个字符的字符串。在控制台中可以看到连乘是什么: ``` >> "a" * 51 => "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" >> ("a" * 51).length => 51 ``` 在电子邮件地址长度的测试中,我们创建了一个比要求多一个字符的地址: ``` >> "a" * 244 + "@example.com" => "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaa@example.com" >> ("a" * 244 + "@example.com").length => 256 ``` 现在,[代码清单 6.14](#listing-length-validation-test) 中的测试应该失败: ##### 代码清单 6.15:**RED** ``` $ bundle exec rake test ``` 为了让测试通过,我们要使用验证参数限制长度,即 `length`,以及限制上线的 `maximum` 参数,如[代码清单 6.16](#listing-length-validation) 所示。 ##### 代码清单 6.16:为 `name` 属性添加长度验证 GREEN app/models/user.rb ``` class User < ActiveRecord::Base validates :name, presence: true, length: { maximum: 50 } validates :email, presence: true, length: { maximum: 255 } end ``` 现在测试应该可以通过了: ##### 代码清单 6.17:**GREEN** ``` $ bundle exec rake test ``` 测试组件再次通过,接下来我们要实现一个更有挑战的验证——电子邮件地址的格式。 ## 6.2.4 格式验证 `name` 属性的验证只需做一些简单的限制就好——任何非空、长度小于 51 个字符的字符串都可以。可是 `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` 方法([4.3.2 节](chapter4.html#blocks))遍历 `addresses` 数组中的元素。掌握这种用法之后,我们就可以编写一些基本的电子邮件地址格式验证测试了。 电子邮件地址格式认证有点棘手,且容易出错,所以我们会先编写检查有效电子邮件地址的测试,这些测试应该能通过,以此捕获验证可能出现的错误。也就是说,添加验证后,不仅要拒绝无效的电子邮件地址,例如 _user@example,com_,还得接受有效的电子邮件地址,例如 _user@example.com_。(显然目前会接受所有电子邮件地址,因为只要不为空值都能通过验证。)检查有效电子邮件地址的测试如[代码清单 6.18](#listing-email-format-valid-tests) 所示。 ##### 代码清单 6.18:测试有效的电子邮件地址格式 GREEN test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com") end . . . 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] valid_addresses.each do |valid_address| @user.email = valid_address assert @user.valid?, "#{valid_address.inspect} should be valid" end end end ``` 注意,我们为 `assert` 方法指定了可选的第二个参数,定制错误消息,识别是哪个地址导致测试失败的: ``` assert @user.valid?, "#{valid_address.inspect} should be valid" ``` 这行代码在字符串插值中使用了 [4.3.3 节](chapter4.html#hashes-and-symbols) 介绍的 `inspect` 方法。像这种使用 `each` 的测试,最好能知道是哪个地址导致失败的,因为不管哪个地址导致测试失败,都无法看到行号,很难查出问题的根源。 接下来,我们要测试一系列无效的电子邮件,确认它们无法通过验证,例如 _user@example,com_(点号变成了逗号)和 _user_at_foo.org_(没有“@”符号)。和[代码清单 6.18](#listing-email-format-valid-tests) 一样,[代码清单 6.19](#listing-email-format-validation-tests) 中也指定了错误消息参数,识别是哪个地址导致测试失败的。 ##### 代码清单 6.19:测试电子邮件地址格式验证 RED test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com") end . . . 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] invalid_addresses.each do |invalid_address| @user.email = invalid_address assert_not @user.valid?, "#{invalid_address.inspect} should be invalid" end end end ``` 现在,测试应该失败: ##### 代码清单 6.20:**RED** ``` $ bundle exec rake test ``` 电子邮件地址格式验证使用 `format` 参数,用法如下: ``` validates :email, format: { with: /<regular expression>/ } ``` 使用指定的正则表达式验证属性。正则表达式很强大,但往往很晦涩,用来模式匹配字符串。所以我们要编写一个正则表达式,匹配有效的电子邮件地址,但不匹配无效的地址。 在官方标准中其实有一个正则表达式,可以匹配全部有效的电子邮件地址,但没必要使用这么复杂的正则表达式。[[11](#fn-11)]本书使用一个更务实的正则表达式,能很好地满足实际需求,如下所示: ``` VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i ``` 为了便于理解,我把 `VALID_EMAIL_REGEX` 拆分成几块来讲,如[表 6.1](#table-valid-email-regex) 所示。 表 6.1:拆解匹配有效电子邮件地址的正则表达式 | 表达式 | 含义 | | --- | --- | | `/\A[\w+\-.]@[a-z\d\-.]\.[a-z]+\z/i` | 完整的正则表达式 | | `/` | 正则表达式开始 | | `\A` | 匹配字符串的开头 | | `[\w+\-.]+` | 一个或多个字母、加号、连字符、或点号 | | `@` | 匹配 @ 符号 | | `[a-z\d\-.]+` | 一个或多个字母、数字、连字符或点号 | | `\.` | 匹配点号 | | `[a-z]+` | 一个或多个字母 | | `\z` | 匹配字符串结尾 | | `/` | 结束正则表达式 | | `i` | 不区分大小写 | 从[表 6.1](#table-valid-email-regex) 中虽然能学到很多,但若想真正理解正则表达式,我觉得交互式正则表达式匹配程序,例如 [Rubular](http://www.rubular.com/)([图 6.6](#fig-rubular))[[12](#fn-12)],是必不可少的的。Rubular 的界面很友好,便于编写所需的正则表达式,而且还有一个便捷的语法速查表。我建议你使用 Rubular 来理解[表 6.1](#table-valid-email-regex)中的正则表达式——读得次数再多也不比不上在 Rubular 中实操几次。(注意:如果你在 Rubular 中输入[表 6.1](#table-valid-email-regex) 中的正则表达式,要把 `\A` 和 `\z` 去掉,因为 Rubular 无法正确处理字符串的头尾。) ![rubular](https://box.kancloud.cn/2016-05-11_5732bd0c1cf85.png)图 6.6:强大的 Rubular 正则表达式编辑器 在 `email` 属性的格式验证中使用这个表达式后得到的代码如[代码清单 6.21](#listing-validates-format-of-email) 所示。 ##### 代码清单 6.21:使用正则表达式验证电子邮件地址的格式 GREEN app/models/user.rb ``` class User < ActiveRecord::Base 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 } end ``` 其中,`VALID_EMAIL_REGEX` 是一个常量,在 Ruby 中常量的首字母为大写形式。这段代码: ``` VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i validates :email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX } ``` 确保只有匹配正则表达式的电子邮件地址才是有效的。这个正则表达式有一个缺陷:能匹配 `foo@bar..com` 这种有连续点号的地址。修正这个瑕疵需要一个更复杂的正则表达式,留作练习由你完成([6.5 节](#modeling-users-exercises))。 现在测试应该可以通过了: ##### 代码清单 6.22:**GREEN** ``` $ bundle exec rake test:models ``` 那么就只剩一个限制要实现了:确保电子邮件地址的唯一性。 ## 6.2.5 唯一性验证 确保电子邮件地址的唯一性(这样才能作为用户名),要使用 `validates` 方法的 `:unique` 参数。提前说明,实现的过程中有一个很大的陷阱,所以不要轻易跳过本节,要认真阅读。 我们要先编写一些简短的测试。之前的模型测试,只是使用 `User.new` 在内存中创建一个 Ruby 对象,但是测试唯一性时要把数据存入数据库。[[13](#fn-13)]对重复电子邮件地址的测试如[代码清单 6.23](#listing-validates-uniqueness-of-email-test) 所示。 ##### 代码清单 6.23:拒绝重复电子邮件地址的测试 RED test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase 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.save assert_not duplicate_user.valid? end end ``` 我们使用 `@user.dup` 方法创建一个和 `@user` 的电子邮件地址一样的用户对象,然后保存 `@user`,因为数据库中的 `@user` 已经占用了这个电子邮件地址,所有 `duplicate_user` 对象无效。 在 `email` 属性的验证中加入 `uniqueness: true` 可以让[代码清单 6.23](#listing-validates-uniqueness-of-email-test) 中的测试通过,如[代码清单 6.24](#listing-validates-uniqueness-of-email) 所示。 ##### 代码清单 6.24:电子邮件地址唯一性验证 GREEN app/models/user.rb ``` class User < ActiveRecord::Base 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 }, uniqueness: true end ``` 这还不行,一般来说电子邮件地址不区分大小写,也就说 `foo@bar.com` 和 `FOO@BAR.COM` 或 `FoO@BAr.coM` 是同一个地址,所以验证时也要考虑这种情况。[[14](#fn-14)]因此,还要测试不区分大小写,如[代码清单 6.25](#listing-validates-uniqueness-of-email-case-insensitive-test) 所示。 ##### 代码清单 6.25:测试电子邮件地址的唯一性验证不区分大小写 RED test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com") end . . . test "email addresses should be unique" do duplicate_user = @user.dup duplicate_user.email = @user.email.upcase @user.save assert_not duplicate_user.valid? end end ``` 上面的代码,在字符串上调用 `upcase` 方法([4.3.2 节](chapter4.html#blocks)简介过)。这个测试和前面对重复电子邮件的测试作用一样,只是把地址转换成全部大写字母的形式。如果觉得太抽象,那就在控制台中实操一下吧: ``` $ rails console --sandbox >> user = User.create(name: "Example User", email: "user@example.com") >> user.email.upcase => "USER@EXAMPLE.COM" >> duplicate_user = user.dup >> duplicate_user.email = user.email.upcase >> duplicate_user.valid? => true ``` 当然,现在 `duplicate_user.valid?` 的返回值是 `true`,因为唯一性验证还区分大小写。我们希望得到的结果是 `false`。幸好 `:uniqueness` 可以指定 `:case_sensitive` 选项,正好可以解决这个问题,如[代码清单 6.26](#listing-validates-uniqueness-of-email-case-insensitive) 所示。 ##### 代码清单 6.26:电子邮件地址唯一性验证,不区分大小写 GREEN app/models/user.rb ``` class User < ActiveRecord::Base 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 }, uniqueness: { case_sensitive: false } end ``` 注意,我们直接把 `true` 换成了 `case_sensitive: false`,Rails 会自动指定 `:uniqueness` 的值为 `true`。 至此,我们的应用虽还有不足,但基本可以保证电子邮件地址的唯一性了,测试组件应该可以通过了: ##### 代码清单 6.27:**GREEN** ``` $ bundle exec rake test ``` 现在还有一个小问题——Active Record 中的唯一性验证无法保证数据库层也能实现唯一性。我来解释一下: 1. Alice 使用 alice@wonderland.com 在演示应用中注册; 2. Alice 不小心按了两次提交按钮,连续发送了两次请求; 3. 然后就会发生这种事情:请求 1 在内存中新建了一个用户对象,能通过验证;请求 2 也一样。请求 1 创建的用户存入了数据库,请求 2 创建的用户也存入了数据库。 4. 结果是,尽管有唯一性验证,数据库中还是有两条用户记录的电子邮件地址是一样的。 相信我,上面这种难以置信的情况可能发生,只要有一定的访问量,在任何 Rails 网站中都可能发生。幸好解决的办法很容易,只需在数据库层也加上唯一性限制。我们要做的是在数据库中为 `email` 列建立索引([旁注 6.2](#aside-database-indices)),然后为索引加上唯一性限制。 ##### 旁注 6.2:数据库索引 在数据库中创建列时要考虑是否需要通过这个列查找记录。以[代码清单 6.2](#listing-users-migration)中的迁移创建的 `email` 属性为例,[第 7 章](chapter7.html#sign-up)实现登录功能后,我们要根据提交的电子邮件地址查找对应的用户记录。可是在这个简单的数据模型中通过电子邮件地址查找用户只有一种方法——检查数据库中的所有用户记录,比较记录中的 `email` 属性和指定的电子邮件地址。也就是说,可能要检查每一条记录(毕竟用户可能是数据库中的最后一条记录)。在数据库领域,这叫“全表扫描”。如果网站中有几千个用户,这可不是一件轻松的事。 在 `email` 列加上索引可以解决这个问题。我们可以把数据库索引看成书籍的索引。如果要在一本书中找出某个字符串(例如 `"foobar"`)出现的所有位置,需要翻看书中的每一页。但是如果有索引的话,只需在索引中找到 `"foobar"` 条目,就能看到所有包含 `"foobar"` 的页码。数据库索引基本上也是这种原理。 为 `email` 列建立索引要改变数据模型,在 Rails 中可以通过迁移实现。在 [6.1.1 节](#database-migrations)我们看到,生成用户模型时会自动创建一个迁移文件([代码清单 6.2](#listing-users-migration))。现在我们是要改变已经存在的模型结构,那么使用 `migration` 命令直接创建迁移文件就可以了: ``` $ rails generate migration add_index_to_users_email ``` 和用户模型的迁移不一样,实现电子邮件地址唯一性的操作没有事先定义好的模板可用,所以我们要自己动手编写,如[代码清单 6.28](#listing-email-uniqueness-index) 所示。[[15](#fn-15)] ##### 代码清单 6.28:添加电子邮件唯一性约束的迁移 db/migrate/[timestamp]_add_index_to_users_email.rb ``` class AddIndexToUsersEmail < ActiveRecord::Migration def change add_index :users, :email, unique: true end end ``` 上述代码调用了 Rails 中的 `add_index` 方法,为 `users` 表中的 `email` 列建立索引。索引本身并不能保证唯一性,所以还要指定 `unique: true`。 最后,执行数据库迁移: ``` $ bundle exec rake db:migrate ``` (如果迁移失败的话,退出所有打开的沙盒模式控制台会话试试。这些会话可能会锁定数据库,拒绝迁移操作。) 现在测试组件应该无法通过,因为“固件”(fixture)中的数据违背了唯一性约束。固件的作用是为测试数据库提供示例数据。执行[代码清单 6.1](#listing-generate-user-model) 中的命令时会自动生成用户固件,如[代码清单 6.29](#listing-default-fixtures) 所示,电子邮件地址有重复。(电子邮件地址也无效,但固件中的数据不会应用验证规则。) ##### 代码清单 6.29:默认生成的用户固件 RED test/fixtures/users.yml ``` # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/ # FixtureSet.html one: name: MyString email: MyString two: name: MyString email: MyString ``` 我们到[第 8 章](chapter8.html#log-in-log-out)才会用到固件,现在暂且把其中的数据删除,只留下一个空文件,如[代码清单 6.30](#listing-empty-fixtures) 所示。 ##### 代码清单 6.30:没有内容的固件文件 GREEN test/fixtures/users.yml ``` # empty ``` 为了保证电子邮件地址的唯一性,还要做些修改。有些数据库适配器的索引区分大小写,会把“Foo@ExAMPle.CoM”和“foo@example.com”视作不同的字符串,但我们的应用会把他们看做同一个地址。为了避免不兼容,我们要统一使用小写形式的地址,存入数据库前,把“Foo@ExAMPle.CoM”转换成“foo@example.com”。为此,我们要使用“回调”(callback),在 Active Record 对象生命周期的特定时刻调用。[[16](#fn-16)]现在,我们要使用的回调是 `before_save`,在用户存入数据库之前把电子邮件地址转换成全小写字母形式,如[代码清单 6.31](#listing-email-downcase) 所示。(这只是初步实现方式,[10.1.1 节](chapter10.html#account-activations-resource)会再次讨论这个话题,届时会使用常用的“方法引用”定义回调。) ##### 代码清单 6.31:把 `email` 属性的值转换为小写形式,确保电子邮件地址的唯一性 GREEN app/models/user.rb ``` class User < ActiveRecord::Base before_save { self.email = email.downcase } 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 }, uniqueness: { case_sensitive: false } end ``` 在[代码清单 6.31](#listing-email-downcase) 中,`before_save` 后有一个块,块中的代码调用字符串的 `downcase` 方法,把用户的电子邮件地址转换成小写形式。(针对电子邮件地址转换成小写形式的测试留作[练习](#modeling-users-exercises)。) 在[代码清单 6.31](#listing-email-downcase) 中,我们可以把赋值语句写成: ``` self.email = self.email.downcase ``` 其中 `self` 表示当前用户。但是在用户模型中,右侧的 `self` 关键字是可选的,我们在 `palindrome` 方法中调用 `reverse` 方法时说过([4.4.2 节](chapter4.html#class-inheritance)): ``` self.email = email.downcase ``` 注意,左侧的 `self` 不能省略,所以写成 ``` email = email.downcase ``` 是不对的。([8.4 节](chapter8.html#remember-me)会进一步讨论这个话题。) 现在,前面 Alice 遇到的问题解决了,数据库会存储请求 1 创建的用户,不会存储请求 2 创建的用户,因为后者违反了唯一性约束。(在 Rails 的日志中会显示一个错误,不过无大碍。)为 `email` 列建立索引同时也解决了 [6.1.4 节](#finding-user-objects)提到的问题:如[旁注 6.2](#aside-database-indices) 所说,在 `email` 列上添加索引后,使用电子邮件地址查找用户时不会进行全表扫描,解决了潜在的效率问题。