This post is written as a set of Literate Commits. The goal of this style is to show you how this program came together from beginning to end. Each commit in the project is represented by a section of the article. Click each section’s header to see the commit on Github, or check out the repository and follow along.
List and Todo Models
We’re getting to the point where we’ll be wanting real data in our application. To use real data, we’ll need to define the schemas and models that describe the data.
Looking at our original Lists
schema,
we know that each list needs a name
, an incompleteCount
(which we’ll
call incomplete_count
), and an optional reference to a user.
We can use Phoenix’s phoenix.gen.model
generator to create this model for us:
mix phoenix.gen.model List lists name:string \
incomplete_count:integer \
user_id:references:users
Running this command creates a migration to create the "users"
table
in our database. It also creates our PhoenixTodos.List
model.
We can repeat this process for our Todos
collection. Looking at the
Todos
schema,
we know we’ll need a text
field, a checked
field, a reference to its
parent list, and a timestamp.
Once again, we can use the phoenix.gen.model
generator to create this
model and migration for us:
mix phoenix.gen.model Todo todos text:string \
checked:boolean \
list_id:references:lists
Notice that we left the timestamp out of our generator call. Phoenix adds timestamp fields for us automatically.
Nearly all of the code generated for us is perfect. We only need to make
one small tweak to our PhoenixTodos.List
model. In addition to
specifying that it belongs_to
the PhoenixTodos.User
model, we need
to specify that each PhoenixTodos.List
model has_many
PhoenixTodos.Todo
children:
has_many :todos, PhoenixTodos.Todo
Specifying this relationship on the parent List
as well as the child
Todo
model will be very helpful down the line.
priv/repo/migrations/20160920202201_create_list.exs
+defmodule PhoenixTodos.Repo.Migrations.CreateList do + use Ecto.Migration + + def change do + create table(:lists) do + add :name, :string + add :incomplete_count, :integer + add :user_id, references(:users, on_delete: :delete_all) + + timestamps + end + create index(:lists, [:user_id]) + + end +end
priv/repo/migrations/20160920202208_create_todo.exs
+defmodule PhoenixTodos.Repo.Migrations.CreateTodo do + use Ecto.Migration + + def change do + create table(:todos) do + add :text, :string + add :checked, :boolean, default: false + add :list_id, references(:lists, on_delete: :delete_all) + + timestamps + end + create index(:todos, [:list_id]) + + end +end
test/models/list_test.exs
+defmodule PhoenixTodos.ListTest do + use PhoenixTodos.ModelCase + + alias PhoenixTodos.List + + @valid_attrs %{incomplete_count: 42, name: "some content"} + @invalid_attrs %{} + + test "changeset with valid attributes" do + changeset = List.changeset(%List{}, @valid_attrs) + assert changeset.valid? + end + + test "changeset with invalid attributes" do + changeset = List.changeset(%List{}, @invalid_attrs) + refute changeset.valid? + end +end
test/models/todo_test.exs
+defmodule PhoenixTodos.TodoTest do + use PhoenixTodos.ModelCase + + alias PhoenixTodos.Todo + + @valid_attrs %{checked: true, text: "some content"} + @invalid_attrs %{} + + test "changeset with valid attributes" do + changeset = Todo.changeset(%Todo{}, @valid_attrs) + assert changeset.valid? + end + + test "changeset with invalid attributes" do + changeset = Todo.changeset(%Todo{}, @invalid_attrs) + refute changeset.valid? + end +end
web/models/list.ex
+defmodule PhoenixTodos.List do + use PhoenixTodos.Web, :model + + schema "lists" do + field :name, :string + field :incomplete_count, :integer + belongs_to :user, PhoenixTodos.User + has_many :todos, PhoenixTodos.Todo + + timestamps + end + + @required_fields ~w(name incomplete_count) + @optional_fields ~w() + + @doc """ + Creates a changeset based on the `model` and `params`. + + If no params are provided, an invalid changeset is returned + with no validation performed. + """ + def changeset(model, params \\ :empty) do + model + |> cast(params, @required_fields, @optional_fields) + end +end
web/models/todo.ex
+defmodule PhoenixTodos.Todo do + use PhoenixTodos.Web, :model + + schema "todos" do + field :text, :string + field :checked, :boolean, default: false + belongs_to :list, PhoenixTodos.List + + timestamps + end + + @required_fields ~w(text checked) + @optional_fields ~w() + + @doc """ + Creates a changeset based on the `model` and `params`. + + If no params are provided, an invalid changeset is returned + with no validation performed. + """ + def changeset(model, params \\ :empty) do + model + |> cast(params, @required_fields, @optional_fields) + end +end
Seeding Data
Now that we’ve defined our schemas and models, we need to seed our database with data.
But before we do anything, we need to make sure that our migrations are up to date:
mix ecto.migrate
This will create our "lists"
and "todos"
tables our PostgreSQL database.
Now we can start writing our seeding script. We’ll model this script
after the original fixtures.js
file
in our Meteor application.
We’ll start by creating a list of in-memory lists and todos that we’ll use to build our database objects:
[
%{
name: "Meteor Principles",
items: [
"Data on the Wire",
"One Language",
"Database Everywhere",
"Latency Compensation",
"Full Stack Reactivity",
"Embrace the Ecosystem",
"Simplicity Equals Productivity",
]
},
...
]
Notice that we’re using double quote strings here instead of single quote strings, like the original Meteor appliaction. This is because single quote strings have a special meaning in Elixir.
Next, we’ll Enum.map
over each object in this list. Each object
represents a List
, so we’ll build a List
model object and insert
it into our database:
list = Repo.insert!(%List{
name: data.name,
incomplete_count: length(data.items)
})
Each string in list.items
represents a single Todo
. We’ll map over
this list build a new Todo
model object, associating it with the
List
we just created using Ecto.build_assoc
, and inserting it into
the database:
Ecto.build_assoc(list, :todos, text: item)
|> Repo.insert!
Now we can run our seed script with the following command:
mix run priv/repo/seeds.exs
Or we can wipe our database and re-run our migrations and seed script with the following command:
mix ecto.reset
After running either of these, our database should have three lists, each with a set of associated todos.
priv/repo/seeds.exs
# mix run priv/repo/seeds.exs -# -# Inside the script, you can read and write to any of your -# repositories directly: -# -# PhoenixTodos.Repo.insert!(%PhoenixTodos.SomeModel{}) -# -# We recommend using the bang functions (`insert!`, `update!` -# and so on) as they will fail if something goes wrong. + +alias PhoenixTodos.{Repo, List} + +[ + %{ + name: "Meteor Principles", + items: [ + "Data on the Wire", + "One Language", + "Database Everywhere", + "Latency Compensation", + "Full Stack Reactivity", + "Embrace the Ecosystem", + "Simplicity Equals Productivity", + ] + }, + %{ + name: "Languages", + items: [ + "Lisp", + "C", + "C++", + "Python", + "Ruby", + "JavaScript", + "Scala", + "Erlang", + "6502 Assembly", + ] + }, + %{ + name: "Favorite Scientists", + items: [ + "Ada Lovelace", + "Grace Hopper", + "Marie Curie", + "Carl Friedrich Gauss", + "Nikola Tesla", + "Claude Shannon", + ] + } +] +|> Enum.map(fn data -> + list = Repo.insert!(%List{ + name: data.name, + incomplete_count: length(data.items) + }) + Enum.map(data.items, fn item -> + Ecto.build_assoc(list, :todos, text: item) + |> Repo.insert! + end) +end)
Public Lists
Now that our database is populated with Lists
and Todos
, we’re in a
position where we can start passing this data down the the client.
To keep things as similar to our original Meteor application as possible, we’ll be doing all of our commuication via WebSockets. Specifically, we’ll be using Phoenix Channels.
We’ll start by creating a "lists.public"
channel. This channel will
emulate the "lists.public"
publication in our Meteor application:
channel "lists.public", PhoenixTodos.ListChannel
When a client joins this channel, we’ll send them all public lists:
lists = List |> List.public |> Repo.all
{:ok, lists, socket}
Where public lists are lists without an associated User
:
def public(query) do
from list in query,
where: is_nil(list.user_id)
end
In order to send these lists down the wire, we need to use
Poison to tell Phoenix how to
serialize our List
objects into JSON:
@derive {Poison.Encoder, only: [
:id,
:name,
:incomplete_count,
:user_id
]}
Now our client can connect to our server and join the "lists.public"
channel:
socket.connect();
socket.channel("lists.public", {})
.join()
For each of the lists we receive back, well fire an ADD_LIST
Redux
action. The resolver for this action simply pushes the List
object
onto our application’s lists
array:
return Object.assign({}, state, {
lists: [...state.lists, action.list]
});
And with that (and a few minor bug fixes), our application is now showing lists pulled from the server!
test/models/list_test.exs
... alias PhoenixTodos.List + alias PhoenixTodos.User + alias PhoenixTodos.Repo ... end + + test "public" do + user = User.changeset(%User{}, %{ + email: "user@example.com", + password: "password" + }) |> Repo.insert! + public = Repo.insert!(%List{ + name: "public", + incomplete_count: 1 + }) + Repo.insert!(%List{ + name: "private", + incomplete_count: 1, + user_id: user.id + }) + + lists = List |> List.public |> Repo.all + + assert lists == [public] + end end
web/channels/list_channel.ex
+defmodule PhoenixTodos.ListChannel do + use Phoenix.Channel + alias PhoenixTodos.{Repo, List} + + def join("lists.public", _message, socket) do + lists = List |> List.public |> Repo.all + {:ok, lists, socket} + end + +end
web/channels/user_socket.ex
... # channel "rooms:*", PhoenixTodos.RoomChannel + channel "lists.public", PhoenixTodos.ListChannel
web/models/list.ex
... + @derive {Poison.Encoder, only: [ + :id, + :name, + :incomplete_count, + :user_id + ]} + schema "lists" do ... end + + def public(query) do + from list in query, + where: is_nil(list.user_id) + end + end
web/static/js/actions/index.js
... +export const ADD_LIST = "ADD_LIST"; + export function signUpRequest() { ... +export function addList(list) { + return { type: ADD_LIST, list }; +} + export function signUp(email, password, password_confirm) {
web/static/js/app.js
... import thunkMiddleware from "redux-thunk"; +import { + addList +} from "./actions"; +import socket from "./socket"; ... store.subscribe(render); + +socket.connect(); +socket.channel("lists.public", {}) + .join() + .receive("ok", (res) => { + res.forEach((list) => { + store.dispatch(addList(list)); + }); + }) + .receive("error", (res) => { + console.log("error", res); + });
web/static/js/components/ListList.jsx
... <Link - to={`/lists/${ list._id }`{:.language-javascript}} - key={list._id} + to={`/lists/${ list.id }`{:.language-javascript}} + key={list.id} title={list.name}
web/static/js/layouts/App.jsx
... // redirect / to a list once lists are ready - if (!loading && !children) { - const list = Lists.findOne(); - this.context.router.replace(`/lists/${ list._id }`{:.language-javascript}); + if (!loading && !children && this.props.lists.length) { + const list = this.props.lists[0]; + this.context.router.replace(`/lists/${ list.id }`{:.language-javascript}); } ... const publicList = Lists.findOne({ userId: { $exists: false } }); - this.context.router.push(`/lists/${ publicList._id }`{:.language-javascript}); + this.context.router.push(`/lists/${ publicList.id }`{:.language-javascript}); }
web/static/js/reducers/index.js
... SIGN_IN_FAILURE, + ADD_LIST, } from "../actions"; ... }); + case ADD_LIST: + return Object.assign({}, state, { + lists: [...state.lists, action.list] + }); default:
web/static/js/socket.js
... -// Now that you are connected, you can join channels with a topic: -let channel = socket.channel("topic:subtopic", {}) -channel.join() - .receive("ok", resp => { console.log("Joined successfully", resp) }) - .receive("error", resp => { console.log("Unable to join", resp) }) - export default socket