ORM

Mirageは当初、データ層としてデータベースのみを提供していました。これは便利でしたが、ユーザーは依然として、最新の複雑なバックエンドを再現するために多くのコードを書く必要がありました。特に、リレーションシップの処理は大きな課題でした。

その解決策として、Mirageにオブジェクト関係マッパー(ORM)を追加しました。

ORMがどのようにMirageの負担を軽減するかを見てみましょう。

ORMを使う理由

次のようなデータベースを考えてみましょう。

db.dump()

// Result
{
  movies: [
    { id: "1", title: "Interstellar" },
    { id: "2", title: "Inception" },
    { id: "3", title: "Dunkirk" },
  ]
}

ルートハンドラを作成する際に最初に遭遇する問題は、この生データをアプリケーションが期待する形式に変換する方法、つまり、本番APIの形式に一致させる方法です。

バックエンドでJSON:API仕様を使用しているとします。/api/movies/1へのGETリクエストに対するレスポンスは、次のようになります。

// GET /api/movies/1
{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Interstellar"
    }
  }
}

それほど大したことではありません。このフォーマットロジックをルートハンドラに直接記述することもできます。

this.get("/movies/:id", (schema, request) => {
  let movie = schema.db.movies.find(request.params.id)

  return {
    data: {
      id: movie.id,
      type: "movies",
      attributes: {
        title: movie.title,
      },
    },
  }
})

これはうまくいきます。しかし、/api/movies/1モデルにさらにいくつかの属性があるとしましょう。

{
  "id": "1",
  "title": "Interstellar",
  "releaseDate": "October 26, 2014",
  "genre": "Sci-Fi"
}

ルートハンドラはより賢くする必要があり、id以外のすべてのプロパティがattributesハッシュに含まれるようにする必要があります。

this.get('/movies/:id', (schema, request) => {
  let movie = schema.db.movies.find(request.params.id);
  let movieJSON = {
    data: {
      id: movie.id,
      type: 'movies',
      attributes: { }
    }
  };
  Object.keys(movie)
    .filter(key => key !=== 'id')
    .forEach(key => {
      movieJSON.attributes[key] = movie[key];
    });

  return movieJSON;
});

ご覧のとおり、事態はすぐに複雑になります。

リレーションシップを追加するとどうなるでしょうか?Moviedirectorとのリレーションシップを持ち、directorId外部キーを使用してそのリレーションシップを格納しているとします。

attrs = {
  id: "1",
  title: "Interstellar",
  releaseDate: "October 26, 2014",
  genre: "Sci-Fi",
  directorId: "23",
}

このモデルの予想されるHTTPレスポンスは次のようになります。

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Interstellar"
    },
    "relationships": {
      "directors": {
        "data": { "type": "people", "id": "23" }
      }
    }
  }
}

つまり、ルートハンドラはさらに複雑になる必要があります。特に、モデルの属性(titleなど)とリレーションシップキー(directorIdなど)を区別するための堅牢な方法が必要です。

これらの問題は、Mirageがアプリケーションのモデルとそのリレーションシップを認識していれば、一般的に解決できるほど一般的なものであることがわかりました。

ORMによって解決される問題

Mirageがアプリケーションのドメインを認識している場合、モックサーバーを適切に実装するために必要な低レベルのブックキーピング作業の責任を負うことができます。

その方法の例をいくつか見てみましょう。

フォーマットロジックの分離

まず、Mirageモデルを定義することで、アプリケーションのスキーマをMirageに伝えることができます。これらのモデルはORMに登録され、Mirageにデータの形状を伝えます。

Movieモデルを定義してみましょう。

import { createServer, Model } from "miragejs"

createServer({
  models: {
    movie: Model.extend(),
  },
})

Mirageモデルは*属性においてスキーマレス*です。つまり、titlereleaseDateのようなプレーン属性を定義する必要はありません。そのため、上記のモデル定義は、Movieモデルがどのような属性を持っているかに関係なく機能します。

Movieモデルが定義されたので、ルートハンドラを更新してORMを使用してMirageモデルインスタンスで応答するようにできます。

import { createServer, Model } from "miragejs"

createServer({
  models: {
    movie: Model.extend(),
  },

  routes() {
    this.get("/movies/:id", (schema, request) => {
      let id = request.params.id

      return schema.movies.find(id)
    })
  },
})

schema引数は、ORMと対話する方法です。

ルートハンドラからプレーンな JavaScript オブジェクトの代わりに Mirage モデルのインスタンスを返すことで、Mirage の Serializer レイヤーを活用できるようになります。Serializer は、モデルとコレクションをフォーマットされた JSON レスポンスに変換する役割を担います。

Mirage には JSONAPISerializer が標準で搭載されているため、これをアプリケーションの serializer として設定すると

import { createServer, Model, JSONAPISerializer } from "miragejs"

createServer({
  models: {
    movie: Model.extend(),
  },

  serializers: {
    application: JSONAPISerializer,
  },

  routes() {
    this.get("/movies/:id", (schema, request) => {
      let id = request.params.id

      return schema.movies.find(id)
    })
  },
})

このルートハンドラは、期待通りのペイロードで応答するようになります。

/* GET /movies/1 */

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Interstellar",
      "releaseDate": "October 26, 2014",
      "genre": "Sci-Fi"
    }
  }
}

ORM は、モデルを JSON に変換する作業を Serializer レイヤーに委任することで、ルートハンドラを整理するのに役立っています。

しかし、リレーションシップを追加すると、さらに強力になります。

Moviedirector との belongs-to リレーションシップがあるとします。

// mirage/models/movie.js
import { createServer, Model, belongsTo, JSONAPISerializer } from "miragejs"

createServer({
  models: {
    person: Model.extend(),

    movie: Model.extend({
      director: belongsTo("person"),
    }),
  },

  serializers: {
    application: JSONAPISerializer,
  },

  routes() {
    this.get("/movies/:id", (schema, request) => {
      let id = request.params.id

      return schema.movies.find(id)
    })
  },
})

director は、Person モデルを指す名前付きリレーションシップです。

ルートハンドラまたは serializer を変更することなく、JSON:API の includes を使用してデータのグラフを取得できるようになりました。

以下のリクエストは

GET /api/movies/1?include=director

以下のレスポンスを生成します。

{
  "data": {
    "id": "1",
    "type": "movies",
    "attributes": {
      "title": "Interstellar",
      "releaseDate": "October 26, 2014",
      "genre": "Sci-Fi"
    },
    "relationships": {
      "director": {
        "data": { "type": "people", "id": "1" }
      }
    }
  },
  "included": [
    {
      "id": "1",
      "type": "people",
      "attributes": {
        "name": "Christopher Nolan"
      }
    }
  ]
}

JSONAPISerializer は ORM を検査することで、すべてのモデル、属性、およびリレーションシップを適切な場所に配置できます。ルートハンドラを変更する必要はまったくありません。

実際、私たちが記述したルートハンドラは、省略記法のデフォルトの動作と同じです。つまり、省略記法を使用するように切り替えることができます。

- this.get('/movies/:id', (schema, request) => {
-   let id = request.params.id;

-   return schema.movies.find(id);
- });
+ this.get('/movies/:id');

これは、ORM が、省略記法や Serializer など、Mirage のさまざまな部分を連携させてサーバー定義を簡素化するのに役立つもう1つの例です。

ORM を使用すると、生のデータベースレコードのみを操作する場合よりも、関連データの作成と編集が容易になります。

たとえば、データベースのみを使用してリレーションシップを持つ MoviePerson を作成するには、次のような操作を行う必要があります。

server.db.loadData({
  people: [
    {
      id: "1",
      name: "Christopher Nolan",
    },
  ],
  movies: [
    {
      id: "1",
      title: "Interstellar",
      releaseDate: "October 26, 2014",
      genre: "Sci-Fi",
      directorId: "1",
    },
  ],
})

Movies レコードの directorId 外部キーは、関連付けられた People レコードの id と一致する必要があることに注意してください。

このような生のデータベースデータを管理することは、特にリレーションシップが時間とともに変化するにつれて、すぐに扱いにくくなります。

server.schema を介して ORM を使用すると、ID を管理することなく、このグラフを作成できます。

let nolan = schema.people.create({ name: "Christopher Nolan" })

schema.movies.create({
  director: nolan,
  title: "Interstellar",
  releaseDate: "October 26, 2014",
  genre: "Sci-Fi",
})

ムービーを作成するときに、モデルインスタンス nolandirector 属性として渡すだけで、すべてのキーが適切に設定されます。

ORM は、リレーションシップが編集されるときに外部キーを同期させます。データベースが以下のようになっている場合、

{
  movies: [
    {
      id: '1',
      title: 'Star Wars: The Rise of Skywalker',
      directorId: '2'
    }
  ],
  people: [
    {
      id: '2',
      name: 'Rian Johnson'
    },
    {
      id: '3',
      name: 'J.J. Abrams'
    }
  ]
}

ムービーの監督を次のように更新できます。

let episode9 = schema.movies.findBy({
  title: 'Star Wars: The Rise of Skywalker'
});

episode9.update({
  director: schema.people.findBy({ name: 'J.J. Abrams' });
});

新しいデータベースは次のようになります。

{
  movies: [
    {
      id: '1',
      title: 'Star Wars: The Rise of Skywalker',
      directorId: '3'
    }
  ],
  people: [
    {
      id: '2',
      name: 'Rian Johnson'
    },
    {
      id: '3',
      name: 'J.J. Abrams'
    }
  ]
}

モデルインスタンスのみを操作したにもかかわらず、データベース内の directorId が変更されたことに注意してください。

重要なことに、これは、逆を持つ 1 対多または多対多のリレーションシップなど、より複雑なリレーションシップにも当てはまります。

ORM を使用すると、Mirage はこのすべてのブックキーピングをコードから抽象化し、省略記法に複雑なリレーションシップグラフへの任意の更新を尊重するのに十分な機能を提供します。


これらは、Mirage の ORM によって対処される主な問題の一部です。一般的に、Mirage がアプリケーションのスキーマを認識している場合、モックサーバーの構成の責任をより多く負うことができます。

次に、Mirage でモデルとそのリレーションシップを実際に定義する方法を見ていきます。