10
1

More than 1 year has passed since last update.

Ectoを使ってみよう

Last updated at Posted at 2021-12-07

Elixir Advent Calendar 2021、昨日は一緒にpiyopiyo.exを運営する@kn339264「Elixir/Phoenix入門者向けコミュニティpiyopiyo.exを立ち上げた話」でした

はじめに

今回はPhoenix1.6でEctoを使ってみたいと思います。
Ectoとは簡単にElixirでデータベースを使うライブラリーです。
Phoenix1.6を使う理由としてはSQLite3を簡単に使うことができるからです。
今回はPhoenixのことについては触れません。

SQLの知識があれば、[debug]出力しているSQL文を確認すれば理解できると思います。
あえて説明を書きません。
SQLが読めることを前提に記述してます。

検証環境

cat /etc/os-release | grep VERSION=
VERSION="20.04.3 LTS (Focal Fossa)"

$ mix phx.new  -v
Phoenix installer v1.6.0

$ iex
Erlang/OTP 24 [erts-12.0] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit]

Interactive Elixir (1.12.2) - press Ctrl+C to exit (type h() ENTER for help)

sqlite3が使えるプロジェクトを用意する

$ mix phx.new sample --database sqlite3
* creating sample/config/config.exs
    :
* creating sample/priv/static/favicon.ico

Fetch and install dependencies? [Yn] y
* running mix deps.get
* running mix deps.compile

We are almost there! The following steps are missing:

    $ cd sample

Then configure your database in config/dev.exs and run:

    $ mix ecto.create

Start your Phoenix app with:

    $ mix phx.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phx.server

プロジェクトのフォルダーに移動してDBを初期化する

$ cd sample
$ mix ecto.create
Compiling 14 files (.ex)
Generated sample app
The database for Sample.Repo has been created
$ ls
README.md  assets  deps  mix.exs   priv           test
_build     config  lib   mix.lock  sample_dev.db

テーブルを作成する

$ mix phx.gen.context Users User users name:string
* creating lib/sample/users/user.ex
* creating priv/repo/migrations/20211112044236_create_users.exs
* creating lib/sample/users.ex
* injecting lib/sample/users.ex
* creating test/sample/users_test.exs
* injecting test/sample/users_test.exs
* creating test/support/fixtures/users_fixtures.ex
* injecting test/support/fixtures/users_fixtures.ex

Remember to update your repository by running migrations:

    $ mix ecto.migrate

マイグレーションを実行

$ mix ecto.migrate
Compiling 2 files (.ex)
Generated sample app

13:43:37.788 [info]  == Running 20211112044236 Sample.Repo.Migrations.CreateUsers.change/0 forward

13:43:37.794 [info]  create table users

13:43:37.795 [info]  == Migrated 20211112044236 in 0.0s

iexで起動

$ iex -S mix
Erlang/OTP 24 [erts-12.0] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit]

Interactive Elixir (1.12.2) - press Ctrl+C to exit (type h() ENTER for help)

データを追加

iex(1)> import Ecto.Query, warn: false

iex(5)> %{name: "hoge"} |>  Sample.Users.create_user()
[debug] QUERY OK db=1.5ms decode=1.1ms idle=1012.3ms
INSERT INTO "users" ("name","inserted_at","updated_at") VALUES (?,?,?) RETURNING "id" ["hoge", "2021-11-12T04:52:31", "2021-11-12T04:52:31"]
{:ok,
 %Sample.Users.User{
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   id: 1,
   inserted_at: ~N[2021-11-12 04:52:31],
   name: "hoge",
   updated_at: ~N[2021-11-12 04:52:31]
 }}

追加したデータを確認

iex(6)> Sample.Users.list_users()                     
[debug] QUERY OK source="users" db=0.1ms queue=0.1ms idle=137.4ms
SELECT u0."id", u0."name", u0."inserted_at", u0."updated_at" FROM "users" AS u0 []
[
  %Sample.Users.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: 1,
    inserted_at: ~N[2021-11-12 04:52:31],
    name: "hoge",
    updated_at: ~N[2021-11-12 04:52:31]
  }
]
iex(7)> %{name: "hoge2"} |>  Sample.Users.create_user()
[debug] QUERY OK db=1.7ms queue=0.1ms idle=1278.7ms
INSERT INTO "users" ("name","inserted_at","updated_at") VALUES (?,?,?) RETURNING "id" ["hoge2", "2021-11-12T04:55:49", "2021-11-12T04:55:49"]
{:ok,
 %Sample.Users.User{
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   id: 2,
   inserted_at: ~N[2021-11-12 04:55:49],
   name: "hoge2",
   updated_at: ~N[2021-11-12 04:55:49]
 }}

さらにデータを追加する

iex(8)> Sample.Users.list_users()                      
[debug] QUERY OK source="users" db=0.6ms queue=0.1ms idle=1766.4ms
SELECT u0."id", u0."name", u0."inserted_at", u0."updated_at" FROM "users" AS u0 []
[
  %Sample.Users.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: 1,
    inserted_at: ~N[2021-11-12 04:52:31],
    name: "hoge",
    updated_at: ~N[2021-11-12 04:52:31]
  },
  %Sample.Users.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: 2,
    inserted_at: ~N[2021-11-12 04:55:49],
    name: "hoge2",
    updated_at: ~N[2021-11-12 04:55:49]
  }
]

追加したデータをIDを指定して確認

iex(10)> Sample.Users.get_user!(1)
[debug] QUERY OK source="users" db=0.1ms queue=0.1ms idle=1622.1ms
SELECT u0."id", u0."name", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = ?) [1]
%Sample.Users.User{
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  id: 1,
  inserted_at: ~N[2021-11-12 04:52:31],
  name: "hoge",
  updated_at: ~N[2021-11-12 04:52:31]
}
iex(11)> Sample.Users.get_user!(2)
[debug] QUERY OK source="users" db=0.5ms queue=0.1ms idle=1281.2ms
SELECT u0."id", u0."name", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = ?) [2]
%Sample.Users.User{
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  id: 2,
  inserted_at: ~N[2021-11-12 04:55:49],
  name: "hoge2",
  updated_at: ~N[2021-11-12 04:55:49]
}

自動生成されたソースを見てみよう

lib/sample/users.ex
defmodule Sample.Users.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :name, :string

    timestamps()
  end

  @doc false
  def changeset(user, attrs) do
    user
    |> cast(attrs, [:name])
    |> validate_required([:name])
  end
end
lib/sample/users/user.ex
defmodule Sample.Users do
  import Ecto.Query, warn: false
  alias Sample.Repo

  alias Sample.Users.User

  def list_users do
    Repo.all(User)
  end


  def get_user!(id), do: Repo.get!(User, id)

  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end

  def update_user(%User{} = user, attrs) do
    user
    |> User.changeset(attrs)
    |> Repo.update()
  end

  def delete_user(%User{} = user) do
    Repo.delete(user)
  end

  def change_user(%User{} = user, attrs \\ %{}) do
    User.changeset(user, attrs)
  end
end

SQL風に使ってみる from |> Repo.all()

iex(15)> alias Sample.Repo
Sample.Repo
iex(16)> alias Sample.Users.User
Sample.Users.User
iex(17)> from(User) |> Repo.all()
[debug] QUERY OK source="users" db=0.8ms idle=1143.4ms
SELECT u0."id", u0."name", u0."inserted_at", u0."updated_at" FROM "users" AS u0 []
[
  %Sample.Users.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: 1,
    inserted_at: ~N[2021-11-12 04:52:31],
    name: "hoge",
    updated_at: ~N[2021-11-12 04:52:31]
  },
  %Sample.Users.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: 2,
    inserted_at: ~N[2021-11-12 04:55:49],
    name: "hoge2",
    updated_at: ~N[2021-11-12 04:55:49]
  }
]

条件指定 where:

iex(18)> from(u in User, where: u.id == 1)  |> Repo.all()
[debug] QUERY OK source="users" db=0.1ms queue=0.1ms idle=1797.3ms
SELECT u0."id", u0."name", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = 1) []
[
  %Sample.Users.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: 1,
    inserted_at: ~N[2021-11-12 04:52:31],
    name: "hoge",
    updated_at: ~N[2021-11-12 04:52:31]
  }
]

データを追加 Repo.insert()

iex(21)> attrs = %{name: "hoge3"}

iex(25)> %User{} |> User.changeset(attrs) |> Repo.insert()
[debug] QUERY OK db=0.3ms idle=1352.6ms
INSERT INTO "users" ("name","inserted_at","updated_at") VALUES (?,?,?) RETURNING "id" ["hoge3", "2021-11-12T05:37:24", "2021-11-12T05:37:24"]
{:ok,
 %Sample.Users.User{
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   id: 3,
   inserted_at: ~N[2021-11-12 05:37:24],
   name: "hoge3",
   updated_at: ~N[2021-11-12 05:37:24]
 }}

Elixir上で表現するテーブルの形式を確認

iex(26)>%User{}
%Sample.Users.User{
  __meta__: #Ecto.Schema.Metadata<:built, "users">,
  id: nil,
  inserted_at: nil,
  name: nil,
  updated_at: nil
}

出力項目を指定 select:

iex(34)> from(u in User, select: %{id: u.id, name: u.name}) |> Repo.all() 
[debug] QUERY OK source="users" db=0.1ms idle=553.7ms
SELECT u0."id", u0."name" FROM "users" AS u0 []
[%{id: 1, name: "hoge"}, %{id: 2, name: "hoge2"}, %{id: 3, name: "hoge3"}]

並び替え order_by:

iex(35)> from(u in User, order_by: [desc: u.id], select: %{id: u.id, name: u.name}) |> Repo.all() 
[debug] QUERY OK source="users" db=0.1ms queue=0.1ms idle=1543.0ms
SELECT u0."id", u0."name" FROM "users" AS u0 ORDER BY u0."id" DESC []
[%{id: 3, name: "hoge3"}, %{id: 2, name: "hoge2"}, %{id: 1, name: "hoge"}]
iex(46)> from(u in User, order_by: [desc: u.name, asc: u.id ], select: %{id: u.id, name: u.name}) |> Repo.all() 
[debug] QUERY OK source="users" db=0.2ms idle=1714.7ms
SELECT u0."id", u0."name" FROM "users" AS u0 ORDER BY u0."name" DESC, u0."id" []
[%{id: 3, name: "hoge3"}, %{id: 2, name: "hoge2"}, %{id: 1, name: "hoge"}]

更新 Repo.update()

iex(54)> user = from(u in User, where: u.id == 3) |> Repo.one
[debug] QUERY OK source="users" db=0.6ms queue=0.2ms idle=1336.1ms
SELECT u0."id", u0."name", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = 3) []
%Sample.Users.User{
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  id: 3,
  inserted_at: ~N[2021-11-12 05:37:24],
  name: "hoge3",
  updated_at: ~N[2021-11-12 05:37:24]
}
iex(55)> attrs =  %{name: "hoge"}                            
%{name: "hoge"}
iex(56)> user |> User.changeset(attrs) |> Repo.update()      
[debug] QUERY OK db=1.6ms idle=1910.5ms
UPDATE "users" SET "name" = ?, "updated_at" = ? WHERE "id" = ? ["hoge", "2021-11-12T06:53:58", 3]
{:ok,
 %Sample.Users.User{
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   id: 3,
   inserted_at: ~N[2021-11-12 05:37:24],
   name: "hoge",
   updated_at: ~N[2021-11-12 06:53:58]
 }}
iex(57)> user = from(u in User, where: u.id == 3) |> Repo.one
[debug] QUERY OK source="users" db=0.2ms idle=1763.3ms
SELECT u0."id", u0."name", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = 3) []
%Sample.Users.User{
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  id: 3,
  inserted_at: ~N[2021-11-12 05:37:24],
  name: "hoge",
  updated_at: ~N[2021-11-12 06:53:58]
}

グループ化 group_by:

iex(61)> user = from(u in User, select: u.name) |> Repo.all()
[debug] QUERY OK source="users" db=0.1ms queue=0.1ms idle=1286.9ms
SELECT u0."name" FROM "users" AS u0 []
["hoge", "hoge2", "hoge"]
iex(62)> user = from(u in User, select: u.name, group_by: [u.name] ) |> Repo.all()
[debug] QUERY OK source="users" db=0.1ms queue=0.1ms idle=1795.9ms
SELECT u0."name" FROM "users" AS u0 GROUP BY u0."name" []
["hoge", "hoge2"]
iex(63)> 
user = from(u in User, select: [u.name, count(u.name)] , group_by: [u.name] ) |> Repo.all()
[debug] QUERY OK source="users" db=0.1ms queue=0.1ms idle=1589.5ms
SELECT u0."name", count(u0."name") FROM "users" AS u0 GROUP BY u0."name" []
[["hoge", 2], ["hoge2", 1]]

テーブル連結

テーブル連結の為に新しいテーブルを準備

$ mix phx.gen.context Memos Memo memos text:string user_id:integer
* creating lib/sample/memos/memo.ex
* creating priv/repo/migrations/20211112080730_create_memos.exs
* creating lib/sample/memos.ex
* injecting lib/sample/memos.ex
* creating test/sample/memos_test.exs
* injecting test/sample/memos_test.exs
* creating test/support/fixtures/memos_fixtures.ex
* injecting test/support/fixtures/memos_fixtures.ex

Remember to update your repository by running migrations:

    $ mix ecto.migrate
$ mix ecto.migrate

17:09:02.360 [info]  == Running 20211112080730 Sample.Repo.Migrations.CreateMemos.change/0 forward

17:09:02.369 [info]  create table memos

17:09:02.371 [info]  == Migrated 20211112080730 in 0.0s

自動生成されたソースを見てみよう

lib/sample/memos.ex
defmodule Sample.Memos do
  @moduledoc """
  The Memos context.
  """

  import Ecto.Query, warn: false
  alias Sample.Repo

  alias Sample.Memos.Memo

  @doc """
  Returns the list of memos.

  ## Examples

      iex> list_memos()
      [%Memo{}, ...]

  """
  def list_memos do
    Repo.all(Memo)
  end

  @doc """
  Gets a single memo.

  Raises `Ecto.NoResultsError` if the Memo does not exist.

  ## Examples

      iex> get_memo!(123)
      %Memo{}

      iex> get_memo!(456)
      ** (Ecto.NoResultsError)

  """
  def get_memo!(id), do: Repo.get!(Memo, id)

  @doc """
  Creates a memo.

  ## Examples

      iex> create_memo(%{field: value})
      {:ok, %Memo{}}

      iex> create_memo(%{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def create_memo(attrs \\ %{}) do
    %Memo{}
    |> Memo.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Updates a memo.

  ## Examples

      iex> update_memo(memo, %{field: new_value})
      {:ok, %Memo{}}

      iex> update_memo(memo, %{field: bad_value})
      {:error, %Ecto.Changeset{}}

  """
  def update_memo(%Memo{} = memo, attrs) do
    memo
    |> Memo.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes a memo.

  ## Examples

      iex> delete_memo(memo)
      {:ok, %Memo{}}

      iex> delete_memo(memo)
      {:error, %Ecto.Changeset{}}

  """
  def delete_memo(%Memo{} = memo) do
    Repo.delete(memo)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking memo changes.

  ## Examples

      iex> change_memo(memo)
      %Ecto.Changeset{data: %Memo{}}

  """
  def change_memo(%Memo{} = memo, attrs \\ %{}) do
    Memo.changeset(memo, attrs)
  end
end
lib/samole/memos/memo.ex
defmodule Sample.Memos.Memo do
  use Ecto.Schema
  import Ecto.Changeset

  schema "memos" do
    field :text, :string
    field :user_id, :integer

    timestamps()
  end

  @doc false
  def changeset(memo, attrs) do
    memo
    |> cast(attrs, [:text, :user_id])
    |> validate_required([:text, :user_id])
  end
end

iexで起動し新しく作ったテーブルの準備

iex -S mix

iex(1)> import Ecto.Query, warn: false
Ecto.Query
iex(2)> alias Sample.Repo
Sample.Repo
iex(3)> alias Sample.Memos.Memo
Sample.Memos.Memo
iex(4)> alias Sample.Memos
Sample.Memos
iex(5)> %{text: "Test memo", user_id: 1} |> Memos.create_memo()
[debug] QUERY OK db=6.9ms decode=2.0ms idle=1719.5ms
INSERT INTO "memos" ("text","user_id","inserted_at","updated_at") VALUES (?,?,?,?) RETURNING "id" ["Test memo", 1, "2021-11-12T08:14:12", "2021-11-12T08:14:12"]
{:ok,
 %Sample.Memos.Memo{
   __meta__: #Ecto.Schema.Metadata<:loaded, "memos">,
   id: 1,
   inserted_at: ~N[2021-11-12 08:14:12],
   text: "Test memo",
   updated_at: ~N[2021-11-12 08:14:12],
   user_id: 1
 }}
iex(6)> %{text: "Test memo hogehoge", user_id: 2} |> Memos.create_memo()
[debug] QUERY OK db=0.2ms idle=1980.7ms
INSERT INTO "memos" ("text","user_id","inserted_at","updated_at") VALUES (?,?,?,?) RETURNING "id" ["Test memo hogehoge", 2, "2021-11-12T08:14:41", "2021-11-12T08:14:41"]
{:ok,
 %Sample.Memos.Memo{
   __meta__: #Ecto.Schema.Metadata<:loaded, "memos">,
   id: 2,
   inserted_at: ~N[2021-11-12 08:14:41],
   text: "Test memo hogehoge",
   updated_at: ~N[2021-11-12 08:14:41],
   user_id: 2
 }}
iex(7)> %{text: "Test memo 2", user_id: 1} |> Memos.create_memo()       
[debug] QUERY OK db=0.2ms idle=1683.2ms
INSERT INTO "memos" ("text","user_id","inserted_at","updated_at") VALUES (?,?,?,?) RETURNING "id" ["Test memo 2", 1, "2021-11-12T08:15:06", "2021-11-12T08:15:06"]
{:ok,
 %Sample.Memos.Memo{
   __meta__: #Ecto.Schema.Metadata<:loaded, "memos">,
   id: 3,
   inserted_at: ~N[2021-11-12 08:15:06],
   text: "Test memo 2",
   updated_at: ~N[2021-11-12 08:15:06],
   user_id: 1
 }}

データの確認

iex(8)> Memos.list_memos()
[debug] QUERY OK source="memos" db=0.1ms queue=0.1ms idle=1898.2ms
SELECT m0."id", m0."text", m0."user_id", m0."inserted_at", m0."updated_at" FROM "memos" AS m0 []
[
  %Sample.Memos.Memo{
    __meta__: #Ecto.Schema.Metadata<:loaded, "memos">,
    id: 1,
    inserted_at: ~N[2021-11-12 08:14:12],
    text: "Test memo",
    updated_at: ~N[2021-11-12 08:14:12],
    user_id: 1
  },
  %Sample.Memos.Memo{
    __meta__: #Ecto.Schema.Metadata<:loaded, "memos">,
    id: 2,
    inserted_at: ~N[2021-11-12 08:14:41],
    text: "Test memo hogehoge",
    updated_at: ~N[2021-11-12 08:14:41],
    user_id: 2
  },
  %Sample.Memos.Memo{
    __meta__: #Ecto.Schema.Metadata<:loaded, "memos">,
    id: 3,
    inserted_at: ~N[2021-11-12 08:15:06],
    text: "Test memo 2",
    updated_at: ~N[2021-11-12 08:15:06],
    user_id: 1
  }
]

内部結合 join:

iex(10)> alias Sample.Users.User
Sample.Users.User

iex(12)> from(m in Memo, join: u in User, on: m.user_id == u.id) |> Repo.all()
[debug] QUERY OK source="memos" db=0.3ms queue=0.4ms idle=1328.8ms
SELECT m0."id", m0."text", m0."user_id", m0."inserted_at", m0."updated_at" FROM "memos" AS m0 INNER JOIN "users" AS u1 ON m0."user_id" = u1."id" []
[
  %Sample.Memos.Memo{
    __meta__: #Ecto.Schema.Metadata<:loaded, "memos">,
    id: 1,
    inserted_at: ~N[2021-11-12 08:14:12],
    text: "Test memo",
    updated_at: ~N[2021-11-12 08:14:12],
    user_id: 1
  },
  %Sample.Memos.Memo{
    __meta__: #Ecto.Schema.Metadata<:loaded, "memos">,
    id: 2,
    inserted_at: ~N[2021-11-12 08:14:41],
    text: "Test memo hogehoge",
    updated_at: ~N[2021-11-12 08:14:41],
    user_id: 2
  },
  %Sample.Memos.Memo{
    __meta__: #Ecto.Schema.Metadata<:loaded, "memos">,
    id: 3,
    inserted_at: ~N[2021-11-12 08:15:06],
    text: "Test memo 2",
    updated_at: ~N[2021-11-12 08:15:06],
    user_id: 1
  }
]

内部結合 フィールド指定

iex(14)> from(m in Memo, join: u in User, on: m.user_id == u.id, select: [u.id, u.name, m.text] ) |> Repo.all()
[debug] QUERY OK source="memos" db=0.4ms queue=0.4ms idle=1722.6ms
SELECT u1."id", u1."name", m0."text" FROM "memos" AS m0 INNER JOIN "users" AS u1 ON m0."user_id" = u1."id" []
[
  [1, "hoge", "Test memo"],
  [2, "hoge2", "Test memo hogehoge"],
  [1, "hoge", "Test memo 2"]
]

内部結合 フィールド指定と駆動テーブルを反転

iex(16)> from(u in User, join: m in Memo, on: m.user_id == u.id, select: [u.id, u.name, m.text] ) |> Repo.all()
[debug] QUERY OK source="users" db=0.2ms queue=0.3ms idle=1429.9ms
SELECT u0."id", u0."name", m1."text" FROM "users" AS u0 INNER JOIN "memos" AS m1 ON m1."user_id" = u0."id" []
[
  [1, "hoge", "Test memo"],
  [2, "hoge2", "Test memo hogehoge"],
  [1, "hoge", "Test memo 2"]
]

外部結合 left_join:

from(u in User, left_join: m in Memo, on: m.user_id == u.id, select: [u.id, u.name, m.text] ) |> Repo.all()
[debug] QUERY OK source="users" db=0.3ms queue=0.3ms idle=1095.7ms
SELECT u0."id", u0."name", m1."text" FROM "users" AS u0 LEFT OUTER JOIN "memos" AS m1 ON m1."user_id" = u0."id" []
[
  [1, "hoge", "Test memo"],
  [1, "hoge", "Test memo 2"],
  [2, "hoge2", "Test memo hogehoge"],
  [3, "hoge", nil]
]

明日は、オカザキリンビームのオーガナイザ@pojiroさんで「苦節二年の時を経て職業錬金術師になれた話」です

10
1
1

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
10
1