Casting a date attribute issue

After adding a new attribute to the Post model via a migration, I’d like to set a value of published_on attribute of the Post every time before saving a Post.
So, I added first this to the Posts module:

  def create_post(attrs \\ %{}) do
    attrs = Map.merge(attrs, %{"published_on" => Date.utc_today() |> Date.to_string()})

    %Post{}
    |> Post.changeset(attrs)
    |> Repo.insert()
  end

But the existing controller tests fail: mix test test/blog_web/controllers/post_controller_test.exs:

test update post renders errors when data is invalid (BlogWeb.PostControllerTest)
     test/blog_web/controllers/post_controller_test.exs:64
     ** (Ecto.CastError) expected params to be a map with atoms or string keys, got a map with mixed keys: %{:content => "some body", :title => "some title", "published_on" => "2023-07-13"}
...

When changing the attrs map to have an atom key as follows:

def create_post(attrs \\ %{}) do
    attrs = Map.merge(attrs, %{published_on: Date.utc_today() |> Date.to_string()})

    %Post{}
    |> Post.changeset(attrs)
    |> Repo.insert()
  end

it fails again with another error:

 1) test update post renders errors when data is invalid (BlogWeb.PostControllerTest)
     test/blog_web/controllers/post_controller_test.exs:64
     ** (Ecto.CastError) expected params to be a map with atoms or string keys, got a map with mixed keys: %{:published_on => "2023-07-13", "content" => nil, "title" => nil}
...

The `Post#changeset/2 looks like that:

  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :content, :published_on])
    |> validate_required([:title, :content])
  end

What’s wrong with that? Thank you.

@belgoros in these cases the attrs map isn’t really the one you want to edit. You want to either do %Post{published_on: Date.utc_today{}) or make a dedicated function %Post{} |> Post.set_published(Date.utc_today()) that calls put_change internally.

2 Likes

@benwilson512 so where the modification should be done in this case as you suggested:
%Post{published_on: Date.utc_today{}) if the attrs should not be modified if the call is coming from this line in the test:
conn = post(conn, ~p"/posts", post: @create_attrs).

In Rails we used a kind of callback in this case, but In Phoenix it is different :(.

@belgoros I am saying you should have:

  def create_post(attrs \\ %{}) do
    %Post{published_on: Date.utc_today()}
    |> Post.changeset(attrs)
    |> Repo.insert()
  end

OR (even better)

  def create_post(attrs \\ %{}) do
    %Post{}
    |> Post.publish_on(Date.utc_today())
    |> Post.changeset(attrs)
    |> Repo.insert()
  end

  # In your `Post` module.
  def publish_on(post, publication_date) do
    post |> put_change(:publish_on, publication_date)
  end
1 Like

Ah, I see now, cool :slight_smile: And how the update_post will be different from create_post in this case? Smth like this:

  def update_post(%Post{} = post, attrs) do
    post
    |> put_change(:publish_on, Date.utc_today())
    |> Post.changeset(attrs)
    |> Repo.update()
  end

PS. I have an error in VS Code saying that put_change is not found.
PPS: put_changeis available in Post module only and not in Posts where I tried to call it :slight_smile:

Why do you want to update the publish on when the post is updated?

Hmm, it is rather a business-related question :). It was not detailed in the exercise I’m following and I did believe that it has to be updated on every Post update (there are already inserted_at and updated_at attributes though).

A post is published when it is first made available, not on every update. However if you do want to do this, the code you wrote would do that.

It still fails on create:

1) test create post redirects to show when data is valid (BlogWeb.PostControllerTest)
     test/blog_web/controllers/post_controller_test.exs:28
     ** (FunctionClauseError) no function clause matching in Ecto.Changeset.put_change/3

     The following arguments were given to Ecto.Changeset.put_change/3:
     
         # 1
         %Blog.Posts.Post{__meta__: #Ecto.Schema.Metadata<:built, "posts">, id: nil, content: nil, title: nil, published_on: nil, inserted_at: nil, updated_at: nil}
     
         # 2
         :published_on
     
         # 3
         ~D[2023-07-13]
     
     Attempted function clauses (showing 2 out of 2):
     
         def put_change(%Ecto.Changeset{types: nil}, _key, _value)
         def put_change(%Ecto.Changeset{data: data, types: types} = changeset, key, value)
     
     code: conn = post(conn, ~p"/posts", post: @create_attrs)
     stacktrace:
       (ecto 3.10.3) lib/ecto/changeset.ex:1734: Ecto.Changeset.put_change/3
       (blog 0.1.0) lib/blog/posts.ex:54: Blog.Posts.create_post/1
       (blog 0.1.0) lib/blog_web/controllers/post_controller.ex:18: BlogWeb.PostController.create/2
       (blog 0.1.0) lib/blog_web/controllers/post_controller.ex:1: BlogWeb.PostController.action/2
       (blog 0.1.0) lib/blog_web/controllers/post_controller.ex:1: BlogWeb.PostController.phoenix_controller_pipeline/2
       (phoenix 1.7.7) lib/phoenix/router.ex:430: Phoenix.Router.__call__/5
       (blog 0.1.0) lib/blog_web/endpoint.ex:1: BlogWeb.Endpoint.plug_builder_call/2
       (blog 0.1.0) lib/blog_web/endpoint.ex:1: BlogWeb.Endpoint.call/2
       (phoenix 1.7.7) lib/phoenix/test/conn_test.ex:225: Phoenix.ConnTest.dispatch/5
       test/blog_web/controllers/post_controller_test.exs:29: (test)

Here is the create_post definition:

def create_post(attrs \\ %{}) do
    %Post{}
    |> Post.update_published_on(Date.utc_today())
    |> Post.changeset(attrs)
    |> Repo.insert()
  end

And here is how Post.update_published_on is defined:

def update_published_on(post, publication_date) do
    post |> put_change(:published_on, publication_date)
  end

What’s wrong here?

I also have a warning for the create_post function in VS Code saying:

The function call will not succeed.

Blog.Posts.Post.update_published_on(
  %Blog.Posts.Post{
    :__meta__ => %Ecto.Schema.Metadata{
      :context => nil,
      :prefix => nil,
      :schema => Blog.Posts.Post,
      :source => <<112, 111, 115, 116, 115>>,
      :state => :built
    },
    :content => nil,
    :id => nil,
    :inserted_at => nil,
    :published_on => nil,
    :title => nil,
    :updated_at => nil
  },
  %Date{
    :calendar => atom(),
    :day => pos_integer(),
    :month => pos_integer(),
    :year => integer()
  }
)

I may be missing something, but why don’t you just use the inserted_at / updated_at that Ecto deals with automatically? Seems to me like you will more or less duplicate the logic for updated_at.

Ah whoops, put_change takes a changeset, I meant to have you do |> change(published_on: publication_date).

Because the requirement of the YardAcademy was to modify exiting Post’s schema and add a new attribute published_on.

put_change is a function from the Ecto.Changeset module.

When you define a schema such as Post on the top of your module you will have a use Echo.Schema allowing you define your schema and set its fields, and also import Ecto.Changeset giving you the functions such cast/3 put_change/2 and validate_required/3 and etc.

But looking at the code example, you were calling it from YourApp.Posts that is the context module and usually we don’t import Ecto.Changeset stuff there, and that is the reason you were receiving this error. ^^

Here you can find more details about it Changesets · Elixir School

1 Like

Check also this small lib I developed to manipulate attrs irrespective of whether they contain string or atom keys: GitHub - mathieuprog/attrs: Unifying atom and string key handling for user data (attrs maps) given to Ecto's cast function