LoginSignup
6
3

More than 5 years have passed since last update.

ectoのSchema周りを読んでみた

Posted at

はじめに

実装したい機能の参考にectoのEcto.Schema周りを読みました。その備忘録的に書いていきたいと思います。
ちなみにバージョンは2.2.11になります(v3.0.0-devまで出てますが、今回は2.2.11に)

読んだところ

lib/ecto/schema.ex

ここ読みました。

Schema

Schema macroの利用

まずは一番最初、schemaの宣言から

schema.ex
defmacro schema(source, [do: block]) do
  schema(source, true, :id, block)
end

最初はマクロ宣言から。ここからすぐにschema関数に入ります。
そしてschema関数内をすぐにquoteで囲んでメタプロをしていきます。

schame.ex
defp schema(source, meta?, type, block) do
  quote do
    @after_compile Ecto.Schema
    Module.register_attribute(__MODULE__, :changeset_fields, accumulate: true)
    Module.register_attribute(__MODULE__, :struct_fields, accumulate: true)

まずは最初に@after_compileEcto.Schemaがコンパイルされた後にコンパイルされるように宣言します。
この、「Ecto.Schemaがコンパイルされた後にコンパイルされる」っとあるが、どれがEcto.Schemaの後にコンパイルされるかというとdefmacro schemaを利用したモジュールがEcto.Schemaの後にコンパイルされます。
例としてサンプルのスキーマを記述しておきます。

user.ex
defmodule SampleProject.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field(:name, :string)
    field(:password, :string)
    field(:email, :string)
    field(:role, :integer)

    timestamps()
  end
end

このSampleProject.UserモジュールがEcto.Schemaモジュールの後にコンパイルされます。(違ったらごめんなさい)

次に、モジュールにアトリビュートを追加します。
changeset_fieldsfieldマクロで宣言したフィールドをいろいろセットしていきます(後から詳しく記載します)
struct_fields は全体的なスキーマ定義の値になります。ここで入れた値を構造体として宣言します。以下のはサンプルとして作ったものになります。このような構造体になります。

user.ex
%SampleProject.User{
  __meta__: #Ecto.Schema.Metadata<:built, "users">,
  email: nil,
  id: nil,
  inserted_at: nil,
  name: nil,
  password: nil,
  role: nil,
  updated_at: nil
}

changeset_fieldsstruct_fieldsのアトリビュート値になります。

IO.inspect @changeset_fields
[
  updated_at: :naive_datetime,
  inserted_at: :naive_datetime,
  role: :integer,
  email: :string,
  password: :string,
  name: :string,
  id: :id
]

IO.inspect @struct_fields
[
  updated_at: nil,
  inserted_at: nil,
  role: nil,
  email: nil,
  password: nil,
  name: nil,
  id: nil,
  __meta__: #Ecto.Schema.Metadata<:built, "users">
]

引数の値をquote内で使うためunquoteします。

schema.ex
meta?  = unquote(meta?)
source = unquote(source)
prefix = @schema_prefix

meta?には schema のマクロで宣言するとtrueが入ります。
sourceにはスキーマ名が入ります。

ここで、スキーマがメタプロなのかどうかif meta? doで判定し、スキーマ名がbinaryかどうかチェックします。(""で囲まれたのはElixirはバイナリになります)

schema.ex
  if meta? do

    unless is_binary(source) do
      raise ArgumentError, "schema source must be a string, got: #{inspect source}"
    end

    Module.put_attribute(__MODULE__, :struct_fields,
                          {:__meta__, %Metadata{state: :built, source: {prefix, source}}})
  end

ectoのprimary key

schema.ex
  if @primary_key == nil do
    @primary_key {:id, unquote(type), autogenerate: true}
  end

  primary_key_fields =
    case @primary_key do
      false ->
        []
      {name, type, opts} ->
        Ecto.Schema.__field__(__MODULE__, name, type, [primary_key: true] ++ opts)
        [name]
      other ->
        raise ArgumentError, "@primary_key must be false or {name, type, opts}"
    end

プライマリーキーの宣言です。schemaモジュール内で@primary_keyが宣言されてなかった場合は{:id, unquote(type), autogenerate: true}で宣言します。
なので、@primary_keyを宣言すればid以外をプライマリーキーとして利用することができます。

Ecto.Schema.__field__(__MODULE__, name, type, [primary_key: true] ++ opts)でプライマリーキーとしてフィールドに追加します。
[primary_key: true]っというのでこのフィールドがプライマリーキーっとして利用するようにします。ここの第4引数はフィールドのオプションになります。
例えば、上記のuserスキーマでnameをプライマリーキーとして利用したい場合は、以下のようにするとプライマリーキーとして利用できます。

field(:name, :string, primary_key: true)

プライマリーキーになったかどうかは@ecto_primary_keysの値をみればプライマリーキーになったかどうかわかります。

IO.inspect @ecto_primary_keys
[:name, :id]

@ecto_primary_keys アトリビュートにプライマリーキーとして利用されるフィールドが配列として入ります。後々ここの値を使ってプライマリーキーの宣言をしていきます。

コードブロック内の評価

次にschemaのコードブロック内の評価を行っていきます。

schema.ex
  try do
    import Ecto.Schema
    unquote(block)
  after
    :ok
  end

unquote(block)schemado ・・・ end内のブロックを評価します。コードブロック内はfieldのマクロを実行していきます。

schema.ex
  defmacro field(name, type \\ :string, opts \\ []) do
    quote do
      Ecto.Schema.__field__(__MODULE__, unquote(name), unquote(type), unquote(opts))
    end
  end
schema.ex
  @doc false
  def __field__(mod, name, type, opts) do
    virtual? = opts[:virtual] || false
    check_type!(name, type, virtual?)
    pk? = opts[:primary_key] || false

check_type!typeが正しいタイプかチェックします。
pk?にこのフィールドがプライマリーキーかどうかチェックするための値を入れます。上で記述したようにオプションにprimary_key: trueがあった場合はプライマリーキーとして利用されます。

schema.ex
  default = default_for_type(type, opts)
  Module.put_attribute(mod, :changeset_fields, {name, type})
  put_struct_field(mod, name, default)

default_for_typeでデフォルト値を設定します。

schema.ex
  defp default_for_type(_, opts) do
    Keyword.get(opts, :default)
  end

ここでもオプションにdefaultの値を返します。ない場合はnilになります。
Module.put_attribute(mod, :changeset_fields, {name, type})changeset_fieldsにフィールドの名前とタイプを入れます。

schema.ex
  defp put_struct_field(mod, name, assoc) do
    fields = Module.get_attribute(mod, :struct_fields)

    if List.keyfind(fields, name, 0) do
      raise ArgumentError, "field/association #{inspect name} is already set on schema"
    end

    Module.put_attribute(mod, :struct_fields, {name, assoc})
  end

put_struct_fieldstruct_fieldsアトリビュートに値を追加します。
これでstruct_fieldsにフィールドの追加を行っていき、上記で記述したように最終的に構造体として宣言準備を行っていきます。

ここでまた__field__関数に戻ります。ちょっと大きいですが、バーチャルフィールドがどうかチェックを行います。

  unless virtual? do
    source = opts[:source] || Module.get_attribute(mod, :field_source_mapper).(name)
    if name != source do
      Module.put_attribute(mod, :ecto_field_sources, {name, source})
    end

    if raw = opts[:read_after_writes] do
      Module.put_attribute(mod, :ecto_raw, name)
    end

    case gen = opts[:autogenerate] do
      {_, _, _} ->
        store_mfa_autogenerate!(mod, name, type, gen)
      true ->
        store_type_autogenerate!(mod, name, source || name, type, pk?)
      _ ->
        :ok
    end

    if raw && gen do
      raise ArgumentError, "cannot mark the same field as autogenerate and read_after_writes"
    end

    if pk? do
      Module.put_attribute(mod, :ecto_primary_keys, name)
    end

    Module.put_attribute(mod, :ecto_fields, {name, type})
end

バーチャルフィールドではない場合、フィールド値として追加します。
ここではオートインクリメントの設定やプライマリーキーを設定するためecto_primary_keysに値を追加したりします。

アトリビュートの値を設定

  primary_key_fields = @ecto_primary_keys |> Enum.reverse
  autogenerate = @ecto_autogenerate |> Enum.reverse
  autoupdate = @ecto_autoupdate |> Enum.reverse
  fields = @ecto_fields |> Enum.reverse
  field_sources = @ecto_field_sources |> Enum.reverse
  assocs = @ecto_assocs |> Enum.reverse
  embeds = @ecto_embeds |> Enum.reverse

アトリビュート値を反転させておきます。

  Module.eval_quoted __ENV__, [
    Ecto.Schema.__defstruct__(@struct_fields),
    Ecto.Schema.__changeset__(@changeset_fields),
    Ecto.Schema.__schema__(prefix, source, fields, primary_key_fields),
    Ecto.Schema.__types__(fields, field_sources),
    Ecto.Schema.__dumper__(fields, field_sources),
    Ecto.Schema.__loader__(fields, field_sources),
    Ecto.Schema.__field_sources__(fields, field_sources),
    Ecto.Schema.__assocs__(assocs),
    Ecto.Schema.__embeds__(embeds),
    Ecto.Schema.__read_after_writes__(@ecto_raw),
    Ecto.Schema.__autogenerate__(@ecto_autogenerate_id, autogenerate, autoupdate)]

最終的にアトリビュート値をもとにスキーマの最終宣言やもろもろを宣言していきます。
一つ一つの関数内はまた別の機会に記述していきたいと思います。

まとめ

スキーマでのマクロの使い方とか、シンプルで読みやすかったです。
もうちょっと深く読んでみたいのがbelongs_toとかhas_manyとかも今後読んでみたいと思います。
めちゃくちゃアトリビュート値を使っているのと、PhoenixのルーティングやAbsintheのobjectなどもアトリビュート値を駆使して宣言していました。(ココらへんもどこかの機会にまとめようかと思います)

以上です、

6
3
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
6
3