Custom Primary Keys

Sometimes we inherit a legacy database on top of which we need to build a new application. We can’t control how these databases were created, and changing them to meet our current needs can be both difficult and expensive.

Ecto expects each table to have an auto-incremented integer for a primary key. What if our legacy database requires a string as the primary key instead? No problem. We can create our models with a custom primary key, and Ecto will work just the same as if we had an integer.

Note: While Ecto allows us to do this, it’s not the natural path Ecto wants to take. Allowing Ecto to use an auto-incremented integer is definitely the right way to go for new applications.

Also, we chose this example for simplicity. name might not be the best choice for a primary key.

Let’s say that we need a JSON resource that stores rows of team athletes. Each athlete has a name, a position they play on the field, and the number of their jersey. The database that will back this resource requires that each table have a string for a primary key.

We can generate that resource like this.

$ mix phoenix.gen.json Player players name:string position:string number:integer
* creating priv/repo/migrations/20150908003815_create_player.exs
* creating web/models/player.ex
* creating test/models/player_test.exs
* creating web/controllers/player_controller.ex
* creating web/views/player_view.ex
* creating test/controllers/player_controller_test.exs
* creating web/views/changeset_view.ex

Add the resource to your api scope in web/router.ex:

    resources "/players", PlayerController

and then update your repository by running migrations:

    $ mix ecto.migrate

The first thing we need to do is add the resources route to the api scope in the router.

. . .
scope "/api", Hello do
  pipe_through :api

  resources "/players", PlayerController
end
. . .

Now we’ll need to make a few quick changes to the generated files.

Let’s take a look at the migration first, priv/repo/migrations/20150908003815_create_player.exs. We’ll need to do two things. The first is to pass in a second argument - primary_key: false to the table/2 function so that it won’t create a primary_key. Then we’ll need to pass primary_key: true to the add/3 function for the name field to signal that it will be the primary_key instead.

defmodule Hello.Repo.Migrations.CreatePlayer do
  use Ecto.Migration

  def change do
    create table(:players, primary_key: false) do
      add :name, :string, primary_key: true
      add :position, :string
      add :number, :integer

      timestamps
    end
  end
end

Let’s move on to lib/hello_web/models/player.ex next. We’ll need to add a module attribute @primary_key {:name, :string, []} describing our primary key as a string. Then we’ll need to tell Phoenix how to convert our data structure to an ID that is used in the routes: @derive {Phoenix.Param, key: :name}. We’ll also need to remove the field :name, :string line because this is our new primary key. If this seems unusual, recall that the schema doesn’t list the id field in models where id is the primary key.

defmodule Hello.Player do
  use Hello.Web, :model

  @primary_key {:name, :string, []}
  @derive {Phoenix.Param, key: :name}
  schema "players" do
    field :position, :string
    field :number, :integer

    timestamps
  end
  . . .

There’s just one more thing we’ll need to do, and that’s remove the reference to id: player.id, in the def render("player.json", %{player: player}) function body.

defmodule Hello.PlayerView do
  use Hello.Web, :view

  . . .

  def render("player.json", %{player: player}) do
    %{name: player.name,
      position: player.position,
      number: player.number}
  end
end

With all of that taken care of, let’s run our migration.

$mix ecto.migrate

The resulting players table will look like this:

hello_dev=# \d players
                Table "public.players"
   Column    |            Type             | Modifiers
-------------+-----------------------------+-----------
 name        | character varying(255)      | not null
 position    | character varying(255)      |
 number      | integer                     |
 inserted_at | timestamp without time zone | not null
 updated_at  | timestamp without time zone | not null
Indexes:
    "players_pkey" PRIMARY KEY, btree (name)

Now we have a model with the primary key name that we can query for with Repo.get!/2. We can also use it in our routes instead of an integer id - localhost:4000/players/iguberman.

Composite primary keys

In some cases, you will want two or more fields to make up the primary key. In this case, the syntax becomes:

defmodule Hello.Repo.Migrations.CreatePlayer do
  use Ecto.Migration

  def change do
    create table(:players, primary_key: false) do
      add :first_name, :string, primary_key: true
      add :last_name, :string, primary_key: true
      add :position, :string
      add :number, :integer
  . . .

and

defmodule Hello.Player do
  use Hello.Web, :model

  @primary_key false
  schema "players" do
    field :first_name, :string, primary_key: true
    field :last_name, :string, primary_key: true
    field :position, :string
    field :number, :integer
  . . .

With composite primary keys, you can no longer use get/3 or get!/3, but you have to use the more generic get_by/3 or get_by!/3, like so:

localhost:4000/players?first_name=John&last_name=Doe

def index(conn, %{"first_name" => first_name, "last_name" => last_name}) do
  player = Repo.get_by!(Player, first_name: first_name, last_name: last_name)
  . . .