17
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ElixirAdvent Calendar 2023

Day 3

Livebook から Ecto を使い、 Phoenix での実装と同じ方法でデータベースを操作する

Last updated at Posted at 2023-11-01

はじめに

Livebook はブラウザ上で Elixir や Erlang などのコードを実行し、結果を視覚化できるツールです

Python における Jupyter に相当します

Livebook のはじめ方はこちら

Elixir には Phoenix という非常に堅牢で便利な Web フレームワークが存在します

Phoenix では Ecto というモジュールを使用することで、様々なデータベースを効率的に操作できるようになっています

今回は Livebook 上で Ecto を使って、 Phoenix での実装と同じ方法でのデータベース操作を実行します

実装したノートブックはこちら

環境の準備

Livebook と PostgreSQL をローカル起動している場合はそのまま進めて問題ありません

コンテナで起動したい場合、こちらの記事を参考にしてください

ノートブックの作成

Livebook のホーム画面、右上の + New notebook をクリックします

スクリーンショット 2023-10-31 9.29.34.png

新しいノートブックが開きます

スクリーンショット 2023-10-31 9.31.07.png

セットアップ

Notebook dependencies and setup と書いてある枠(セットアップセル)をクリックし、その中に以下のコードを入力します

Mix.install([
  {:ecto, "~> 3.10"},
  {:ecto_sql, "~> 3.10"},
  {:jason, "~> 1.4"},
  {:kino, "~> 0.11.0"},
  {:postgrex, "~> 0.17.3"}
])

DB接続

DB接続用モジュールの作成

Ecto を使って DB に接続するためのモジュール Repo を作成します

defmodule Repo do
  use Ecto.Repo,
    otp_app: :my_notebook,
    adapter: Ecto.Adapters.Postgres
end

adapterEcto.Adapters.Postgres を指定することにより、 PostgreSQL に接続できるようにしています

秘密情報の設定

DB接続のパスワードは秘密情報なので、 Livebook の秘密情報に登録しておきます

Livebook の左メニュー南京錠🔒のアイコンをクリックすると、 SECRETS のメニューが表示されます

+ New secrets をクリックしてください

スクリーンショット 2023-10-31 11.58.19.png

表示されたモーダルの Name に DB_PASSWORD 、 Value にパスワードの値を入力し、 + Add をクリックします

スクリーンショット 2023-10-31 12.00.02.png

これによって、 DB のパスワードは LB_DB_PASSWORD という環境変数に格納されました

Repo を子プロセスとして起動し、 DB に接続します

opts = [
  hostname: "postgres_for_livebook",
  port: 5432,
  username: "postgres",
  password: System.fetch_env!("LB_DB_PASSWORD"),
  database: "postgres"
]

Kino.start_child({Repo, opts})

{:ok, #PID<0.2572.0>} というような結果が表示されれば接続できています

マイグレーション(テーブル作成)

Ecto ではマイグレーションによってテーブルの作成、変更を実行します

Phoenix プロジェクトの場合、 mix setup 実行時に自動実行され、データベースが最新の定義に更新される仕組みです

マイグレーションの定義

マイグレーションを以下のように定義します

このマイグレーションにより、 team_member というテーブルが指定した各列、型で作成されます

また、個別に指定しなくても以下の列が生成されます

  • id: bigint 型(主キーとして設定される)
  • inserted_at: タイムゾーンなしの timestamp 型(timestamps() の指定によるもの)
  • updated_at: タイムゾーンなしの timestamp 型(timestamps() の指定によるもの)
defmodule Migrations.CreateTeamMemberTable do
  use Ecto.Migration

  def change do
    create table(:team_member) do
      add(:name, :string)
      add(:age, :integer)
      add(:weight, :float)
      add(:has_license, :boolean)
      add(:hash, :bit, size: 8)
      add(:languages, {:array, :string})
      add(:skil_level, {:map, :integer})
      add(:salary, :decimal)
      add(:date_of_birth, :date)
      add(:starting_time_of_work, :time)

      timestamps()
    end
  end
end

マイグレーションの適用

定義したマイグレーションを DB に適用します

Ecto.Migrator.up(Repo, 1, Migrations.CreateTeamMemberTable)

実行すると、以下のように結果が表示されます

04:04:43.037 [info] == Running 1 Migrations.CreateTeamMemberTable.change/0 forward

04:04:43.043 [info] create table team_member

04:04:43.066 [info] == Migrated 1 in 0.0s

:ok

マイグレーション結果の確認

psql で DB に接続し、マイグレーション結果を確認しましょう

ローカルのターミナルから接続する場合、以下のコマンドを実行します

psql --host=localhost --username=postgres postgres

接続した DB 内で以下のコマンドを実行します

\dt

すると、現在のテーブル一覧が表示されます

               List of relations
 Schema |       Name        | Type  |  Owner
--------+-------------------+-------+----------
 public | schema_migrations | table | postgres
 public | team_member       | table | postgres
(2 rows)

マイグレーションで定義した team_member テーブルと、マイグレーションの状態を管理するための schema_migrations テーブルができています

以下のコマンドで team_member テーブルの定義を確認しましょう

\d team_member

結果は以下のようになります

                                               Table "public.team_member"
        Column         |              Type              | Collation | Nullable |                 Default
-----------------------+--------------------------------+-----------+----------+-----------------------------------------
 id                    | bigint                         |           | not null | nextval('team_member_id_seq'::regclass)
 name                  | character varying(255)         |           |          |
 age                   | integer                        |           |          |
 weight                | double precision               |           |          |
 has_license           | boolean                        |           |          |
 hash                  | bit(8)                         |           |          |
 languages             | character varying(255)[]       |           |          |
 skil_level            | jsonb                          |           |          |
 salary                | numeric                        |           |          |
 date_of_birth         | date                           |           |          |
 starting_time_of_work | time(0) without time zone      |           |          |
 inserted_at           | timestamp(0) without time zone |           | not null |
 updated_at            | timestamp(0) without time zone |           | not null |
Indexes:
    "team_member_pkey" PRIMARY KEY, btree (id)

マイグレーションでの型指定と PostgreSQL での型が以下のように対応していることが分かります

マイグレーション PostgreSQL
string character varying(255)
integer integer
float double precision
boolean boolean
bit bit
array []
map jsonb
decimal numeric
date date
time time(0) without time zone

schema_migrations テーブルのデータを参照してみます

SELECT * FROM schema_migrations;

結果は以下のようになります

 version |     inserted_at
---------+---------------------
       1 | 2023-10-31 04:04:43
(1 row)

Ecto.Migrator.up の第2引数で指定していた 1 が version の値になっています

Ecto のマイグレーションでは、このテーブルのデータを参照し、どのマイグレーションが実行済かを把握しています

例えばこの状態でもう一度 Ecto.Migrator.up(Repo, 1, Migrations.CreateTeamMemberTable) を実行しても、 version = 1 のマイグレーションは実行済なのでスキップされます

もう一度マイグレーションを適用したい場合、当該レコードを削除する必要があります

クエリの実行

スキーマの定義

Ecto ではスキーマにデータを格納して操作します

以下のコードで team_member テーブルのスキーマを定義します

changeset 関数により、与えられたデータからテーブルに存在する列だけを抽出し、必須項目のチェックもしています

defmodule TeamMember do
  use Ecto.Schema
  import Ecto.Changeset

  schema "team_member" do
    field(:name, :string)
    field(:age, :integer)
    field(:weight, :float)
    field(:has_license, :boolean)
    field(:hash, :binary)
    field(:languages, {:array, :string})
    field(:skil_level, {:map, :integer})
    field(:salary, :decimal)
    field(:date_of_birth, :date)
    field(:starting_time_of_work, :time)

    timestamps()
  end

  def changeset(team_member, attrs) do
    team_member
    |> cast(attrs, [
      :name,
      :age,
      :weight,
      :has_license,
      :hash,
      :languages,
      :skil_level,
      :salary,
      :date_of_birth,
      :starting_time_of_work
    ])
    |> validate_required([:name])
  end
end

データの追加

データの追加は以下のように実行します

%TeamMember{}
|> TeamMember.changeset(%{
  name: "Alice",
  age: 20,
  weight: 60.0,
  has_license: true,
  hash: <<0b11111111>>,
  languages: ["Japanese", "English"],
  skil_level: %{frontend: 5, backend: 3},
  salary: 1000_000,
  date_of_birth: ~D[2000-01-01],
  starting_time_of_work: ~T[08:30:00.0010]
})
|> Repo.insert()

実行結果は以下のようになります

ログ

04:37:51.908 [debug] QUERY OK db=10.1ms queue=2.9ms idle=1605.6ms
INSERT INTO "team_member" ("name","hash","age","weight","has_license","languages","skil_level","salary","date_of_birth","starting_time_of_work","inserted_at","updated_at") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING "id" ["Alice", <<255>>, 20, 60.0, true, ["Japanese", "English"], %{frontend: 5, backend: 3}, Decimal.new("1000000"), ~D[2000-01-01], ~T[08:30:00], ~N[2023-10-31 04:37:51], ~N[2023-10-31 04:37:51]]

実行結果

{:ok,
 %TeamMember{
   __meta__: #Ecto.Schema.Metadata<:loaded, "team_member">,
   id: 1,
   name: "Alice",
   age: 20,
   weight: 60.0,
   has_license: true,
   hash: <<255>>,
   languages: ["Japanese", "English"],
   skil_level: %{frontend: 5, backend: 3},
   salary: Decimal.new("1000000"),
   date_of_birth: ~D[2000-01-01],
   starting_time_of_work: ~T[08:30:00],
   inserted_at: ~N[2023-10-31 04:37:51],
   updated_at: ~N[2023-10-31 04:37:51]
 }}

ログを見ると、実際に DB に発行された SQL を確認することができます

以下のようにして複数レコードを一気に追加することも可能です

now =
  NaiveDateTime.utc_now()
  |> NaiveDateTime.truncate(:second)

entries =
  [
    %{name: "Bob", age: 20},
    %{name: "John", age: 30},
    %{name: "Ryo", age: 39}
  ]
  |> Enum.map(fn attr ->
    Map.merge(attr, %{
      inserted_at: now,
      updated_at: now
    })
  end)

Repo.insert_all(TeamMember, entries)

結果は以下のようになります

ログ

05:11:15.876 [debug] QUERY OK db=2.8ms queue=4.1ms idle=279.6ms
INSERT INTO "team_member" ("name","age","inserted_at","updated_at") VALUES ($1,$2,$3,$4),($5,$6,$7,$8),($9,$10,$11,$12) ["Bob", 20, ~N[2023-10-31 05:11:15], ~N[2023-10-31 05:11:15], "John", 30, ~N[2023-10-31 05:11:15], ~N[2023-10-31 05:11:15], "Ryo", 39, ~N[2023-10-31 05:11:15], ~N[2023-10-31 05:11:15]]

実行結果

{3, nil}

データの取得

データを全て取得する場合、以下のようにします

team_members = Repo.all(TeamMember)

結果は以下のようになります

ログ

04:56:22.512 [debug] QUERY OK source="team_member" db=4.3ms queue=1.3ms idle=1282.0ms
SELECT t0."id", t0."name", t0."age", t0."weight", t0."has_license", t0."hash", t0."languages", t0."skil_level", t0."salary", t0."date_of_birth", t0."starting_time_of_work", t0."inserted_at", t0."updated_at" FROM "team_member" AS t0 []

実行結果

[
  %TeamMember{
    __meta__: #Ecto.Schema.Metadata<:loaded, "team_member">,
    id: 1,
    name: "Alice",
    age: 20,
    weight: 60.0,
    has_license: true,
    hash: <<255>>,
    languages: ["Japanese", "English"],
    skil_level: %{"backend" => 3, "frontend" => 5},
    salary: Decimal.new("1000000"),
    date_of_birth: ~D[2000-01-01],
    starting_time_of_work: ~T[08:30:00],
    inserted_at: ~N[2023-10-31 04:37:51],
    updated_at: ~N[2023-10-31 04:37:51]
  },
  ...
]

データを見やすいようにテーブル表示します

ただし、 :hash だけはテーブル表示できないため項目を削除します

team_members
|> Enum.map(fn team_member ->
  Map.drop(team_member, [:hash])
end)
|> Kino.DataTable.new()

スクリーンショット 2023-10-31 14.37.58.png

データの射影、選択をする場合、 Ecto.Query をインポートし、マクロを使います

import Ecto.Query

例えば、 age < 25 を条件として選択し、 nameage で射影する場合、以下のようなコードになります

Repo.all from tm in TeamMember,
  select: %{member_name: tm.name, member_age: tm.age},
  where: tm.age < 25

結果は以下のようになります

ログ

05:23:26.045 [debug] QUERY OK source="team_member" db=1.5ms queue=0.3ms idle=1622.0ms
SELECT t0."name", t0."age" FROM "team_member" AS t0 WHERE (t0."age" < 25) []

実行結果

[%{member_name: "Alice", member_age: 20}, %{member_name: "Bob", member_age: 20}]

データの更新

id = 2 のデータについて、 age と weight を更新する例です

TeamMember
|> Repo.get!(2)
|> TeamMember.changeset(%{
  age: 21,
  weight: 62.0
})
|> Repo.update()

ログ

05:35:37.953 [debug] QUERY OK source="team_member" db=5.7ms queue=0.1ms idle=713.6ms
SELECT t0."id", t0."name", t0."age", t0."weight", t0."has_license", t0."hash", t0."languages", t0."skil_level", t0."salary", t0."date_of_birth", t0."starting_time_of_work", t0."inserted_at", t0."updated_at" FROM "team_member" AS t0 WHERE (t0."id" = $1) [2]

05:35:37.964 [debug] QUERY OK db=10.5ms queue=0.1ms idle=718.0ms
UPDATE "team_member" SET "age" = $1, "weight" = $2, "updated_at" = $3 WHERE "id" = $4 [21, 62.0, ~N[2023-10-31 05:35:37], 2]

実行結果

{:ok,
 %TeamMember{
   __meta__: #Ecto.Schema.Metadata<:loaded, "team_member">,
   id: 2,
   name: "Bob",
   age: 21,
   weight: 62.0,
   has_license: nil,
   hash: nil,
   languages: nil,
   skil_level: nil,
   salary: nil,
   date_of_birth: nil,
   starting_time_of_work: nil,
   inserted_at: ~N[2023-10-31 05:35:23],
   updated_at: ~N[2023-10-31 05:35:37]
 }}

データの削除

name = 'Alice' のデータを削除する例です

TeamMember
|> Repo.get_by!(name: "Alice")
|> Repo.delete()

ログ

05:43:58.799 [debug] QUERY OK source="team_member" db=4.8ms queue=0.5ms idle=255.5ms
SELECT t0."id", t0."name", t0."age", t0."weight", t0."has_license", t0."hash", t0."languages", t0."skil_level", t0."salary", t0."date_of_birth", t0."starting_time_of_work", t0."inserted_at", t0."updated_at" FROM "team_member" AS t0 WHERE (t0."name" = $1) ["Alice"]

05:43:58.810 [debug] QUERY OK db=5.0ms queue=6.1ms idle=261.1ms
DELETE FROM "team_member" WHERE "id" = $1 [1]

実行結果

{:ok,
 %TeamMember{
   __meta__: #Ecto.Schema.Metadata<:deleted, "team_member">,
   id: 1,
   name: "Alice",
   age: 20,
   weight: 60.0,
   has_license: true,
   hash: <<255>>,
   languages: ["Japanese", "English"],
   skil_level: %{"backend" => 3, "frontend" => 5},
   salary: Decimal.new("1000000"),
   date_of_birth: ~D[2000-01-01],
   starting_time_of_work: ~T[08:30:00],
   inserted_at: ~N[2023-10-31 05:35:21],
   updated_at: ~N[2023-10-31 05:35:21]
 }}

まとめ

Livebook 上で Ecto によるデータベース操作を実行できました

Livebook はログや実行結果を見やすく表示できるため、検証や学習に向いています

Livebook を入り口として、 Elixir の学習を進めていきましょう

17
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?