iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 24
0
Modern Web

用 JavaScript 打造全端產品的入門學習筆記系列 第 24

用 MongoDB 及 Mongoose Model.Populate() 實作關連式資料庫——全端刻意練習 III

MongoDB

from MongoDB - Relational vs non-relational databases

現實生活中事物處處關聯

在後端學習的過程中,不時需要復刻我們生活中常使用的工具,來刻意練習。雖然 MongoDBNoSQL 的資料庫,但現實中非常多的概念是互相關聯的。以記帳為例,每筆記錄都對應到一個類別,而每個類別下都包含了數筆不同的紀錄。那在建置資料庫及資料操作時,究竟如何設計,能大幅度提高資料庫的效能,而不重複存放非常多相同名稱的類別資料呢?

為了解決此問題,在作業中我嘗試使用 MongoDB 搭配 Mongoose 來攻略關聯式資料庫的建置與操作。

筆記目的

本篇筆記將解決以下問題:

  • 如何使用 MongoDB 搭建相關聯的資料表(collections)?
  • Mongoose 提供的 .populate() 方法如何協助我們運用關聯資料?

誰適合閱讀:

  • 資料庫中的資料(models)有一定程度的關聯者

參考資料:

 

MongoDB 關聯式資料庫實作

本篇筆記將依照 產品工匠日常:打造全端產品的宏觀程序 中,資料庫架設及功能實作的架構及順序來記錄,我如何設計 Data Model(Schema)及以 .populate() 關聯資料,僅摘錄部分程式碼,細節請參考 GitHub repo

設計 Data Model

關聯部份設定路徑,用以存放對應資料的 ObjectId:

  • 一個類別對應多筆資料 models/category.js
const categorySchema = new Schema({
  title: {
    type: String,
    trim: true,
    required: true
  },
  icon: {
    type: String,
    trim: true,
    required: true
  },
  records: [{
    type: Schema.Types.ObjectId,
    ref: 'Record'
  }]
})

module.exports = mongoose.model('Category', categorySchema)
  • 一筆記錄對應一個類別 models/record.js
const recordSchema = new Schema({
  name: {
    type: String,
    trim: true,
    required: true
  },
  category: {
    type: Schema.Types.ObjectId,
    ref: 'Category'
  },
  date: {
    type: String,
    required: true
  },
  amount: {
    type: Number,
    min: [1, 'at least one dollar'],
    required: true
  }
})

module.exports = mongoose.model('Record', recordSchema)

建立 Data Seed

先新增類別,再新增種子記錄,以撈取對應的類別。比較麻煩的部分是我想兩個 collection 都更新對應資料,所以有兩層的資料操作:

  • 建置種子類別$ node models/seeds/categorySeeder.js
const categories = [
  ['家居物業', 'fa-home'],
  ['交通出行', 'fa-shuttle-van'],
  ['休閒娛樂', 'fa-grin-beam'],
  ['餐飲食品', 'fa-utensils'],
  ['其他', 'fa-pen']
].map(category => ({
  title: category[0],
  icon: `<i class="fas ${category[1]}"></i>`
}))

// Generate category seed
db.once('open', () => {
  Category.create(categories)
    .then(() => {
      db.close()
    })
  console.log('categorySeeder.js done ^_^')
})
  • 新增種子記錄 $ node models/seeds/recordSeeder.js
db.once('open', () => {
  createRecords()
  console.log('recordSeeder.js done ^_^')
})

function createRecords() {
  Category.find()
    .then(categories => {
      const categoriesId = []
      categories.forEach(category => {
        categoriesId.push(category._id)
      })
      return categoriesId // 含有所有類別 _id 的 array
    })
    .then(id => {
      for (let i = 0; i < 5; i++) {
        Record.create({ // 新增紀錄
          name: `name-${i}`,
          category: id[i],
          date: `2020-09-0${i + 1}`,
          amount: (i + 1) * 100
        })
          .then(record => { // 將對應紀錄存入類別的 collection 中
            Category.findById(id[i])
              .then(category => {
                category.records.push(record._id)
                category.save()
              })
          })
      }
    })
    .catch(error => console.error(error))
}

資料 CRUD 操作

最複雜的部分屬 CRUD 的操作,因為想同步 records 和 categories 的 collections。

P.s. 後來在 MDN 上才發現,其實也不一定要同步,可以統一更新在其中一個 collection 在使用 .populate() 關聯即可。

新增 Create

// routes/modules/records.js

router.post('/new', (req, res) => {
  const record = req.body // 整筆紀錄存放在 object 中
  Category.findOne({ title: record.category })
    .then(category => {
      record.category = category._id // 找到對應的 category._id

      Record.create(record) // 新增紀錄
        .then(record => {
          category.records.push(record._id) // 更新 categories collection 中對應的類別
          category.save()
        })
        .then(() => res.redirect('/'))
        .catch(error => console.error(error))
    })
    .catch(error => console.error(error))
})

讀取 Read

// routes/modules/home.js

router.get('/', (req, res) => {
  Category.find()
    .lean()
    .sort({ _id: 'asc' })
    .then(checkedCategories => {
      Record.find()
        .populate('category') // 將所有紀錄的類別關聯 categories collection
        .lean()
        .sort({ _id: 'asc' })
        .then(records => {
          let totalAmount = 0
          records.forEach(record => totalAmount += record.amount)
          res.render('index', { records, totalAmount, checkedCategories })
        })
        .catch(error => console.error(error))
    })
    .catch(error => console.error(error))
})

更新 Update

// routes/modules/records.js

router.put('/:id', (req, res) => {
  const { id } = req.params
  const update = req.body
  
  // remove this record from old category
  Record.findById(id)
    .then(record => {
      Category.findById(record.category)
        .then(category => {
          category.records = category.records.filter(record => record.toString() !== id)
          category.save()
        })
        .catch(error => console.error(error))
    })
    .catch(error => console.error(error))

  // assign category id in update object
  Category.findOne({ title: update.category })
    .then(category => {
      update.category = category._id

      // update record
      Record.findByIdAndUpdate(id, update, { new: true })
        .then(record => {
          category.records.push(record._id)
          category.save()
        })
        .then(() => res.redirect(`/`))
        .catch(error => console.error(error))
    })
    .catch(error => console.error(error))
})

刪除 Delete

// routes/modules/records.js

router.delete('/:id', (req, res) => {
  const { id } = req.params

  Record.findById(id)
    .then(record => {
      Category.findById(record.category)
        // remove record from collection of category
        .then(category => {
          category.records = category.records.filter(record => record.toString() !== id)
          category.save()
        })
        .catch(error => console.error(error))

      // delete this record
      record.remove()
    })
    .then(() => res.redirect('/'))
    .catch(error => console.error(error))
})

 


閱讀更多

Infinite Gamer
關於本系列更多內容及導讀,請閱讀作者於 Medium 個人專欄 【無限賽局玩家 Infinite Gamer | Publication – 】 上的文章 《用 JavaScript 打造全端產品的入門學習筆記》系列指南


上一篇
產品工匠日常:打磨全端產品的實作細節——全端刻意練習 II
下一篇
JavaScript 物件導向白話文筆記——全端開發者內功 I
系列文
用 JavaScript 打造全端產品的入門學習筆記30

尚未有邦友留言

立即登入留言