【初心者向け】Node.jsとMongoDBを繋ぐMongooseの使い方

【初心者向け】Node.jsとMongoDBを繋ぐMongooseの使い方

Node.jsを使ったアプリケーション開発において、データの永続化は避けて通れない課題です。リレーショナルデータベースとしてPostgreSQLやMySQLなどがありますが、Node.jsとの相性の良さから、ドキュメント指向データベースであるMongoDBを選択する開発者も多くいます。

MongoDBは非常に柔軟で扱いやすいデータベースですが、Node.jsから直接操作するには、いくつか手間がかかる部分があります。そこで登場するのがMongooseです。MongooseはNode.jsとMongoDBの間を取り持ち、より簡単に、より構造的にデータ操作を行うための強力なライブラリです。

この記事では、Node.js開発が初めての方や、MongoDBを使ったことがない方、そしてMongooseをこれから学びたい方向けに、Mongooseの基本的な使い方から、スキーマ定義、モデルを使ったCRUD操作(作成、読み込み、更新、削除)、さらにはミドルウェアや関連データの扱い方まで、詳細かつ丁寧に解説していきます。

約5000語にわたるこの記事を読み終える頃には、Node.jsとMongoDBをMongooseで繋ぎ、基本的なデータベース操作ができるようになっているはずです。さあ、Mongooseの世界へ飛び込みましょう!

1. はじめに:なぜNode.js, MongoDB, Mongooseの組み合わせなのか?

WebアプリケーションやAPIを開発する際、バックエンド(サーバーサイド)の技術としてNode.jsは非常に人気があります。Node.jsはJavaScriptでサーバーサイド開発ができるため、フロントエンドとバックエンドで言語を統一できるという大きなメリットがあります。また、イベントループに基づく非同期処理が得意なため、I/O処理(ファイル読み書きやネットワーク通信、データベースアクセスなど)を効率的に行うことができます。

データベースとしては、PostgreSQLやMySQLといったリレーショナルデータベースが長らく主流でした。これらのデータベースは厳格なスキーマ(テーブル構造)を持ち、データの整合性を保つのに優れています。しかし、開発の途中でデータ構造が頻繁に変わるようなアジャイルな開発スタイルや、非構造化・半構造化データを扱う場合には、スキーマの変更が手間になることがあります。

ここで登場するのがMongoDBです。MongoDBはドキュメント指向データベース、いわゆるNoSQLデータベースの一つです。データをJSONライクな形式(BSON: Binary JSON)のドキュメントとして保存します。リレーショナルデータベースのような厳格なスキーマを持たず、柔軟にデータ構造を扱えるのが特徴です。Node.jsと同じくJavaScriptベースのデータ形式(JSON/BSON)を使うため、データのやり取りが非常にスムーズに行えます。

しかし、MongoDBはスキーマレスである反面、アプリケーション側でデータの構造やバリデーションを管理する必要があります。また、Node.jsからMongoDBを操作するためには、公式のMongoDB Node.js Driverを使うのが基本ですが、そのままでは少し低レベルな操作になりがちです。例えば、特定の構造を持ったデータを登録する際に、データの型チェックや必須項目の確認などを自分で実装する必要があります。

そこで、Mongooseが登場します。Mongooseは、MongoDBのために作られたODM (Object Data Modeling) ライブラリです。ODMは、オブジェクト指向プログラミングの概念をデータベース操作に取り入れるためのものです。リレーショナルデータベースにおけるORM (Object Relational Mapping) のMongoDB版と考えると分かりやすいでしょう。

Mongooseを使うことで、以下のメリットが得られます。

  • スキーマ定義: ドキュメントの構造やデータ型、バリデーションルールをコード上で定義できます。これにより、MongoDBのスキーマレスな性質に構造を与え、データの整合性を保ちやすくなります。
  • データ検証 (Validation): スキーマに基づいて自動的にデータのバリデーションを行えます。不正なデータの登録を防ぐのに役立ちます。
  • 型変換 (Type Casting): スキーマ定義に従って、適切なデータ型に自動的に変換してくれます。
  • クエリビルダ (Query Builder): MongoDBの複雑なクエリを、より分かりやすいJavaScriptのオブジェクトやメソッドチェーンで記述できます。
  • ミドルウェア (Middleware): データの保存前や削除後など、特定のイベント発生時に実行される処理(フック)を定義できます。
  • 関連データの扱い (Population): リレーショナルデータベースのリレーションシップに似た形で、関連するドキュメントを参照しやすくなります。

Node.jsの非同期処理能力、MongoDBの柔軟性、そしてMongooseの構造化と利便性を組み合わせることで、効率的かつ堅牢なWebアプリケーションを開発できるようになります。

この記事では、以下の内容を順を追って解説します。

  1. 開発環境のセットアップ(Node.js, MongoDB, Mongoose)
  2. Mongooseの基本的な概念(ODM, スキーマ, モデル)
  3. MongoDBへの接続方法
  4. スキーマとモデルの定義
  5. CRUD操作(データの作成、読み込み、更新、削除)
  6. 関連データの扱い(Population)
  7. ミドルウェア(Pre/Postフック)
  8. 簡単なサンプルコード

それでは、まずは開発環境の準備から始めましょう。

2. 開発環境のセットアップ

Node.jsとMongoDB、そしてMongooseを使うための準備を行います。

2.1 Node.jsのインストール

Node.jsがまだインストールされていない場合は、公式ウェブサイトからダウンロードしてインストールするか、バージョン管理ツールを使用します。バージョン管理ツールとしてNVM (Node Version Manager) を使うのがおすすめです。これにより、複数のNode.jsバージョンを簡単に切り替えることができます。

NVMのインストール方法はOSによって異なりますので、公式ドキュメントを参照してください。インストール後、任意のNode.jsバージョンをインストールし、使用するバージョンを選択します。

“`bash

例: 最新のLTS版をインストールして使用する

nvm install –lts
nvm use –lts
“`

Node.jsとnpm(Node.jsに付属するパッケージマネージャー)が正しくインストールされたか確認します。

bash
node -v
npm -v

バージョン情報が表示されれば成功です。

2.2 MongoDBのインストールまたは利用

MongoDBを利用する方法はいくつかあります。

  • ローカルにインストール: ご自身のPCにMongoDBサーバーをインストールする方法です。開発環境としては最も一般的です。MongoDB公式ウェブサイトからOSに合ったインストーラーをダウンロードしてインストールします。インストール後、MongoDBサーバーを起動する必要があります。
  • MongoDB Atlasを利用: MongoDBが提供するクラウド上のデータベースサービスです。アカウントを作成すれば無料で利用できるプラン(M0クラスター)もあります。ローカル環境の準備が不要で、すぐに始められるのがメリットです。本番環境でも利用されることが多いです。

どちらの方法でも構いませんが、初心者の方にはMongoDB Atlasが手軽かもしれません。Atlasを使用する場合、クラスターを作成した後に表示される「Connect」ボタンから、アプリケーションを接続するための接続文字列(Connection String)を取得しておいてください。ローカルにインストールした場合は、デフォルトでは mongodb://localhost:27017 のような接続文字列になります。

2.3 プロジェクトディレクトリの作成と初期化

Node.jsアプリケーションのプロジェクトディレクトリを作成し、npmプロジェクトとして初期化します。

“`bash

プロジェクトディレクトリ作成

mkdir my-mongoose-app
cd my-mongoose-app

npmプロジェクト初期化

npm init -y
“`

npm init -y コマンドは、対話形式の質問をスキップして、デフォルト設定で package.json ファイルを作成します。

2.4 Mongooseのインストール

プロジェクトにMongooseライブラリをインストールします。

bash
npm install mongoose

これにより、node_modules ディレクトリにMongooseとその依存関係がインストールされ、package.jsondependencies セクションに mongoose が追加されます。

これで、Node.js、MongoDB、Mongooseを使うための基本的な環境セットアップは完了です。

3. Mongooseとは何か?(再確認)

Mongooseは、Node.js環境でMongoDBを操作するためのODM (Object Data Modeling) ライブラリです。先ほども触れましたが、もう少し詳しくMongooseの役割を見てみましょう。

MongoDBはドキュメント指向データベースであり、基本的にスキーマレスです。これは非常に柔軟な半面、アプリケーションコード側でデータの構造を意識し、検証を行う必要があります。例えば、「ユーザー情報には必ず名前とメールアドレスが必要で、メールアドレスはユニークでなければならない」といったルールを実装しようとすると、データベースに登録する前にこれらのチェックを手動で行う必要があります。

Mongooseは、このような手作業を減らし、より構造的にデータを取り扱うための仕組みを提供します。Mongooseを使うと、アプリケーションのコード内で「このコレクションには、こういう構造のドキュメントが保存されるべきだ」というルール(スキーマ)を定義できます。そして、そのスキーマを使ってデータを操作するためのクラス(モデル)を作成します。

例えるなら、MongoDBが自由な形の入れ物(ドキュメント)をたくさんしまっておける倉庫だとすると、Mongooseは「この棚には、名前、住所、電話番号が書かれたラベルが貼られた箱だけを、名前順に整理して置こう」といったルール(スキーマ)を決め、そのルールに従って箱(ドキュメント)を入れたり取り出したりする担当者(モデル)を用意してくれる、というイメージです。

Mongooseは、MongoDBネイティブドライバーの上に構築されています。つまり、Mongooseを使っていても、内部ではMongoDBドライバーが実際にデータベースとの通信を行っています。Mongooseは、この低レベルなドライバーの操作を抽象化し、開発者がより高レベルなオブジェクト指向の概念でデータベースを扱えるようにする役割を担っています。

Mongooseを使う主なメリット:

  • 構造と整合性: スキーマを定義することで、スキーマレスなMongoDBに構造を与え、データの整合性を高めます。
  • データ検証: スキーマに定義したバリデーションルールに基づいて、自動的にデータの検証を行います。
  • コードの簡潔化: MongoDBのクエリを、メソッドチェーンなどを使ってより直感的に記述できます。
  • 機能の拡張: ミドルウェアを使って、保存や更新などの操作の前後にカスタムロジックを追加できます。
  • 開発効率向上: 定型的なデータ操作や検証ロジックをMongooseが肩代わりしてくれるため、開発者はビジネスロジックに集中できます。

これらのメリットを理解した上で、具体的なMongooseの使い方を見ていきましょう。

4. Mongooseを使ったMongoDBへの接続

Mongooseを使う最初のステップは、MongoDBデータベースへの接続です。Node.jsアプリケーションからMongooseを通じてMongoDBサーバーに接続します。

4.1 基本的な接続方法

Mongooseの接続は、mongoose.connect() メソッドを使用します。このメソッドは非同期処理であり、接続が成功または失敗するまで待機するため、async/await と組み合わせて使うのが一般的です。

接続には、MongoDBの接続文字列が必要です。接続文字列には、データベースの場所(ホスト名やIPアドレス)、ポート番号、認証情報(ユーザー名、パスワード)、接続オプションなどが含まれます。

例:ローカルのMongoDBに接続する場合

“`javascript
const mongoose = require(‘mongoose’);

async function connectDB() {
try {
const mongoUri = ‘mongodb://localhost:27017/mydatabase’; // 接続文字列
await mongoose.connect(mongoUri);
console.log(‘MongoDB connected successfully’);
} catch (err) {
console.error(‘MongoDB connection error:’, err);
process.exit(1); // 接続失敗時はプロセスを終了
}
}

// アプリケーション起動時に接続関数を呼び出す
connectDB();

// その他のアプリケーションロジック…
“`

例:MongoDB Atlasに接続する場合

MongoDB Atlasの接続文字列は、通常 mongodb+srv://<username>:<password>@<cluster-url>/<dbname>?retryWrites=true&w=majority のような形式になります。<username>, <password>, <cluster-url>, <dbname> はご自身のAtlasクラスターの情報に置き換えてください。パスワードに特殊文字が含まれる場合はURLエンコードが必要です。

“`javascript
const mongoose = require(‘mongoose’);

async function connectDB() {
try {
// 環境変数などから安全に取得することを推奨
const mongoUri = ‘mongodb+srv://myuser:[email protected]/mydatabase?retryWrites=true&w=majority’;
await mongoose.connect(mongoUri);
console.log(‘MongoDB Atlas connected successfully’);
} catch (err) {
console.error(‘MongoDB Atlas connection error:’, err);
process.exit(1);
}
}

connectDB();

// その他のアプリケーションロジック…
“`

mongoose.connect() メソッドの最初の引数は接続文字列です。第二引数にはオプションオブジェクトを渡すことができますが、Mongooseの最近のバージョン(おおよそv5.x以降)では、多くの一般的なオプション (useNewUrlParser, useUnifiedTopology, useFindAndModify, useCreateIndex など) はデフォルトで有効になっているか、もはや不要になっています。 したがって、特別な理由がない限り、これらのオプションを明示的に指定する必要はほとんどありません。

もし、これらのオプションに関する警告メッセージが表示される場合や、古いバージョンのMongooseを使用している場合は、以下のように指定することがあります。

javascript
// 過去のバージョンでの一般的なオプション指定例 (最新バージョンでは不要または無視される可能性が高い)
await mongoose.connect(mongoUri, {
useNewUrlParser: true, // 新しいURLパーサーを使う
useUnifiedTopology: true, // 新しいサーバーディスカバリとモニタリングエンジンを使う
// useFindAndModify: false, // findOneAndUpdate() や findByIdAndUpdate() で findAndModify() ではなくネイティブな MongoDB ドライバーのメソッドを使う (非推奨)
// useCreateIndex: true // ensureIndex() ではなく createIndex() を使う (非推奨)
});

注意: 上記の useNewUrlParser, useUnifiedTopology, useFindAndModify, useCreateIndex オプションは、Mongoose v6以降では無視されるか削除されています。最新のMongooseを使用する場合は、これらのオプションは指定しないでください。

4.2 接続イベントの監視

Mongooseは、データベース接続に関する様々なイベントを発火します。これらのイベントを監視することで、接続の状態を把握したり、接続エラーに適切に対応したりできます。mongoose.connection オブジェクトを使ってイベントリスナーを設定します。

主要なイベント:

  • connected: データベースに正常に接続したときに発生します。
  • error: 接続中にエラーが発生したときに発生します。
  • disconnected: データベースから切断されたときに発生します。
  • open: 接続が確立され、モデルが使用可能になったときに発生します。connected と似ていますが、open はより低いレベルのイベントです。通常は connected を使えば十分です。
  • reconnected: 自動再接続が成功したときに発生します。

これらのイベントは、on または once メソッドを使ってリッスンできます。on はイベントが発生するたびにリスナー関数を実行し、once は一度だけ実行します。

“`javascript
const mongoose = require(‘mongoose’);
const mongoUri = ‘mongodb://localhost:27017/mydatabase’;

// 接続イベントのリスナーを設定
mongoose.connection.on(‘connected’, () => {
console.log(‘Mongoose connected to DB’);
});

mongoose.connection.on(‘error’, (err) => {
console.error(‘Mongoose connection error:’, err);
});

mongoose.connection.on(‘disconnected’, () => {
console.log(‘Mongoose disconnected from DB’);
});

mongoose.connection.once(‘open’, () => {
console.log(‘Mongoose connection opened!’);
// ここでサーバーを起動するなど、データベース接続後に実行したい処理を行う
});

// データベースへの接続を開始
async function connectDB() {
try {
await mongoose.connect(mongoUri);
} catch (err) {
console.error(‘Initial MongoDB connection failed:’, err);
// 接続イベントリスナーがエラーをキャッチする
}
}

connectDB();

// プロセス終了シグナルを受け取ったときの処理 (graceful shutdown)
process.on(‘SIGINT’, async () => {
await mongoose.connection.close();
console.log(‘Mongoose connection closed due to app termination’);
process.exit(0);
});
“`

このようにイベントリスナーを設定しておくと、アプリケーションの接続状態をより詳細に把握できます。特にエラーハンドリングは重要です。接続に失敗した場合にアプリケーションを終了させる (process.exit(1)) か、再接続を試みるかなど、適切なエラー戦略を立てる必要があります。Mongooseはデフォルトで自動再接続を試みます。

4.3 接続の切断

アプリケーションの終了時などに、明示的にデータベース接続を切断したい場合があります。これには mongoose.disconnect() メソッドを使用します。

“`javascript
// データベース接続を切断する関数
async function disconnectDB() {
try {
await mongoose.disconnect();
console.log(‘MongoDB disconnected successfully’);
} catch (err) {
console.error(‘MongoDB disconnection error:’, err);
}
}

// 例: 何か処理が終わった後に切断する場合
// await someOperation();
// disconnectDB();

// または、上記のSIGINTハンドラーのようにプロセス終了時に切断する
“`

多くのNode.jsサーバーアプリケーションでは、データベース接続は一度確立したらアプリケーションが実行されている間ずっと維持されます。そのため、明示的な切断処理はアプリケーションの終了時やエラー発生時などに行うのが一般的です。

これで、Mongooseを使ってMongoDBに接続する基本的な方法を理解できました。次に、Mongooseの核となる概念である「スキーマ」と「モデル」について見ていきましょう。

5. スキーマとモデル

Mongooseの中心的な機能は、スキーマを定義し、それに基づいてモデルを作成することです。これは、MongoDBのスキーマレスな性質に構造を与え、データの整合性や操作のしやすさを向上させるために非常に重要です。

5.1 スキーマの概念

スキーマ (Schema) とは、MongoDBのコレクション内に保存されるドキュメントの構造を定義するものです。どのフィールドが存在するか、そのフィールドのデータ型は何か、必須項目か、デフォルト値は何か、バリデーションルールはあるか、といったことを定義します。

Mongooseでは、mongoose.Schema クラスを使ってスキーマオブジェクトを作成します。

“`javascript
const mongoose = require(‘mongoose’);

// ユーザー情報のスキーマを定義する例
const userSchema = new mongoose.Schema({
name: String, // フィールド名: データ型
email: String,
age: Number,
isActive: Boolean,
createdAt: Date
});
“`

これは最もシンプルなスキーマ定義です。フィールド名と、それに続くデータ型を指定します。

5.2 スキーマのデータ型

Mongooseスキーマで利用できる主なデータ型は以下の通りです。

  • String: 文字列
  • Number: 数値 (整数、浮動小数点数)
  • Boolean: 真偽値 (true or false)
  • Date: 日付と時刻
  • Buffer: バイナリデータ
  • Mixed: 任意の型。オブジェクトや配列など、スキーマを定義しないフィールドに使います。ただし、Mixed 型のフィールドは、Mongooseによる変更追跡やバリデーションが限定的になるため、可能な限り具体的な型を指定するのが推奨されます。
  • ObjectId: MongoDBのドキュメントID (_id) に使われる特別な型です。関連ドキュメントへの参照を格納するのによく使われます。
  • Array: 配列。配列内の要素の型を指定することもできます ([String], [Number], [userSchema] など)。
  • Decimal128: 高精度な小数点数を扱うための型です(通貨データなどに適しています)。

5.3 スキーマオプション

各フィールド定義では、データ型だけでなく、様々なオプションを指定できます。オプションはオブジェクトとしてデータ型の後に指定します。

“`javascript
const mongoose = require(‘mongoose’);

const productSchema = new mongoose.Schema({
name: {
type: String, // データ型
required: true, // 必須項目
unique: true, // 一意であること (インデックスが作成される)
trim: true, // 前後の空白を削除
lowercase: true,// 保存前に小文字に変換
minlength: 3, // 最小文字数
maxlength: 50 // 最大文字数
},
price: {
type: Number,
required: true,
min: 0 // 最小値
},
tags: [String], // 文字列の配列
category: {
type: String,
enum: [‘electronics’, ‘books’, ‘clothing’] // 指定された文字列のいずれかであること
},
description: String,
createdAt: {
type: Date,
default: Date.now // デフォルト値 (ドキュメント作成時の現在時刻)
},
updatedAt: {
type: Date
}
});
“`

主要なオプション:

  • type: フィールドのデータ型を指定します(必須)。
  • required: true にすると、そのフィールドは必須項目になります。ドキュメント作成時や更新時にこのフィールドが含まれていないとバリデーションエラーになります。エラーメッセージをカスタマイズすることも可能です (required: [true, '商品名は必須です'])。
  • default: フィールドに値が指定されなかった場合のデフォルト値を設定します。関数を指定することも可能です。
  • unique: true にすると、そのフィールドの値が一意であることを保証します。MongoDBレベルでインデックスが作成されます。
  • index: true にすると、そのフィールドにインデックスが作成されます。クエリパフォーマンスを向上させます。複合インデックスも定義できます。
  • trim: true にすると、文字列フィールドの保存前に前後の空白が削除されます。
  • lowercase: true にすると、文字列フィールドの保存前に全て小文字に変換されます。
  • uppercase: true にすると、文字列フィールドの保存前に全て大文字に変換されます。
  • min, max: 数値型に設定できる最小値、最大値です。
  • minlength, maxlength: 文字列型に設定できる最小文字数、最大文字数です。
  • enum: 文字列型や数値型に設定できる、許容される値の配列です。
  • validate: カスタムバリデーション関数を定義できます。

5.4 カスタムバリデーション

validate オプションを使うと、Mongooseが提供する組み込みバリデーションでは不十分な場合に、独自のバリデーションロジックを定義できます。

javascript
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true,
validate: {
validator: function(v) {
// シンプルな正規表現によるメールアドレス形式のチェック
return /\S+@\S+\.\S+/.test(v);
},
message: props => `${props.value} は有効なメールアドレス形式ではありません!`
}
},
password: {
type: String,
required: true,
minlength: [6, 'パスワードは6文字以上でなければなりません'],
// カスタムバリデーション例: 特定の文字列を含まないかチェック
validate: {
validator: function(v) {
return v.indexOf('password') === -1;
},
message: 'パスワードに"password"を含めることはできません'
}
}
});

validator プロパティには検証関数を、message プロパティには検証失敗時のエラーメッセージを指定します。message は文字列でも関数でも指定可能です。

5.5 仮想プロパティ (Virtuals)

仮想プロパティは、ドキュメントに存在するフィールドのようにアクセスできますが、MongoDBには保存されないプロパティです。他のフィールドの値から計算される値や、関連ドキュメントへの参照などを定義するのに使われます。

“`javascript
const userSchema = new mongoose.Schema({
firstName: String,
lastName: String
});

// fullNameという仮想プロパティを定義
userSchema.virtual(‘fullName’).get(function() {
return this.firstName + ‘ ‘ + this.lastName;
});

// 仮想プロパティをJSONやObject出力に含める設定
userSchema.set(‘toJSON’, { virtuals: true });
userSchema.set(‘toObject’, { virtuals: true });
“`

virtual() メソッドで仮想プロパティ名を指定し、.get() でゲッター関数を定義します。ゲッター関数内の this は現在のドキュメントインスタンスを参照します。仮想プロパティはデフォルトでは toJSON()toObject() メソッドの出力に含まれないため、schema.set() で設定する必要があります。

5.6 ミドルウェア (Pre/Postフック)

ミドルウェア(Pre/Postフックとも呼ばれます)を使うと、特定の操作(例: save, remove, find, validate など)の実行前(Pre)または実行後(Post)にカスタムロジックを挿入できます。これは、データの加工、バリデーション、ログ記録、関連ドキュメントの更新など、様々な用途に利用できます。

ミドルウェアはスキーマレベルで定義します。

“`javascript
const userSchema = new mongoose.Schema({
name: String,
email: String,
createdAt: Date
});

// save操作の前に実行されるPreミドルウェア
userSchema.pre(‘save’, function(next) {
// this は現在保存されようとしているドキュメントインスタンスを参照します
console.log(Saving user: ${this.name});
// createdAtフィールドが設定されていなければ現在時刻を設定 (defaultオプションでも可能)
if (!this.createdAt) {
this.createdAt = new Date();
}
next(); // 次のミドルウェアまたは実際のsave操作へ進む
});

// remove操作の後に実行されるPostミドルウェア
userSchema.post(‘remove’, function(doc) {
// doc は削除されたドキュメントを参照します
console.log(User ${doc.name} has been removed.);
// 例: このユーザーに関連する他のデータを削除するなどの後処理
});

// find操作の前に実行されるQueryミドルウェア
// クエリミドルウェアでは this はクエリオブジェクトを参照します
userSchema.pre(‘find’, function() {
console.log(‘Executing find query…’);
// 例: 全てのfindクエリにデフォルトで特定の条件を適用
// this.where({ isActive: true });
});

// validate操作の前に実行されるPreミドルウェア
userSchema.pre(‘validate’, function(next) {
console.log(Validating user: ${this.name});
// カスタムバリデーションロジック
if (this.name === ‘Admin’) {
return next(new Error(‘名前をAdminにすることはできません’));
}
next();
});
“`

ミドルウェア関数は、通常 next という引数を受け取ります。Preミドルウェアでは、処理が完了したら next() を呼び出して次の処理に進みます。エラーが発生した場合は next(err) を呼び出します。Postミドルウェアは非同期処理の場合、関数シグネチャに next を加えることがありますが、同期的なPostミドルウェアでは不要です。

ミドルウェアには、ドキュメントミドルウェア (save, remove, validate, init など) とクエリミドルウェア (find, findOne, countDocuments, update, delete など) があります。ドキュメントミドルウェアでは this はドキュメントインスタンスを指し、クエリミドルウェアでは this はクエリオブジェクトを指します。

非同期ミドルウェアを定義する場合は、next を呼び出すか、または async関数として定義し await を使う必要があります。

javascript
// 非同期Preミドルウェアの例
userSchema.pre('save', async function() {
// 何か非同期処理(例: 外部API呼び出し)
// await someAsyncOperation();
console.log('Async pre-save task completed');
});

5.7 モデルの概念

モデル (Model) とは、特定のスキーマを使ってMongoDBコレクション内のドキュメントを操作するためのクラスです。Mongooseでは、mongoose.model() メソッドを使ってモデルを作成します。

mongoose.model() メソッドは、モデル名(文字列)、使用するスキーマ、そしてオプションでコレクション名(文字列)を受け取ります。

“`javascript
const mongoose = require(‘mongoose’);
const productSchema = require(‘./productSchema’); // 前述で定義したスキーマと仮定

// モデルを作成
// ‘Product’ はモデル名 (単数形、パスカルケースが慣例)
// productSchema は使用するスキーマ
// Mongooseはモデル名 ‘Product’ を自動的に複数形、小文字の ‘products’ というコレクション名に変換して使用します。
// 第三引数でコレクション名を明示的に指定することも可能: mongoose.model(‘Product’, productSchema, ‘my_products’);
const Product = mongoose.model(‘Product’, productSchema);

// これで、Productモデルを使ってデータベース操作ができるようになります。
// const newProduct = new Product({…});
// await newProduct.save();
// const foundProducts = await Product.find({…});
“`

モデル名は、Mongooseによって自動的に複数形、小文字に変換されて対応するコレクション名となります。例えば、モデル名が User ならコレクション名は users、モデル名が Product ならコレクション名は products になります。この自動変換ルールに沿わないコレクション名を使いたい場合は、mongoose.model() の第三引数でコレクション名を明示的に指定します。

モデルを作成すると、そのモデルはドキュメントを作成するためのコンストラクタ(例: new Product(...))として、またコレクション全体に対してクエリを実行するための静的メソッド(例: Product.find(), Product.findOne(), Product.create() など)を持つようになります。

これで、Mongooseの基本的な構造であるスキーマとモデルを理解しました。次は、モデルを使った実際のデータベース操作、つまりCRUD操作を見ていきましょう。

6. CRUD操作の実装(モデルを使ったデータ操作)

Mongooseモデルを使用すると、MongoDBのドキュメントに対してCRUD操作(Create, Read, Update, Delete)を簡単に行うことができます。モデルには、ドキュメントインスタンスを操作するためのメソッドと、コレクション全体を操作するための静的メソッドがあります。

ここでは、前述の productSchemaProduct モデルを例に進めます。

“`javascript
// 例としてスキーマとモデルを再定義 (実際のコードでは別ファイルに分けてimportするのが一般的)
const mongoose = require(‘mongoose’);

const productSchema = new mongoose.Schema({
name: { type: String, required: true, unique: true, trim: true },
price: { type: Number, required: true, min: 0 },
tags: [String],
category: { type: String, enum: [‘electronics’, ‘books’, ‘clothing’] },
description: String,
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date }
});

const Product = mongoose.model(‘Product’, productSchema);

// データベース接続は既に行われていると仮定
// await mongoose.connect(…)
“`

6.1 C – Create (データの作成)

新しいドキュメントを作成する方法はいくつかあります。

方法1: モデルインスタンスを作成し、save() メソッドを呼び出す

これは最も基本的な方法です。スキーマ定義に基づいて新しいドキュメントインスタンスを作成し、そのインスタンスの save() メソッドを呼び出してデータベースに保存します。

“`javascript
async function createProduct() {
try {
const newProduct = new Product({
name: ‘Laptop’,
price: 1200,
tags: [‘computer’, ‘electronics’],
category: ‘electronics’,
description: ‘Powerful and lightweight laptop’
});

// save()メソッドはPromiseを返す
const savedProduct = await newProduct.save();
console.log('Product created and saved:', savedProduct);
return savedProduct;

} catch (err) {
console.error(‘Error creating product:’, err);
// バリデーションエラーの場合、err.errors プロパティに詳細が含まれる
if (err.name === ‘ValidationError’) {
console.error(‘Validation Errors:’, err.errors);
}
}
}

createProduct();
“`

new Product({...}) で作成したインスタンスは、まだデータベースに存在しません。await newProduct.save() を呼び出すことで、初めてデータベースにドキュメントとして挿入されます。save() メソッドはバリデーションを実行し、成功すれば保存されたドキュメント(_id などが追加されたもの)を返します。失敗した場合はエラーをスローします。

方法2: create() 静的メソッドを使用する

create() メソッドは、新しいドキュメントを作成して保存する処理を一度に行います。複数のドキュメントをまとめて作成することも可能です。

“`javascript
async function createProducts() {
try {
// 単一のドキュメントを作成
const product1 = await Product.create({
name: ‘Mechanical Keyboard’,
price: 150,
tags: [‘keyboard’, ‘electronics’],
category: ‘electronics’
});
console.log(‘Product created with create():’, product1);

// 複数のドキュメントを作成 (配列を渡す)
const products = await Product.create([
  { name: 'Node.js Guide Book', price: 40, category: 'books' },
  { name: 'Cotton T-Shirt', price: 25, category: 'clothing' }
]);
console.log('Multiple products created with create():', products);

} catch (err) {
console.error(‘Error creating products with create():’, err);
}
}

createProducts();
“`

Product.create({...}) は、ドキュメントデータを受け取り、新しいインスタンスを作成して save() し、保存されたドキュメントを返します。配列を渡すと、それぞれの要素がドキュメントとして作成され、保存されたドキュメントの配列が返されます。create() は内部で insertMany() を呼び出します。

create() メソッドは非常に便利ですが、バリデーションエラーが発生した場合、配列中の最初のバリデーションエラーで処理が中断され、それ以降のドキュメントは保存されない点に注意が必要です。

6.2 R – Read (データの読み込み)

データベースに保存されたドキュメントを読み込むには、モデルの様々な静的メソッドを使用します。

特定の条件に一致するすべてのドキュメントを取得: find()

“`javascript
async function findProducts() {
try {
// 全てのドキュメントを取得
const allProducts = await Product.find({});
console.log(‘All products:’, allProducts);

// 条件を指定して取得 (例: 価格が100ドル以下の商品)
const cheapProducts = await Product.find({ price: { $lte: 100 } });
console.log('Products <= $100:', cheapProducts);

// 複数の条件を指定
const electronicsOver100 = await Product.find({
  category: 'electronics',
  price: { $gt: 100 }
});
console.log('Electronics > $100:', electronicsOver100);

// 正規表現を使った検索 (例: 名前に "Book" を含む商品)
const bookProducts = await Product.find({ name: /Book/i }); // i は大文字小文字を区別しないオプション
console.log('Products with "Book" in name:', bookProducts);

} catch (err) {
console.error(‘Error finding products:’, err);
}
}

findProducts();
“`

find() メソッドは、引数にクエリ条件オブジェクトを受け取ります。条件オブジェクトは、MongoDBのクエリシンタックスを使用します。上記例の $lte$gt はMongoDBの比較演算子です。find() は、条件に一致する全てのドキュメントの配列を返します。一致するドキュメントがない場合は空の配列 [] を返します。

条件に一致する最初のドキュメントを取得: findOne()

findOne() メソッドは、find() と同様にクエリ条件を受け取りますが、条件に一致した最初のドキュメントだけを返します。

“`javascript
async function findOneProduct() {
try {
// 名前に “Laptop” を含む最初のドキュメントを取得
const laptop = await Product.findOne({ name: /Laptop/i });
console.log(‘Found one laptop:’, laptop);

// 存在しない条件を指定した場合
const nonExistent = await Product.findOne({ name: 'Non Existent Product' });
console.log('Found non-existent product:', nonExistent); // null が返される

} catch (err) {
console.error(‘Error finding one product:’, err);
}
}

findOneProduct();
“`

findOne() は、一致するドキュメントが見つかればそのドキュメントを、見つからなければ null を返します。

IDでドキュメントを取得: findById()

MongoDBの各ドキュメントには一意の _id が自動的に付与されます。findById() メソッドは、この _id を使ってドキュメントを検索する便利なメソッドです。内部的には findOne({ _id: id }) と同じです。

“`javascript
async function findProductById(productId) {
try {
const foundProduct = await Product.findById(productId);

if (foundProduct) {
  console.log('Product found by ID:', foundProduct);
} else {
  console.log('Product not found with ID:', productId);
}

} catch (err) {
console.error(‘Error finding product by ID:’, err);
}
}

// 例: 実際に存在する _id を引数として渡して実行
// findProductById(’60c72b2f9b1d8e001c8f4b5c’); // 実際のIDに置き換える
“`

findById() は、指定されたIDのドキュメントが見つかればそのドキュメントを、見つからなければ null を返します。_id はMongoDBの ObjectId 型ですが、文字列で指定してもMongooseが自動的に変換してくれます。

クエリビルダとチェイニング

find(), findOne(), findById() などのクエリメソッドは、クエリオブジェクト(Query オブジェクト)を返します。このクエリオブジェクトに対してメソッドをチェイニングすることで、検索条件、ソート順、取得フィールドなどを柔軟に指定できます。

“`javascript
async function advancedFind() {
try {
// 価格が50ドルより高く、タグに “electronics” を含む商品を検索
// 名前で昇順にソートし、最初の10件だけを取得
const queryResult = await Product
.find({ tags: ‘electronics’ }) // クエリ条件
.where(‘price’).gt(50) // whereメソッドと連鎖
.sort(‘name’) // ソート (name asc)
.limit(10) // 取得件数上限
.select(‘name price category’) // 取得するフィールドを指定 (プロジェクション)
.exec(); // クエリを実行しPromiseを返す

console.log('Advanced query results:', queryResult);

// select() の別の指定方法
// .select({ name: 1, price: 1, _id: 0 }) // nameとpriceを取得し、_idは含めない (1は含める、0は含めない)

} catch (err) {
console.error(‘Error during advanced find:’, err);
}
}

advancedFind();
“`

主要なクエリビルダメソッド:

  • .where(field): 特定のフィールドに対して条件を指定開始。
  • .equals(value): フィールドが指定した値と等しい。
  • .gt(value): より大きい ($gt)。
  • .lt(value): より小さい ($lt)。
  • .gte(value): 以上 ($gte)。
  • .lte(value): 以下 ($lte)。
  • .ne(value): 等しくない ($ne)。
  • .in(array): 配列に含まれる ($in)。
  • .nin(array): 配列に含まれない ($nin)。
  • .regex(regex): 正規表現に一致 ($regex)。
  • .exists(boolean): フィールドが存在するかどうか ($exists)。
  • .sort(field | object): 結果のソート順を指定。文字列の場合 field で昇順、-field で降順。オブジェクトの場合 { field1: 1, field2: -1 } のように指定 (1は昇順、-1は降順)。
  • .limit(number): 取得するドキュメントの最大数を指定。
  • .skip(number): 結果の先頭から指定した数のドキュメントをスキップ。ページネーションに利用。
  • .select(fields | object): 取得するフィールドを指定(プロジェクション)。文字列の場合はフィールド名をスペース区切りで指定 ('name price')。含めたくないフィールドには - を付ける ('-description')。オブジェクトの場合は { fieldName: 1, otherField: 0 } のように指定。

クエリビルダのメソッドは、最後に exec() または await を付けてクエリを実行する必要があります。await を付けると、暗黙的に exec() が呼ばれます。通常は await を使う方が簡潔です。

ドキュメント数のカウント

特定の条件に一致するドキュメントの数をカウントするには、countDocuments() メソッドを使用します。

“`javascript
async function countProducts() {
try {
const totalCount = await Product.countDocuments({});
console.log(‘Total number of products:’, totalCount);

const electronicsCount = await Product.countDocuments({ category: 'electronics' });
console.log('Number of electronics products:', electronicsCount);

} catch (err) {
console.error(‘Error counting products:’, err);
}
}

countProducts();
“`

countDocuments() はクエリ条件オブジェクトを受け取り、一致するドキュメント数を数値で返します。

6.3 U – Update (データの更新)

既存のドキュメントを更新する方法も複数あります。

方法1: ドキュメントを取得し、プロパティを変更して save()

まず対象のドキュメントを取得し、そのインスタンスのプロパティを直接変更した後、save() メソッドを呼び出して変更をデータベースに反映させます。

“`javascript
async function updateProductSave(productId) {
try {
const productToUpdate = await Product.findById(productId);

if (productToUpdate) {
  productToUpdate.price = 1300; // 価格を更新
  productToUpdate.description = 'Updated description for laptop'; // 説明を更新
  productToUpdate.updatedAt = new Date(); // 更新日時を記録 (スキーマに定義があれば)

  const updatedProduct = await productToUpdate.save(); // 変更を保存
  console.log('Product updated and saved:', updatedProduct);
} else {
  console.log('Product not found with ID:', productId);
}

} catch (err) {
console.error(‘Error updating product with save():’, err);
}
}

// 例: 存在するIDを指定
// updateProductSave(’60c72b2f9b1d8e001c8f4b5c’);
“`

この方法は、ドキュメント取得時のPreミドルウェアや、保存時のPre/Postミドルウェア(バリデーションを含む)が実行されるというメリットがあります。ただし、まず読み込み、次に保存という2つのデータベース操作が必要になります。

方法2: クエリを使って直接更新

Mongooseは、クエリ条件に一致するドキュメントを直接更新するためのメソッドを提供します。これらのメソッドは、ドキュメントを読み込まずにデータベース側で更新を行うため、通常は save() を使う方法よりも効率的です。ただし、これらのメソッドはデフォルトではMongooseのバリデーションや一部のミドルウェア(save フックなど)を実行しません。(オプションでバリデーションを有効にできます)

主要な更新メソッド:

  • updateOne(filter, update, options): 条件に一致する最初のドキュメントを更新します。
  • updateMany(filter, update, options): 条件に一致する全てのドキュメントを更新します。
  • findByIdAndUpdate(id, update, options): IDでドキュメントを検索し、更新します。
  • findOneAndUpdate(filter, update, options): 条件に一致する最初のドキュメントを検索し、更新します。

これらのメソッドの update 引数には、MongoDBの更新演算子($set, $inc, $push など)を含むオブジェクトを指定するのが一般的です。

例:updateOne() で価格を更新

“`javascript
async function updateOneProduct(productId) {
try {
// IDが一致するドキュメントの価格を1350に変更
const result = await Product.updateOne(
{ _id: productId }, // filter (検索条件)
{ $set: { price: 1350, updatedAt: new Date() } } // update (更新内容 – $set演算子を使用)
// options (オプション – 例: { runValidators: true, new: true })
);

console.log('updateOne result:', result);
// result は { acknowledged: true, modifiedCount: 1, upsertedId: null, matchedCount: 1 } のようなオブジェクトを返します
// modifiedCount は実際に更新されたドキュメント数を示します

if (result.modifiedCount > 0) {
  console.log('Product updated with updateOne.');
} else {
   console.log('Product not found or not modified with updateOne.');
}

} catch (err) {
console.error(‘Error updating product with updateOne():’, err);
}
}

// 例: 存在するIDを指定
// updateOneProduct(’60c72b2f9b1d8e001c8f4b5c’);
“`

例:updateMany() でカテゴリ内の全商品の価格を10%値上げ

“`javascript
async function updateManyProducts() {
try {
const categoryToUpdate = ‘books’;
const percentageIncrease = 0.10; // 10%

const result = await Product.updateMany(
  { category: categoryToUpdate }, // filter
  { $inc: { price: percentageIncrease * await Product.findOne({ category: categoryToUpdate }).select('price').lean().then(p => p.price) } } // $incで価格を増やす (注意: この$inc内の計算は単純化のため複雑になっています。集計パイプラインを使う方が安全かつ効率的です)
    // シンプルな例として、全ての価格を固定値だけ増やす
    // { $inc: { price: 10 } } // 全ての書籍の価格を10増やす
);

 // より現実的には、各ドキュメントを個別に取得・更新するか、アグリゲーションの$merge/$outを使うなどが必要になる場合があります。
 // もしくは、Mongoose 6.2以降で使える更新ミドルウェアで対応する方法もあります。
 // 例: 単純な全件更新
 const allUpdateResult = await Product.updateMany(
    {}, // 全てのドキュメント
    { $set: { updatedAt: new Date() } } // 全てのドキュメントに更新日時を設定
 );
 console.log('updateMany result:', allUpdateResult);

} catch (err) {
console.error(‘Error updating many products:’, err);
}
}
// updateManyProducts();
``
**注意:** 上記の
updateManyにおける$inc`での計算は、MongoDBの更新クエリでフィールド自身の値を参照して計算する単純な方法は存在しないため、例としては不適切です。特定の割合での値上げなどを行う場合は、各ドキュメントを取得して更新するか、MongoDB 4.2+の更新アグリゲーションパイプラインを使う必要があります。Mongoose 6.2以降では、更新操作に対するミドルウェアを定義して、取得・変更・保存のようなロジックをカプセル化することも可能です。最も簡単な例として、すべてのドキュメントに特定のフィールドを設定する例を追記しました。

例:findByIdAndUpdate() とオプション

“`javascript
async function findByIdAndUpdateProduct(productId) {
try {
const updatedProduct = await Product.findByIdAndUpdate(
productId, // id
{ $set: { price: 1400, description: ‘Revised laptop description’, updatedAt: new Date() } }, // update
{ new: true, runValidators: true } // options
);

if (updatedProduct) {
  console.log('Product updated with findByIdAndUpdate:', updatedProduct);
} else {
  console.log('Product not found with ID:', productId);
}

} catch (err) {
console.error(‘Error updating product with findByIdAndUpdate():’, err);
if (err.name === ‘ValidationError’) {
console.error(‘Validation Errors:’, err.errors);
}
}
}

// 例: 存在するIDを指定
// findByIdAndUpdateProduct(’60c72b2f9b1d8e001c8f4b5c’);
“`

findByIdAndUpdate()findOneAndUpdate() は、更新されたドキュメントを取得したい場合に便利です。デフォルトでは更新前のドキュメントを返しますが、オプションで { new: true } を指定すると、更新後のドキュメントを返します。また、デフォルトではバリデーションが実行されないため、明示的に { runValidators: true } オプションを指定してバリデーションを有効にすることが重要です。

6.4 D – Delete (データの削除)

ドキュメントを削除する方法もいくつかあります。

方法1: ドキュメントを取得し、remove() または deleteOne() メソッドを呼び出す

対象のドキュメントを取得し、そのインスタンスのメソッドを呼び出して削除します。remove() は非推奨になりつつあり、今後は deleteOne() を使うのが推奨されます。

“`javascript
async function deleteProductInstance(productId) {
try {
const productToDelete = await Product.findById(productId);

if (productToDelete) {
  // const result = await productToDelete.remove(); // 非推奨
  const result = await productToDelete.deleteOne(); // 推奨
  console.log('Product deleted:', result); // deleteOne() は削除結果オブジェクトを返します
  // remove() は削除されたドキュメント自体を返していました

} else {
  console.log('Product not found with ID:', productId);
}

} catch (err) {
console.error(‘Error deleting product instance:’, err);
}
}

// 例: 存在するIDを指定
// deleteProductInstance(’60c72b2f9b1d8e001c8f4b5c’);
“`

この方法は、削除対象のドキュメントに対するPre/Postミドルウェア(remove または deleteOne フック)を実行したい場合に適しています。

方法2: クエリを使って直接削除

Mongooseは、クエリ条件に一致するドキュメントを直接削除するためのメソッドを提供します。

  • deleteOne(filter): 条件に一致する最初のドキュメントを削除します。
  • deleteMany(filter): 条件に一致する全てのドキュメントを削除します。
  • findByIdAndDelete(id, options): IDでドキュメントを検索し、削除します。
  • findOneAndDelete(filter, options): 条件に一致する最初のドキュメントを検索し、削除します。

これらのメソッドは、削除されたドキュメント自体ではなく、削除操作に関する情報(削除された件数など)を含むオブジェクトを返します(findByIdAndDeletefindOneAndDelete は削除されたドキュメントを返します)。

例:deleteOne() で条件に一致する最初のドキュメントを削除

“`javascript
async function deleteOneProductByQuery() {
try {
// カテゴリが ‘books’ の最初のドキュメントを削除
const result = await Product.deleteOne({ category: ‘books’ });

console.log('deleteOne query result:', result);
// result は { acknowledged: true, deletedCount: 1 } のようなオブジェクトを返します

if (result.deletedCount > 0) {
  console.log('One book product deleted.');
} else {
  console.log('No book product found or deleted.');
}

} catch (err) {
console.error(‘Error deleting one product by query:’, err);
}
}

// deleteOneProductByQuery();
“`

例:deleteMany() で全ての書籍を削除

“`javascript
async function deleteManyProductsByQuery() {
try {
// カテゴリが ‘books’ の全てのドキュメントを削除
const result = await Product.deleteMany({ category: ‘books’ });

console.log('deleteMany query result:', result);
// result は { acknowledged: true, deletedCount: N } のようなオブジェクトを返します

if (result.deletedCount > 0) {
    console.log(`${result.deletedCount} book products deleted.`);
} else {
    console.log('No book products found to delete.');
}

} catch (err) {
console.error(‘Error deleting many products by query:’, err);
}
}

// deleteManyProductsByQuery();
“`

例:findByIdAndDelete() でIDを指定して削除

“`javascript
async function findByIdAndDeleteProduct(productId) {
try {
const deletedProduct = await Product.findByIdAndDelete(productId);

if (deletedProduct) {
  console.log('Product deleted with findByIdAndDelete:', deletedProduct);
} else {
  console.log('Product not found with ID:', productId);
}

} catch (err) {
console.error(‘Error deleting product with findByIdAndDelete():’, err);
}
}

// 例: 存在するIDを指定
// findByIdAndDeleteProduct(’60c72b2f9b1d8e001c8f4b5c’);
“`

これらのクエリを使った削除メソッドは、一般的にドキュメントインスタンスを取得してから削除する方法よりも効率的ですが、削除対象のドキュメントに関するミドルウェアは実行されません(delete フックは実行されますが、remove フックは実行されません)。用途に応じて適切な方法を選択してください。

7. 関連データの扱い(Population)

リレーショナルデータベースでは、テーブル間にリレーションシップを定義し、JOIN操作で関連データを結合して取得します。MongoDBはドキュメント指向データベースであり、JOINのような組み込みの機能はありません。しかし、ドキュメント内に他のドキュメントのIDを保持することで、関連を示すことができます。Mongooseでは、この関連データを効率的に取得するためのPopulationという機能を提供しています。

Populationは、あるドキュメントのフィールドに格納されている別のドキュメントの _id を見て、その _id に対応するドキュメントを自動的に取得し、元のドキュメントのフィールドに「詰め込む(populate)」機能です。

例として、ユーザーが複数のブログ記事を投稿できるというシナリオを考えます。

“`javascript
const mongoose = require(‘mongoose’);

// ユーザーのスキーマ
const userSchema = new mongoose.Schema({
name: String,
email: String
// ここにユーザーが投稿した記事のIDの配列を持たせる(後述)
});

const User = mongoose.model(‘User’, userSchema);

// 記事のスキーマ
const postSchema = new mongoose.Schema({
title: String,
content: String,
// 投稿者のユーザーIDを保存
author: {
type: mongoose.Schema.Types.ObjectId, // データ型はObjectId
ref: ‘User’ // 参照先のモデル名を指定
}
});

const Post = mongoose.model(‘Post’, postSchema);
“`

記事スキーマの author フィールドに注目してください。

  • type: mongoose.Schema.Types.ObjectId: このフィールドには、MongoDBの _id が格納されることを示します。
  • ref: 'User': この _id が参照しているのは、User という名前のモデルのドキュメントであることを示します。この ref オプションがPopulationにおいて非常に重要です。

これで、特定の記事ドキュメントの author フィールドには、その記事を投稿したユーザーの _id が格納されるようになります。

関連データの作成と取得(Population)

まず、ユーザーと記事を作成します。

“`javascript
async function createUsersAndPosts() {
try {
// ユーザーを作成
const user1 = await User.create({ name: ‘Alice’, email: ‘[email protected]’ });
const user2 = await User.create({ name: ‘Bob’, email: ‘[email protected]’ });

console.log('Users created:', user1, user2);

// 記事を作成し、authorフィールドにユーザーの_idをセット
const post1 = await Post.create({
  title: 'My First Post',
  content: 'Hello, Mongoose!',
  author: user1._id // user1のIDをセット
});

const post2 = await Post.create({
  title: 'Another Post',
  content: 'Exploring Population.',
  author: user1._id // user1のIDをセット
});

const post3 = await Post.create({
    title: 'Bob\'s Post',
    content: 'This is Bob\'s content.',
    author: user2._id // user2のIDをセット
});


console.log('Posts created:', post1, post2, post3);

// (オプション) ユーザーモデルに投稿した記事のID配列を持たせる場合
// user1.posts = [post1._id, post2._id];
// await user1.save();
// user2.posts = [post3._id];
// await user2.save();

} catch (err) {
console.error(‘Error creating users/posts:’, err);
}
}

// createUsersAndPosts(); // 実行してデータを作成
“`

次に、記事を取得する際に、関連する投稿者(ユーザー)情報も同時に取得してみましょう。これには .populate() メソッドを使用します。

“`javascript
async function findPostsWithAuthor() {
try {
// 全ての記事を取得し、authorフィールドをpopulateする
const posts = await Post.find({})
.populate(‘author’) // authorフィールドをpopulate
.exec(); // クエリ実行

console.log('\nPosts with author populated:');
posts.forEach(post => {
  console.log(`- "${post.title}" by ${post.author.name} (${post.author.email})`);
  // post.author はユーザーオブジェクトになっています
  // post.author._id
  // post.author.name
  // post.author.email
});

} catch (err) {
console.error(‘Error finding posts with population:’, err);
}
}

// createUsersAndPosts(); // データ作成後に実行
// findPostsWithAuthor();
“`

populate('author') をクエリに追加することで、Mongooseは以下の処理を行います。

  1. Post.find({}) で記事ドキュメントを取得します。
  2. 取得した各記事ドキュメントの author フィールドの値(これはユーザーの _id です)を取り出します。
  3. その _id を使って、author フィールドの ref オプションで指定されたモデル(ここでは User モデル)から該当するドキュメントを検索します。
  4. 検索で取得したユーザードキュメントを、元の記事ドキュメントの author フィールドに「詰め込み」ます。元の _id の値は、取得したユーザーオブジェクトに置き換えられます。

.populate() メソッドにはオプションを渡すことも可能です。例えば、関連ドキュメントから特定のフィールドだけを取得したり、さらにその関連先のドキュメントをpopulateしたりできます。

“`javascript
async function findPostsWithSelectivePopulation() {
try {
const posts = await Post.find({})
.populate(‘author’, ‘name’) // authorフィールドをpopulateし、nameフィールドだけを取得
.exec();

console.log('\nPosts with selective author population (only name):');
posts.forEach(post => {
  console.log(`- "${post.title}" by ${post.author.name}`);
  // post.author は {_id: ..., name: ...} のオブジェクトになります。emailは含まれません。
});

// authorフィールドをpopulateし、さらにそのauthorの持つpostsフィールドもpopulate (もしUserスキーマにpostsフィールドがある場合)
// const postsWithNestedPopulate = await Post.find({})
//   .populate({
//     path: 'author',
//     populate: { path: 'posts' }
//   })
//   .exec();
// console.log('Posts with nested population:', postsWithNestedPopulate);

} catch (err) {
console.error(‘Error finding posts with selective population:’, err);
}
}

// createUsersAndPosts(); // データ作成後に実行
// findPostsWithSelectivePopulation();
“`

Populationは、MongoDBがリレーションシップを持たないデータベースであるにも関わらず、関連データを効率的に扱うためのMongooseの強力な機能です。ただし、頻繁なPopulationは複数のクエリを発行するため、パフォーマンスに影響を与える可能性があります。大規模なアプリケーションでは、Populationの使いどころを検討したり、必要に応じてデノーマライズ(関連データをドキュメント内に埋め込む)などの設計手法も考慮する必要があります。

8. サンプルアプリケーションの作成

ここまでの内容を組み合わせて、簡単なTODOリストアプリケーションのバックエンドの一部をMongooseで作ってみましょう。

要件:

  • TODO項目は、タイトル、説明、完了状態、作成日時を持つ。
  • TODOの作成、一覧取得、更新、削除ができる。

“`javascript
const mongoose = require(‘mongoose’);

// 1. MongoDBに接続
async function connectDB() {
try {
// ローカルのMongoDBに接続。データベース名は ‘todo_app’
const mongoUri = ‘mongodb://localhost:27017/todo_app’;
await mongoose.connect(mongoUri);
console.log(‘MongoDB connected successfully’);
} catch (err) {
console.error(‘MongoDB connection error:’, err);
process.exit(1);
}
}

// 2. TODO項目のスキーマを定義
const todoSchema = new mongoose.Schema({
title: {
type: String,
required: [true, ‘タイトルは必須です’],
trim: true,
maxlength: [100, ‘タイトルは100文字以内である必要があります’]
},
description: {
type: String,
trim: true
},
completed: {
type: Boolean,
default: false
},
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date
}
});

// saveミドルウェアでupdatedAtを自動更新
todoSchema.pre(‘save’, function(next) {
this.updatedAt = new Date();
next();
});

// 3. TODOモデルを作成
const Todo = mongoose.model(‘Todo’, todoSchema);

// 4. CRUD操作関数を定義

// TODOを作成
async function createTodo(todoData) {
try {
// create() メソッドを使用
const newTodo = await Todo.create(todoData);
console.log(‘Todo created:’, newTodo);
return newTodo;
} catch (err) {
console.error(‘Error creating todo:’, err);
if (err.name === ‘ValidationError’) {
console.error(‘Validation Errors:’, err.errors);
}
throw err; // エラーを呼び出し元に伝える
}
}

// 全てのTODOを取得
async function getAllTodos() {
try {
// find({}) で全てのドキュメントを取得
// 作成日時で降順にソート
const todos = await Todo.find({}).sort(‘-createdAt’); // または { createdAt: -1 }
console.log(‘All todos:’, todos);
return todos;
} catch (err) {
console.error(‘Error getting all todos:’, err);
throw err;
}
}

// IDでTODOを取得
async function getTodoById(todoId) {
try {
// findById() メソッドを使用
const todo = await Todo.findById(todoId);
console.log(‘Todo by ID:’, todo);
return todo;
} catch (err) {
console.error(Error getting todo with ID ${todoId}:, err);
throw err;
}
}

// TODOを更新
async function updateTodo(todoId, updateData) {
try {
// findByIdAndUpdate() メソッドを使用し、更新後のドキュメントを取得、バリデーションを実行
const updatedTodo = await Todo.findByIdAndUpdate(
todoId,
{ $set: updateData }, // 更新データ (例: { completed: true, description: ‘Updated description’ })
{ new: true, runValidators: true } // オプション: 更新後のドキュメントを返し、バリデーションを実行
);

if (updatedTodo) {
  console.log('Todo updated:', updatedTodo);
  return updatedTodo;
} else {
  console.log('Todo not found with ID:', todoId);
  return null;
}

} catch (err) {
console.error(Error updating todo with ID ${todoId}:, err);
if (err.name === ‘ValidationError’) {
console.error(‘Validation Errors:’, err.errors);
}
throw err;
}
}

// TODOを削除
async function deleteTodo(todoId) {
try {
// findByIdAndDelete() メソッドを使用
const deletedTodo = await Todo.findByIdAndDelete(todoId);

if (deletedTodo) {
  console.log('Todo deleted:', deletedTodo);
  return deletedTodo;
} else {
  console.log('Todo not found with ID:', todoId);
  return null;
}

} catch (err) {
console.error(Error deleting todo with ID ${todoId}:, err);
throw err;
}
}

// 5. アプリケーション実行例
async function runExample() {
// データベース接続
await connectDB();

// TODO作成例
const todo1 = await createTodo({ title: '牛乳を買う', description: 'スーパーで牛乳を買う' });
const todo2 = await createTodo({ title: '記事を書く', completed: false });
// バリデーションエラーになる例
// await createTodo({ description: 'タイトルがないTODO' });

// TODO一覧取得例
const allTodos = await getAllTodos();
console.log('\n--- 全てのTODO ---');
allTodos.forEach(todo => console.log(`${todo.title} (完了: ${todo.completed ? 'はい' : 'いいえ'})`));

if (todo1) {
    // TODO IDで取得例
    const foundTodo = await getTodoById(todo1._id);
    console.log('\n--- 特定のTODO取得 ---');
    console.log(foundTodo);

    // TODO更新例
    const updatedTodo = await updateTodo(todo1._id, { completed: true, description: '牛乳は購入済み' });
    console.log('\n--- TODO更新後 ---');
    console.log(updatedTodo);

    // 更新後のTODO一覧再取得
    const updatedTodos = await getAllTodos();
    console.log('\n--- 更新後のTODO一覧 ---');
    updatedTodos.forEach(todo => console.log(`${todo.title} (完了: ${todo.completed ? 'はい' : 'いいえ'})`));


    // TODO削除例
    // const deletedTodo = await deleteTodo(todo1._id);
    // console.log('\n--- TODO削除後 ---');
    // console.log(deletedTodo ? 'TODOを削除しました' : '削除対象のTODOが見つかりませんでした');
}

// データベース切断 (今回はプロセス終了で自動的に切断されるが、明示する場合)
// await mongoose.disconnect();
// console.log('Database disconnected.');

}

runExample();
“`

このサンプルコードでは、

  1. connectDB() でMongoDBに接続します。
  2. todoSchema でTODOドキュメントの構造を定義します。必須項目や最大文字数、デフォルト値、そして save 時の updatedAt 自動更新ミドルウェアを設定しています。
  3. mongoose.model()Todo モデルを作成します。
  4. TODOの作成 (createTodoTodo.create())、一覧取得 (getAllTodosTodo.find().sort())、ID取得 (getTodoByIdTodo.findById())、更新 (updateTodoTodo.findByIdAndUpdate())、削除 (deleteTodoTodo.findByIdAndDelete()) のCRUD操作関数を定義します。非同期処理のため async/await を使用し、エラーハンドリングも行っています。
  5. runExample() 関数内で、これらの関数を呼び出して一連の操作を実行しています。

このコードを実行するには、ローカルでMongoDBサーバーを起動しておくか、MongoDB Atlasへの接続文字列を適切に設定する必要があります。

このサンプルは非常に基本的なものですが、実際のアプリケーションでは、これらのCRUD操作関数をHTTPリクエストハンドラー(Expressなどのフレームワークを使用)から呼び出すことになります。

9. デバッグとトラブルシューティング

Mongooseを使った開発中に問題が発生した場合、デバッグ情報を活用することが重要です。

Mongooseのデバッグモード

Mongooseにはデバッグモードがあり、有効にするとMongooseが実行するMongoDBネイティブドライバーの操作や、Mongoose内部の処理に関する詳細なログが出力されます。

デバッグモードを有効にするには、mongoose.set('debug', true) を呼び出します。

“`javascript
const mongoose = require(‘mongoose’);

// デバッグモードを有効にする (true を指定)
mongoose.set(‘debug’, true);

// または特定のイベントのみをログ出力する場合
// mongoose.set(‘debug’, { shell: true, collection: true, data: true, schema: false, query: true });
// shell: true は mongosh (旧 mongo shell) コマンド形式でクエリを出力します

async function connectDB() { / … 接続処理 … / }
async function getAllTodos() { / … find() 処理 … / }

connectDB().then(getAllTodos);
“`

デバッグモードを有効にして上記のTODO一覧取得を実行すると、以下のようなログが出力されることがあります(出力形式はバージョンによって異なります)。

Mongoose: todos.find({}) { sort: { createdAt: -1 } } {}

これは、「todos コレクションに対して、空のクエリ条件 {}find 操作を実行し、結果を createdAt: -1 (降順)でソートする」という内容を示しています。このログを見れば、Mongooseが実際にどのようなクエリをMongoDBに発行しているかを確認できます。これにより、期待通りのクエリが実行されているか、あるいはクエリ条件が間違っていないかなどをデバッグできます。

一般的なエラーと対処法

  • Mongoose connection error: データベースへの接続に失敗している可能性があります。接続文字列、ホスト名、ポート番号、ユーザー名/パスワード、ネットワーク設定などを確認してください。MongoDBサーバーが起動しているかも確認が必要です。
  • Mongoose: option is deprecated: Mongooseのバージョンアップに伴い、使用している接続オプションやメソッドが非推奨になっている場合に表示されます。Mongooseの公式ドキュメントを確認し、最新の推奨される方法に修正してください。(例: useNewUrlParser など)
  • ValidationError: スキーマで定義したバリデーションルールに違反するデータを保存しようとした場合に発生します。エラーオブジェクトの errors プロパティに詳細なバリデーションエラー情報が含まれています。入力データを確認し、スキーマ定義を満たすように修正してください。
  • Duplicate key error (E11000): スキーマで unique: true と指定したフィールドに対して、既に存在する値と同じ値を登録しようとした場合に発生します。登録しようとしているデータがユニークであることを確認してください。
  • CastError: Cast to ObjectId failed for value “…” at path “_id”: findById() などでObjectIdを期待する場所に、無効な形式の文字列(例: 長さが足りない文字列など)を渡した場合に発生します。引数として渡すIDの形式を確認してください。
  • MissingSchemaError: Schema hasn’t been registered for model “…”: mongoose.model() でモデルを定義する前に、そのモデル名を使おうとした場合に発生します。モデルが正しく定義され、使用する前にその定義が読み込まれているか確認してください。

これらのエラーメッセージやデバッグログを活用することで、問題の原因を特定しやすくなります。

10. まとめ

この記事では、Node.jsアプリケーションでMongoDBを扱うための強力なODMライブラリであるMongooseについて、初心者向けに詳細に解説しました。

Mongooseを使うことで、MongoDBの柔軟性を活かしつつ、スキーマによる構造化、バリデーション、便利なクエリメソッド、ミドルウェアといった機能の恩恵を受けられます。これにより、Node.jsアプリケーションからのデータベース操作がより直感的で効率的になります。

学んだ主要な内容:

  • MongooseはNode.jsとMongoDB間のODMであり、データの構造化や操作を助けること。
  • mongoose.connect() でMongoDBに接続し、接続イベントを監視すること。
  • mongoose.Schema でドキュメントの構造、データ型、バリデーションルール、オプションなどを定義すること。
  • mongoose.model() でスキーマからモデルを作成し、データベース操作の入り口とすること。
  • モデルの静的メソッド(create, find, findOne, findById, updateOne, updateMany, deleteOne, deleteMany など)や、ドキュメントインスタンスのメソッド(save, remove/deleteOne)を使ってCRUD操作を行うこと。
  • クエリビルダメソッド(where, sort, limit, skip, select など)を使って柔軟なデータ取得を行うこと。
  • populate() 機能を使って関連ドキュメントを効率的に取得すること。
  • pre / post ミドルウェアを使って、特定の操作の前後にカスタムロジックを挿入すること。
  • デバッグモードを有効にしてMongooseの動作を詳細に確認すること。

Mongooseには、ここで紹介しきれなかった機能もまだたくさんあります。例えば、集計パイプライン (aggregate())、トランザクション(レプリカセットが必要)、ジオスペーシャルデータ (geo) などです。これらは、より高度なデータベース操作を行う際に役立ちます。

次のステップとしては、

  • より複雑なクエリや集計パイプラインの使い方を学ぶ。
  • ExpressなどのWebフレームワークと組み合わせて、Mongooseを使ったRESTful APIを構築する。
  • エラーハンドリングやバリデーションをより詳細に実装する。
  • パフォーマンス最適化のためのインデックス設計やPopulationの使い分けを学ぶ。
  • 認証・認可の仕組みをデータベースに連携させる。

Mongooseの公式ドキュメントは非常に充実しており、詳細な情報や最新のAPIリファレンスが掲載されています。ぜひ活用してください。

Node.jsとMongoDB、そしてMongooseの組み合わせは、モダンなWebアプリケーション開発において非常に強力な選択肢です。この記事が、皆さんがMongooseを使い始めるための一助となれば幸いです。

Happy coding!

11. 付録

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール