Header logo.
small hallucinations
homeyearstagsaboutrss

Adding a new context and define associations in Phoenix

I'm still doing Dockyard's tutorial of Elixir and Phoenix.

So far I've progressed to building a blog context. Now I'm trying to add a comment context to the app. This would also allow the user to post a comment and list all the comments below a post.

This exercise is designed to teach you about how to create one-to-many relations in Phoenix.

Although the concept is easy to grasp, there are a few steps you need to take and quite a few files you need to change.

The rest of this post shows how I did it. The idea is to remind myself and show other beginners of Phoenix how to do it on a nitty-gritty level. I can't guarantee this is best practice.

SPOILER ALERT If you are also doing this exercise, I strongly recommend you to work on it on your own first.


Steps

For the sake of clarity, suppose we are adding comments to posts.

  1. generate a new context (comments), edit migration file and migrate
  2. change schemas to specify the association between them
  3. change posts repo file to preload comments
  4. change router file to specify which controller function should handle requests made to comments
  5. change comment controller to handle those requests
  6. change post controller to add comment_changeset
  7. change post template to display and edit comments

1. generate a new context; edit migration file and migrate

Generate a context by doing this:

mix phx.gen.context Comments Comment comments content:text post_id:references:posts

Generate a migration file (I forgot if the last command would generate a migration file. So this may or may not be necessary.):

mix ecto.gen.migration add_comments

In the generated empty migration file, add this. This defines a comments table of two fields. null: false makes sure a comment must point to a post.

1def change do
2  create table(:comments) do
3    add :content, :text
4    add :post_id, references(:posts, on_delete: :nothing), null: false
5
6    timestamps()
7  end

Run the migration file.

2. schemas

In lib/blog/posts/post.ex (this file defines the posts schema). Add:

1defmodule Blog.Posts.Post do
2  # ...
3
4  schema "posts" do
5    # ... other fields ...
6    has_many :comments, Blog.Comments.Comment
7    # ...
8  end
9  # ...

In lib/blog/comments/comment.ex (this file defines the comments schema).

 1defmodule Blog.Comments.Comment do
 2  use Ecto.Schema
 3  import Ecto.Changeset
 4
 5  schema "comments" do
 6    field :content, :string
 7    # ADD belongs_to
 8    belongs_to :post, Blog.Posts.Post
 9
10    timestamps()
11  end
12
13  @doc false
14  def changeset(comment, attrs) do
15    comment
16    # ADD post_id
17    |> cast(attrs, [:content, :post_id])
18    |> validate_required([:content])
19    |> foreign_key_constraint(:post_id)
20  end
21end

post_id is added to the cast function so that the created comments will accept and store a post_id value.

3. posts repo

In lib/blog/posts.ex add preload to preload comments. This file defines the Blog.Posts module along with functions to get, create and change posts.

This way, comments associated with the post record will be preloaded. So that the HEEx template will have something to display.

1def get_post!(id) do
2  Repo.get!(Post, id)
3  |> Repo.preload(:comments) # ADDED
4end

4. router

In lib/blog_web/router.ex (where the router is defined), add a route to handle the post request made to /comments. This request is used to create a comment.

1scope "/", BlogWeb do
2  pipe_through :browser
3
4  get "/", PageController, :home
5  post "/comments", CommentController, :create # ADDED
6  resources "/posts", PostController
7end

5. comment controller

At lib/blog_web/controllers/comment_controller.ex create a comment controller. This is actually directly taken from Dockyard tutorial.

 1defmodule BlogWeb.CommentController do
 2  use BlogWeb, :controller
 3
 4  alias Blog.Posts
 5  alias Blog.Comments
 6
 7  def create(conn, %{"comment" => comment_params}) do
 8    case Comments.create_comment(comment_params) do
 9      {:ok, comment} ->
10        conn
11        |> put_flash(:info, "Comment created successfully.")
12        |> redirect(to: ~p"/posts/#{comment.post_id}")
13
14      {:error, %Ecto.Changeset{} = comment_changeset} ->
15        post = Posts.get_post!(comment_params["post_id"])
16        render(conn, :show, post: post, comment_changeset: comment_changeset)
17    end
18  end
19end

6. post controller

In lib/blog_web_controllers/post_controller.ex, because we are trying to show comments below each blog post, we need to add a comment_changeset to the show function.

1def show(conn, %{"id" => id}) do
2  post = Posts.get_post!(id)
3  comment_changeset = Comments.change_comment(%Comment{})
4  render(conn, :show,
5    post: post,
6    comment_changeset: comment_changeset
7    )
8end

7. template

In lib/blog_web/controllers/post_html/show.html.heex, we need to add these lines to be able to create and display the comments. (A lot of the code in this part is also taken from the tutorial.)

 1<.simple_form :let={f} for={@comment_changeset} action={~p"/comments"}>
 2  <.input type="hidden" field={f[:post_id]} value={@post.id} />
 3  <.input field={f[:content]} type="text" label="Content" />
 4  <:actions>
 5    <.button>Comment</.button>
 6  </:actions>
 7</.simple_form>
 8
 9<%= for comment <- @post.comments do %>
10  <p><%= comment.content %></p>
11<% end %>

Ecto search keyword interpolation

Updated on 2023-11-27:

Now I realize I didn't need to use a fragment. I could have used ilike.

The difference between ilike and like is that ilike is explicitly case-insensitive, whereas like could be case sensitive based on the database you are using.


Original post:

I was doing Dockyard's tutorial of Elixir and Phoenix. It took me an hour to figure out how to search a field for partial matching strings.

tl;dr, here's the code:

 1def search_posts(keyword) do
 2  search_pattern = "%#{keyword}%" # ❤️
 3  Repo.all(
 4    from p in Post,
 5    where:
 6      fragment("? LIKE ?",
 7          p.title,
 8          ^search_pattern)
 9  )
10end

Inside an Ecto.Query statement, we need to use the caret to interpolate a variable. And this was novel to me. (Macros in Elixir enable innovative syntax, which is certainly powerful, but tends to be elusive to beginners.)

If we wrote SQL, it would be something like this, % being the wildcard for any number of chars.

1SELECT * WHERE 'title' LIKE '%word%'

In order to send a query that looks something like the above, I used a fragment:

1def search_posts(keyword) do
2  Repo.all(
3    from p in Post,
4    where: fragment("? LIKE %?%", p.title, ^keyword)
5  )
6end

For some reason the SQL query after interpolation ended up looking like this.

1SELECT p0."id", p0."title", p0."subtitle",
2p0."content", p0."inserted_at",
3p0."updated_at"
4FROM "posts" AS p0
5WHERE (p0."title" LIKE %$1%)

That's why I defensively used interpolation to get search_pattern on line ❤️ .

Untracking files in Git

Normally you can avoid tracking files in Git by adding files to .gitignore.

But if files have been previously added for tracking, they will still be tracked even after appending them to .gitignore.

We need to first remove tracked files from cache. And add back all the files – including .gitignore – in the current directory. Now .gitignore will take effect.

1git rm -r --cached .
2git add .

Using Readwise

I realized I read quite a lot on the web. I spend a lot of time browsing answers on StackOverflow and reading documentation.

For the most part, such information is only useful once and it's OK to forget it. But occasionally, I'd come across a new bug that reminds me of an older problem I had seen before. At that time, I'd wish I could remember how I solved that problem earlier.

I take notes in Obsidian when I try to seriously learn something new. In the meantime, I'd create flashcards in Anki if I find what I'm learning really useful.

The downside of this is, when you are in the middle of debugging, it feels costly to pause and change the mode from debugging to note-taking. (To reduce the friction while collecting information in Obsidian, I've enabled global hotkeys. Hope this can be helpful.)

This is a good scenario to use Readwise. It allows you to make highlights on any webpage and all the highlights and annotations are collected in one place.

There's even a small spaced repetition feature to review your highlights, which I find beautiful but a bit unnecessary. You don't need to remember every piece of information. Using spaced repetition for everything is certainly overkill. Then for the serious knowledge that you need to remember for the rest of your life, this seems too flimsy and you would prefer using Anki.

Although the trademark feature of Readwise seems to be importing highlights from Kindle and Apple Books, so far I find read-it-later and highlighting the most relevant. You can even read PDFs on Readwise Reader. But tbh it also feels like it's too much and too little at the same time.

I had used Instapaper for my read-it-later needs since it was created. When Instapaper came out, it was certainly groundbreaking. But in the end, I stopped using it because 1) they blocked users in Europe for months when GDPR took effect and 2) that they charged too much for its offering, which by now looks rather meager in comparison.

When I was using Instapaper, I hoarded too many to-read items. I'm afraid I would do the same with Readwise. If you run into the same problem, follow the advice in this blog post and treat your reading like a river.

ts2322

A moment ago I got an error that reads like this:

Type '(() => void) | null' is not assignable to type '() => void | null'. ts2322

I was really confused for a moment. Then I realized (() => void) | null means: Either a) a function that neither takes any argument nor returns any value; or b) null.

And () => void | null means a function that does not take any argument. And there are two possibilities when this function returns: Either it doesn't return anything or it returns a null.

Here's blog post explaining why the void keyword is useful while devving.

And I mostly agree with this blog post.

Learning fun but irrelevant things

Last week, my friend Joe asked me what's the best way to learn a programming language.

I replied: Go through the basics as quickly as possible. Then begin building things with it. This is how I learn Golang.

I had been curious about Rust for a while. And a coworker talked about how Rust was his favorite language with a lot of exuberance. I thought to myself, maybe I could do the same with Rust.

I was intimidated by “the Rust book”. It's a huge book of I don't know how many pages. And I never got past hello world and the curious exclamation mark after println.

That's when I discovered Tour of Rust after some random Googling. Tour of Rust is a pleasant introduction to the basic syntax and concepts of Rust. With a compiler on the right side of the page, it adds some nice interactivity to the tutorial. It makes it easier to understand when you can try and change things and see how it fails.

So far my feeling is a few concepts (borrowing and referencing) in Rust are quite clever. I don't understand under what circumstances lifetime needs to be managed manually. Then it feels like a few things are added as an afterthought, which leads to a certain degree of internal incongruity in its syntax.

Yet despite the tribal sentiment among Go developers and Rust developers, these languages feel more similar than different.

Curiously, after searching Rust on YouTube, the algorithm god recommended a million Elm talks with one of them promising to give you happiness.

Incidentally, I read a blog post from the Warp team about building a UI in Rust.

In a gist, it was difficult to build a UI in Rust if they followed the widget tree approach of Flutter. Because multilayers of object inheritance do not feel natural or intuitive in Rust.

Although they used a close but different approach, the Model-View-Update approach used in the Elm architecture was an inspiration for the Warp team.

To use an inaccurate analogy in React terms, Model is like Redux, it manages all the states in your entire app. View manages the virtual DOM that renders the page as the states change. Update makes changes to the state upon receiving messages (events) when the user interacts with your app. Pretty neat.

What's charming about Elm is it's easier, way easier, linting and debugging that works out of the box. You don't need to configure anything, it just works. This is so much better than React.

But you do need to learn a new language and get used to the functional programming way of thinking.

Bottom line: Rust and Elm are both fun. Don't know if or when they will be useful at work.

Linter doesn't run in SourceTree

Description of bug: Using SourceTree on React project running on Node. When committing, linter fails to run with a complaint saying something like “node not found”.

Investigation: Googled a bit, the problem might have been caused by nvm`` or it's something about zsh`. Good people on StackOverflow also shared how they solved their similar bugs on their system.

But... Somehow I was using Volta to manage different versions of Node. Can't think of a good reason why I decided to do so.

Conclusion: Decided not to pursue a non-hacky solution. Followed my precious friend Elad Silver's post:

For me I realized that hooks don't work when opening through the launcher.

instead I opened SourceTree through the terminal by running this command:

/Applications/SourceTree.app/Contents/MacOS/Sourcetree

SourceTree began working happily ever after.