PostgresとElixirによる多言語の名前

## 問題

現在のプロジェクトでは、XMLデータベースをPostgresに移植しています。しかし、過去 15年間、徐々にリレーショナルデータベースから離れていきました。ネイティブXMLデータベースが、従来のリレーショナルデータベースよりも多次元にわたって情報をまとめる優れた方法であることと思ってます。この間に構築された情報システムは、MLBチームが購読しているアジアの野球選手に関する情報を提供するだけでなく、ファンタジースポーツサイトにも力を与えています。

XQueryは関数型プログラミング言語であるので、Elixirへの移行は自然に感じられました。しかし、データベースの部分は後回しにしていました。さまざまなEctoの本を読んでおり、Postgresと共に、Ecto の埋め込みJSONB blobを処理できることを知っています。しかし、実際にデータを移植して、現在XQueryで楽しんでいるのと同じように埋め込みデータに簡単にアクセスできるでしょうか?

概念実証を構築しましょう。

## シンプルに始める

簡単なことから始めましょう。リーグ名に関する多言語メタデータ情報を含むリーグのXML文書(レコード)をいくつか持っています。人間の名前はより複雑であるため、リーグは開始するのに適した場所です。

まず、リーグのXMLドキュメントがどのようなものかを見てみましょう。

```xml
<?xml version="1.0" encoding="UTF-8"?>
<y:league xmlns:y="http://scoutdragon.com/schema/yakyu-ml.xsd" id="npb">
    <y:league-metadata>
        <y:name lang="en" shortname="NPB" fullname="Nippon Professional Baseball"/>
        <y:name lang="ja" shortname="プロ野球" fullname="一般社団法人日本野球機構" shortname-ruby="ぷろやきゅう" fullname-ruby="いっぱんしゃだんほうじんにっぽんやきゅうきこう"/>
    </y:league-metadata>
</y:league>
```

リーグの略語も欲しいですので、それも埋め込みマップとして追加しましょう。

```shell-session
$ mix phx.gen.live Sports League leagues sd_id:string abbreviations:array:map names:array:map
```

以上の`sd_id` は、XML データベース内のリーグ ID への参照です。 データはゆっくりと Postgres データベースに移行されるため、着信 XML データを新しいリレーショナル データに関連する必要があります。 確かに、「識別子に意味はない」というルールを破ったことはわかっていますが、XMLドキュメントを手動で作成するときに、人間にとって使いやすいものにすることは非常に便利でした。

とにかく、生成された`MyApp.Sports`コンテキストと`MyApp.Sports.League` データベーススキーマを微調整しました。他のテーブルにも埋め込まれた多言語の略語と名前を使用したかったので、それらのために個別の埋め込みスキーマを作成しました。

## 略語 (abbreviations)

略語は非常に簡単です。

`lib/my_app/sports/abbr_embed.ex`

```elixir
defmodule Jbc.Sports.AbbrEmbed do
  import Ecto.Changeset
  use Ecto.Schema

  @langs ~w(en ja ko zh_cn zh_tw es)a

  @type t :: %__MODULE__{
    id: Ecto.UUID.t(),
    lang: atom,
    abbr: String.t(),
    abbr_ruby: [String.t() | nil]
  }

  embedded_schema do
    field :lang, Ecto.Enum, values: @langs
    field :abbr, :string
    field :abbr_ruby, :string
  end

  @doc false
  def changeset(abbr, attrs) do
    abbr
    |> cast(attrs, [:lang, :abbr, :abbr_ruby])
    |> validate_required([:lang, :abbr])
  end
end
```

#### ルビーとは？

LaTeX などのDTP ソフトウェアでは「ルビ」行があります。たとえば、「漢字」の上に「かんじ」を出力するために、次のように記述します。

```
\usepackage{okumacro}
...
\ruby{漢字}{かんじ}
```

## 名前 (names)

名前は、略語よりもわずかに詳細です。

`lib/my_app/sports/name_embed.ex`

```elixir
defmodule Jbc.Sports.NameEmbed do
  import Ecto.Changeset
  use Ecto.Schema

  @langs ~w(en ja ko zh_cn zh_tw es)a

  @type t :: %__MODULE__{
    id: Ecto.UUID.t(),
    lang: atom,
    name: String.t(),
    full_name: [String.t() | nil],
    name_ruby: [String.t() | nil],
    full_name_ruby: [String.t() | nil]
  }

  embedded_schema do
    field :lang, Ecto.Enum, values: @langs
    field :name, :string
    field :full_name, :string
    field :name_ruby, :string
    field :full_name_ruby, :string
  end

  @doc false
  def changeset(name, attrs) do
    name
    |> cast(attrs, [:lang, :name, :full_name, :name_ruby, :full_name_ruby])
    |> validate_required([:lang, :name])
  end
end
```

ここには通称とフルネームがあり、どちらにもオプションのよみがな(ルビ)フィールドがあります。必須フィールドは `:lang` と `:name` だけです。

これは2回目「@langs」のリストを利用しているので、きっと最後ではないと思います。確かに共通モジュールに引っ張り出すべきだと思われます。 とりあえず、このままでいいでしょう。

## リーグ

ここで注目している主なテーブルである %League{} に到達します。

`lib/my_app/sports/league.ex`

```elixir
defmodule Jbc.Sports.League do
  use Ecto.Schema
  import Ecto.Changeset
  alias Jbc.Sports.{AbbrEmbed, NameEmbed}

  @type t :: %__MODULE__{
    id: integer,
    sd_id: String.t(),

    abbreviations: AbbrEmbed.t(),
    names: NameEmbed.t(),

    inserted_at: NaiveDateTime.t(),
    updated_at: NaiveDateTime.t()
  }

  schema "leagues" do
    field :sd_id, :string

    embeds_many :abbreviations, AbbrEmbed
    embeds_many :names, NameEmbed

    timestamps()
  end

  @doc false
  def changeset(league, attrs) do
    league
    |> cast(attrs, [:sd_id])
    |> cast_embed(:abbreviations)
    |> cast_embed(:names)
    |> validate_required([:sd_id])
    |> unique_constraint(:sd_id)
  end
end
```

これは、「embeds_many」で埋め込みの「略語」と「名前」を宣言した場所です。`on_replace`にデフォルトの`:raise`オプションを使用します。これによる影響はまだわかりませんが、これから学んでいきます。更新時にすべてのオプションを削除して再挿入する必要があることを少し心配しています。

## 移行 (migration)

上記の`mix`マンドでマイグレーションが自動生成されました。

`priv/repo/migrations/20221117075336_create_leagues.exs`

```elixir
defmodule MyApp.Repo.Migrations.CreateLeagues do
  use Ecto.Migration

  def change do
    create table(:leagues) do
      add :sd_id, :string
      add :abbreviations, :map
      add :names, :map

      timestamps()
    end

    create unique_index(:leagues, [:sd_id])
    execute "CREATE INDEX league_abbr_index ON leagues USING GIN ((abbreviations -> 'lang'))"
    execute "CREATE INDEX league_name_index ON leagues USING GIN ((names -> 'lang'))"
  end
end
```

それでは、`abbreviations`と`names`は元々`{:array, :map}`で定義されました。しかし、ドキュメント(HexとElixirフォーラムの投稿)に基づいて、両方とも`:map`型で定義することにしました。

私のXMLデータベースでは、言語に基づいてクエリを作成することが多いため、「lang」フィールドをインデックスとして持つことは必須です。リーグ名もインデックスするかどうかを後で考えてみます。

リーグを取得する際の主要なインデックスは`sd_id`を使えます。

## データベースにリーグを入力する

このテーブルをいじるためには、いくつかのデータレコードが必要です。生成された`Sports`コンテキストを使用する簡単なスクリプトで実行できます。

`/priv/repo/league_seeds.exs`

```elixir
%{
  sd_id: "npb",
  abbreviations: [
    %{lang: "en", abbr: "NPB"},
    %{lang: "ja", abbr: "NPB", abbr_ruby: "エンピービー"}
  ],
  names: [
    %{lang: "en", name: "NPB", full_name: "Nippon Professional Baseball"},
    %{lang: "ja", name: "プロ野球", full_name: "一般社団法人日本野球機構", name_ruby: "エンピービー", full_name_ruby: "いっぱんしゃだんほうじんにっぽんやきゅうきこう"}
  ]
}
|> MyApp.Sports.create_league()

%{
  sd_id: "kbo",
  abbreviations: [
    %{lang: "en", abbr: "KBO"},
    %{lang: "ja", abbr: "KBOリーグ", abbr_ruby: "ケービーオーリーグ"},
    %{lang: "ko", abbr: "KBO 리그"}
  ],
  names: [
    %{lang: "en", name: "KBO League", full_name: "Korea Baseball Organization"},
    %{lang: "ja", name: "KBOリーグ", full_name: "韓国野球委員会", name_ruby: "ケービーオーリーグ", full_name_ruby: "かんこくやきゅういいんかい"},
    %{lang: "ko", name: "KBO 리그", full_name: "한국야구위원회"}
  ]
}
|> MyApp.Sports.create_league()

%{
  sd_id: "cpbl",
  abbreviations: [
    %{lang: "en", abbr: "CPBL"},
    %{lang: "ja", abbr: "台湾プロ野球", abbr_ruby: "たいわんプロやきゅう"},
    %{lang: "zh_tw", abbr: "中華職棒"}
  ],
  names: [
    %{lang: "en", name: "CPBL", full_name: "Chinese Professional Baseball League"},
    %{lang: "ja", name: "中華職棒", full_name: "中華職業棒球大聯盟", name_ruby: "たいわんプロやきゅう", full_name_ruby: "うかしょくぎょうぼうきゅうだいれんめい"},
    %{lang: "zh_tw", name: "中華職棒", full_name: "中華職業棒球大聯盟"}
  ]
}
|> MyApp.Sports.create_league()
```

すべてのリーグが同じ言語をサポートしているわけではないことに注意してください。私が重点的に取り組んでいる主要な2つの言語は、英語と日本語です。しかし、韓国と台湾の野球を扱うときは、母国語の名前が手元にあるのが大事と思ってます。

```shell-session
$ mix run priv/repo/league_seeds.exs
```

以上のコマンドを実行すると、操作するデータがいくつかあるはずです。

`iex`では、簡単な`MyApp.Sports.list_leagues()`が次を返します:

```elixir
[
  %Jbc.Sports.League{
    __meta__: #Ecto.Schema.Metadata<:loaded, "leagues">,
    id: 1,
    sd_id: "npb",
    abbreviations: [
      %Jbc.Sports.AbbrEmbed{
        id: "d233c86b-da71-4b9b-998e-c33c85dee9cb",
        lang: :en,
        abbr: "NPB",
        abbr_ruby: nil
      },
      %Jbc.Sports.AbbrEmbed{
        id: "c095b6c0-5128-458b-bdde-660219f23bd7",
        lang: :ja,
        abbr: "NPB",
        abbr_ruby: "エンピービー"
      }
    ],
    names: [
      %Jbc.Sports.NameEmbed{
        id: "dda5f844-e46d-48fc-9796-70d35839174f",
        lang: :en,
        name: "NPB",
        full_name: "Nippon Professional Baseball",
        name_ruby: nil,
        full_name_ruby: nil
      },
      %Jbc.Sports.NameEmbed{
        id: "ab2394e1-1f2b-48bc-8b58-228e2a2d0247",
        lang: :ja,
        name: "プロ野球",
        full_name: "一般社団法人日本野球機構",
        name_ruby: "エンピービー",
        full_name_ruby: "いっぱんしゃだんほうじんにっぽんやきゅうきこう"
      }
    ],
    inserted_at: ~N[2022-11-23 09:21:22],
    updated_at: ~N[2022-11-23 09:21:22]
  },
  %Jbc.Sports.League{
    __meta__: #Ecto.Schema.Metadata<:loaded, "leagues">,
    id: 2,
    sd_id: "kbo",
    abbreviations: [
      %Jbc.Sports.AbbrEmbed{
        id: "3feee1e1-3410-4b4c-b70e-ae9b3d0524f3",
        lang: :en,
        abbr: "KBO",
        abbr_ruby: nil
      },
      %Jbc.Sports.AbbrEmbed{
        id: "0cd5718d-4ae4-43db-9138-8e36cd918e41",
        lang: :ja,
        abbr: "KBOリーグ",
        abbr_ruby: "ケービーオーリーグ"
      },
      %Jbc.Sports.AbbrEmbed{
        id: "4017287c-88d9-4c31-8b78-6558bcf78437",
        lang: :ko,
        abbr: "KBO 리그",
        abbr_ruby: nil
      }
    ],
    ...
  }
  ...
]
```

出力が長いなので、切りました。でも、3 つのリーグすべてに2つまたは3つの言語の「略語」と「名前」が含まれています。

## 1つの言語のみのリーグをクエリーする

これは、多くの時間と研究を要すると予想していた部分です。そして、それはしました。何度か失敗した後、最終的にmanhtranlihn による [このElixirフォーラムの投稿](https://elixirforum.com/t/writing-queries-with-multiple-from-clauses/28646 "")でわかってきました。まず、データベース側で言語をフィルタリングする方法のコードを見てみましょう。これを`Sports`コンテキストに入ります。

```elixir
  def list_leagues_in_lang(lang) do
    from(l in League,
      inner_join: n in fragment("jsonb_array_elements(?)", l.names),
      inner_join: a in fragment("jsonb_array_elements(?)", l.abbreviations),
      where: fragment("?->>'lang' = ?", a, ^lang),
      where: fragment("?->>'lang' = ?", n, ^lang),
      select: [l.id, l.sd_id, n.value, a.value]
    )
    |> Repo.all()
  end
```

`iex`で`MyApp.Sports.list_leagues_in_lang("en")`を呼び出します。

```elixir
[
  [
    1,
    "npb",
    %{
      "abbr" => "NPB",
      "abbr_ruby" => nil,
      "id" => "d233c86b-da71-4b9b-998e-c33c85dee9cb",
      "lang" => "en"
    },
    %{
      "full_name" => "Nippon Professional Baseball",
      "full_name_ruby" => nil,
      "id" => "dda5f844-e46d-48fc-9796-70d35839174f",
      "lang" => "en",
      "name" => "NPB",
      "name_ruby" => nil
    }
  ],
  [
    2,
    "kbo",
    %{
      "abbr" => "KBO",
      "abbr_ruby" => nil,
      "id" => "3feee1e1-3410-4b4c-b70e-ae9b3d0524f3",
      "lang" => "en"
    },
    %{
      "full_name" => "Korea Baseball Organization",
      "full_name_ruby" => nil,
      "id" => "924c7beb-96ad-44d6-86f5-62966e810ecb",
      "lang" => "en",
      "name" => "KBO League",
      "name_ruby" => nil
    }
  ],
  [
    3,
    "cpbl",
    %{
      "abbr" => "CPBL",
      "abbr_ruby" => nil,
      "id" => "9f05c47c-f38e-4cb9-a4e3-2f75b9c8a049",
      "lang" => "en"
    },
    %{
      "full_name" => "Chinese Professional Baseball League",
      "full_name_ruby" => nil,
      "id" => "ae5a0a4e-936b-4707-89ad-c14e32ea2518",
      "lang" => "en",
      "name" => "CPBL",
      "name_ruby" => nil
    }
  ]
]
```

うーん。行いましたが、期待していたものとはまったく異なります。`%League{}`構造体のリストの代わりに、値の配列のリストを取得します。キー名を紛失しました。

配列されたレコードのリストを調べてレコードを`%League{}`構造体に変換する処理を追加しましょう。`list_leagues_in_lang`関数を次のように更新します。

```elixir
  def list_leagues_in_lang(lang) do
    from(l in League,
      inner_join: a in fragment("jsonb_array_elements(?)", l.abbreviations),
      inner_join: n in fragment("jsonb_array_elements(?)", l.names),
      where: fragment("?->>'lang' = ?", a, ^lang),
      where: fragment("?->>'lang' = ?", n, ^lang),
      select: [l.id, l.sd_id, a.value, n.value]
    )
    |> Repo.all()
    |> Enum.into([], fn l ->
      [id, sd_id, abbreviation, name] = l
      %MyApp.Sports.League{id: id, sd_id: sd_id, abbreviations: abbreviation, names: name}
    end)
  end
```

じゃ、`MyApp.Sports.list_leagues_in_lang("en")`を実行すると、最初に期待していたものが得られます。

```elixir
[
  %MyApp.Sports.League{
    __meta__: #Ecto.Schema.Metadata<:built, "leagues">,
    id: 1,
    sd_id: "npb",
    abbreviations: %{
      "abbr" => "NPB",
      "abbr_ruby" => nil,
      "id" => "d233c86b-da71-4b9b-998e-c33c85dee9cb",
      "lang" => "en"
    },
    names: %{
      "full_name" => "Nippon Professional Baseball",
      "full_name_ruby" => nil,
      "id" => "dda5f844-e46d-48fc-9796-70d35839174f",
      "lang" => "en",
      "name" => "NPB",
      "name_ruby" => nil
    },
    inserted_at: nil,
    updated_at: nil
  },
  %MyApp.Sports.League{
    __meta__: #Ecto.Schema.Metadata<:built, "leagues">,
    id: 2,
    sd_id: "kbo",
    abbreviations: %{
      "abbr" => "KBO",
      "abbr_ruby" => nil,
      "id" => "3feee1e1-3410-4b4c-b70e-ae9b3d0524f3",
      "lang" => "en"
    },
    names: %{
      "full_name" => "Korea Baseball Organization",
      "full_name_ruby" => nil,
      "id" => "924c7beb-96ad-44d6-86f5-62966e810ecb",
      "lang" => "en",
      "name" => "KBO League",
      "name_ruby" => nil
    },
    inserted_at: nil,
    updated_at: nil
  },
  %MyApp.Sports.League{
    __meta__: #Ecto.Schema.Metadata<:built, "leagues">,
    id: 3,
    sd_id: "cpbl",
    abbreviations: %{
      "abbr" => "CPBL",
      "abbr_ruby" => nil,
      "id" => "9f05c47c-f38e-4cb9-a4e3-2f75b9c8a049",
      "lang" => "en"
    },
    names: %{
      "full_name" => "Chinese Professional Baseball League",
      "full_name_ruby" => nil,
      "id" => "ae5a0a4e-936b-4707-89ad-c14e32ea2518",
      "lang" => "en",
      "name" => "CPBL",
      "name_ruby" => nil
    },
    inserted_at: nil,
    updated_at: nil
  }
]
```

本当に欲しかったとは`abbreviation`と`name`という仮フィール名でしたが、`embed_one`で定義されてるフィールドを`:virtual`に指定することは許可されていないようです。今の結果で単一の`abbreviations`と`names`を配列で囲まないことで、技術的にスキーマを破っています。とりあえずいいでしょう。

## デフォルトの言語

とにかく、それは一種の仕事をします。`MyApp.Sports.list_leagues_in_lang("ja")`を実行すると、日本語の略語と名前で3つのリーグが取得されます。ただし、`MyApp.Sports.list_leagues_in_lang("ko")`は韓国野球委員会(KBO)のみを返します。

```elixir
[
  %MyApp.Sports.League{
    __meta__: #Ecto.Schema.Metadata<:built, "leagues">,
    id: 2,
    sd_id: "kbo",
    abbreviations: %{
      "abbr" => "KBO 리그",
      "abbr_ruby" => nil,
      "id" => "4017287c-88d9-4c31-8b78-6558bcf78437",
      "lang" => "ko"
    },
    names: %{
      "full_name" => "한국야구위원회",
      "full_name_ruby" => nil,
      "id" => "58eeffb8-280a-4ebc-a555-082441a84af7",
      "lang" => "ko",
      "name" => "KBO 리그",
      "name_ruby" => nil
    },
    inserted_at: nil,
    updated_at: nil
  }
]
```

もちろん、そうですよ！WHEREの条件が行っていることです。XQuery では、言語固有の部分を抽出するためのサブクエリはルート レベルをフィルタリングしません。

それより、XQueryで行っていることは、指定された言語のリソースが存在しない場合、デフォルトで英語を返すということです。それも存在しない場合は、最初の要素が何であれ返します。

最初に考えたのは、各略語と名前の`WHERE`フラグメントを次のように展開することでした。

```elixir
      where: fragment("?->>'lang' = ? or ?->>'lang' = 'en'", a, ^lang, a),
      where: fragment("?->>'lang' = ? or ?->>'lang' = 'en'", n, ^lang, n),
```

これは英語では機能しましたが、日本語では、英語と日本語の`abbreviations`と`names`の組み合わせごとに`%League{}`を取得しました。韓国語と台湾語の場合は少し混沌としていませんでしたが、それでも望んでいたものではありませんでした.

Postgresの`WITH ORDINALITY`機能をやるを調べることでした。これはPostgresの生のSQLで機能しましたが、Ectoは機能しないフラグメントに`AS`を追加しようとし続けました。`WITH ORDINALITY`の後にオプションの`AS`が続き、さらに `table_alias (column_alias, ordinal_alias)`が続きます。Ecto は、この3つのエイリアスの処理方法をまだ知らないようです。この方法は将来有望に見えますが、今はそうではありません。

## 言語によるフィルター

Postgresの機能を活用するのではなく、Elixir側で言語によってフィルター処理することの方がいいかも知りません。結局、潜在的な6つの言語のみを扱っています: 

```elixir
@langs ~w(en ja ko zh_cn zh_tw es)a
```

2つやり方があります:

1. `WITH ORDINALITY`を使用して生のSQLを実行し、可能性を`[lang, :en, Removal]`順位でソートして、利用可能な最初のものを選択する。

2. 各結果を処理して`%League{}`構造体の場合、各言語固有のフィールドで`Enum.sort() |> Enum.at()`を使用してElixir側で同じ優先ソート処理を実行する。

2番の方がほど複雑ではない解決策になる可能性があることに気づきました。

まず、`lang`キーを持つマップ(スキーマ構造体を含む)のリストからアイテムを取得する関数を書きましょう。

```elixir
  def get_lang_item(items, lang) do
    items
    |> Enum.sort(fn a, b -> rank_lang(a, lang) <= rank_lang(b, lang) end)
    |> Enum.at(0)
  end

  defp rank_lang(record, lang) when is_binary(lang), do: rank_lang(record, String.to_atom(lang))
  defp rank_lang(%{lang: value} = _record, lang) when is_atom(lang) and value == lang, do: 0
  defp rank_lang(%{lang: value} = _record, _lang) when value == :en, do: 1
  defp rank_lang(_record, _lang), do: 2
```

これを機能させるための鍵は、並べ替えアルゴリズムを好みでランク付けすることです。`lang`が存在する場合は、`0`の値でランクを付けされます。英語のレコード(`lang`でない場合)は、`1` にランクを付けされます。それ以外はすべて`2`で均等にランクを付けされます。 `Enum.sort/2`はランクされたレコードを`:asc`順 (`a <= b`) でソートします。次に、ランク付けされた並べ替えの最初のレコードが返されます。

`rank_lang/2`の最初のパターンマッチは、文字列の`lang`パラメータをアトムに変換します。データベースからの結果の`lang`フィールドがアトムにキャストされて、上記のデフォルトの`list_leagues/0`関数で`Repo.all(League)`を直接呼び出して取得した戻り値に反映されます。内部的には、アトムで `list_leagues_in_lang(lang)`を呼び出しますが、後でフォームを扱うため、文字列から変換することを考えなくて済むようにするとよいでしょう。

そして`list_leagues_in_lang/1`といえば、これが新しいバージョンです:

```elixir
  def list_leagues_in_lang(lang) do
    Repo.all(League)
    |> Enum.into([], fn l ->
      %{l |
        abbreviations: [get_lang_item(l.abbreviations, lang)],
        names: [get_lang_item(l.names, lang)]
    }
    end)
  end
```

それはとても素敵に見えます。

返された`%League{}`をそれぞれ処理が必要がありますが、それらを新しく作成する代わりに、`abbreviations`と`names`を1つにフィルター処理された言語JSONレコードに置き換えます(依然として配列にラップされて)。

それでは、テストしてみましょう。

`MyApp.Sports.list_leagues_in_lang(:ja)`を実行すると:

```elixir
[
  %MyApp.Sports.League{
    __meta__: #Ecto.Schema.Metadata<:loaded, "leagues">,
    id: 1,
    sd_id: "npb",
    abbreviations: [
      %MyApp.Sports.AbbrEmbed{
        id: "c095b6c0-5128-458b-bdde-660219f23bd7",
        lang: :ja,
        abbr: "NPB",
        abbr_ruby: "エンピービー"
      }
    ],
    names: [
      %MyApp.Sports.NameEmbed{
        id: "ab2394e1-1f2b-48bc-8b58-228e2a2d0247",
        lang: :ja,
        name: "プロ野球",
        full_name: "一般社団法人日本野球機構",
        name_ruby: "エンピービー",
        full_name_ruby: "いっぱんしゃだんほうじんにっぽんやきゅうきこう"
      }
    ],
    inserted_at: ~N[2022-11-23 09:21:22],
    updated_at: ~N[2022-11-23 09:21:22]
  },
  %MyApp.Sports.League{
    __meta__: #Ecto.Schema.Metadata<:loaded, "leagues">,
    id: 2,
    sd_id: "kbo",
    abbreviations: [
      %MyApp.Sports.AbbrEmbed{
        id: "0cd5718d-4ae4-43db-9138-8e36cd918e41",
        lang: :ja,
        abbr: "KBOリーグ",
        abbr_ruby: "ケービーオーリーグ"
      }
    ],
    names: [
      %MyApp.Sports.NameEmbed{
        id: "5abd32ad-1976-4661-8ba2-c7eaa885cb67",
        lang: :ja,
        name: "KBOリーグ",
        full_name: "韓国野球委員会",
        name_ruby: "ケービーオーリーグ",
        full_name_ruby: "かんこくやきゅういいんかい"
      }
    ],
    inserted_at: ~N[2022-11-23 09:21:22],
    updated_at: ~N[2022-11-23 09:21:22]
  },
  %MyApp.Sports.League{
    __meta__: #Ecto.Schema.Metadata<:loaded, "leagues">,
    id: 3,
    sd_id: "cpbl",
    abbreviations: [
      %MyApp.Sports.AbbrEmbed{
        id: "65bf65b2-c827-48b1-99a5-f29772045c87",
        lang: :ja,
        abbr: "台湾プロ野球",
        abbr_ruby: "たいわんプロやきゅう"
      }
    ],
    names: [
      %MyApp.Sports.NameEmbed{
        id: "7123f315-fab2-4e3e-b4c9-6281bd3fabd3",
        lang: :ja,
        name: "中華職棒",
        full_name: "中華職業棒球大聯盟",
        name_ruby: "たいわんプロやきゅう",
        full_name_ruby: "うかしょくぎょうぼうきゅうだいれんめい"
      }
    ],
    inserted_at: ~N[2022-11-23 09:21:22],
    updated_at: ~N[2022-11-23 09:21:22]
  }
]
```

完全だよ！では、`:ko`はどうか?

```elixir
[
  %MyApp.Sports.League{
    __meta__: #Ecto.Schema.Metadata<:loaded, "leagues">,
    id: 1,
    sd_id: "npb",
    abbreviations: [
      %MyApp.Sports.AbbrEmbed{
        id: "d233c86b-da71-4b9b-998e-c33c85dee9cb",
        lang: :en,
        abbr: "NPB",
        abbr_ruby: nil
      }
    ],
    names: [
      %MyApp.Sports.NameEmbed{
        id: "dda5f844-e46d-48fc-9796-70d35839174f",
        lang: :en,
        name: "NPB",
        full_name: "Nippon Professional Baseball",
        name_ruby: nil,
        full_name_ruby: nil
      }
    ],
    inserted_at: ~N[2022-11-23 09:21:22],
    updated_at: ~N[2022-11-23 09:21:22]
  },
  %MyApp.Sports.League{
    __meta__: #Ecto.Schema.Metadata<:loaded, "leagues">,
    id: 2,
    sd_id: "kbo",
    abbreviations: [
      %MyApp.Sports.AbbrEmbed{
        id: "4017287c-88d9-4c31-8b78-6558bcf78437",
        lang: :ko,
        abbr: "KBO 리그",
        abbr_ruby: nil
      }
    ],
    names: [
      %MyApp.Sports.NameEmbed{
        id: "58eeffb8-280a-4ebc-a555-082441a84af7",
        lang: :ko,
        name: "KBO 리그",
        full_name: "한국야구위원회",
        name_ruby: nil,
        full_name_ruby: nil
      }
    ],
    inserted_at: ~N[2022-11-23 09:21:22],
    updated_at: ~N[2022-11-23 09:21:22]
  },
  %MyApp.Sports.League{
    __meta__: #Ecto.Schema.Metadata<:loaded, "leagues">,
    id: 3,
    sd_id: "cpbl",
    abbreviations: [
      %MyApp.Sports.AbbrEmbed{
        id: "9f05c47c-f38e-4cb9-a4e3-2f75b9c8a049",
        lang: :en,
        abbr: "CPBL",
        abbr_ruby: nil
      }
    ],
    names: [
      %MyApp.Sports.NameEmbed{
        id: "ae5a0a4e-936b-4707-89ad-c14e32ea2518",
        lang: :en,
        name: "CPBL",
        full_name: "Chinese Professional Baseball League",
        name_ruby: nil,
        full_name_ruby: nil
      }
    ],
    inserted_at: ~N[2022-11-23 09:21:22],
    updated_at: ~N[2022-11-23 09:21:22]
  }
]
```

KBOの`abbreviationsと`names`の両方を韓国語で表記しているが、他のリーグはデフォルトの英語を返した。また、`MyApp.Sports.list_leagues_in_lang("ko")`で呼び出すと、`:ko`と同じ結果が返されます。

最後に、`MyApp.Sports.list_leagues_in_lang("invalid")`を呼び出すと、すべてのリーグが英語の`abbreviations`と`names`だけで返されます。これはまさに私が望むように機能しています。

## 結論

PostgresのJSONBオブジェクトにXQueryと同じようにアクセスするには、Postgresの拡張機能について多くを学ぶ必要があると予想していました。でも、データベースレヤーの代わりにExistで結局のところ、コンテキスト内でデータをフィルタリングする方が実際には簡単です。

まだまだ道のりは長く、データ構造の多くの再編成が行われます。しかし、このプロジェクトにとって非常に有望なスタートです。ここではPostgresの特別なJSONB機能を利用しないが、将来的には便利になると確信しています。