0

# Bài 03 — MongoDB căn bản & nạp dữ liệu mẫu

⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️


🎯 Mục tiêu

  • Hiểu MongoDB khác SQL ở chỗ nào
  • Nắm 4 khái niệm: database, collection, document, ObjectId
  • Viết được truy vấn find, findOne, insertOne, updateOne, deleteOne
  • Tạo xong database lith_perfume với đủ 3 collection và dữ liệu mẫu

Cuối bài bạn sẽ có database sẵn sàng cho backend ở bài 04.


📚 1. MongoDB là gì?

MongoDB là cơ sở dữ liệu NoSQL dạng document. Thay vì bảng và dòng như MySQL, nó lưu dữ liệu dưới dạng các "tài liệu" trông y hệt object JavaScript.

So sánh với SQL

SQL (MySQL, PostgreSQL) MongoDB Ví dụ trong dự án
Database Database lith_perfume
Table (bảng) Collection products, categories, orders
Row (dòng) Document Một sản phẩm
Column (cột) Field name, price, rating
Schema cố định Không bắt buộc schema Sản phẩm A có size, sản phẩm B không cũng được
JOIN Không có (phải tự ghép hoặc dùng $lookup) Bài 06 sẽ ghép thủ công

Một document trông như thế nào?

{
  "_id": ObjectId("6675905ecb0dded448a58bb0"),
  "name": "Gucci Flora Gorgeous Magnolia",
  "image": "sp1.jpg",
  "price": 5050000,
  "description": "Hương đầu: Quả mâm xôi, Dừa...",
  "rating": 4.5,
  "categoryId": "669a978e6e282b058a8e3f2c",
  "size": "100ml"
}

Đúng là một object JavaScript. Đây là lý do MongoDB rất hợp với dự án Node.js: không cần "dịch" qua lại giữa object và bảng.


📚 2. _idObjectId — khái niệm gây nhầm lẫn nhất

Mọi document đều có _id

Khi bạn thêm một document mà không chỉ định _id, MongoDB tự sinh một ObjectId.

ObjectId là gì?

Một chuỗi 24 ký tự hex, nhưng không phải kiểu chuỗi — nó là một kiểu dữ liệu riêng.

6675905ecb0dded448a58bb0
└──┬──┘└─┬──┘└┬─┘└─┬──┘
   │     │    │    └── Bộ đếm tăng dần (3 byte)
   │     │    └─────── ID tiến trình (2 byte)
   │     └──────────── ID máy (5 byte)
   └────────────────── Thời gian tạo (4 byte)

Vì có timestamp bên trong, ObjectId tự nhiên sắp xếp theo thời gian tạo.

⚠️ Cạm bẫy số 1: chuỗi ≠ ObjectId

// ❌ SAI — trả về null, dù _id đó có tồn tại
db.collection("products").findOne({ _id: "6675905ecb0dded448a58bb0" });

// ✅ ĐÚNG — phải chuyển sang ObjectId
const { ObjectId } = require("mongodb");
db.collection("products").findOne({ _id: new ObjectId("6675905ecb0dded448a58bb0") });

Đây là lỗi số 1 của người mới học MongoDB. Query không báo lỗi, chỉ đơn giản là không tìm thấy gì cả — nên rất khó phát hiện.

⚠️ Cạm bẫy số 2 (lỗi thật trong dự án này)

Trong file dữ liệu mẫu products.txt:

{
  "_id": { "$oid": "6675905ecb0dded448a58bb0" },   ← sẽ thành ObjectId khi import
  "categoryId": "669a978e6e282b058a8e3f2c"          ← chỉ là CHUỖI thường
}

Còn trong categories.json:

{
  "_id": { "$oid": "669a978e6e282b058a8e3f2c" }     ← là ObjectId
}

Nghĩa là products.categoryId (chuỗi) không khớp kiểu với categories._id (ObjectId).

Hậu quả trong code gốc:

// backend/routes/products.js — route lọc theo danh mục
router.get("/byCategory/:id", async (req, res, next) => {
  const products = await productCollection
    .find({ categoryId: new ObjectId(req.params.id) })   // ← tìm ObjectId
    .toArray();                                          //   nhưng DB lưu chuỗi
  // → LUÔN trả về mảng rỗng []
});

Trong khi route GET /products lại may mắn đúng, vì nó so sánh sau khi ép về chuỗi:

const category = categories.find(
  (cat) => cat._id.toString() === item.categoryId.toString()   // ← cả 2 đều về chuỗi
);

Cách xử lý — chọn 1 trong 2 (tài liệu này chọn cách A):

Cách A: Chuẩn hóa dữ liệu Cách B: Sửa code
Làm gì Import với categoryId{"$oid": "..."} để thành ObjectId Sửa query thành { categoryId: req.params.id } (giữ chuỗi)
Ưu Đúng chuẩn MongoDB, có thể dùng $lookup sau này Không cần sửa dữ liệu
Nhược Phải sửa file dữ liệu trước khi import Sai chuẩn, khó mở rộng

Phần 5 dưới đây sẽ hướng dẫn cách A cụ thể.


📚 3. Các truy vấn cơ bản

Bạn có thể gõ trực tiếp trong MongoDB Compass (tab >_MONGOSH ở dưới cùng) hoặc dùng mongosh trong terminal.

Trước tiên chọn database:

use lith_perfume

find() — lấy nhiều document

// Lấy tất cả
db.products.find()

// Lọc theo điều kiện
db.products.find({ rating: 4.5 })

// Giá > 4 triệu
db.products.find({ price: { $gt: 4000000 } })

// Giá trong khoảng 3-5 triệu
db.products.find({ price: { $gte: 3000000, $lte: 5000000 } })

// Tên chứa chữ "Gucci" (không phân biệt hoa thường)
db.products.find({ name: /Gucci/i })

Các toán tử so sánh

Toán tử Nghĩa Ví dụ
$gt greater than — lớn hơn { price: { $gt: 100 } }
$gte greater or equal — lớn hơn hoặc bằng { price: { $gte: 100 } }
$lt less than — nhỏ hơn { price: { $lt: 100 } }
$lte less or equal { price: { $lte: 100 } }
$ne not equal — khác { rating: { $ne: 0 } }
$in nằm trong danh sách { size: { $in: ["S", "M"] } }
$regex khớp biểu thức chính quy { name: { $regex: "Gucci", $options: "i" } }

findOne() — lấy đúng 1 document

db.products.findOne({ _id: ObjectId("6675905ecb0dded448a58bb0") })

Khác biệt: find() trả về một con trỏ (phải .toArray() mới ra mảng), còn findOne() trả về thẳng 1 object hoặc null.

sort(), limit(), skip()

// 10 sản phẩm đánh giá cao nhất
db.products.find().sort({ rating: -1 }).limit(10)

// Trang 2, mỗi trang 5 sản phẩm
db.products.find().skip(5).limit(5)

sort: 1 = tăng dần, -1 = giảm dần.

insertOne() / insertMany()

db.categories.insertOne({ name: "Dior", image: "h11.png" })

db.categories.insertMany([
  { name: "Chanel", image: "h12.png" },
  { name: "Hermès", image: "h13.png" }
])

updateOne() / updateMany()

db.products.updateOne(
  { _id: ObjectId("6675905ecb0dded448a58bb0") },   // tìm cái nào
  { $set: { price: 4990000 } }                     // sửa gì
)

// Tăng rating thêm 0.5 cho tất cả sản phẩm
db.products.updateMany({}, { $inc: { rating: 0.5 } })

⚠️ Bắt buộc phải có $set. Nếu viết updateOne({...}, { price: 100 }) MongoDB sẽ báo lỗi. Còn nếu dùng replaceOne, document sẽ bị thay hoàn toàn — mọi field khác biến mất.

deleteOne() / deleteMany()

db.products.deleteOne({ _id: ObjectId("...") })
db.products.deleteMany({ rating: 0 })
db.products.deleteMany({})   // ⚠️ Xóa SẠCH collection

countDocuments()

db.products.countDocuments()                  // đếm tất cả
db.products.countDocuments({ rating: 4.5 })   // đếm theo điều kiện

💻 4. Tạo database lith_perfume

Cách 1 — Bằng MongoDB Compass (khuyến nghị cho người mới)

  1. Mở Compass, connect vào mongodb://localhost:27017
  2. Bấm nút + cạnh chữ "Databases" (hoặc Create database)
  3. Điền:
    • Database Name: lith_perfume
    • Collection Name: categories
  4. Bấm Create Database
  5. Bên trong lith_perfume, bấm Create collection thêm 2 lần để tạo productsorders

Cách 2 — Bằng mongosh

use lith_perfume
db.createCollection("categories")
db.createCollection("products")
db.createCollection("orders")

💡 MongoDB không tạo database rỗng. Database chỉ thực sự tồn tại khi có ít nhất 1 collection chứa ít nhất 1 document. Đừng hoảng nếu chưa thấy nó xuất hiện.


💻 5. Nạp dữ liệu mẫu

Bước 5.1 — Chuẩn bị file categories.json

File này đã đúng định dạng, import thẳng được:

[
  { "_id": { "$oid": "66758da7cb0dded448a58ba7" }, "name": "Valentino",          "image": "h3.png"  },
  { "_id": { "$oid": "66758e26cb0dded448a58ba8" }, "name": "Chloé",              "image": "h5.png"  },
  { "_id": { "$oid": "669a970b6e282b058a8e3f27" }, "name": "Jean Paul Gaultier", "image": "h1.png"  },
  { "_id": { "$oid": "669a97426e282b058a8e3f28" }, "name": "Versace",            "image": "h2.png"  },
  { "_id": { "$oid": "669a97596e282b058a8e3f29" }, "name": "Ralph Lauren",       "image": "h4.png"  },
  { "_id": { "$oid": "669a97706e282b058a8e3f2a" }, "name": "Burberry",           "image": "h6.png"  },
  { "_id": { "$oid": "669a977c6e282b058a8e3f2b" }, "name": "Narciso Rodriguez",  "image": "h7.png"  },
  { "_id": { "$oid": "669a978e6e282b058a8e3f2c" }, "name": "Gucci",              "image": "h8.png"  },
  { "_id": { "$oid": "669cf8e86e282b058a8e3f46" }, "name": "Tom Ford",           "image": "h9.png"  },
  { "_id": { "$oid": "669cfa0f6e282b058a8e3f47" }, "name": "Kilian",             "image": "h10.png" }
]

💡 { "$oid": "..." } là cú pháp Extended JSON — báo cho MongoDB biết "hãy chuyển chuỗi này thành ObjectId, đừng lưu như chuỗi thường".

Bước 5.2 — Sửa products.txt để categoryId thành ObjectId

Đây là bước quan trọng để tránh lỗi đã nói ở mục 2.

File gốc có dạng:

{
  "_id": { "$oid": "6675905ecb0dded448a58bb0" },
  "name": "Gucci Flora Gorgeous Magnolia",
  "categoryId": "669a978e6e282b058a8e3f2c"
}

Cần đổi thành:

{
  "_id": { "$oid": "6675905ecb0dded448a58bb0" },
  "name": "Gucci Flora Gorgeous Magnolia",
  "categoryId": { "$oid": "669a978e6e282b058a8e3f2c" }
}

Cách làm nhanh bằng VS Code (Find & Replace với Regex):

  1. Mở products.txt trong VS Code
  2. Ctrl + H để mở Replace
  3. Bật nút .* (Use Regular Expression)
  4. Find:
    "categoryId": "([a-f0-9]{24})"
    
  5. Replace:
    "categoryId": { "$oid": "$1" }
    
  6. Bấm Replace All (Ctrl + Alt + Enter)

Lưu file lại. Bạn nên đổi tên thành products.json cho đúng định dạng.

Hoặc dùng lệnh (PowerShell):

(Get-Content products.txt -Raw) -replace '"categoryId": "([a-f0-9]{24})"', '"categoryId": { "$oid": "$1" }' | Set-Content products.json -Encoding utf8

Bước 5.3 — Import vào Compass

Với từng collection:

  1. Trong Compass, mở database lith_perfume → chọn collection categories
  2. Bấm ADD DATAImport JSON or CSV file
  3. Chọn file categories.json
  4. Input File Type: JSON
  5. Bấm Import

Lặp lại cho products với file products.json vừa sửa.

Bước 5.4 — Import bằng dòng lệnh (cách khác)

Nếu bạn có mongoimport (đi kèm MongoDB Database Tools):

mongoimport --db lith_perfume --collection categories --file categories.json --jsonArray
mongoimport --db lith_perfume --collection products   --file products.json   --jsonArray

Cờ --jsonArray là bắt buộc vì file của ta là một mảng [...].


✅ 6. Kiểm tra dữ liệu đã đúng chưa

Mở tab >_MONGOSH trong Compass và chạy lần lượt:

use lith_perfume

// 1. Phải ra 10
db.categories.countDocuments()

// 2. Phải ra 12
db.products.countDocuments()

// 3. Kiểm tra kiểu của categoryId — QUAN TRỌNG NHẤT
db.products.findOne()

Nhìn vào kết quả câu 3, categoryId phải hiển thị là:

categoryId: ObjectId('669a978e6e282b058a8e3f2c')   // ✅ Đúng

Chứ không phải:

categoryId: '669a978e6e282b058a8e3f2c'             // ❌ Vẫn là chuỗi, làm lại bước 5.2

Kiểm tra thêm bằng cách đếm sản phẩm của một danh mục:

// 4. Phải ra số > 0 (Gucci có 2 sản phẩm)
db.products.countDocuments({ categoryId: ObjectId("669a978e6e282b058a8e3f2c") })

Nếu ra 0categoryId vẫn đang là chuỗi. Quay lại bước 5.2.

Cách sửa nhanh nếu đã lỡ import sai

Không cần import lại. Chạy script này trong mongosh:

use lith_perfume
db.products.find({ categoryId: { $type: "string" } }).forEach(function (doc) {
  db.products.updateOne(
    { _id: doc._id },
    { $set: { categoryId: new ObjectId(doc.categoryId) } }
  );
});
// Kiểm tra lại
db.products.countDocuments({ categoryId: { $type: "string" } });   // phải ra 0

📚 7. Ghép dữ liệu giữa 2 collection

MongoDB không có JOIN như SQL. Có 2 cách ghép:

Cách 1 — Ghép trong code (dự án dùng cách này)

// Lấy hết sản phẩm, lấy hết danh mục, rồi ghép bằng JavaScript
const products = await db.collection("products").find().toArray();
const categories = await db.collection("categories").find().toArray();

products.forEach((p) => {
  p.category = categories.find((c) => c._id.toString() === p.categoryId.toString());
});

✅ Dễ hiểu · ❌ Tải hết dữ liệu về mới ghép, chậm khi dữ liệu lớn

Cách 2 — Dùng $lookup (aggregation)

db.products.aggregate([
  {
    $lookup: {
      from: "categories",       // ghép với collection nào
      localField: "categoryId", // field bên products
      foreignField: "_id",      // field bên categories
      as: "category"            // kết quả đặt vào field tên gì
    }
  },
  { $unwind: "$category" }      // biến mảng 1 phần tử thành object
])

✅ Nhanh, MongoDB làm hết · ❌ Cú pháp khó nhớ hơn ⚠️ Chỉ chạy đúng khi categoryId là ObjectId — thêm một lý do phải làm bước 5.2.

Thử ngay trong mongosh để thấy kết quả:

db.products.aggregate([
  { $lookup: { from: "categories", localField: "categoryId", foreignField: "_id", as: "category" } },
  { $unwind: "$category" },
  { $project: { name: 1, price: 1, "category.name": 1 } }
])

Tài liệu chọn cách 1 cho bài 06 vì dễ hiểu hơn với người mới, nhưng bài 21 sẽ giới thiệu lại $lookup khi làm phân trang.


📚 8. Index — tăng tốc truy vấn

Khi dữ liệu lớn, truy vấn không có index sẽ phải quét toàn bộ collection.

// Tạo index cho categoryId (vì hay lọc theo nó)
db.products.createIndex({ categoryId: 1 })

// Index cho tìm kiếm theo tên
db.products.createIndex({ name: "text" })

// Xem các index hiện có
db.products.getIndexes()

Với 12 sản phẩm thì chưa cần. Nhưng bài 21 (tìm kiếm & phân trang) sẽ dùng đến.


📝 Bài tập

Chạy trong mongosh, tự viết trước khi xem đáp án:

  1. Đếm số sản phẩm có giá trên 4.000.000đ
  2. Lấy 3 sản phẩm đắt nhất, chỉ hiện nameprice
  3. Tìm tất cả sản phẩm có chữ "Eau" trong tên (không phân biệt hoa thường)
  4. Tăng giá tất cả sản phẩm của Gucci lên 10%
  5. Đếm số sản phẩm theo từng danh mục (gợi ý: dùng aggregate với $group)
<details> <summary>Đáp án</summary>
// 1
db.products.countDocuments({ price: { $gt: 4000000 } })

// 2
db.products.find({}, { name: 1, price: 1, _id: 0 }).sort({ price: -1 }).limit(3)

// 3
db.products.find({ name: /Eau/i })

// 4
db.products.updateMany(
  { categoryId: ObjectId("669a978e6e282b058a8e3f2c") },
  { $mul: { price: 1.1 } }
)

// 5
db.products.aggregate([
  { $group: { _id: "$categoryId", soLuong: { $sum: 1 } } },
  { $lookup: { from: "categories", localField: "_id", foreignField: "_id", as: "cat" } },
  { $unwind: "$cat" },
  { $project: { _id: 0, danhMuc: "$cat.name", soLuong: 1 } },
  { $sort: { soLuong: -1 } }
])
</details>

✅ Checklist trước khi sang bài 04

  • [ ] Database lith_perfume tồn tại
  • [ ] Collection categories10 document
  • [ ] Collection products12 document
  • [ ] Collection orders tồn tại (rỗng cũng được)
  • [ ] db.products.findOne().categoryId hiển thị là ObjectId(...), không phải chuỗi
  • [ ] db.products.countDocuments({ categoryId: { $type: "string" } }) trả về 0

⬅️ Bài trước | Mục lục | Bài tiếp theo: Khởi tạo dự án Express ➡️


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí