MongoDB: Mongooseバリデーションとミドルウェア
最終更新:2026-08-26
バリデーションとミドルウェアは、データ整合性を保つための重要な仕組みです。
1. このレッスンで学ぶこと
- 組み込みバリデータ(required、min、max、enum等)
- カスタムバリデータ
- バリデーション実行タイミング
- pre/postミドルウェア
- 実践的なバリデーション設計
graph LR
A[ドキュメント保存] --> B{バリデーション}
B -->|成功| C[pre save フック]
C --> D[データベース書き込み]
D --> E[post save フック]
E --> F[✅ 完了]
B -->|失敗| G[❌ エラー送出]
style F fill:#d4edda
style G fill:#f8d7da
2. 組み込みバリデータ
概念説明: mongooseは一般的なバリデーション要件を満たす組み込みバリデータを提供します。必須フィールド、型チェック、範囲制限、列挙値などをスキーマ定義で宣言的に設定できます。
動作原理: ドキュメントの保存(save()、create())前に自動的にバリデーションが実行されます。検証失敗時はValidationErrorがスローされ、データベースへの書き込みが阻止されます。
(1) 文字列バリデータ
| バリデータ | 機能 | 例 |
|---|---|---|
required |
必須フィールド | required: [true, '必須項目です'] |
minlength |
最小文字数 | minlength: [3, '3文字以上必要'] |
maxlength |
最大文字数 | maxlength: 100 |
trim |
前後空白削除 | trim: true |
lowercase |
小文字変換 | lowercase: true |
uppercase |
大文字変換 | uppercase: true |
match |
正規表現 | match: [/^\w+$/, '形式エラー'] |
enum |
列挙値 | enum: ['draft', 'published'] |
JAVASCRIPT
const userSchema = new mongoose.Schema({
username: {
type: String,
required: [true, 'Username is required'],
minlength: [3, 'Username must be at least 3 characters'],
maxlength: 30,
trim: true,
match: [/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores']
},
email: {
type: String,
required: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, 'Invalid email format']
},
role: {
type: String,
enum: {
values: ['customer', 'admin', 'moderator'],
message: 'Role must be customer, admin, or moderator'
},
default: 'customer'
}
});
(2) 数値バリデータ
| バリデータ | 機能 | 例 |
|---|---|---|
min |
最小値 | min: [0, '0以上必要'] |
max |
最大値 | max: 100 |
JAVASCRIPT
const productSchema = new mongoose.Schema({
price: {
type: Number,
required: true,
min: [0, 'Price cannot be negative'],
max: [100000, 'Price cannot exceed 100000']
},
stock: {
type: Number,
min: 0,
default: 0
},
rating: {
type: Number,
min: 1,
max: 5
}
});
(3) 日付バリデータ
| バリデータ | 機能 | 例 |
|---|---|---|
min |
最小日付 | min: new Date() |
max |
最大日付 | max: '2026-12-31' |
JAVASCRIPT
const eventSchema = new mongoose.Schema({
title: { type: String, required: true },
startDate: {
type: Date,
required: true,
min: [new Date(), 'Start date cannot be in the past']
},
endDate: {
type: Date,
required: true,
validate: {
validator: function(v) {
return v > this.startDate;
},
message: 'End date must be after start date'
}
}
});
3. カスタムバリデータ
概念説明: 組み込みバリデータで表現できない複雑なルールは、validateオプションでカスタムバリデータを定義します。非同期バリデーションもサポートしています。
動作原理: バリデータ関数がtrueを返せば検証成功、falseまたはエラーで検証失敗。messageプロパティでカスタムエラーメッセージを指定できます。
(1) 同期バリデータ
JAVASCRIPT
const userSchema = new mongoose.Schema({
phone: {
type: String,
validate: {
validator: function(v) {
return /^\+?[1-9]\d{1,14}$/.test(v);
},
message: props => `${props.value} is not a valid phone number!`
}
},
age: {
type: Number,
validate: {
validator: function(v) {
return v >= 18 && v <= 120;
},
message: 'Age must be between 18 and 120'
}
}
});
(2) 非同期バリデータ
JAVASCRIPT
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
validate: {
validator: async function(v) {
// ユーザー名の一意性を非同期チェック
const count = await this.constructor.countDocuments({ username: v });
return count === 0;
},
message: 'Username already exists'
}
},
email: {
type: String,
validate: {
isAsync: true,
validator: function(v, cb) {
// 外部APIでメール検証(例)
setTimeout(() => {
cb(v.includes('@'));
}, 100);
},
message: 'Invalid email address'
}
}
});
(3) 配列要素バリデーション
JAVASCRIPT
const orderSchema = new mongoose.Schema({
items: {
type: [{
productId: { type: mongoose.Schema.Types.ObjectId, required: true },
qty: { type: Number, min: 1 },
price: { type: Number, min: 0 }
}],
validate: {
validator: function(v) {
return v.length > 0;
},
message: 'Order must have at least one item'
}
}
});
4. バリデーション実行タイミング
概念説明: mongooseのバリデーションは特定の操作でのみ自動実行されます。理解しておくことで、意図しない検証回避やパフォーマンス最適化が可能です。
| 操作 | バリデーション | 備考 |
|---|---|---|
save() |
✅ 実行 | 新規・更新両方 |
create() |
✅ 実行 | save()のショートカット |
insertMany() |
✅ 実行 | 各ドキュメントを検証 |
update() |
❌ 未実行 | 廃止予定 |
updateOne() |
❌ 未実行 | runValidators: true必要 |
findByIdAndUpdate() |
❌ 未実行 | runValidators: true必要 |
findOneAndUpdate() |
❌ 未実行 | runValidators: true必要 |
JAVASCRIPT
// update操作でバリデーション有効化
await User.updateOne(
{ _id: userId },
{ $set: { email: newEmail } },
{ runValidators: true, context: 'query' }
);
// findByIdAndUpdateでバリデーション有効化
await User.findByIdAndUpdate(
userId,
{ username: newUsername },
{ runValidators: true, new: true }
);
💡 ヒント:
runValidators: trueを設定しても、requiredバリデーションは更新対象フィールドのみ実行されます。未更新フィールドのrequiredは検証されません。
5. ミドルウェア(フック)
概念説明: ミドルウェアはドキュメントのライフサイクルイベント(保存・削除・検証など)の前後にカスタム処理を挿入する仕組みです。preはイベント前、postはイベント後に実行されます。
(1) ドキュメントミドルウェア
対応イベント: save、remove、validate、init
JAVASCRIPT
const orderSchema = new mongoose.Schema({
orderNumber: String,
total: Number,
status: String,
createdAt: Date
});
// pre save:保存前に注文番号を自動生成
orderSchema.pre('save', function(next) {
if (this.isNew) {
this.orderNumber = `ORD-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
this.createdAt = new Date();
}
next();
});
// post save:保存後にログ記録
orderSchema.post('save', function(doc) {
console.log(`Order ${doc.orderNumber} has been saved`);
});
// pre remove:削除前に関連データクリーンアップ
orderSchema.pre('remove', function(next) {
// 関連する在庫を復元等の処理
console.log(`About to delete order ${this.orderNumber}`);
next();
});
(2) クエリミドルウェア
対応イベント: count、deleteOne、deleteMany、find、findOne、findOneAndDelete、findOneAndUpdate、update、updateOne、updateMany
JAVASCRIPT
// ソフトデリート:find系クエリで削除済みを除外
productSchema.pre(/^find/, function(next) {
this.where({ isDeleted: { $ne: true } });
next();
});
// 更新前にupdatedAtを自動設定
productSchema.pre('findOneAndUpdate', function(next) {
this.set({ updatedAt: new Date() });
next();
});
(3) 集計ミドルウェア
対応イベント: aggregate
JAVASCRIPT
// ソフトデリート:集計で削除済みを除外
productSchema.pre('aggregate', function(next) {
this.pipeline().unshift({ $match: { isDeleted: { $ne: true } } });
next();
});
6. 実践的なバリデーション設計
▶ サンプル 1:ユーザー登録バリデーション(難易度 ⭐)
JAVASCRIPT
// TechCorp ユーザースキーマ:完全なバリデーション付き
const userSchema = new mongoose.Schema({
username: {
type: String,
required: [true, 'Username is required'],
unique: true,
minlength: [3, 'Username must be at least 3 characters'],
maxlength: [30, 'Username cannot exceed 30 characters'],
trim: true,
match: [/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores']
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
validate: {
validator: function(v) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
},
message: props => `${props.value} is not a valid email address`
}
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: [8, 'Password must be at least 8 characters'],
validate: {
validator: function(v) {
// 少なくとも1つの大文字、1つの小文字、1つの数字を含む
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/.test(v);
},
message: 'Password must contain at least one uppercase letter, one lowercase letter, and one number'
}
},
role: {
type: String,
enum: ['customer', 'admin', 'moderator'],
default: 'customer'
},
isActive: {
type: Boolean,
default: true
},
createdAt: {
type: Date,
default: Date.now
}
});
// pre save:パスワードハッシュ化(概念例)
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
// 実際はbcrypt等でハッシュ化
// this.password = await bcrypt.hash(this.password, 10);
next();
});
const User = mongoose.model('User', userSchema);
// バリデーションテスト
try {
await User.create({
username: 'alice',
email: 'invalid-email',
password: '123'
});
} catch (error) {
console.error(error.message);
// Validation failed: email is not a valid email address
}
出力:
TEXT 📖 参照専用ValidationError: User validation failed: email: invalid-email is not a valid email address, password: Password must be at least 8 characters
▶ サンプル 2:製品スキーマとミドルウェア(難易度 ⭐⭐)
JAVASCRIPT
// ShopHub 製品スキーマ:バリデーション + ミドルウェア
const productSchema = new mongoose.Schema({
sku: {
type: String,
required: true,
unique: true,
uppercase: true,
match: [/^[A-Z0-9-]+$/, 'SKU can only contain letters, numbers, and hyphens']
},
title: {
type: String,
required: [true, 'Product title is required'],
maxlength: [200, 'Title cannot exceed 200 characters']
},
price: {
type: Number,
required: true,
min: [0, 'Price cannot be negative'],
get: v => v.toFixed(2), // 表示用ゲッター
set: v => Math.round(v * 100) / 100 // 丸め
},
stock: {
type: Number,
min: 0,
default: 0
},
category: {
type: String,
required: true,
enum: ['Electronics', 'Books', 'Clothing', 'Home', 'Sports']
},
isActive: {
type: Boolean,
default: true
},
isDeleted: {
type: Boolean,
default: false
},
metadata: {
views: { type: Number, default: 0 },
purchases: { type: Number, default: 0 }
},
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date }
});
// インデックス
productSchema.index({ title: 'text', description: 'text' });
// pre save:updatedAtを自動設定
productSchema.pre('save', function(next) {
if (!this.isNew) {
this.updatedAt = new Date();
}
next();
});
// pre find:削除済みを除外
productSchema.pre(/^find/, function(next) {
this.where({ isDeleted: { $ne: true } });
next();
});
// pre update:updatedAtを自動設定
productSchema.pre('findOneAndUpdate', function(next) {
this.set({ updatedAt: new Date() });
next();
});
// post save:在庫アラート
productSchema.post('save', function(doc) {
if (doc.stock < 10) {
console.log(`⚠️ Low stock alert: ${doc.sku} has only ${doc.stock} items`);
}
});
const Product = mongoose.model('Product', productSchema);
出力:
TEXT 📖 参照専用製品保存時、在庫が10未満の場合にアラートをログ出力。updatedAtフィールドが自動更新される。
▶ サンプル 3:複雑なバリデーションとカスケード削除(難易度 ⭐⭐⭐)
JAVASCRIPT
// ShopHub:注文スキーマに複雑なバリデーション + カスケード削除
const orderSchema = new mongoose.Schema({
orderNumber: {
type: String,
required: true,
unique: true
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
items: [{
productId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Product',
required: true
},
qty: { type: Number, min: 1, required: true },
price: { type: Number, min: 0, required: true }
}],
total: {
type: Number,
required: true,
validate: {
validator: function(v) {
// 合計金額がアイテムの合計と一致するか検証
const itemsTotal = this.items.reduce((sum, item) => sum + item.qty * item.price, 0);
return Math.abs(v - itemsTotal) < 0.01; // 小数点誤差を許容
},
message: '合計金額がアイテムの合計と一致しません'
}
},
status: {
type: String,
enum: ['pending', 'paid', 'shipped', 'delivered', 'cancelled'],
default: 'pending'
},
shippingAddress: {
street: { type: String, required: true },
city: { type: String, required: true },
zipCode: {
type: String,
required: true,
validate: {
validator: function(v) {
return /^[0-9]{3}-?[0-9]{4}$/.test(v);
},
message: '郵便番号の形式が無効です(例: 123-4567)'
}
}
}
}, { timestamps: true });
// 注文番号自動生成(pre-save)
orderSchema.pre('save', async function(next) {
if (this.isNew) {
const count = await this.constructor.countDocuments();
this.orderNumber = `ORD-${Date.now()}-${String(count + 1).padStart(6, '0')}`;
}
next();
});
// カスケード削除:注文削除時に在庫を復元
orderSchema.post('findOneAndDelete', async function(doc) {
if (doc) {
// 各アイテムの在庫を復元(Productモデルが必要)
const Product = mongoose.model('Product');
for (const item of doc.items) {
await Product.findByIdAndUpdate(item.productId, {
$inc: { stock: item.qty }
});
}
console.log(`注文 ${doc.orderNumber} 削除完了、在庫を復元しました`);
}
});
const Order = mongoose.model('Order', orderSchema);
// テスト:バリデーションエラー
try {
await Order.create({
userId: userId,
items: [{ productId: productId, qty: 2, price: 100 }],
total: 300 // アイテム合計(200)と不一致
});
} catch (error) {
console.error(error.message);
}
出力:
TEXT 📖 参照専用ValidationError: 合計金額がアイテムの合計と一致しません。pre-saveで注文番号を自動生成し、post-deleteで在庫を復元するカスケード処理を実装。
❓ よくある質問
Q updateOneでバリデーションを実行するには?
A
{ runValidators: true }オプションを追加。Q バリデーションをスキップしたい場合は?
A
validateBeforeSave: falseオプションでスキップ可能(非推奨)。Q preとpostどちらを使うべき?
A データ変更や検証ならpre、ログや通知ならpost。
Q 非同期バリデータでエラー処理は?
A Promiseをrejectするか、
throw new Error()でエラー送出。📖 まとめ
- 組み込みバリデータ:required、min、max、enum、match等
- カスタムバリデータ:複雑なルールに対応
- 実行タイミング:save系は自動、update系は
runValidators必要 - preミドルウェア:イベント前処理(データ変換、検証)
- postミドルウェア:イベント後処理(ログ、通知)
📝 練習問題
- 基本問題(⭐):メールアドレスの形式バリデーションを追加せよ。
- 基本問題(⭐):パスワードの最小文字数バリデーションを追加せよ。
- 応用問題(⭐⭐):注文スキーマに金額が正の値であるカスタムバリデータを実装せよ。
- 応用問題(⭐⭐):pre saveフックで作成日時を自動設定せよ。
- チャレンジ問題(⭐⭐⭐):製品スキーマにソフトデリート機能(pre findで除外)を実装せよ。