MongoDB: Mongooseバリデーションとミドルウェア

最終更新:2026-08-26

バリデーションとミドルウェアは、データ整合性を保つための重要な仕組みです。

1. このレッスンで学ぶこと


100%
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) ドキュメントミドルウェア

対応イベント: saveremovevalidateinit

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) クエリミドルウェア

対応イベント: countdeleteOnedeleteManyfindfindOnefindOneAndDeletefindOneAndUpdateupdateupdateOneupdateMany

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()でエラー送出。

📖 まとめ


📝 練習問題

  1. 基本問題(⭐):メールアドレスの形式バリデーションを追加せよ。
  2. 基本問題(⭐):パスワードの最小文字数バリデーションを追加せよ。
  3. 応用問題(⭐⭐):注文スキーマに金額が正の値であるカスタムバリデータを実装せよ。
  4. 応用問題(⭐⭐):pre saveフックで作成日時を自動設定せよ。
  5. チャレンジ問題(⭐⭐⭐):製品スキーマにソフトデリート機能(pre findで除外)を実装せよ。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%