「Mongoose ODM:MongoDBのデータモデリングと検証」
ネイティブのMongoDBドライバーを使用して書かれたボブのデータ検証コードは、さまざまなルートに分散していました。登録エンドポイントではメールアドレスの形式をチェックし、記事公開エンドポイントではタイトルの長さを検証し、パスワード変更エンドポイントでは新旧のパスワードが異なることを確認していました。新しいフィールドが追加されるたびに、彼は対応するコントローラーにif (!field)ロジックを追加しなければならず、その結果、同じメール検証用の正規表現が3つのルートで重複して使用されることになっていました。Mongooseに切り替えた後、ボブはすべての検証ルールをスキーマ定義に一元化しました。つまり、一度定義すればどこでも適用できるようにしたことで、コントローラーのコードを60%削減することができました。
学習内容:
- 3層アーキテクチャ(スキーマ、モデル、ドキュメント)の関係と用途
- フィールド型およびバリデータ(required / enum / min / max / match)の宣言型定義
- 「virtual」属性の計算フィールドのパターン
- プレフックおよびポストフックのライフサイクルをインターセプトする
- クエリビルダーでの連鎖呼び出し
- インデックス(インデックス/一意)とパフォーマンスの最適化
- インスタンスメソッドおよび静的メソッド用のカスタム拡張
populateを使用して、ドキュメント間の参照に対する結合クエリを実装する
1. Mongooseのコアアーキテクチャ
Mongoose は、MongoDB 向けのオブジェクト・ドキュメント・マッピング(ODM)ライブラリであり、ネイティブドライバの上にスキーマ駆動型のデータモデリング層を提供します。その中核となる概念は 3 つの層で構成されています。すなわち、スキーマが構造を定義し → モデルがコンストラクタにコンパイルされ → ドキュメントはモデルのインスタンスとなります。
(1) スキーマ → モデル → ドキュメントの関係
graph LR
A["Schema<br/>Defining Structures and Verification"] -->|mongoose.model() Compilation| B["Model<br/>Constructor + Query Interface"]
B -->|new Model() Instantiation| C["Document<br/>Examples of Verified Documents"]
C -->|.save() Persistence| D[("MongoDB<br/>Gathering")]
B -->|Model.find() etc.| D
D -->|Back| C
(2) 設置と接続
▶ 例:MongooseのインストールとMongoDBへの接続
npm install mongoose
const mongoose = require('mongoose');
mongoose.connect('mongodb://localhost:27017/myapp')
.then(() => console.log('MongoDB connected'))
.catch(err => console.error('Connection error:', err));
(3) 最小限のスキーマとモデル
▶ 例:ユーザースキーマの定義とモデルの作成
const userSchema = new mongoose.Schema({
name: String,
email: String,
age: Number
});
const User = mongoose.model('User', userSchema);
| 概念 | 役割 | 例え |
|---|---|---|
| スキーマ | 設計図/構造定義 | 建築図面 |
| モデル | コンストラクタ + データベース操作インターフェース | 建設チーム |
| 資料 | 認証済み資料の例 | 完成事例 |
2. スキーマのフィールド型
Mongoose は各フィールドに対して豊富な型マッピングを提供しており、ネイティブドライバーにおける型制約の欠如をはるかに上回っています。
(1) フィールド型のクイックリファレンス
| Mongooseの型 | 対応するJSの型 | 例 | 説明 |
|---|---|---|---|
String |
文字列 | name: String |
自動トリミング(設定が必要) |
Number |
番号 | age: Number |
最小値・最大値に対応 |
Boolean |
ブール値 | active: Boolean |
自動的に 0/1/"true" に変換されます |
Date |
日付 | createdAt: Date |
組み込みの日付メソッド |
ObjectId |
ObjectId | author: mongoose.Schema.Types.ObjectId |
他の文書への参照 |
Array |
配列 | tags: [String] |
子ドキュメントの配列、または型の配列 |
Mixed |
オブジェクト | meta: mongoose.Schema.Types.Mixed |
任意の型、検証なし |
Buffer |
バッファ | avatar: Buffer |
バイナリデータ |
Map |
Map | prefs: { type: Map, of: String } |
ES6のMap構造 |
Decimal128 |
128進数 | price: mongoose.Schema.Types.Decimal128 |
高精度128進数 |
(2) フィールド定義の完全な構文
▶ 例:フィールドオプションの詳細な説明
const productSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'The product name cannot be left blank.'],
trim: true,
minlength: 2,
maxlength: 100
},
price: {
type: Number,
required: true,
min: [0, 'Prices cannot be negative.'],
default: 0
},
category: {
type: String,
enum: ['electronics', 'books', 'clothing', 'food'],
lowercase: true
},
tags: [String],
metadata: {
type: mongoose.Schema.Types.Mixed,
default: {}
}
});
3. バリデータ
バリデーションはMongooseの中核をなす機能であり、データバリデーションをコントローラーから切り離し、スキーマ宣言に一元化します。
(1) バリデータ クイックリファレンス
| バリデータ | 適用対象 | 説明 | 例 |
|---|---|---|---|
required |
すべて | 必須項目 | required: [true, 'Cannot be empty'] |
enum |
文字列 | 列挙型の値の制約 | enum: ['A', 'B', 'C'] |
min |
番号/日付 | 最小値 | min: 0 |
max |
番号/日付 | 最大値 | max: 150 |
minlength |
文字列 | 最小長 | minlength: 6 |
maxlength |
文字列 | 最大長 | maxlength: 200 |
match |
文字列 | 正規表現による一致 | match: [/^\S+@\S+\.\S+$/, 'Invalid email format'] |
validate |
すべて | カスタム検証関数 | validate: v => v > 0 |
(2) カスタムバリデータ
▶ 例:カスタムバリデータとエラーメッセージ
const userSchema = new mongoose.Schema({
password: {
type: String,
required: true,
validate: {
validator: function(v) {
return /^(?=.*[A-Z])(?=.*\d).{8,}$/.test(v);
},
message: props => `${props.value} Password does not meet requirements: at least 8 characters, includes uppercase and digits`
}
},
phone: {
type: String,
validate: {
validator: function(v) {
return /^1[3-9]\d{9}$/.test(v);
},
message: 'The phone number format is incorrect'
}
}
});
(3) トリガーのタイミングを確認する
検証は、new Model().save() および Model.create() の時点で自動的に実行されます。validate() メソッドを使用すると、手動で実行することもできます。updateOne() や updateMany() などについては、検証は自動的に実行されません。これらの場合は、runValidators: true オプションを設定する必要があります。
▶ 例:更新操作に対する検証を有効にする
User.updateOne(
{ email: 'bob@test.com' },
{ age: -5 },
{ runValidators: true }
);
4. 仮想プロパティ
仮想属性はデータベースには保存されず、クエリ実行時にのみ動的に計算されるため、派生フィールドに適しています。
(1) 定義と用法
▶ 例:「ユーザーの氏名」仮想属性
const userSchema = new mongoose.Schema({
firstName: String,
lastName: String,
email: String
});
userSchema.virtual('fullName')
.get(function() {
return `${this.firstName} ${this.lastName}`;
})
.set(function(v) {
const parts = v.split(' ');
this.firstName = parts[0];
this.lastName = parts[1] || '';
});
const User = mongoose.model('User', userSchema);
const user = new User({ firstName: 'Bob', lastName: 'Smith' });
console.log(user.fullName);
Bob Smith
(2) virtual および toJSON
仮想属性は、デフォルトでは toJSON() および toObject() の出力には含まれません。スキーマオプションで明示的に有効にする必要があります:
const userSchema = new mongoose.Schema({
firstName: String,
lastName: String
}, {
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
5. フック(ミドルウェア)
フック(ミドルウェア)は、ドキュメントのライフサイクルの特定の段階で自動的に実行され、データの事前処理、ロギング、カスケード操作などに使用されます。
(1) フックの種類と発動条件
| フックの種類 | 作動条件 | 一般的な用途 |
|---|---|---|
pre('save') |
保存前 | パスワードのハッシュ化、データの整形、更新タイムスタンプ |
post('save') |
保存後 | 通知の送信、ログの記録 |
pre('remove') |
削除前 | 関連文書の連鎖削除 |
post('remove') |
削除後 | リソースとログのクリーンアップ |
pre('find') |
クエリ実行前 | デフォルトのフィルタ条件(例:ソフト削除) |
post('find') |
クエリ実行後 | データの匿名化 |
pre('updateOne') |
更新前 | 更新日時 |
post('aggregate') |
集計後 | ログ記録 |
(2) フックについて
▶ 例:保存前にパスワードを自動的にハッシュ化する
const bcrypt = require('bcrypt');
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});
(3) 投稿フック
▶ 例:保存後にウェルカムメールを送信する
userSchema.post('save', function(doc, next) {
console.log(`User ${doc.email} Saved`);
next();
});
(4) クエリフック
▶ 例:クエリ実行時に削除済みのドキュメントを自動的に除外する
userSchema.pre('find', function() {
this.where({ deletedAt: null });
});
6. クエリビルダー
Mongooseのクエリビルダーは、ネイティブドライバで使用されるオブジェクトパラメータよりも直感的な、チェーン呼び出しをサポートしています。
(1) 連鎖クエリ法
▶ 例:クエリビルダーの連鎖呼び出し
const users = await User.find()
.where('age').gte(18).lte(65)
.where('role').equals('admin')
.sort({ createdAt: -1 })
.select('name email age')
.limit(10)
.skip(0);
console.log(users);
(2) 一般的なクエリ手法の比較
| ネイティブドライバーの実装 | Mongoose クエリビルダー | 説明 |
|---|---|---|
db.users.find({ age: { $gte: 18 } }) |
User.find().where('age').gte(18) |
条件検索 |
db.users.find().sort({ name: 1 }) |
User.find().sort({ name: 1 }) |
並べ替え |
db.users.find().limit(10) |
User.find().limit(10) |
登録件数の上限は |
db.users.find().skip(20) |
User.find().skip(20) |
スキップ |
db.users.find({}, { name: 1 }) |
User.find().select('name') |
フィールドフィルター |
(3) ページネーション付きクエリのカプセル化
▶ 例:ページネーション付きクエリ用の補助関数
async function paginate(Model, filter = {}, page = 1, limit = 10) {
const skip = (page - 1) * limit;
const [docs, total] = await Promise.all([
Model.find(filter).skip(skip).limit(limit).sort({ createdAt: -1 }),
Model.countDocuments(filter)
]);
return {
data: docs,
total,
page,
totalPages: Math.ceil(total / limit)
};
}
const result = await paginate(User, { role: 'user' }, 2, 10);
7. 索引
インデックスは、データベースのクエリパフォーマンスにとって極めて重要です。Mongoose は、スキーマ内での宣言型インデックス定義をサポートしています。
(1) 単一カラムインデックスと複合インデックス
▶ 例:スキーマでのインデックスの定義
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true
},
username: {
type: String,
index: true
},
region: String,
status: String
});
userSchema.index({ region: 1, status: 1 });
| インデックスの種類 | 定義 | 説明 |
|---|---|---|
| 一意インデックス | unique: true |
フィールドの値は一意でなければならない |
| 通常インデックス | index: true |
高速クエリ |
| 総合指数 | schema.index({ a: 1, b: -1 }) |
多分野総合指数 |
| テキスト索引 | schema.index({ title: 'text' }) |
全文検索 |
(2) 開発環境におけるインデックスの自動作成
mongoose.connect(uri, { autoIndex: true });
本番環境では、起動時の遅延を防ぐため、autoIndex を無効にし、移行スクリプトを使用して手動でインデックスを作成することをお勧めします。
8. インスタンスメソッドと静的メソッド
Mongoose では、カスタムメソッドを使ってスキーマを拡張することができます。これらのメソッドは、インスタンスメソッドと静的メソッドの 2 つのカテゴリに分類されます。
(1) インスタンスメソッド
インスタンスメソッドは単一のドキュメントに対して動作します。現在のドキュメントにアクセスするには、this を使用してください。
▶ 例:パスワード照合の例となる手法
userSchema.methods.comparePassword = function(candidate) {
return bcrypt.compare(candidate, this.password);
};
const user = await User.findOne({ email: 'bob@test.com' });
const isMatch = await user.comparePassword('mypassword');
(2) 静的メソッド
静的メソッドはModelクラス上で定義され、ドキュメントのインスタンスに依存しないため、クエリのサポートに適しています。
▶ 例:役割による静的メソッドの検索
userSchema.statics.findByRole = function(role) {
return this.find({ role }).sort({ createdAt: -1 });
};
const admins = await User.findByRole('admin');
| タイプ | 定義 | 呼び出し | 本リファレンス |
|---|---|---|---|
| インスタンスメソッド | schema.methods.xxx = function |
doc.xxx() |
ドキュメントインスタンス |
| 静的メソッド | schema.statics.xxx = function |
Model.xxx() |
モデル |
9. 結合クエリへのデータ投入
Mongooseのpopulate()は、SQLのJOINと同様に、MongoDBドキュメント間の参照の解決を実装しています。
(1) 参照定義と結合
▶ 例:著者と関連する記事
const postSchema = new mongoose.Schema({
title: String,
content: String,
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
}
});
const Post = mongoose.model('Post', postSchema);
const posts = await Post.find().populate('author', 'firstName lastName email');
(2) マルチレベルでのデータ投入と条件付きフィルタリング
▶ 例:多階層の結合とフィルタ
const commentSchema = new mongoose.Schema({
content: String,
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
post: { type: mongoose.Schema.Types.ObjectId, ref: 'Post' }
});
const Comment = mongoose.model('Comment', commentSchema);
const comments = await Comment.find()
.populate('author', 'firstName lastName')
.populate({
path: 'post',
select: 'title content',
match: { status: 'published' }
});
(3) populate の性能に関する考慮事項
populate() 基本的には、追加のクエリを実行してその結果を結合するものであり、厳密な意味でのJOINではありません。N+1問題は依然として残っています。つまり、100件の記事が100人の異なる著者と関連付けられている場合、101件のクエリが実行されます。関連付けが頻繁に行われるシナリオでは、ドキュメントの埋め込みや$lookup集約を検討してください。
10. Mongoose とネイティブドライバの比較
| ディメンション | MongoDBネイティブドライバー | Mongoose ODM |
|---|---|---|
| データ検証 | コントローラーの至る所に散在する手書きのif/else文 | スキーマに基づく宣言型検証(一元管理) |
| 型の制約 | なし。どのフィールドにも任意の値を格納可能 | スキーマによる型の制約。自動変換 |
| 結合クエリ | 手動 $lookup 集計 |
populate() 1行で完了 |
| ライフサイクルフック | なし | プリ/ポストフック |
| 仮想属性 | なし | 動的に計算される仮想フィールド |
| インデックス管理 | マニュアル createIndex() |
スキーマ宣言 + 自動作成 |
| クエリ API | オブジェクトパラメータ find({ age: { $gte: 18 } }) |
チェーンビルダー + オブジェクトパラメータ |
| 習得の難易度 | 低;MongoDBのネイティブ構文と非常に似ている | 中;スキーマ、モデル、ドキュメントに関する理解が必要 |
| 柔軟性 | 高い、完全に制御可能 | 中程度、スキーマ外のフィールドはデフォルトで無視される |
| パフォーマンス | わずかに向上、中間層なし | わずかに低下、検証やフックによるオーバーヘッド |
11. 包括的な例:ユーザーおよび記事のデータモデル
スキーマ、検証、フック、仮想属性、インスタンスメソッド、および結合クエリを組み合わせて、完全なデータモデルシステムを構築します。
▶ 例:models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
firstName: {
type: String,
required: [true, 'The last name cannot be left blank.'],
trim: true
},
lastName: {
type: String,
required: [true, 'The name cannot be empty'],
trim: true
},
email: {
type: String,
required: [true, 'The email address cannot be left blank.'],
unique: true,
lowercase: true,
match: [/^\S+@\S+\.\S+$/, 'The email address format is incorrect.']
},
password: {
type: String,
required: [true, 'The password cannot be empty.'],
minlength: 8,
validate: {
validator: function(v) {
return /^(?=.*[A-Z])(?=.*\d).{8,}$/.test(v);
},
message: 'Password must be at least 8 characters, must include uppercase and digits'
}
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
},
age: {
type: Number,
min: [0, 'Age cannot be a negative number.'],
max: [150, 'Age must not exceed150']
},
createdAt: {
type: Date,
default: Date.now
}
}, {
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
userSchema.virtual('fullName').get(function() {
return `${this.firstName} ${this.lastName}`;
});
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});
userSchema.methods.comparePassword = function(candidate) {
return bcrypt.compare(candidate, this.password);
};
userSchema.statics.findByRole = function(role) {
return this.find({ role }).sort({ createdAt: -1 });
};
module.exports = mongoose.model('User', userSchema);
▶ 例:models/Post.js
const mongoose = require('mongoose');
const postSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'The title cannot be left blank.'],
trim: true,
minlength: [2, 'Title must be at least2characters'],
maxlength: [200, 'Most Titles200characters']
},
content: {
type: String,
required: [true, 'Content cannot be empty']
},
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
status: {
type: String,
enum: ['draft', 'published', 'archived'],
default: 'draft'
},
tags: [{
type: String,
lowercase: true
}],
viewCount: {
type: Number,
default: 0,
min: 0
},
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date,
default: Date.now
}
});
postSchema.index({ status: 1, createdAt: -1 });
postSchema.index({ tags: 1 });
postSchema.virtual('excerpt').get(function() {
return this.content.substring(0, 100) + '...';
});
postSchema.pre('save', function(next) {
if (this.isModified('content')) {
this.updatedAt = new Date();
}
next();
});
postSchema.post('remove', async function(doc) {
await mongoose.model('Comment').deleteMany({ post: doc._id });
});
postSchema.statics.findPublished = function() {
return this.find({ status: 'published' })
.populate('author', 'firstName lastName email')
.sort({ createdAt: -1 });
};
module.exports = mongoose.model('Post', postSchema);
▶ 例:クエリと結合
const mongoose = require('mongoose');
const User = require('./models/User');
const Post = require('./models/Post');
async function main() {
await mongoose.connect('mongodb://localhost:27017/blog');
const user = await User.create({
firstName: 'Bob',
lastName: 'Smith',
email: 'bob@example.com',
password: 'Secure123',
role: 'admin',
age: 28
});
const post = await Post.create({
title: 'Mongoose Getting Started Guide',
content: 'Mongoose is MongoDB ODM library, providing schema-driven data modeling...',
author: user._id,
status: 'published',
tags: ['mongodb', 'mongoose', 'nodejs']
});
const published = await Post.findPublished();
console.log(published[0].excerpt);
console.log(published[0].author.fullName);
const match = await user.comparePassword('Secure123');
console.log('Password match:', match);
await mongoose.connection.close();
}
main();
node app.js
Mongoose is MongoDB ODM library, providing schema-driven data modeling......
Bob Smith
Password match: true
❓ よくある質問
Model.collectionを介してネイティブコレクションにアクセスすることをサポートしています。pre-saveフックを使って値を計算・割り当ててください。デフォルトでは、仮想フィールドはJSON出力に表示されません。表示させるには、toJSON: { virtuals: true }を設定する必要があります。populateのパフォーマンスはどのようになっていますか?populateはSQLのJOINではありません。基本的には、追加のクエリを実行し、その結果をマージする処理です。関連するドキュメントが少ない場合はパフォーマンスは良好ですが、関連するドキュメントの種類が多数あると、N+1クエリの問題が発生する可能性があります。高頻度で利用されるシナリオでは、ドキュメントの埋め込み、手動での$lookup、またはキャッシュの利用を検討してください。ValidationErrorをスローします。このerrorsオブジェクトには、各フィールドのエラー詳細が含まれています。Object.values(err.errors).map(e => e.message)を使用してすべてのメッセージを抽出し、Expressのエラー処理ミドルウェアと組み合わせて、統一された400ステータスコードを返すことができます。pre-save フックにおける this とは何を指しますか?this は、保存されようとしている Document のインスタンスを指します。注:矢印関数を使用すると、thisのバインディングが失われます。フック関数は通常の関数でなければなりません。this.isModified('field')を使用すると、フィールドが変更されたかどうかを確認できます。schema.add({ newField: String }) を使用すれば動的に追加できます。ただし、コンパイル済みのモデルは自動的に更新されません。再コンパイルを行うか、schema.plugin() 拡張機能を使用する必要があります。プロジェクトの初期段階でフィールド構造を慎重に計画することをお勧めします。📖 まとめ
- Mongooseコアアーキテクチャの主要な概念と使用方法
- スキーマのフィールド型の基本概念と使用方法
- バリデーターの基本概念と使い方
- 仮想プロパティの基本概念と使い方
- フック(ミドルウェア)の基本概念と使い方
- クエリビルダーの基本概念と使い方
- インデックスの基本概念と使用方法
- インスタンスメソッドと静的メソッドの基本概念と使い方
📝 練習問題
- このレッスンにあるすべてのコード例を完成させ、それぞれが正しく動作することを確認してください。
- 包括的な例を修正し、独自の拡張機能を追加する
- 公式ドキュメントを確認し、このレッスンで扱われていないAPIを1~2つ見つけ、それらのテストコードを作成してください。
- 振り返り:このレッスンで学んだことを、実際のプロジェクトにどのように活かしますか?
- このレッスンで学んだことと、これまでのレッスンの内容を組み合わせて、小さなプロジェクトを作成してみてください。



