添加视频地址

我们的菜谱现在有对应的节目名、哪一季哪一集,如果能直接附上视频的链接,就更完美了。

我们需要一个数据库迁移(migration)文件:

$ mix ecto.gen.migration add_url_to_recipe
* creating priv/repo/migrations
* creating priv/repo/migrations/20170211030550_add_url_to_recipe.exs

修改新建的迁移文件:

diff --git a/priv/repo/migrations/20170211030550_add_url_to_recipe.exs b/priv/repo/migrations/20170211030550_add_url_to_recipe.exs
index 01e5f17..f0918c6 100644
--- a/priv/repo/migrations/20170211030550_add_url_to_recipe.exs
+++ b/priv/repo/migrations/20170211030550_add_url_to_recipe.exs
@@ -2,6 +2,8 @@ defmodule TvRecipe.Repo.Migrations.AddUrlToRecipe do
   use Ecto.Migration

   def change do
-
+    alter table(:recipes) do
+      add :url, :string
+    end
   end
 end

接着将 :url 加入 schema 中:

diff --git a/web/models/recipe.ex b/web/models/recipe.ex
index 230f290..104db50 100644
--- a/web/models/recipe.ex
+++ b/web/models/recipe.ex
@@ -6,6 +6,7 @@ defmodule TvRecipe.Recipe do
     field :title, :string
     field :season, :integer, default: 1
     field :episode, :integer, default: 1
+    field :url, :string
     field :content, :string
     belongs_to :user, TvRecipe.User

@@ -17,7 +18,7 @@ defmodule TvRecipe.Recipe do
   """
   def changeset(struct, params \\ %{}) do
     struct
-    |> cast(params, [:name, :title, :season, :episode, :content])
+    |> cast(params, [:name, :title, :season, :episode, :content, :url])
     |> validate_required([:name, :title, :season, :episode, :content], message: "请填写")
     |> validate_number(:season, greater_than: 0, message: "请输入大于 0 的数字")
     |> validate_number(:episode, greater_than: 0, message: "请输入大于 0 的数字")

最后,执行 mix ecto.migrate

$ mix ecto.migrate
Compiling 13 files (.ex)

11:53:37.646 [info]  == Running TvRecipe.Repo.Migrations.AddUrlToRecipe.change/0 forward

11:53:37.646 [info]  alter table recipes

11:53:37.676 [info]  == Migrated in 0.0s

接着新增一个测试,我们需要验证 url 的有效性:

diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs
index 8b093ed..f1ba3f9 100644
--- a/test/models/recipe_test.exs
+++ b/test/models/recipe_test.exs
@@ -60,4 +60,9 @@ defmodule TvRecipe.RecipeTest do
     assert %{user_id: ["does not exist"]} = errors_on(changeset)
   end

+  test "url should be valid" do
+    attrs = Map.put(@valid_attrs, :url, "fjsalfa")
+    assert %{url: ["url 错误"]} = errors_on(%Recipe{}, attrs)
+  end
+
 end

运行测试:

mix test
...............................

  1) test url should be valid (TvRecipe.RecipeTest)
     test/models/recipe_test.exs:63
     Assertion with in failed
     code:  %{url: ["url 错误"]} = errors_on(%Recipe{}, attrs)
     left:  %{url: ["url 错误"]}
     right: %{}
     stacktrace:
       test/models/recipe_test.exs:65: (test)

...........................

Finished in 1.0 seconds
59 tests, 1 failure

那么我们要如何在 recipe.ex 文件中验证 url 的有效性?

我们可以考虑用正则表达式配合 validate_format,但有个更好的办法,是直接引用 Erlang 的方法:

diff --git a/web/models/recipe.ex b/web/models/recipe.ex
index 104db50..3b849c8 100644
--- a/web/models/recipe.ex
+++ b/web/models/recipe.ex
@@ -22,6 +22,16 @@ defmodule TvRecipe.Recipe do
     |> validate_required([:name, :title, :season, :episode, :content], message: "请填写")
     |> validate_number(:season, greater_than: 0, message: "请输入大于 0 的数字")
     |> validate_number(:episode, greater_than: 0, message: "请输入大于 0 的数字")
+    |> validate_url(:url)
     |> foreign_key_constraint(:user_id)
   end
+
+  defp validate_url(changeset, field, _options \\ []) do
+    validate_change changeset, field, fn _, url ->
+     with %{host: _, scheme: scheme} <- :uri_string.parse(url),
+           true <- String.starts_with?(scheme, "http") do
+        []
+      else
+        _ -> [url: "url 错误"]
+      end
+    end
+  end
 end

我们在 recipe.ex 文件中新增了一个 validate_url 私有方法,并调用 Ecto 提供的 validate_change 函数来验证属性是否有效。http_uri 是 Erlang 的模块,在 Elixir 中,我们能够以 :http_uri 的形式调用。

我们所有的 recipe 模板都需要做调整 - 此时,我想你可能已经意识到测试驱动的好处了,如果我们给各个模板添加过测试,那么有新特性加入时,我们先在测试中体现我们的目的,然后运行测试,就知道需要修改哪些文件来达到我们的目的。

参照前一节的代码,我们来进一步完善 RecipeViewTest 模块的代码:

diff --git a/test/views/recipe_view_test.exs b/test/views/recipe_view_test.exs
index 8174c14..9695647 100644
--- a/test/views/recipe_view_test.exs
+++ b/test/views/recipe_view_test.exs
-  @recipe1 %{id: "1", name: "淘米", title: "侠饭", season: "1", episode: "1", content: "洗掉米表面的淀粉", user_id: "999"}
-  @recipe2 %{id: "2", name: "煮饭", title: "侠饭", season: "1", episode: "1", content: "浸泡", user_id: "888"}
# 使用带有 url field 的数据
+  @recipe1 %{id: "1", name: "淘米", title: "侠饭", season: "1", episode: "1", content: "洗掉米表面的淀粉", user_id: "999", url: "http://localhost"}
+  @recipe2 %{id: "2", name: "煮饭", title: "侠饭", season: "1", episode: "1", content: "浸泡", user_id: "888", url: "http://localhost"}
@@ -28,4 +28,23 @@ defmodule TvRecipe.RecipeViewTest do
     end
   end

+  test "render new.html", %{conn: conn} do
+    changeset = Recipe.changeset(%Recipe{}, %{})
+    content = render_to_string(TvRecipe.RecipeView, "new.html", conn: conn, changeset: changeset)
+    assert String.contains?(content, "url")
+  end
+
+  test "render show.html", %{conn: conn} do
+    recipe = struct(Recipe, @recipe1)
+    content = render_to_string(TvRecipe.RecipeView, "show.html", conn: conn, recipe: recipe)
+    assert String.contains?(content, @recipe1.url)
+  end
+
+  test "render edit.html", %{conn: conn} do
+    recipe = struct(Recipe, @recipe1)
+    changeset = Recipe.changeset(%Recipe{}, @recipe1)
+    content = render_to_string(TvRecipe.RecipeView, "edit.html", conn: conn, changeset: changeset, recipe: recipe)
+    assert String.contains?(content, @recipe1.url)
+  end
+
 end

然后运行测试:

mix test
Compiling 1 file (.ex)
...

  1) test render new.html (TvRecipe.RecipeViewTest)
     test/views/recipe_view_test.exs:31
     Expected truthy, got false
     code: String.contains?(content, "url")
     stacktrace:
       test/views/recipe_view_test.exs:34: (test)



  2) test render show.html (TvRecipe.RecipeViewTest)
     test/views/recipe_view_test.exs:37
     Expected truthy, got false
     code: String.contains?(content, recipe.url())
     stacktrace:
       test/views/recipe_view_test.exs:40: (test)

.

  3) test render edit.html (TvRecipe.RecipeViewTest)
     test/views/recipe_view_test.exs:43
     Expected truthy, got false
     code: String.contains?(content, recipe.url())
     stacktrace:
       test/views/recipe_view_test.exs:47: (test)

.......................................................

Finished in 0.9 seconds
62 tests, 3 failures

根据测试结果,我们修改文件:

diff --git a/web/templates/recipe/form.html.eex b/web/templates/recipe/form.html.eex
index 3bf90ff..ab12be3 100644
--- a/web/templates/recipe/form.html.eex
+++ b/web/templates/recipe/form.html.eex
@@ -30,6 +30,12 @@
   </div>

   <div class="form-group">
+    <%= label f, :url, class: "control-label" %>
+    <%= text_input f, :url, class: "form-control" %>
+    <%= error_tag f, :url %>
+  </div>
+
+  <div class="form-group">
     <%= label f, :content, class: "control-label" %>
     <%= textarea f, :content, class: "form-control" %>
     <%= error_tag f, :content %>
diff --git a/web/templates/recipe/show.html.eex b/web/templates/recipe/show.html.eex
index 3ef437d..f4ea463 100644
--- a/web/templates/recipe/show.html.eex
+++ b/web/templates/recipe/show.html.eex
@@ -23,6 +23,11 @@
   </li>

   <li>
+    <strong>Url:</strong>
+    <%= @recipe.url %>
+  </li>
+
+  <li>
     <strong>Content:</strong>
     <%= @recipe.content %>
   </li>

最后再运行一次测试:

mix test
Compiling 1 file (.ex)
..............................................................

Finished in 0.9 seconds
62 tests, 0 failures

测试全部通过。

上一章:菜谱视图

最后更新于