Sequelize で1対多の関係のテーブル定義を作る方法

Heroku Postgres を扱うために、以前試した Sequelize を使うことにした。そこで、「1対多」の関係にあるテーブルの関係を定義する必要が出てきたので、そのやり方をまとめる。

目次

Sequelize のおさらい

Sequelize は Node.js 向けの O/R マッパー。PostgreSQL、SQLite、MySQL などに対応していて、テーブルに対応するモデルを定義することで CRUD 操作を行える。

PostgreSQL との接続時は、Sequelize と一緒に pg パッケージも一緒にインストールしておく。

$ npm install --save sequelize pg

Heroku Postgres との接続は、process.env.DATABASE_URL で参照できる接続文字列を利用するのが手っ取り早い。詳しくはこの次のサンプルコードで説明する。

複数のモデルを定義する場合のよくある雛形

Sequelize で複数のモデル (= テーブル) を扱いたい場合は、以下のような単一のファイルを作ると操作しやすい。

// model.js

const Sequelize = require('sequelize');

// 必要に応じて dotenv パッケージで環境変数をロードする
require('dotenv').config();

/** Sequelize と生成したモデルを束ねる */
const Model = {};

// DB 接続する
const connectionString = process.env.DATABASE_URL;
const sequelize = new Sequelize(connectionString, {
  timezone: '+09:00',  // JST タイムゾーン : Sequelize で SELECT した値は UTC 形式 (ISOString) になっている
  logging: false       // ログ出力を抑制する
});

// TODO : 各モデルを定義する
Model.User = require('./user-model')(sequelize);

// Sequelize を格納する
Model.Sequelize = Sequelize;
Model.sequelize = sequelize;

module.exports = Model;

途中に出てきた user-model.js はこんな感じで作る。

// user-model.js

const Sequelize = require('sequelize');

/** users テーブルのモデルを定義する */
module.exports = (sequelize) => {
  // define() の第1引数がテーブル名
  // 第2引数のキーが、モデルのプロパティ名になり、実際のテーブルのカラム名は field プロパティで示す
  const User = sequelize.define('users', {
    id      : { field: 'id'       , type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
    userName: { field: 'user_name', type: Sequelize.TEXT   , allowNull: false }
  });
  // テーブルがなければ作成し、同期する
  User.sync();
  // モデルを返す
  return User;
};

このように作っておくことで、モデルを利用したいところでは、以下のように利用できる。

// モデルを取得する
const Model = require('./model');

// ユーザ名が「サンプルユーザ」に合致するデータを1件取得する
Model.User.findOne({
  where: {
    userName: 'サンプルユーザ'
  }
})
  .then((result) => {
    // 結果は result.dataValues 配下にあり、プロパティ名はモデル定義のキーになる
    console.log('ユーザ情報を取得', result.dataValues.id, result.dataValues.userName);
  })
  .catch((error) => {
    console.error(error);
  });

この時、require('./model'); という require() が複数のファイルに記述されていても、DB 接続の処理は1回しか実行されないので一安心。

親子関係にあるテーブルの定義

ココまでの例では、users テーブルは単独で存在するテーブルだったので、コレで十分だった。

ココからは、「1つのカテゴリに複数の書籍が登録されているデータベース」を表現する、2つのテーブルを作ろうと思う。

ココでは、books テーブルの1レコード、つまり1つの書籍は、必ず1つのカテゴリに所属する、という関係とする。つまり、「カテゴリ:書籍」は「1:n」 (= 1 対 多 = one-to-many) となる。

この関係を Sequelize 上で定義しておくと、books.category_id を「外部キー」として宣言できるようになる。sync() によって Sequelize のモデルからテーブルを生成させる時に、実際の DB 上に制約を付与できるようになるので、上手く定義してやりたい。

自分に紐づく複数の子が存在する : hasMany

まず、「1:n」の「1」側となる、categories テーブルに、子テーブルが存在することを定義する。先程の user-model.js と同じ作りで、category-model.js を作ったとする。

const Sequelize = require('sequelize');

module.export = (sequelize) => {
  const Category = sequelize.define('categories', {
    id  : { field: 'id'  , type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },  // カテゴリ ID
    name: { field: 'name', type: Sequelize.TEXT, allowNull: false },  // カテゴリ名
  });
  
  // categories : books で 1:n の関係であることを示す
  // FIXME : このコードは動かない
  Category.hasMany(Model.Book, {
    foreignKey: 'categoryId'  // 対象 (book テーブル) のカラム名を指定する
  });
  
  Category.sync();
  return Category;
};

この時点で分かるかもしれないが、このコードは動かない。Sequelize の hasMany() の仕様では、子テーブルを示すために、hasMany() の第1引数に対象のモデルを渡す必要がある。つまり今回の場合は、このあと示す Book モデルを渡す必要があるのだが、このタイミングでは Book モデルの存在は分からないのである。

この点は後で直すとして、ひとまず API としては、hasMany() を使うと子テーブルとの関係を示せる、ということだけ押さえておこう。

一つの親が存在する : belongsTo

次に「1:n」の「n」側となる、books テーブルを定義する。book-model.js を作ったとする。

const Sequelize = require('sequelize');

module.export = (sequelize) => {
  const Book = sequelize.define('books', {
    id        : { field: 'id'         , type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },  // 書籍 ID
    categoryId: { field: 'category_id', type: Sequelize.INTEGER, allowNull: false },  // 紐付くカテゴリ ID
    name      : { field: 'name'       , type: Sequelize.TEXT, allowNull: false }      // カテゴリ名
  });
  
  // categories : books で 1:n の関係であることを示す
  // FIXME : このコードは動かない
  Book.belongsTo(Model.Category, {
    foreignKey: 'categoryId', // books.category_id のカラム名を指定する
    targetKey : 'id'          // 対応する category テーブルのカラム名を指定する
  });
  
  Book.sync();
  return Book;
};

こちらも動作しないコードであることが分かるだろうか。親テーブルの存在を示す belongsTo() メソッドの第1引数に、対象のモデルクラスを与える必要があるのだ。

さて、コレをどう解決するか。

関係の定義をモデル生成後に一括実行する

親と子のテーブルモデルで、それぞれお互いを参照する必要がある。ということは、hasMany()belongsTo() のいずれかを実行するタイミングでは、2つのモデルの定義が完了していないといけない、ということだ。コレをどのように実現するか。

色々な文献を見ていたところ、良いやり方が見つかったので紹介する。

const Sequelize = require('sequelize');

module.export = (sequelize) => {
  const Category = sequelize.define('categories', {
    id  : { field: 'id'  , type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },  // カテゴリ ID
    name: { field: 'name', type: Sequelize.TEXT, allowNull: false },  // カテゴリ名
  });
  
  // categories : books で 1:n の関係であることを示す
  Category.associate = (Model) => {
    Category.hasMany(Model.Book, {
      foreignKey: 'categoryId'  // 対象 (book テーブル) のカラム名を指定する
    });
  };
  
  Category.sync();
  return Category;
};
const Sequelize = require('sequelize');

module.export = (sequelize) => {
  const Book = sequelize.define('books', {
    id        : { field: 'id'         , type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },  // 書籍 ID
    categoryId: { field: 'category_id', type: Sequelize.INTEGER, allowNull: false },  // 紐付くカテゴリ ID
    name      : { field: 'name'       , type: Sequelize.TEXT, allowNull: false }      // カテゴリ名
  });
  
  // categories : books で 1:n の関係であることを示す
  Book.associate = (Model) => {
    Book.belongsTo(Model.Category, {
      foreignKey: 'categoryId', // books.category_id のカラム名を指定する
      targetKey : 'id'          // 対応する category テーブルのカラム名を指定する
    });
  };
  
  Book.sync();
  return Book;
};

先程動作しないと書いた hasMany()belongsTo() の処理を、それぞれのモデルに定義した associate() というメソッドに内包した。つまり各ファイルの中では「associate プロパティに関数を定義しただけ」で、この時点では外部キーの関係を宣言できていない。

この associate() 関数を呼び出すのは、これらのモデルを取りまとめる、メインの model.js となる。

const Sequelize = require('sequelize');

// 必要に応じて dotenv パッケージで環境変数をロードする
require('dotenv').config();

/** Sequelize と生成したモデルを束ねる */
const Model = {};

// DB 接続する
const connectionString = process.env.DATABASE_URL;
const sequelize = new Sequelize(connectionString, {
  timezone: '+09:00',  // JST タイムゾーン : Sequelize で SELECT した値は UTC 形式 (ISOString) になっている
  logging: false       // ログ出力を抑制する
});

// 各モデルを定義する
Model.User = require('./user-model')(sequelize);

// --------------------------------------------------
// ココまでは前述のコードと同じ。

// Category と Book モデルを読み込む
Model.Category = require('./category-model')(sequelize);
Model.Book = require('./book-model')(sequelize);

// 各モデルの定義が終わったら associate 関数を呼び出し、テーブルの関係を定義させる
Object.keys(Model).forEach((key) => {
  const model = Model[key];
  if(model.associate) {
    model.associate(Model);
  }
});

// 追記ココまで
// --------------------------------------------------

// Sequelize を格納する
Model.Sequelize = Sequelize;
Model.sequelize = sequelize;

module.exports = Model;

お分かりいただけただろうか。

各モデルの定義は、require() で先に済ませておき、定数 Model に蓄えておく。

全てのモデルの定義が終わったら、Object.keys(Model)Model 内のプロパティをループし、モデルを走査する。この時、そのモデルが associate() 関数を持っていれば、引数に Model 自身を渡して実行する、という作りだ。

コレなら、Category.associate() (→ hasMany()) が実行された時は Model.Book が参照できるようになっているし、Book.associate() (→ belongsTo()) が実行された時は Model.Category が参照できるようになっている、というワケだ。

関係を定義するメリット : JOIN しやすくなる

このように各モデル (= テーブル) とその関係を定義してやれば、Sequzelize を通じて DB の定義も同期でるだけでなく、SELECT 時の JOIN がやりやすくなる。

// カテゴリ ID : 1 のデータと、それに紐付く書籍データを取得する
Model.Category.findById(1, {
  include: [{
    model: Model.Book,  // 子テーブルを示す
    required: false     // false で OUTER JOIN になる (true で INNER JOIN)
  }]
})
  .then((results) => {
    console.log(results);
  });

このようにすると、次のような連想配列の構造でデータが取得できるようになる。

{
  "id": 1,
  "name": "小説カテゴリ",
  "books": [
    { id: 101, categoryId: 1, name: "なんたらミステリー" },
    { id: 105, categoryId: 1, name: "なんたらサスペンス" },
    { id: 129, categoryId: 1, name: "なんたらラブストーリー" }
  ]
}

include : model で渡したモデルのプロパティ (ココでいう books) が増えており、その中に Book モデルの形式に沿った形で、category_id1 なデータが配列で格納されているのだ。

実際に SQL を書いて SELECT した場合は、どうしても categories テーブルのカラム情報が含まれたレコードの形になってしまうが、コレなら Book モデルの情報は books テーブルのカラムしか含まれない、プレーンな状態で取得できるというワケだ。テーブル構造がそのまんま連想配列で表現されていて分かりやすい。

参考文献