From a6b404dddc21457bae73bfa471ca27f136225168 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Thu, 15 Sep 2022 10:53:44 +0100 Subject: [PATCH 01/18] Update dependencies Run `mix deps.udpate --all` ref: #245 --- mix.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.lock b/mix.lock index ffa72704..2ec06536 100644 --- a/mix.lock +++ b/mix.lock @@ -41,7 +41,7 @@ "mochiweb": {:hex, :mochiweb, "2.22.0", "f104d6747c01a330c38613561977e565b788b9170055c5241ac9dd6e4617cba5", [:rebar3], [], "hexpm", "cbbd1fd315d283c576d1c8a13e0738f6dafb63dc840611249608697502a07655"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "phoenix": {:hex, :phoenix, "1.6.11", "29f3c0fd12fa1fc4d4b05e341578e55bc78d96ea83a022587a7e276884d397e4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1664e34f80c25ea4918fbadd957f491225ef601c0e00b4e644b1a772864bfbc2"}, + "phoenix": {:hex, :phoenix, "1.6.12", "f8f8ac077600f84419806dd53114b2e77aedde7a502e74181a7d886355aa0643", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2d6cf5583c9c20f7103c40e6014ef802d96553b8e5d6585ad6e627bd5ddb0d12"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, @@ -50,7 +50,7 @@ "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, "postgrex": {:hex, :postgrex, "0.16.4", "26d998467b4a22252285e728a29d341e08403d084e44674784975bb1cd00d2cb", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {: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", "3234d1a70cb7b1e0c95d2e242785ec2a7a94a092bbcef4472320b950cfd64c5f"}, "pre_commit": {:hex, :pre_commit, "0.3.4", "e2850f80be8090d50ad8019ef2426039307ff5dfbe70c736ad0d4d401facf304", [:mix], [], "hexpm", "16f684ba4f1fed1cba6b19e082b0f8d696e6f1c679285fedf442296617ba5f4e"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, From 68033ed522b50d6c13e528125e3b6c95eb961261 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Thu, 15 Sep 2022 12:07:34 +0100 Subject: [PATCH 02/18] Add tags and items_tags tables Create Ecto migration with unique indexes for tags and items_tags ref: #245 app --- priv/repo/migrations/20220915103524_add_tags.exs | 13 +++++++++++++ .../migrations/20220915104854_add_items_tags.exs | 14 ++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 priv/repo/migrations/20220915103524_add_tags.exs create mode 100644 priv/repo/migrations/20220915104854_add_items_tags.exs diff --git a/priv/repo/migrations/20220915103524_add_tags.exs b/priv/repo/migrations/20220915103524_add_tags.exs new file mode 100644 index 00000000..c0d0f789 --- /dev/null +++ b/priv/repo/migrations/20220915103524_add_tags.exs @@ -0,0 +1,13 @@ +defmodule App.Repo.Migrations.AddTags do + use Ecto.Migration + + def change do + create table(:tags) do + add(:text, :string) + + timestamps() + end + + create(unique_index(:tags, ["lower(text)"])) + end +end diff --git a/priv/repo/migrations/20220915104854_add_items_tags.exs b/priv/repo/migrations/20220915104854_add_items_tags.exs new file mode 100644 index 00000000..a9f0356a --- /dev/null +++ b/priv/repo/migrations/20220915104854_add_items_tags.exs @@ -0,0 +1,14 @@ +defmodule App.Repo.Migrations.AddItemsTags do + use Ecto.Migration + + def change do + create table(:items_tags) do + add(:item_id, references(:items, on_delete: :nothing)) + add(:tag_id, references(:tags, on_delete: :nothing)) + + timestamps() + end + + create(unique_index(:items_tags, [:item_id, :tag_id])) + end +end From 024702d365743dac7df65de3a8b0ec59dd8be551 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Thu, 15 Sep 2022 16:00:36 +0100 Subject: [PATCH 03/18] Update Schemas for using tags Add schemas and use many_to_many function to create associations between tags and items --- lib/app/item.ex | 4 +++- lib/app/item_tag.ex | 11 +++++++++++ lib/app/tag.ex | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 lib/app/item_tag.ex create mode 100644 lib/app/tag.ex diff --git a/lib/app/item.ex b/lib/app/item.ex index fbd1b494..fe75382a 100644 --- a/lib/app/item.ex +++ b/lib/app/item.ex @@ -2,7 +2,7 @@ defmodule App.Item do use Ecto.Schema import Ecto.Changeset import Ecto.Query - alias App.Repo + alias App.{Repo, Tag, ItemTag} alias __MODULE__ schema "items" do @@ -10,6 +10,8 @@ defmodule App.Item do field :status, :integer field :text, :string + many_to_many(:tags, Tag, join_through: ItemTag) + timestamps() end diff --git a/lib/app/item_tag.ex b/lib/app/item_tag.ex new file mode 100644 index 00000000..906adc0c --- /dev/null +++ b/lib/app/item_tag.ex @@ -0,0 +1,11 @@ +defmodule App.ItemTag do + use Ecto.Schema + alias App.{Item, Tag} + + schema "items_tags" do + belongs_to(:item, Item) + belongs_to(:tag, Tag) + + timestamps() + end +end diff --git a/lib/app/tag.ex b/lib/app/tag.ex new file mode 100644 index 00000000..fb9312e6 --- /dev/null +++ b/lib/app/tag.ex @@ -0,0 +1,19 @@ +defmodule App.Tag do + use Ecto.Schema + import Ecto.Changeset + alias App.{Item, ItemTag} + + schema "tags" do + field :text, :string + + many_to_many(:items, Item, join_through: ItemTag) + timestamps() + end + + @doc false + def changeset(tag, attrs) do + tag + |> cast(attrs, [:text]) + |> validate_required([:text]) + end +end From 16282b10b9b53e3e57a40df27b66e8fdf9d6d893 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Fri, 16 Sep 2022 09:12:34 +0100 Subject: [PATCH 04/18] Update tag migration and schema Link tag to a person --- lib/app/tag.ex | 3 ++- priv/repo/migrations/20220915103524_add_tags.exs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/app/tag.ex b/lib/app/tag.ex index fb9312e6..a9e46acf 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -5,6 +5,7 @@ defmodule App.Tag do schema "tags" do field :text, :string + field :person_id, :integer many_to_many(:items, Item, join_through: ItemTag) timestamps() @@ -13,7 +14,7 @@ defmodule App.Tag do @doc false def changeset(tag, attrs) do tag - |> cast(attrs, [:text]) + |> cast(attrs, [:person_id, :text]) |> validate_required([:text]) end end diff --git a/priv/repo/migrations/20220915103524_add_tags.exs b/priv/repo/migrations/20220915103524_add_tags.exs index c0d0f789..889d0924 100644 --- a/priv/repo/migrations/20220915103524_add_tags.exs +++ b/priv/repo/migrations/20220915103524_add_tags.exs @@ -4,10 +4,11 @@ defmodule App.Repo.Migrations.AddTags do def change do create table(:tags) do add(:text, :string) + add(:person_id, :integer) timestamps() end - create(unique_index(:tags, ["lower(text)"])) + create(unique_index(:tags, ["lower(text)", :person_id])) end end From 165072937eb8739f51fff976a650a63d2f16149f Mon Sep 17 00:00:00 2001 From: SimonLab Date: Fri, 16 Sep 2022 11:53:50 +0100 Subject: [PATCH 05/18] Remove primary key from join items tags table Add primary_key: false option in migration --- priv/repo/migrations/20220915104854_add_items_tags.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/repo/migrations/20220915104854_add_items_tags.exs b/priv/repo/migrations/20220915104854_add_items_tags.exs index a9f0356a..320225b9 100644 --- a/priv/repo/migrations/20220915104854_add_items_tags.exs +++ b/priv/repo/migrations/20220915104854_add_items_tags.exs @@ -2,7 +2,7 @@ defmodule App.Repo.Migrations.AddItemsTags do use Ecto.Migration def change do - create table(:items_tags) do + create table(:items_tags, primary_key: false) do add(:item_id, references(:items, on_delete: :nothing)) add(:tag_id, references(:tags, on_delete: :nothing)) From 600feecfeadf2d031d81e7d4094485a4c6b41586 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Fri, 16 Sep 2022 12:37:57 +0100 Subject: [PATCH 06/18] Add test for Tag schema Test the Tag schema see https://hexdocs.pm/phoenix/testing_contexts.html#testing-schemas --- lib/app/tag.ex | 2 +- test/app/tag_test.exs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 test/app/tag_test.exs diff --git a/lib/app/tag.ex b/lib/app/tag.ex index a9e46acf..6f15a6c1 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -15,6 +15,6 @@ defmodule App.Tag do def changeset(tag, attrs) do tag |> cast(attrs, [:person_id, :text]) - |> validate_required([:text]) + |> validate_required([:person_id, :text]) end end diff --git a/test/app/tag_test.exs b/test/app/tag_test.exs new file mode 100644 index 00000000..aa82976b --- /dev/null +++ b/test/app/tag_test.exs @@ -0,0 +1,19 @@ +defmodule App.TagTest do + use App.DataCase + alias App.Tag + + test "valid tag changeset" do + changeset = Tag.changeset(%Tag{}, %{person_id: 1, text: "tag1"}) + assert changeset.valid? + end + + test "invalid tag changeset when person_id value missing" do + changeset = Tag.changeset(%Tag{}, %{text: "tag1"}) + refute changeset.valid? + end + + test "invalid tag changeset when text value missing" do + changeset = Tag.changeset(%Tag{}, %{person_id: 1}) + refute changeset.valid? + end +end From f6b94b1ebce1207b05f97d667587b92a38549a38 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Wed, 21 Sep 2022 15:18:04 +0100 Subject: [PATCH 07/18] Add alias for iex Create .iex.exs file with aliases. This allow us to access directly the aliases when running a `iex -S mix` session ref: https://twitter.com/mplatts/status/1570674202465935360 --- .iex.exs | 1 + lib/app/tag.ex | 1 + 2 files changed, 2 insertions(+) create mode 100644 .iex.exs diff --git a/.iex.exs b/.iex.exs new file mode 100644 index 00000000..84a8b2af --- /dev/null +++ b/.iex.exs @@ -0,0 +1 @@ +alias App.{Repo, Item, Tag, ItemTag} diff --git a/lib/app/tag.ex b/lib/app/tag.ex index 6f15a6c1..66083b8c 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -16,5 +16,6 @@ defmodule App.Tag do tag |> cast(attrs, [:person_id, :text]) |> validate_required([:person_id, :text]) + |> unique_constraint([:person_id, :text]) end end From e4df19290d377e6dcc060004d6de4545f780a66f Mon Sep 17 00:00:00 2001 From: SimonLab Date: Thu, 22 Sep 2022 10:47:19 +0100 Subject: [PATCH 08/18] Add doc for tags Add documentation on how to create the tags migrations and schemas --- BUILDIT.md | 141 +++++++++++++++++- lib/app/item_tag.ex | 1 + .../20220915104854_add_items_tags.exs | 4 +- priv/repo/seeds_test.exs | 15 ++ 4 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 priv/repo/seeds_test.exs diff --git a/BUILDIT.md b/BUILDIT.md index 162b172e..c229df77 100644 --- a/BUILDIT.md +++ b/BUILDIT.md @@ -84,10 +84,11 @@ With that in place, let's get building! - [8.1 Update the `root` layout/template](#81-update-the-root-layouttemplate) - [8.2 Create the `icons` template](#82-create-the-icons-template) - [9. Update the `LiveView` Template](#9-update-the-liveview-template) -- [Filter Items](#filter-items) -- [11. Run the _Finished_ MVP App!](#11-run-the-finished-mvp-app) - - [11.1 Run the Tests](#111-run-the-tests) - - [11.2 Run The App](#112-run-the-app) +- [10. Filter Items](#10-filter-items) +- [11. Tags](#11-tags) +- [12. Run the _Finished_ MVP App!](#12-run-the-finished-mvp-app) + - [12.1 Run the Tests](#121-run-the-tests) + - [12.2 Run The App](#122-run-the-app) - [Thanks!](#thanks) @@ -2110,7 +2111,7 @@ The bulk of the App is containted in this one template file.
Work your way through it and if anything is unclear, let us know! -# Filter Items +# 10. Filter Items On this section we want to add LiveView links to filter items by status. We first update the template to add the following footer: @@ -2266,12 +2267,136 @@ items are properly displayed and removed from the view. See also the [Live Navigation](https://hexdocs.pm/phoenix_live_view/live-navigation.html) Phoenix documentation for using `live_patch` +# 11. Tags -# 11. Run the _Finished_ MVP App! +In this section we're going to add tags to items. +Tags belong to a person (ie different user can create the same tag name). +A person can't create tag duplicates (case insensitive). + + +We first want to create a new `tags` table in our database. +We can use the `mix ecto.gen.migration add_tags` command to create a new +migration and then create manually a `App.Tag` schema, or we can directly use +the [mix phx.gen.schema](https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Schema.html) +command to create the schema and the migration in one step: + +```sh +mix phx.gen.schema Tag tags person_id:integer text:string +``` + +You should see a similar response: + +```sh +* creating lib/app/tags.ex +* creating priv/repo/migrations/20220922084231_create_tags.exs + +Remember to update your repository by running migrations: + + $ mix ecto.migrate +``` + + +We can repeat this process to create a `items_tags` table and `ItemTag` +schema. This [join table](https://en.wikipedia.org/wiki/Associative_entity) +is used to link items and tags together. + +```sh +mix phx.gen.schema ItemTag items_tags item_id:references:items tag_id:references:tags +``` + +We are using the [references](https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Schema.html#module-attributes) +attribute to link the `item_id` field to the `items` table and `tag_id` to `tags`. + +Before running our migrations file, we need to add a few changes to them. + + +In our `create_tags` migration, update the file to: + +```elixir + def change do + create table(:tags) do + add(:person_id, :integer) + add(:text, :string) + + timestamps() + end + + create(unique_index(:tags, ["lower(text)", :person_id])) + end +``` + +We have added a unique index on the fields `text` and `person_id`. +This means a person can't create duplicated tags. +The `"lower(text)"` function also makes sure the tags are case insensitive, +for example if a tag `UI` has been created, the person then won't be able to create +the `ui` tag. + +In our `create_items_tags` migration, update the file with: + +```elixir + def change do + create table(:items_tags, primary_key: false) do + add(:item_id, references(:items, on_delete: :delete_all)) + add(:tag_id, references(:tags, on_delete: :delete_all)) + + timestamps() + end + + create(unique_index(:items_tags, [:item_id, :tag_id])) + end +``` + +- We have added the `primary_key: false` option. This to avoid having the `id` +column created automatically by the migration. + +- We've updated the `on_delete` option to `delete_all`. This means that if an +item or a tag is deleted, we then remove the rows linked to this item/tag +in the join table `items_tags`. However if for example an item is deleted the +references in the join table will be removed but the tags linked to the deleted +item won't be removed. + +- Finally we create a unique index on the `item_id` and `tag_id` fields to make +sure that a same tag can't be added multiple times to an item. + + +We can now run our migrations with `mix ecto.migrate`: + +```sh +Compiling 2 files (.ex) +Generated app app + +10:16:42.276 [info] == Running 20220922091606 App.Repo.Migrations.CreateTags.change/0 forward + +10:16:42.279 [info] create table tags + +10:16:42.284 [info] == Migrated 20220922091606 in 0.0s + +10:16:42.307 [info] == Running 20220922091636 App.Repo.Migrations.CreateItemsTags.change/0 forward + +10:16:42.307 [info] create table items_tags + +10:16:42.313 [info] create index items_tags_item_id_index + +10:16:42.315 [info] create index items_tags_tag_id_index + +10:16:42.316 [info] == Migrated 20220922091636 in 0.0s +``` + +- [ ] upate tags schema +- [ ] update itemTag schema attribute @noprimarykey otherwise inserting will failed +- [ ] Tips: add .iex.exs file to automatically create alias when using `iex -S mix` +- [ ] Create seeds file and Testing manually association and `on_delete`. ref stackoverflow + + +Learn more about Ecto with the guides documenation, especially the How to section: +https://hexdocs.pm/ecto/getting-started.html (taken from: https://dashbit.co/ebooks/the-little-ecto-cookbook) + + +# 12. Run the _Finished_ MVP App! With all the code saved, let's run the tests one more time. -## 11.1 Run the Tests +## 12.1 Run the Tests In your terminal window, run: @@ -2300,7 +2425,7 @@ COV FILE LINES RELEVANT MISSED All tests pass and we have **`100%` Test Coverage**. This reminds us just how few _relevant_ lines of code there are in the MVP! -## 11.2 Run The App +## 12.2 Run The App In your second terminal tab/window, run: diff --git a/lib/app/item_tag.ex b/lib/app/item_tag.ex index 906adc0c..265d1919 100644 --- a/lib/app/item_tag.ex +++ b/lib/app/item_tag.ex @@ -2,6 +2,7 @@ defmodule App.ItemTag do use Ecto.Schema alias App.{Item, Tag} + @primary_key false schema "items_tags" do belongs_to(:item, Item) belongs_to(:tag, Tag) diff --git a/priv/repo/migrations/20220915104854_add_items_tags.exs b/priv/repo/migrations/20220915104854_add_items_tags.exs index 320225b9..14029e36 100644 --- a/priv/repo/migrations/20220915104854_add_items_tags.exs +++ b/priv/repo/migrations/20220915104854_add_items_tags.exs @@ -3,8 +3,8 @@ defmodule App.Repo.Migrations.AddItemsTags do def change do create table(:items_tags, primary_key: false) do - add(:item_id, references(:items, on_delete: :nothing)) - add(:tag_id, references(:tags, on_delete: :nothing)) + add(:item_id, references(:items, on_delete: :nilify_all)) + add(:tag_id, references(:tags, on_delete: :nilify_all)) timestamps() end diff --git a/priv/repo/seeds_test.exs b/priv/repo/seeds_test.exs new file mode 100644 index 00000000..95708989 --- /dev/null +++ b/priv/repo/seeds_test.exs @@ -0,0 +1,15 @@ +alias App.{Repo, Item, Tag, ItemTag} + +# reset +Repo.delete_all(Item) +Repo.delete_all(Tag) + +item1 = Repo.insert!(%Item{person_id: 1, text: "task1"}) +item2 = Repo.insert!(%Item{person_id: 1, text: "task2"}) + +tag1 = Repo.insert!(%Tag{person_id: 1, text: "tag1"}) +tag2 = Repo.insert!(%Tag{person_id: 1, text: "tag2"}) + +Repo.insert!(%ItemTag{item_id: item1.id, tag_id: tag1.id}) +Repo.insert!(%ItemTag{item_id: item1.id, tag_id: tag2.id}) +Repo.insert!(%ItemTag{item_id: item2.id, tag_id: tag2.id}) From 9bd353453695cc93530e300f4f6853c610f83ac0 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Thu, 22 Sep 2022 15:36:21 +0100 Subject: [PATCH 09/18] Add sections to BUILDIT file - Add more documentation about the migrations and schemas - use citext in migration --- BUILDIT.md | 223 +++++++++++++++++- .../migrations/20220915103524_add_tags.exs | 6 +- .../20220915104854_add_items_tags.exs | 4 +- 3 files changed, 225 insertions(+), 8 deletions(-) diff --git a/BUILDIT.md b/BUILDIT.md index c229df77..b7a85aef 100644 --- a/BUILDIT.md +++ b/BUILDIT.md @@ -2274,6 +2274,8 @@ Tags belong to a person (ie different user can create the same tag name). A person can't create tag duplicates (case insensitive). +## 11.1 Migrations + We first want to create a new `tags` table in our database. We can use the `mix ecto.gen.migration add_tags` command to create a new migration and then create manually a `App.Tag` schema, or we can directly use @@ -2331,6 +2333,39 @@ The `"lower(text)"` function also makes sure the tags are case insensitive, for example if a tag `UI` has been created, the person then won't be able to create the `ui` tag. + +Another solution for case insensitive with Postgres is to use the +`citext` extension. Update the migration with: + +```elixir + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext" + + create table(:tags) do + add(:person_id, :integer) + add(:text, :citext) + + timestamps() + end + + create(unique_index(:tags, [:text, :person_id])) + end +``` + +And that's all, Postgres will take care of checking the text value case-sensitivity +for us. + + + +see also for some information about `lower` and `citext`: +- https://hexdocs.pm/ecto/Ecto.Changeset.html#unique_constraint/3-case-sensitivity +- https://elixirforum.com/t/case-insensitive-column-in-ecto/2062/5 +- https://www.postgresql.org/docs/current/citext.html +- https://nandovieira.com/using-insensitive-case-columns-in-postgresql-with-citext + + + In our `create_items_tags` migration, update the file with: ```elixir @@ -2355,6 +2390,15 @@ in the join table `items_tags`. However if for example an item is deleted the references in the join table will be removed but the tags linked to the deleted item won't be removed. +The [`on_delete` values](https://hexdocs.pm/ecto_sql/Ecto.Migration.html#references/2-options) +can be +- `:nothing` (default), Postgres raises an error if the deleted data is still linked in +the join table +- `:delete_all`, delete the data and the references in the join table +- `:nilify_all`, delete the data and change the id to nil in the join table +- `:restrict`, similar to `:nothing`, see https://stackoverflow.com/questions/60043008/when-to-use-nothing-or-restrict-for-on-delete-with-ecto + + - Finally we create a unique index on the `item_id` and `tag_id` fields to make sure that a same tag can't be added multiple times to an item. @@ -2382,10 +2426,181 @@ Generated app app 10:16:42.316 [info] == Migrated 20220922091636 in 0.0s ``` -- [ ] upate tags schema -- [ ] update itemTag schema attribute @noprimarykey otherwise inserting will failed -- [ ] Tips: add .iex.exs file to automatically create alias when using `iex -S mix` -- [ ] Create seeds file and Testing manually association and `on_delete`. ref stackoverflow +## 11.2 Schemas + +Now that our database is setup for tags, we can update our schemas. + + +In `lib/app/tag.ex`, update the file to: + + +```elixir +defmodule App.Tag do + use Ecto.Schema + import Ecto.Changeset + alias App.{Item, ItemTag} + + schema "tags" do + field :text, :string + field :person_id, :integer + + many_to_many(:items, Item, join_through: ItemTag) + timestamps() + end + + @doc false + def changeset(tag, attrs) do + tag + |> cast(attrs, [:person_id, :text]) + |> validate_required([:person_id, :text]) + |> unique_constraint([:person_id, :text]) + end +end + +``` + +We have added the [many_to_many](https://hexdocs.pm/ecto/Ecto.Schema.html#many_to_many/3) function. +We've also added in the `changeset` the [unique_constraint](https://hexdocs.pm/ecto/Ecto.Changeset.html#unique_constraint/3) +for the `person_id` and `text` values. + + +In `lib/app/item.ex`, add also the `many_to_many` function to the schema + +```elixir + schema "items" do + field :person_id, :integer + field :status, :integer + field :text, :string + + many_to_many(:tags, Tag, join_through: ItemTag) + + timestamps() + end +``` + +Finally in `lib/app/item_tag.ex`: + +```elixir + @primary_key false + schema "items_tags" do + belongs_to(:item, Item) + belongs_to(:tag, Tag) + + timestamps() + end +``` + +Because we have define our `items_tags` migration to not use the default `id` +for the primary we want to reflect this change on the schema by using the +[primary_key false](https://hexdocs.pm/ecto/Ecto.Schema.html#module-schema-attributes) +schema attribute. + +If we don't add this attribute if we attempt +to insert or to get one of the `item_tag` value from the database, +the query will fail as the schema will try to retreive the non existent `id` column. + + +We also use the `belongs_to` function to define the association with the `Item` and +`Tag` schemas. + + +## 11.3 Test tags with Iex + +Let's use `iex` to create some items and tags and to check our constraints +are working on the tags + +To make our life easier when using `iex` we're going to first create a `.iex.exs` +file containing any aliases you want to have when starting a session: + + +```elixir +alias App.{Repo, Item, Tag, ItemTag} +``` + +So when running the Phoenix application `iex -S mix` you will have access +directly to `Repo`, `Item`, `Tag` and `ItemTag`! +see also: https://alchemist.camp/episodes/iex-exs + + + +now run `iex -S mix` and let's create a few items and tags: + +```sh +item1 = Repo.insert!(%Item{person_id: 1, text: "item1"}) +item2 = Repo.insert!(%Item{person_id: 1, text: "item2"}) + +tag1 = Repo.insert!(%Tag{person_id: 1, text: "Tag1"}) +tag2 = Repo.insert!(%Tag{person_id: 1, text: "Tag2"}) +``` + +We've created two items and two tags, now if we attempt to create "tag1" with the +same person id: + +```sh +Repo.insert!(%Tag{person_id: 1, text: "tag1"}) + +** (Ecto.ConstraintError) constraint error when attempting to insert struct: + + * tags_text_person_id_index (unique_constraint) +``` + +We can see that the `citext` type is working as "Tag1" and "tag1" can't coexist. + +However if we change the person id we can still create the tag: + +```sh +Repo.insert!(%Tag{person_id: 2, text: "tag1"}) +[debug] QUERY OK db=5.8ms queue=0.1ms idle=1767.0ms +``` + +We can manually link the tag and the item: + +```sh +Repo.insert!{%ItemTag{item_id: item1.id, tag_id: tag1.id}) +Repo.delete(item1) +Repo.all(ItemTag) +``` + +We are creating a link then we delete the item and finally we verify the list +of `ItemTag` is empty. However if we check the list of tags we can see the tag +with id 1 still exist + +Finally we can check that we can't add duplicate tags to an item: + +```sh +Repo.insert!{%ItemTag{item_id: item2.id, tag_id: tag2.id}) +Repo.insert!{%ItemTag{item_id: item2.id, tag_id: tag2.id}) +** (Ecto.ConstraintError) constraint error when attempting to insert struct: + + * items_tags_item_id_tag_id_index (unique_constraint) +``` + + +Typing all of this in iex is a slow and if we want to add data to our database +we can use the `priv/repo/seeds.exs` file: + +```elixir +alias App.{Repo, Item, Tag, ItemTag} + +# reset +Repo.delete_all(Item) +Repo.delete_all(Tag) + +item1 = Repo.insert!(%Item{person_id: 1, text: "task1"}) +item2 = Repo.insert!(%Item{person_id: 1, text: "task2"}) + +tag1 = Repo.insert!(%Tag{person_id: 1, text: "tag1"}) +tag2 = Repo.insert!(%Tag{person_id: 1, text: "tag2"}) + +Repo.insert!(%ItemTag{item_id: item1.id, tag_id: tag1.id}) +Repo.insert!(%ItemTag{item_id: item1.id, tag_id: tag2.id}) +Repo.insert!(%ItemTag{item_id: item2.id, tag_id: tag2.id}) +``` + +Then running `mix run priv/repo/seeds.exs` command will populate our database +with our items and tags. + +## 11.4 Items, Tags association Learn more about Ecto with the guides documenation, especially the How to section: diff --git a/priv/repo/migrations/20220915103524_add_tags.exs b/priv/repo/migrations/20220915103524_add_tags.exs index 889d0924..8ee5ace9 100644 --- a/priv/repo/migrations/20220915103524_add_tags.exs +++ b/priv/repo/migrations/20220915103524_add_tags.exs @@ -2,13 +2,15 @@ defmodule App.Repo.Migrations.AddTags do use Ecto.Migration def change do + execute("CREATE EXTENSION IF NOT EXISTS citext") + create table(:tags) do - add(:text, :string) + add(:text, :citext) add(:person_id, :integer) timestamps() end - create(unique_index(:tags, ["lower(text)", :person_id])) + create(unique_index(:tags, [:text, :person_id])) end end diff --git a/priv/repo/migrations/20220915104854_add_items_tags.exs b/priv/repo/migrations/20220915104854_add_items_tags.exs index 14029e36..724a7f96 100644 --- a/priv/repo/migrations/20220915104854_add_items_tags.exs +++ b/priv/repo/migrations/20220915104854_add_items_tags.exs @@ -3,8 +3,8 @@ defmodule App.Repo.Migrations.AddItemsTags do def change do create table(:items_tags, primary_key: false) do - add(:item_id, references(:items, on_delete: :nilify_all)) - add(:tag_id, references(:tags, on_delete: :nilify_all)) + add(:item_id, references(:items, on_delete: :delete_all)) + add(:tag_id, references(:tags, on_delete: :delete_all)) timestamps() end From 2e0db9572c3977d140975c147f021afcdca859f8 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Thu, 22 Sep 2022 15:54:25 +0100 Subject: [PATCH 10/18] Update LiveView - Get the latest version of LiveView - assign_new is now part of Phoenix.Component module --- lib/app_web/controllers/auth_controller.ex | 12 ++++++------ mix.exs | 2 +- mix.lock | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/app_web/controllers/auth_controller.ex b/lib/app_web/controllers/auth_controller.ex index 0e44b5aa..242c0dda 100644 --- a/lib/app_web/controllers/auth_controller.ex +++ b/lib/app_web/controllers/auth_controller.ex @@ -1,13 +1,13 @@ defmodule AppWeb.AuthController do use AppWeb, :controller - import Phoenix.LiveView, only: [assign_new: 3] + import Phoenix.Component, only: [assign_new: 3] def on_mount(:default, _params, %{"jwt" => jwt} = _session, socket) do - - claims = jwt - |> AuthPlug.Token.verify_jwt!() - |> AuthPlug.Helpers.strip_struct_metadata() - |> Useful.atomize_map_keys() + claims = + jwt + |> AuthPlug.Token.verify_jwt!() + |> AuthPlug.Helpers.strip_struct_metadata() + |> Useful.atomize_map_keys() socket = socket diff --git a/mix.exs b/mix.exs index d09ee831..dd74d3bc 100644 --- a/mix.exs +++ b/mix.exs @@ -47,7 +47,7 @@ defmodule App.MixProject do {:postgrex, ">= 0.0.0"}, {:phoenix_html, "~> 3.0"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, - {:phoenix_live_view, "~> 0.17.5"}, + {:phoenix_live_view, "~> 0.18.0"}, {:floki, ">= 0.30.0", only: :test}, {:esbuild, "~> 0.4", runtime: Mix.env() == :dev}, {:telemetry_metrics, "~> 0.6"}, diff --git a/mix.lock b/mix.lock index 2ec06536..8a05dccc 100644 --- a/mix.lock +++ b/mix.lock @@ -45,13 +45,13 @@ "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.11", "205f6aa5405648c76f2abcd57716f42fc07d8f21dd8ea7b262dd12b324b50c95", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7177791944b7f90ed18f5935a6a5f07f760b36f7b3bdfb9d28c57440a3c43f99"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.0", "8705283efbc623df6290d5f8cb233afa9bcdcfc969749ce6e313877108f65887", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6 or ~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "545f11c15d595595690da16c4f607417bfb1862e518c07c9f78c754ac186cd7d"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, - "postgrex": {:hex, :postgrex, "0.16.4", "26d998467b4a22252285e728a29d341e08403d084e44674784975bb1cd00d2cb", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {: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", "3234d1a70cb7b1e0c95d2e242785ec2a7a94a092bbcef4472320b950cfd64c5f"}, + "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {: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", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, "pre_commit": {:hex, :pre_commit, "0.3.4", "e2850f80be8090d50ad8019ef2426039307ff5dfbe70c736ad0d4d401facf304", [:mix], [], "hexpm", "16f684ba4f1fed1cba6b19e082b0f8d696e6f1c679285fedf442296617ba5f4e"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, From 45b22b41ed23890333f2235687d6f610e323a41d Mon Sep 17 00:00:00 2001 From: SimonLab Date: Mon, 26 Sep 2022 12:23:18 +0100 Subject: [PATCH 11/18] Add ItemTag schema test Add simple test to test field value for ItemTag schema --- test/app/item_tag_test.exs | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 test/app/item_tag_test.exs diff --git a/test/app/item_tag_test.exs b/test/app/item_tag_test.exs new file mode 100644 index 00000000..aa5741e9 --- /dev/null +++ b/test/app/item_tag_test.exs @@ -0,0 +1,10 @@ +defmodule App.ItemTagTest do + use App.DataCase + alias App.ItemTag + + test "valid tag changeset" do + item_tag = %ItemTag{item_id: 1, tag_id: 1} + assert item_tag.item_id == 1 + assert item_tag.tag_id == 1 + end +end From 6e80e6666a0e04b8c89f4d3903292d16a0018f8a Mon Sep 17 00:00:00 2001 From: SimonLab Date: Mon, 26 Sep 2022 19:02:57 +0100 Subject: [PATCH 12/18] Define name for unique constraint in Tag Define name of the unique constraint. This will convert the raised error to changeset error --- BUILDIT.md | 15 +++++-- lib/app/tag.ex | 13 +++++- .../migrations/20220915103524_add_tags.exs | 4 +- test/app/tag_test.exs | 41 ++++++++++++++----- 4 files changed, 57 insertions(+), 16 deletions(-) diff --git a/BUILDIT.md b/BUILDIT.md index b7a85aef..a6a3fccf 100644 --- a/BUILDIT.md +++ b/BUILDIT.md @@ -2323,11 +2323,13 @@ In our `create_tags` migration, update the file to: timestamps() end - create(unique_index(:tags, ["lower(text)", :person_id])) + create(unique_index(:tags, ["lower(text)", :person_id], name: tags_text_person_id_index)) end ``` We have added a unique index on the fields `text` and `person_id`. +We have specify the name `tags_text_person_id_index` to the index to make +sure later one to use it in the `Tag` changeset. This means a person can't create duplicated tags. The `"lower(text)"` function also makes sure the tags are case insensitive, for example if a tag `UI` has been created, the person then won't be able to create @@ -2349,7 +2351,7 @@ Another solution for case insensitive with Postgres is to use the timestamps() end - create(unique_index(:tags, [:text, :person_id])) + create(unique_index(:tags, [:text, :person_id], name: tags_text_person_id_index)) end ``` @@ -2453,7 +2455,7 @@ defmodule App.Tag do tag |> cast(attrs, [:person_id, :text]) |> validate_required([:person_id, :text]) - |> unique_constraint([:person_id, :text]) + |> unique_constraint([:person_id, :text], name: :tags_text_person_id_index) end end @@ -2462,6 +2464,8 @@ end We have added the [many_to_many](https://hexdocs.pm/ecto/Ecto.Schema.html#many_to_many/3) function. We've also added in the `changeset` the [unique_constraint](https://hexdocs.pm/ecto/Ecto.Changeset.html#unique_constraint/3) for the `person_id` and `text` values. +We have define the name of the unique contrtraint to match the one define +in our migration. In `lib/app/item.ex`, add also the `many_to_many` function to the schema @@ -2600,6 +2604,11 @@ Repo.insert!(%ItemTag{item_id: item2.id, tag_id: tag2.id}) Then running `mix run priv/repo/seeds.exs` command will populate our database with our items and tags. +## 11.4 Testing Schemas + + +ref: https://hexdocs.pm/phoenix/1.3.2/testing_schemas.html + ## 11.4 Items, Tags association diff --git a/lib/app/tag.ex b/lib/app/tag.ex index 66083b8c..e5991167 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -1,7 +1,8 @@ defmodule App.Tag do use Ecto.Schema import Ecto.Changeset - alias App.{Item, ItemTag} + alias App.{Item, ItemTag, Repo} + alias __MODULE__ schema "tags" do field :text, :string @@ -16,6 +17,14 @@ defmodule App.Tag do tag |> cast(attrs, [:person_id, :text]) |> validate_required([:person_id, :text]) - |> unique_constraint([:person_id, :text]) + |> unique_constraint([:text, :person_id], name: :tags_text_person_id_index) end + + def create_tag(attrs) do + %Tag{} + |> changeset(attrs) + |> Repo.insert() + end + + def get_tag!(id), do: Repo.get!(Tag, id) end diff --git a/priv/repo/migrations/20220915103524_add_tags.exs b/priv/repo/migrations/20220915103524_add_tags.exs index 8ee5ace9..f64dee38 100644 --- a/priv/repo/migrations/20220915103524_add_tags.exs +++ b/priv/repo/migrations/20220915103524_add_tags.exs @@ -11,6 +11,8 @@ defmodule App.Repo.Migrations.AddTags do timestamps() end - create(unique_index(:tags, [:text, :person_id])) + create( + unique_index(:tags, [:text, :person_id], name: :tags_text_person_id_index) + ) end end diff --git a/test/app/tag_test.exs b/test/app/tag_test.exs index aa82976b..3c80ceb5 100644 --- a/test/app/tag_test.exs +++ b/test/app/tag_test.exs @@ -2,18 +2,39 @@ defmodule App.TagTest do use App.DataCase alias App.Tag - test "valid tag changeset" do - changeset = Tag.changeset(%Tag{}, %{person_id: 1, text: "tag1"}) - assert changeset.valid? - end + describe "Test contraints and requirements for Tag schema" do + test "valid tag changeset" do + changeset = Tag.changeset(%Tag{}, %{person_id: 1, text: "tag1"}) + assert changeset.valid? + end + + test "invalid tag changeset when person_id value missing" do + changeset = Tag.changeset(%Tag{}, %{text: "tag1"}) + refute changeset.valid? + end - test "invalid tag changeset when person_id value missing" do - changeset = Tag.changeset(%Tag{}, %{text: "tag1"}) - refute changeset.valid? + test "invalid tag changeset when text value missing" do + changeset = Tag.changeset(%Tag{}, %{person_id: 1}) + refute changeset.valid? + end end - test "invalid tag changeset when text value missing" do - changeset = Tag.changeset(%Tag{}, %{person_id: 1}) - refute changeset.valid? + describe "Save tags in Postgres" do + @valid_attrs %{text: "tag1", person_id: 1} + @invalid_attrs %{text: nil} + + test "get_tag!/1 returns the tag with given id" do + {:ok, tag} = Tag.create_tag(@valid_attrs) + assert Tag.get_tag!(tag.id).text == tag.text + end + + test "create_tag/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Tag.create_tag(@invalid_attrs) + end + + test "create_tag/1 returns invalid changeset when trying to insert a duplicate tag" do + assert {:ok, _tag} = Tag.create_tag(@valid_attrs) + assert {:error, _changeset} = Tag.create_tag(@valid_attrs) + end end end From 3ee466d2b73207835dae9bae01bd2bba6ae284ef Mon Sep 17 00:00:00 2001 From: SimonLab Date: Mon, 26 Sep 2022 21:19:22 +0100 Subject: [PATCH 13/18] Create item with tags Associate tags to item using put_assoc --- lib/app/item.ex | 16 ++++++++++++-- lib/app/tag.ex | 47 ++++++++++++++++++++++++++++++++++++++++++ test/app/item_test.exs | 36 +++++++++++++++++++++++++------- test/app/tag_test.exs | 10 +++++++++ 4 files changed, 99 insertions(+), 10 deletions(-) diff --git a/lib/app/item.ex b/lib/app/item.ex index fe75382a..8a534f53 100644 --- a/lib/app/item.ex +++ b/lib/app/item.ex @@ -10,7 +10,7 @@ defmodule App.Item do field :status, :integer field :text, :string - many_to_many(:tags, Tag, join_through: ItemTag) + many_to_many(:tags, Tag, join_through: ItemTag, on_replace: :delete) timestamps() end @@ -19,7 +19,12 @@ defmodule App.Item do def changeset(item, attrs) do item |> cast(attrs, [:person_id, :status, :text]) - |> validate_required([:text]) + |> validate_required([:text, :person_id]) + end + + def changeset_with_tags(item, attrs) do + changeset(item, attrs) + |> put_assoc(:tags, Tag.parse_and_create_tags(attrs)) end @doc """ @@ -40,6 +45,13 @@ defmodule App.Item do |> Repo.insert() end + def create_item_with_tags(attrs) do + %Item{} + |> changeset_with_tags(attrs) + |> IO.inspect() + |> Repo.insert() + end + @doc """ Gets a single item. diff --git a/lib/app/tag.ex b/lib/app/tag.ex index e5991167..03cb273b 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -1,6 +1,7 @@ defmodule App.Tag do use Ecto.Schema import Ecto.Changeset + import Ecto.Query alias App.{Item, ItemTag, Repo} alias __MODULE__ @@ -26,5 +27,51 @@ defmodule App.Tag do |> Repo.insert() end + def parse_and_create_tags(attrs) do + (attrs[:tags] || "") + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> create_tags(attrs[:person_id]) + end + + @doc """ + Insert the list of tag names given as argument. + The function takes the list of tag names (string) and the person's id + and returns the list of created tags. + """ + @spec create_tags(tag_name :: list(String.t()), person_id: integer) :: map() + def create_tags([], _person_id), do: [] + + def create_tags(tag_names, person_id) do + timestamp = + NaiveDateTime.utc_now() + |> NaiveDateTime.truncate(:second) + + placeholders = %{timestamp: timestamp} + + maps = + Enum.map( + tag_names, + &%{ + text: &1, + person_id: person_id, + inserted_at: {:placeholder, :timestamp}, + updated_at: {:placeholder, :timestamp} + } + ) + + Repo.insert_all( + Tag, + maps, + placeholders: placeholders, + on_conflict: :nothing + ) + + Repo.all( + from t in Tag, where: t.text in ^tag_names and t.person_id == ^person_id + ) + end + def get_tag!(id), do: Repo.get!(Tag, id) end diff --git a/test/app/item_test.exs b/test/app/item_test.exs index 7771994e..353cc7c6 100644 --- a/test/app/item_test.exs +++ b/test/app/item_test.exs @@ -22,8 +22,7 @@ defmodule App.ItemTest do end test "create_item/1 with invalid data returns error changeset" do - assert {:error, %Ecto.Changeset{}} = - Item.create_item(@invalid_attrs) + assert {:error, %Ecto.Changeset{}} = Item.create_item(@invalid_attrs) end test "list_items/0 returns a list of items stored in the DB" do @@ -41,12 +40,26 @@ defmodule App.ItemTest do end end + describe "items with tags" do + @valid_attrs %{ + text: "new item", + person_id: 1, + status: 2, + tags: "tag1, tag2, tag3" + } + + test "get_item!/1 returns the item with given id" do + {:ok, item} = Item.create_item_with_tags(@valid_attrs) + assert length(item.tags) == 3 + end + end + describe "accumulate timers for a list of items #103" do test "accummulate_item_timers/1 to display cummulative timer" do # https://hexdocs.pm/elixir/1.13/NaiveDateTime.html#new/2 # "Add" -7 seconds: https://hexdocs.pm/elixir/1.13/Time.html#add/3 {:ok, seven_seconds_ago} = - NaiveDateTime.new(Date.utc_today, Time.add(Time.utc_now, -7)) + NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7)) items_with_timers = [ %{ @@ -60,35 +73,40 @@ defmodule App.ItemTest do stop: ~N[2022-07-17 11:18:10.000000], id: 2, start: ~N[2022-07-17 11:18:00.000000], - text: "Item #2 has one active (no end) and one complete timer should total 17sec", + text: + "Item #2 has one active (no end) and one complete timer should total 17sec", timer_id: 3 }, %{ stop: nil, id: 2, start: seven_seconds_ago, - text: "Item #2 has one active (no end) and one complete timer should total 17sec", + text: + "Item #2 has one active (no end) and one complete timer should total 17sec", timer_id: 4 }, %{ stop: ~N[2022-07-17 11:18:31.000000], id: 1, start: ~N[2022-07-17 11:18:26.000000], - text: "Item with 3 complete timers that should add up to 42 seconds elapsed", + text: + "Item with 3 complete timers that should add up to 42 seconds elapsed", timer_id: 2 }, %{ stop: ~N[2022-07-17 11:18:24.000000], id: 1, start: ~N[2022-07-17 11:18:18.000000], - text: "Item with 3 complete timers that should add up to 42 seconds elapsed", + text: + "Item with 3 complete timers that should add up to 42 seconds elapsed", timer_id: 1 }, %{ stop: ~N[2022-07-17 11:19:42.000000], id: 1, start: ~N[2022-07-17 11:19:11.000000], - text: "Item with 3 complete timers that should add up to 42 seconds elapsed", + text: + "Item with 3 complete timers that should add up to 42 seconds elapsed", timer_id: 5 } ] @@ -105,6 +123,7 @@ defmodule App.ItemTest do assert NaiveDateTime.diff(item1.stop, item1.start) == 42 # This is the fun one that we need to be 17 seconds: assert NaiveDateTime.diff(NaiveDateTime.utc_now(), item2.start) == 17 + # The diff will always be 17 seconds because we control the start in the test data above. # But we still get the function to calculate it so we know it works. @@ -121,6 +140,7 @@ defmodule App.ItemTest do {:ok, timer1} = Timer.start(%{item_id: item1.id, person_id: 1, start: started}) + {:ok, _timer2} = Timer.start(%{item_id: item2.id, person_id: 1, start: started}) diff --git a/test/app/tag_test.exs b/test/app/tag_test.exs index 3c80ceb5..27395faa 100644 --- a/test/app/tag_test.exs +++ b/test/app/tag_test.exs @@ -36,5 +36,15 @@ defmodule App.TagTest do assert {:ok, _tag} = Tag.create_tag(@valid_attrs) assert {:error, _changeset} = Tag.create_tag(@valid_attrs) end + + test "insert list of tag names" do + assert tags = Tag.create_tags(["tag1", "tag2", "tag3"], 1) + assert length(tags) == 3 + end + + test "returns empty list when attempting to insert empty list of tags" do + assert tags = Tag.create_tags([], 1) + assert length(tags) == 0 + end end end From 39aedb40e07e82d335292bd793e9807805886523 Mon Sep 17 00:00:00 2001 From: SimonLab Date: Mon, 26 Sep 2022 21:44:27 +0100 Subject: [PATCH 14/18] Add input for tags Allow user to create tags at the same time as creating an item --- lib/app/item.ex | 1 - lib/app_web/live/app_live.ex | 10 ++++++++-- lib/app_web/live/app_live.html.heex | 2 ++ test/app_web/live/app_live_test.exs | 6 +++++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/app/item.ex b/lib/app/item.ex index 8a534f53..e302e5f8 100644 --- a/lib/app/item.ex +++ b/lib/app/item.ex @@ -48,7 +48,6 @@ defmodule App.Item do def create_item_with_tags(attrs) do %Item{} |> changeset_with_tags(attrs) - |> IO.inspect() |> Repo.insert() end diff --git a/lib/app_web/live/app_live.ex b/lib/app_web/live/app_live.ex index 73b71e70..c702e383 100644 --- a/lib/app_web/live/app_live.ex +++ b/lib/app_web/live/app_live.ex @@ -20,9 +20,15 @@ defmodule AppWeb.AppLive do end @impl true - def handle_event("create", %{"text" => text}, socket) do + def handle_event("create", %{"text" => text, "tags" => tags}, socket) do person_id = get_person_id(socket.assigns) - Item.create_item(%{text: text, person_id: person_id, status: 2}) + + Item.create_item_with_tags(%{ + text: text, + person_id: person_id, + status: 2, + tags: tags + }) AppWeb.Endpoint.broadcast(@topic, "update", :create) {:noreply, socket} diff --git a/lib/app_web/live/app_live.html.heex b/lib/app_web/live/app_live.html.heex index 9bf87ac8..147df395 100644 --- a/lib/app_web/live/app_live.html.heex +++ b/lib/app_web/live/app_live.html.heex @@ -24,6 +24,8 @@ x-on:input="resize" > + +
diff --git a/test/app_web/live/app_live_test.exs b/test/app_web/live/app_live_test.exs index e6286dbb..07db8edc 100644 --- a/test/app_web/live/app_live_test.exs +++ b/test/app_web/live/app_live_test.exs @@ -13,7 +13,11 @@ defmodule AppWeb.AppLiveTest do test "connect and create an item", %{conn: conn} do {:ok, view, _html} = live(conn, "/") - assert render_submit(view, :create, %{text: "Learn Elixir", person_id: nil}) + assert render_submit(view, :create, %{ + text: "Learn Elixir", + person_id: nil, + tags: "" + }) end test "toggle an item", %{conn: conn} do From 8a5c84718534b157451bf11168929dbbdbd0199c Mon Sep 17 00:00:00 2001 From: SimonLab Date: Wed, 28 Sep 2022 12:05:50 +0100 Subject: [PATCH 15/18] Display tags under items Display list of tags under each items Run formatter on project --- .formatter.exs | 6 ++++- BUILDIT.md | 21 ++++++++++++++++- config/config.exs | 2 +- config/dev.exs | 3 +-- lib/app/item.ex | 26 ++++++++++++++++++---- lib/app/release.ex | 7 ++++-- lib/app/timer.ex | 16 ++++++++----- lib/app_web/controllers/init_controller.ex | 7 +++--- lib/app_web/live/app_live.html.heex | 6 ++++- lib/app_web/views/app_view.ex | 2 +- mix.exs | 2 +- test/app/timer_test.exs | 14 +++++++----- 12 files changed, 85 insertions(+), 27 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index f1e6d018..f04c0832 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,6 +1,10 @@ [ import_deps: [:ecto, :phoenix], - inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], + inputs: [ + "*.{heex,ex,exs}", + "priv/*/seeds.exs", + "{config,lib,test}/**/*.{ex,exs}" + ], subdirectories: ["priv/*/migrations"], line_length: 80 ] diff --git a/BUILDIT.md b/BUILDIT.md index a6a3fccf..bccf88d0 100644 --- a/BUILDIT.md +++ b/BUILDIT.md @@ -2609,7 +2609,26 @@ with our items and tags. ref: https://hexdocs.pm/phoenix/1.3.2/testing_schemas.html -## 11.4 Items, Tags association +## 11.4 Items-Tags association + +We want to create the tags at the same time the item is created. +The tags are represented as string where tag values are seperated by comma: +"tag1, tag2, ..." + +So we need first to parse the tags string value, create any new tags in Postgres, +then associate the list of tags to the item. + +We'll first update our `Item` schema to add the `on_replace` option to the +`many_to_many` function: + +```elixir +many_to_many(:tags, Tag, join_through: ItemTag, on_replace: :delete) +``` + +The `:delete` value will remove the association between the item and tags that +have been removed, see https://hexdocs.pm/ecto/Ecto.Schema.html#many_to_many/3. + + Learn more about Ecto with the guides documenation, especially the How to section: diff --git a/config/config.exs b/config/config.exs index 07458c92..2ef0fc20 100644 --- a/config/config.exs +++ b/config/config.exs @@ -49,4 +49,4 @@ config :joken, default_signer: System.get_env("SECRET_KEY_BASE") # config :auth_plug, - api_key: System.get_env("AUTH_API_KEY") \ No newline at end of file + api_key: System.get_env("AUTH_API_KEY") diff --git a/config/dev.exs b/config/dev.exs index 140a39c6..480c52f9 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -29,8 +29,7 @@ config :app, AppWeb.Endpoint, # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, - tailwind: - {Tailwind, :install_and_run, [:default, ~w(--watch)]} + tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} ] # ## SSL Support diff --git a/lib/app/item.ex b/lib/app/item.ex index e302e5f8..22b3e4a3 100644 --- a/lib/app/item.ex +++ b/lib/app/item.ex @@ -83,6 +83,13 @@ defmodule App.Item do |> Repo.all() end + def list_person_items(person_id) do + Item + |> where(person_id: ^person_id) + |> preload(:tags) + |> Repo.all() + end + @doc """ Updates a item. @@ -131,9 +138,18 @@ defmodule App.Item do ORDER BY timer_id ASC; """ - Ecto.Adapters.SQL.query!(Repo, sql, [person_id]) - |> map_columns_to_values() - |> accumulate_item_timers() + values = + Ecto.Adapters.SQL.query!(Repo, sql, [person_id]) + |> map_columns_to_values() + + items_tags = + list_person_items(person_id) + |> Enum.reduce(%{}, fn i, acc -> Map.put(acc, i.id, i) end) + + accumulate_item_timers(values) + |> Enum.map(fn t -> + Map.put(t, :tags, items_tags[t.id].tags) + end) end @doc """ @@ -146,7 +162,9 @@ defmodule App.Item do """ def map_columns_to_values(res) do Enum.map(res.rows, fn row -> - Enum.zip(res.columns, row) |> Map.new() |> AtomicMap.convert() + Enum.zip(res.columns, row) + |> Map.new() + |> AtomicMap.convert(safe: false) end) end diff --git a/lib/app/release.ex b/lib/app/release.ex index 8dc3e712..67cca7a5 100644 --- a/lib/app/release.ex +++ b/lib/app/release.ex @@ -9,13 +9,16 @@ defmodule App.Release do load_app() for repo <- repos() do - {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) + {:ok, _, _} = + Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) end end def rollback(repo, version) do load_app() - {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) + + {:ok, _, _} = + Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) end defp repos do diff --git a/lib/app/timer.ex b/lib/app/timer.ex index 7b10eead..f775df87 100644 --- a/lib/app/timer.ex +++ b/lib/app/timer.ex @@ -33,7 +33,6 @@ defmodule App.Timer do """ def get_timer!(id), do: Repo.get!(Timer, id) - @doc """ `start/1` starts a timer. @@ -60,7 +59,7 @@ defmodule App.Timer do """ def stop(attrs \\ %{}) do get_timer!(attrs.id) - |> changeset(%{stop: NaiveDateTime.utc_now}) + |> changeset(%{stop: NaiveDateTime.utc_now()}) |> Repo.update() end @@ -75,7 +74,9 @@ defmodule App.Timer do """ def stop_timer_for_item_id(item_id) when is_nil(item_id) do - Logger.debug("stop_timer_for_item_id/1 called without item_id: #{item_id} fail.") + Logger.debug( + "stop_timer_for_item_id/1 called without item_id: #{item_id} fail." + ) end def stop_timer_for_item_id(item_id) do @@ -83,12 +84,17 @@ defmodule App.Timer do sql = """ SELECT t.id FROM timers t WHERE t.item_id = $1 AND t.stop IS NULL ORDER BY t.id DESC LIMIT 1; """ + res = Ecto.Adapters.SQL.query!(Repo, sql, [item_id]) - + if res.num_rows > 0 do # IO.inspect(res.rows) timer_id = res.rows |> List.first() |> List.first() - Logger.debug("Found timer.id: #{timer_id} for item: #{item_id}, attempting to stop.") + + Logger.debug( + "Found timer.id: #{timer_id} for item: #{item_id}, attempting to stop." + ) + stop(%{id: timer_id}) else Logger.debug("No active timers found for item: #{item_id}") diff --git a/lib/app_web/controllers/init_controller.ex b/lib/app_web/controllers/init_controller.ex index c6c8f2c7..7c97002f 100644 --- a/lib/app_web/controllers/init_controller.ex +++ b/lib/app_web/controllers/init_controller.ex @@ -10,7 +10,7 @@ defmodule AppWeb.InitController do conn |> assign(:loggedin, true) |> assign(:person, %{picture: "https://dwyl.com/img/favicon-32x32.png"}) - |> render(:index, + |> render(:index, env: check_env(@env_required), api_key_set: api_key_set?() ) @@ -25,11 +25,12 @@ defmodule AppWeb.InitController do defp api_key_set?() do case AuthPlug.Token.api_key() do # coveralls-ignore-start - nil -> + nil -> # IO.puts("AuthPlug.Token.api_key() #{AuthPlug.Token.api_key()}") false + # coveralls-ignore-stop - + key -> String.length(key) > 1 end diff --git a/lib/app_web/live/app_live.html.heex b/lib/app_web/live/app_live.html.heex index 147df395..8de70d27 100644 --- a/lib/app_web/live/app_live.html.heex +++ b/lib/app_web/live/app_live.html.heex @@ -1,6 +1,5 @@
-