Dev Series – Phoenix Part 3 – CRUD Generators


Phoenix Elixir PostgreSQL
tutorial phoenix crud generators ecto

Dev Series – Phoenix Part 3 – CRUD Generators

After getting our authentication setup, we will now implement our first feature: CRUD for our exercises.

What are Phoenix Generators?

Phoenix generators are powerful tools that help you quickly scaffold common patterns in web applications. They generate:

  • Contexts (business logic layer)
  • Schemas (database models)
  • Controllers and views
  • Templates
  • Tests
  • Migrations

Planning Our Exercise Feature

For our workout tracking app, we need to manage exercises. An exercise should have:

  • Name (string) - e.g., “Push-ups”, “Squats”
  • Type (string) - e.g., “timed”, “distance”, “reps”
  • Description (text) - detailed description
  • Shared (boolean) - whether it’s shared publicly
  • User association - belongs to a user

Generating the Exercise Resource

Let’s use Phoenix’s HTML generator to create our Exercise resource:

mix phx.gen.html Repz Workout name:string type:string description:text shared:boolean user_id:references:users

This command generates:

  • Context: lib/repz_exercise/repz.ex
  • Schema: lib/repz_exercise/repz/workout.ex
  • Migration: priv/repo/migrations/*_create_workouts.rb
  • Controller: lib/repz_exercise_web/controllers/workout_controller.ex
  • Views: lib/repz_exercise_web/views/workout_view.ex
  • Templates: lib/repz_exercise_web/templates/workout/
  • Tests: Various test files

Examining the Generated Schema

Let’s look at the generated schema in lib/repz_exercise/repz/workout.ex:

defmodule RepzExercise.Repz.Workout do
  use Ecto.Schema
  import Ecto.Changeset

  @workout_types ["timed", "distance", "reps"]

  schema "workouts" do
    field :name, :string
    field :type, :string
    field :description, :string
    field :shared, :boolean, default: false
    belongs_to :user, RepzExercise.Users.User

    timestamps()
  end

  @doc false
  def changeset(workout, attrs) do
    workout
    |> cast(attrs, [:name, :type, :description, :shared, :user_id])
    |> validate_required([:name, :type, :user_id])
    |> validate_length(:name, min: 4, max: 100)
    |> validate_inclusion(:type, @workout_types,
         message: "Invalid workout type. Must be one of: #{Enum.join(@workout_types, ", ")}")
  end
end

Understanding the Generated Migration

The migration file creates our workouts table:

defmodule RepzExercise.Repo.Migrations.CreateWorkouts do
  use Ecto.Migration

  def change do
    create table(:workouts) do
      add :name, :string
      add :type, :string
      add :description, :text
      add :shared, :boolean, default: false, null: false
      add :user_id, references(:users, on_delete: :nothing)

      timestamps()
    end

    create index(:workouts, [:user_id])
  end
end

Run the migration:

mix ecto.migrate

Adding Routes

Add the workout routes to your router.ex:

scope "/", RepzExerciseWeb do
  pipe_through [:browser, :protected]

  resources "/workouts", WorkoutController
end

Examining the Generated Context

The context in lib/repz_exercise/repz.ex provides our business logic layer:

defmodule RepzExercise.Repz do
  @moduledoc """
  The Repz context.
  """

  import Ecto.Query, warn: false
  alias RepzExercise.Repo
  alias RepzExercise.Repz.Workout

  @doc """
  Returns the list of workouts.
  """
  def list_workouts do
    Repo.all(Workout)
  end

  @doc """
  Gets a single workout.
  """
  def get_workout!(id), do: Repo.get!(Workout, id)

  @doc """
  Creates a workout.
  """
  def create_workout(attrs \\ %{}) do
    %Workout{}
    |> Workout.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Updates a workout.
  """
  def update_workout(%Workout{} = workout, attrs) do
    workout
    |> Workout.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes a workout.
  """
  def delete_workout(%Workout{} = workout) do
    Repo.delete(workout)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking workout changes.
  """
  def change_workout(%Workout{} = workout, attrs \\ %{}) do
    Workout.changeset(workout, attrs)
  end
end

Scoping Workouts to Current User

We need to modify our context to scope workouts to the current user. Update the context functions:

def list_workouts(user) do
  Workout
  |> where([w], w.user_id == ^user.id)
  |> Repo.all()
end

def get_workout!(id, user) do
  Workout
  |> where([w], w.user_id == ^user.id)
  |> Repo.get!(id)
end

def create_workout(attrs, user) do
  %Workout{}
  |> Workout.changeset(Map.put(attrs, "user_id", user.id))
  |> Repo.insert()
end

Updating the Controller

Modify the generated controller to work with the current user:

defmodule RepzExerciseWeb.WorkoutController do
  use RepzExerciseWeb, :controller

  alias RepzExercise.Repz
  alias RepzExercise.Repz.Workout

  def index(conn, _params) do
    current_user = Pow.Plug.current_user(conn)
    workouts = Repz.list_workouts(current_user)
    render(conn, "index.html", workouts: workouts)
  end

  def show(conn, %{"id" => id}) do
    current_user = Pow.Plug.current_user(conn)
    workout = Repz.get_workout!(id, current_user)
    render(conn, "show.html", workout: workout)
  end

  def new(conn, _params) do
    changeset = Repz.change_workout(%Workout{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"workout" => workout_params}) do
    current_user = Pow.Plug.current_user(conn)
    
    case Repz.create_workout(workout_params, current_user) do
      {:ok, workout} ->
        conn
        |> put_flash(:info, "Workout created successfully.")
        |> redirect(to: Routes.workout_path(conn, :show, workout))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

  # ... other actions follow similar pattern
end

Customizing Templates

The generated templates provide a good starting point. Let’s customize the index template:

<h1>My Workouts</h1>

<%= link "New Workout", to: Routes.workout_path(@conn, :new), class: "btn btn-primary mb-4" %>

<div class="grid gap-4">
  <%= for workout <- @workouts do %>
    <div class="card bg-base-100 shadow-xl">
      <div class="card-body">
        <h2 class="card-title">
          <%= workout.name %>
          <div class="badge badge-secondary"><%= workout.type %></div>
          <%= if workout.shared do %>
            <div class="badge badge-accent">Shared</div>
          <% end %>
        </h2>
        <p><%= workout.description %></p>
        <div class="card-actions justify-end">
          <%= link "View", to: Routes.workout_path(@conn, :show, workout), class: "btn btn-primary btn-sm" %>
          <%= link "Edit", to: Routes.workout_path(@conn, :edit, workout), class: "btn btn-secondary btn-sm" %>
          <%= link "Delete", to: Routes.workout_path(@conn, :delete, workout), method: :delete, 
                   data: [confirm: "Are you sure?"], class: "btn btn-error btn-sm" %>
        </div>
      </div>
    </div>
  <% end %>
</div>

![Workout Index Page][phoenix-part-3/workout-index-page]

Form Template

The form template handles both new and edit actions:

<%= form_for @changeset, @action, fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-error">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <div class="form-control w-full max-w-xs">
    <%= label f, :name, class: "label" %>
    <%= text_input f, :name, class: "input input-bordered w-full max-w-xs" %>
    <%= error_tag f, :name %>
  </div>

  <div class="form-control w-full max-w-xs">
    <%= label f, :type, class: "label" %>
    <%= select f, :type, ["timed", "distance", "reps"], 
               {prompt: "Choose a type"}, class: "select select-bordered w-full max-w-xs" %>
    <%= error_tag f, :type %>
  </div>

  <div class="form-control">
    <%= label f, :description, class: "label" %>
    <%= textarea f, :description, class: "textarea textarea-bordered h-24" %>
    <%= error_tag f, :description %>
  </div>

  <div class="form-control">
    <label class="label cursor-pointer">
      <%= checkbox f, :shared, class: "checkbox" %>
      <span class="label-text">Share this workout publicly</span>
    </label>
    <%= error_tag f, :shared %>
  </div>

  <div class="mt-4">
    <%= submit "Save", class: "btn btn-primary" %>
  </div>
<% end %>

![Workout Form][phoenix-part-3/workout-form]

Testing Our CRUD Operations

Start your server and test the functionality:

mix phx.server

Viewing the Workout List

Navigate to /workouts to see the list of workouts:

Listing Workouts

Creating a New Workout

Click “New Workout” to access the creation form:

New Workout Form

Form Validation

The form includes validation. If you try to submit without required fields, you’ll see validation errors:

Form Validation Error - Description Required

Form Validation Error - Invalid Type

Workout Type Selection

The type field uses a dropdown with predefined options:

Workout Type Dropdown Workout Type Dropdown Workout Type Dropdown

  1. Create: Navigate to /workouts/new and create a new workout
  2. Read: View the list at /workouts and individual workouts
  3. Update: Edit existing workouts
  4. Delete: Remove workouts you no longer need

What’s Next?

In the next part of this series, we’ll:

  • Improve the UI using TailwindCSS
  • Add more sophisticated styling
  • Implement responsive design
  • Add interactive elements

Conclusion

We’ve successfully implemented CRUD operations for our workout exercises! Phoenix generators provided us with:

  • A solid foundation with proper separation of concerns
  • Database schema with validations
  • Complete CRUD interface
  • User-scoped data access

The generated code follows Phoenix conventions and best practices, giving us a maintainable codebase that we can build upon.

In Part 4, we’ll enhance the user interface using TailwindCSS to make our application more visually appealing and user-friendly.