Writing Mix Task in Elixir Phoenix

Writing Mix Task in Elixir Phoenix

Imagine you are working on an application that has multiple models (Tables) and each table has some columns. Your manager came up with a requirement to add a new column in one table, say User table. The new column you have asked to add is user_type of string type. You will write the migration to add the new column and as per the new column, you added this new field in form as well. Everything works fine, but what about the records that already exist in the database on Production or another environment. That might can give unexpected results. The solution to this problem is task in Phoenix. It is somewhat inspired by the rails rake task. But the question arises we can directly run SQL queries on the database as soon as we run the migration before deploying other changes that are going to change new data. Why there was a need to come with a new concept like task to change the data. There are few benefits of using the Elixir task:

  • Task code is nothing but pure Elixir code with which you can use to write Ecto Queries (which is more readable and expressive) to do changes on a database.
  • Since, it is Elixir code it is Testable. Imagine, you are writing some task to do changes to 1 million data. You need to be an SQL ninja and have a strong heart to directly run SQL queries directly on data. With Task you can write a Test case and be 100% sure that your query is going to work on the production.
  • Also, if you have multiple environments you have to run the script one by one on all environments. With task, you will have just one file which you have to call with just one command.

Let's write one!!

  • Continuing with the user_type column example I explained in the introduction. We will add that column to the User struct.
  • You will add migration something like below
    def change do
      alter table(:user) do
        add :user_type, :string
      end
    end
    
  • As per the new field you will add a field in the user form.
  • Everything looks fine, now you are able to store value in this form but what about the old data?
  • As discussed we will be writing task.
  • For writing a task add a file at lib/mix/tasks/add_user_type.ex.
  • In the add_user_type.ex file we will add the following code.
 defmodule Mix.Tasks.AddUserType do
    use Mix.Task

    def run(_args) do
     # Write your task logic here
   end
end
  • To create our task, we’ll need to start the module name with Mix.Tasks. So, we will name our module Mix.Tasks.AddUserType.
  • We have added use Mix.Task macro, which adds task capability to this file.
  • To list the task within our application we can run mix help in the terminal of the project. You will get the list of the tasks but you'll not find the task that we created.
  • It is because in order to list our task with a description we need to add the @shortdoc module attribute. We will add one.

    defmodule Mix.Tasks.AddUserType do
      use Mix.Task
    
      @shortdoc "Add user_type value to existing user data"
    
      def run(_args) do
       # Write your task logic here
     end
    end
    
  • Now on running mix compile and mix help again you can see the task we created (see the second line in the image below).

Screenshot 2021-08-21 at 11.21.24 PM.png

  • So, far so good. Now to write the logic to change our data. It should be written in the run function.
  • We are going to write our existing data changing logic inside the run function which takes one parameter args. We are going to ignore it because we don't need one.
  • We will alias User, Repo and import Ecto.Query to write some SQL.

    defmodule Mix.Tasks.AddUserType do
      use Mix.Task
    
      @shortdoc "Add user_type value to existing user data"
    
      alias MyProject.{ Repo, User }
      import Ecto.Query
    
      def run(_args) do
          # Logic to change existing data.
          from(u in User, where: is_nil(u.user_type))
          |> Repo.update_all(set: [some_condition])
     end
    end
    
  • We will implement some logic to transform our old existing user data. Now, we will run the task.
  • To run any task we the command mix task_name and in our case it will be mix add_user_type.
  • On running the task we will get this error (RuntimeError) could not lookup MyProject.Repo because it was not started or it does not exist.
  • It is because our Repo isn’t started when we run our task, so we’ll need to do that manually.
  • To run the task we need to pass Repo to Mix.EctoSQL.ensure_started function and also start the Task inside the run function.
      Mix.Task.run("app.start", [])
      Mix.EctoSQL.ensure_started(MyProject.Repo, [])
    
  • Now, on running the task with the command mix add_user_typ it will run the task without any issue.
  • Refer below complete running code

    defmodule Mix.Tasks.AddUserType do
      use Mix.Task
    
      @shortdoc "Add user_type value to existing user data"
    
      alias MyProject.{ Repo, User }
      import Ecto.Query
    
      def run(_args) do
          Mix.Task.run("app.start", [])
          Mix.EctoSQL.ensure_started(MyProject.Repo, [])
    
          from(u in User, where: is_nil(u.user_type))
          |> Repo.update_all(set: [some_condition])
     end
    end
    

    What about Test?

    At the start of the blog while explaining the benefits of using task for writing the query. I emphasized writing the test to make sure our query is working fine. For writing the test you can add a test file in the folder test/mix/task/add_user_type_test.ex file and you can write the test for your task. We can cover about writing test for mix task in some other post.

    I hope you like this post. If you have any questions then please comment below. Thanks for reading 😊.

References:

Did you find this article valuable?

Support Abul Asar's Blog by becoming a sponsor. Any amount is appreciated!