Skip to content

Project 10 Basic Mongo Pipeline

Theethawat Savastham edited this page Jul 9, 2024 · 8 revisions

บางส่วนของบทความนี้ใช้ AI GitHub Copitlot ช่วยเขียน

จากเดิมที่เรามีการทำ Routes ของ Order และ Product อยู่แล้ว ในโปรเจกต์ที่ 9 โดยเวลาที่เราเรียกใช้ Product ใน Order เราจะใช้การ join ข้อมูล ด้วยการใช้ populate ของ mongoose แต่ในโปรเจกต์นี้เราจะใช้การทำ join ข้อมูลด้วยการใช้ aggregation pipeline ของ MongoDB แทน การ Populate ก็สามารถทำได้ แต่การใช้ aggregation pipeline จะช่วยให้เราสามารถทำการ join ข้อมูลได้หลากหลายมากขึ้น จัดการข้อมูลได้หลากหลายรูปแบบมากขึ้น และในโค้ดส่วนใหญ่ของศูนย์วิจัยจะเป็นการใช้ aggregation pipeline ในการจัดการข้อมูลเป็นหลัก

การใช้ aggregation เป็นหลักการพื้นฐานของ mongodb เลย ในขณะที่เรื่องของ populate ในส่วนของ mongodb ไม่ได้มีในส่วนนี้ แต่เป็นการใช้งานผ่าน mongoose ซึ่งเป็น ORM Library ของ mongodb อีกที การเรียนรู้การใช้ aggregation pipeline จะทำให้เราเข้าใจการทำงานของ mongodb ได้มากขึ้น

ทดลองการทำ aggregation pipeline ใน MongoDB

เราจะเริ่มจาก Pipeline ที่มีการใช้งานแบบง่ายๆ ก่อน ด้วยการ Lookup หรือ join ข้อมูลจาก Collection อื่น ๆ มาแสดงผล แบบธรรมดา ไม่มี Array

การเตรียมโปรแกรม

  1. เราจะเพิ่ม Field ของ ผู้สร้าง Order ใน Order โดยลิงค์มาจาก User โดยใช้ Field ชื่อ user ใน Order เราจะไปเพิ่มในโมเดลของ Order
const mongoose = require('mongoose')

const Order = new mongoose.Schema(
  {
    date: Date,
    products: [
      {
        product: { type: mongoose.Types.ObjectId, ref: 'Product' },
        quantity: Number,
      },
    ],
    user: { type: mongoose.Types.ObjectId, ref: 'User' },
  },
  {
    timestamps: true,
  }
)

module.exports = mongoose.model('Order', Order)
  1. ทำการแก้ไข Order หรือ สร้าง Order ใหม่ให้มี user ด้วย (จะทำบน UI ให้สมจริงก็ได้ แต่ในบทความนี้เราจะใช้ Postman ในการทดสอบ)

Order เดิม Order เดิม

ทำการแก้ไข image

ได้ออเดอร์ที่มี User อยู่ด้วย image

เริ่มการทำ aggregation pipeline อย่างง่าย

โดย Pipeline แรกที่เราจะทำนั้นคือ Pipeline $lookup ซึ่งเป็นการ join ข้อมูลจาก Collection อื่น ๆ มาแสดงผล ในที่นี้เราจะ join ข้อมูลของ User มาแสดงผลใน Order โดยใช้ Field user ใน Order และ _id ใน User

Pipeline ทั้งหมดของ Mongodb ที่ใช้งานได้ สามารถดูได้ที่ Aggregate Pipeline Official Reference

  1. เข้าไปใน ไฟล์ order.routes.js ในโฟลเดอร์ routes และเพิ่ม Pipeline เข้าไป โดยสร้างเป็นตัวแปรใหม่ขึ้นมาด้านบนของไฟล์
const pipeline = [
  {
    $lookup: {
      from: 'users',
      localField: 'user',
      foreignField: '_id',
      as: 'user',
    },
  },
]

ที่ใช้อยู่นั้นคือ Pipeline Lookup ซึ่งจะเป็น Lookup แบบง่าย มันจะมีการกำหนดอยู่ว่า forms คือ Collection ที่เราจะ join มา และ localField คือ Field ของ Collection ที่เราจะ join มา และ foreignField คือ Field ของ Collection ที่เราจะ join มา และ as คือ Field ที่จะเก็บข้อมูลที่ join มา โดยชื่อ collections ใน form จะต้องเป็นพหูพจน์เสมอ (เติม s หรือ es)

  1. ทำการเพิ่ม Pipeline ที่เราสร้างขึ้นมาในการ Query ข้อมูล แทนที่เราจะใช้ Order.find().populate('user') และเปลี่ยนเป็นการใช้ Order.aggregate(pipeline) ลองรันแล้วดูผลลัพธ์ที่ได้
router.get('/', async (req, res) => {
  try {
    const orders = await Order.aggregate(pipeline)
    return res.json(orders)
  } catch (error) {
    return res.status(500).json({ error: error.message })
  }
})

image

  1. เราจะเห็นว่าข้อมูลของ User ถูก join มาแล้ว แต่ข้อมูลของ User ถูกเก็บไว้ใน Array ทำให้เราต้องเข้าไปเลือกข้อมูลใน Array อีกที ซึ่งเราจะทำการแก้ไข Pipeline ให้เป็นแบบนี้
const pipeline = [
  {
    $lookup: {
      from: 'users',
      localField: 'user',
      foreignField: '_id',
      as: 'user',
    },
  },
  {
    $set: {
      user: { $arrayElemAt: ['$user', 0] },
    },
  },
]

สำหรับการ set มีความหมายตามตัว คือ เราจะ set ค่าของ user ให้เป็นค่าของ user ใน Array ที่เรา join มา โดยใช้ $arrayElemAt ซึ่งเป็นการเลือกข้อมูลใน Array โดยใช้ Index ของ Array นั้น ๆ ในที่นี้เราใช้ 0 คือ Index แรก และเราจะได้ผลลัพธ์ที่ดีขึ้น

  1. เพื่อให้ได้ข้อมูลอยู่ใน Rows เหมือนเดิม เราจะปรับการ Return ออกเป็น
router.get('/', async (req, res) => {
  try {
    const orders = await Order.aggregate(pipeline)
    return res.json({ rows: orders })
  } catch (error) {
    return res.status(500).json({ error: error.message })
  }
})

จะเห็นว่าเราก็จะสามารถ Lookup ข้อมูลในเบื้องต้นได้แล้ว

การใช้ Lookup ที่ซับซ้อนขึ้น

เมื่อสักครู่ได้มีการทำ Lookup แบบธรรมดาไปแล้ว แต่เรายังมีอีกอย่างที่เรายังไม่ได้ Lookup นั่นก็คือ การ Lookup ข้อมูลที่มี Array อยู่ ซึ่งในที่นี้เราจะทำการ Lookup ข้อมูลของ Product ที่อยู่ใน Array ของ Order โดยใช้ Field products ใน Order และ _id ใน Product

  1. เราจะเพิ่มการ Lookup ข้อมูลของ Product ทั้ง Array ออกมาก่อน
const pipeline = [
  {
    $lookup: {
      from: 'users',
      localField: 'user',
      foreignField: '_id',
      as: 'user',
    },
  },
  {
    $set: {
      user: { $arrayElemAt: ['$user', 0] },
    },
  },
  {
    $lookup: {
      from: 'products',
      localField: 'products.product',
      foreignField: '_id',
      as: 'product_array',
    },
  },
]

เนื่องจากเราเก็บ id ไว้ที่ products.product เราเลยต้องไป Lookup จากตรงนั้น โดยเราจะไม่ไปวางไว้ที่ products เพราะมันจะทับของเดิม แต่เราไปวางที่ใหม่แทน ตั้งชื่อว่า product_array ลองรันโปรแกรมดู จะเห็นว่าค่าที่ได้ยังใช้งานไม่ได้ เพราะว่า product_array ตัวใหม่นี้ ไม่ได้มี field ของ quantity อยู่ด้วย

image ดังนั้นเราจะทำการ Merge สิ่งที่ได้มากับ products เดิมให้เข้าด้วยกัน

  1. เราจะทำการ Merge ข้อมูลของ product_array กับ products โดยใช้ $map และ $mergeObjects โดยการ Map คือการวนรอบข้อมูลใน Array และ MergeObjects คือการ Merge ข้อมูลของ Object ทั้ง 2 ออกมาเป็น Object ใหม่ โดยในที่นี้เราจะ Map เข้าไปใน โดย Map ไม่ใช่ Pipeline แต่เป็น Operator ของ Aggregation และเราจะใช้ $map ต่อจาก Lookup เดิมไม่ได้ เราจะต้องใช้ การ $addFields เพื่อเพิ่ม Field ใหม่ แต่ใช้ชื่อเดิม แล้ว field นั้นแทนที่ใช้ค่าเดิม ก็ไปใช้ค่าที่ได้จาก map แทน
const pipeline = [
  {
    $lookup: {
      from: 'users',
      localField: 'user',
      foreignField: '_id',
      as: 'user',
    },
  },
  {
    $set: {
      user: { $arrayElemAt: ['$user', 0] },
    },
  },
  {
    $lookup: {
      from: 'products',
      localField: 'products.product',
      foreignField: '_id',
      as: 'product_array',
    },
  },
  {
    $addFields: {
      products: {
        $map: {
          input: '$products',
          as: 'inside_product',
          in: 1,
        },
      },
    },
  },
]

หลักการตรงนี้คือ เราจะวนใน products โดยระหว่างการวน product แต่ละตัวข้างในจะเป็น inside_product และเราจะใส่ผลเข้าไปข้างใน in

image

จะเห็นว่าข้างใน products เราจะเป็น Array ของเลข 1 หมดเลย เพราะเราใส่ in เป็น 1 แต่เราไม่ได้ต้องการให้เป็นอย่างนั้น

  1. ในการใส่ข้อมูลเข้าไปใน in เราจะใช้ $mergeObjects โดยเราจะ Merge ข้อมูลของ inside_product กับ product_array โดยใช้ _id เป็น Key และเราจะได้ Pipeline ดังนี้
    {
    $addFields: {
      products: {
        $map: {
          input: '$products',
          as: 'inside_product',
          in: {
              $mergeObjects: [
              "$$inside_product",
              {
                product: {
                  $arrayElemAt: [
                    "$product_array",
                    {
                      $indexOfArray: [
                        "$product_array._id",
                        "$$inside_product.product",
                      ],
                    },
                  ],
                },
              },
            ],
          },
        },
      },
    },
  },

เราจะใช้ mergeObject เป็นการ Merge ระหว่าง 2 Objects นั่นก็คือ $$inside_product ที่เป็นค่าเดิม กับข้อมูลของ product ที่เรา Lookup มาใน array ของ product array

โดยเราจะใช้ $arrayElemAt เพื่อเลือกข้อมูลใน Array ที่ Element ใด Element หนึ่ง โดยใช้ $indexOfArray เพื่อหา Index ของข้อมูลใน Array นั้น ๆ โดยใช้ _id เป็น Key และเราจะได้ข้อมูลที่ Merge แล้ว

ท้ายที่สุดผลจะออกมาเป็นแบบนี้

image

  1. เนื่องจากเราไม่ได้ใช้งานข้อมูลใน product_array เราจะใช้ pipeline อีกตัวหนึ่งขื่อ $project เพื่อลบ Field ที่เราไม่ต้องการออกไป
    {
    $project: {
      product_array: 0,
    },
  },
Pipeline ทั้งหมด
const pipeline = [
  {
    $lookup: {
      from: 'users',
      localField: 'user',
      foreignField: '_id',
      as: 'user',
    },
  },
  {
    $set: {
      user: { $arrayElemAt: ['$user', 0] },
    },
  },
  {
    $lookup: {
      from: 'products',
      localField: 'products.product',
      foreignField: '_id',
      as: 'product_array',
    },
  },
  {
    $addFields: {
      products: {
        $map: {
          input: '$products',
          as: 'inside_product',
          in: {
            $mergeObjects: [
              '$$inside_product',
              {
                product: {
                  $arrayElemAt: [
                    '$product_array',
                    {
                      $indexOfArray: [
                        '$product_array._id',
                        '$$inside_product.product',
                      ],
                    },
                  ],
                },
              },
            ],
          },
        },
      },
    },
  },
  {
    $project: {
      product_array: 0,
    },
  },
]

ก็จะเป็นการทำ Pipeline เบื้องต้น

การทำ Pipeline สำหรับการ Find One

หลังจากที่เราทำ Pipeline สำหรัย Find All มาแล้ว เราจะมาสร้าง Pipeline สำหรับ Find One

  1. สร้าง findOnePipeline ขึ้นมา เราจะใช้ pipeline ที่ชื่อว่า $match เพื่อใช้ในการกรองเฉพาะตัวที่ ID เหมือนกัน
const findOnePipeline = (id) => [
  {
    $match: {
      _id: new mongoose.Types.ObjectId(id),
    },
  },
]

จะเห็นว่าเราเขียนต่างกับ pipeline ด้านบน เนื่องจากเราจะทำเป็น function (arrow function) ไม่ได้เขียนเป็นค่าคงที่แบบ pipeline ด้านบน แล้วมีการส่งค่า id มาด้วย แล้วใช้ $match ซึ่งมีความหมายตามตัวคือใช้ในการกรองข้อมูลที่ตรงกับเงื่อนไขที่กำหนด เช่นในข้อนี้คือ กรองที่ _id เท่ากับ id ที่ส่งมา แต่เราต้องทำให้มันอยู่ใน type ของ Object ID ด้วย

  1. ปรับการ findOne จากการใช้ Order.findById().populate('user').populate('products.product') ให้เป็นการใช้ Order.aggregate(findOnePipeline())
router.get('/:id', async (req, res) => {
  console.log('Find All Order')
  try {
    const result = await Order.aggregate(findOnePipeline(req?.params?.id))
    res.json(result)
  } catch (error) {
    res.status(404).json({ err: error })
  }
})

อย่าลืม import mongoose ข้างบนของไฟล์

const mongoose = require('mongoose')

image

  1. ลองเรียกใช้ Route ของ Find One จะพบว่าข้อมูลจะออกมา แต่ออกมาในรูปของ Array โดยมีแค่ Element เดียว ดงันั้น เราจะเปลี่ยนใหม่ให้ return เฉพาะ array ที่ 0
router.get('/:id', async (req, res) => {
  console.log('Find All Order')
  try {
    const result = await Order.aggregate(findOnePipeline(req?.params?.id))
    res.json(result?.[0])
  } catch (error) {
    res.status(404).json({ err: error })
  }
})
  1. จะเห็นว่า Pipeline สำหรับ Find One ก็ทำงานได้แล้ว แต่ยังไม่ได้มีข้อมูลที่เรา Lookup มา ดังนั้นเราจะต่อ Pipeline สำหรับ Findone นี้ กับ pipeline เดิม โดยการ Spread Array
const findOnePipeline = (id) => [
  {
    $match: {
      _id: new mongoose.Types.ObjectId(id),
    },
  },
  ...pipeline,
]

image

ในที่สุด เราก็จะได้ Pipeline ที่สามารถใช้งานได้ทั้ง Find All และ Find One แล้ว

สรุป

ในหน้านี้เราได้เรียนรู้เกี่ยวกับการใช้ aggregation pipeline ใน MongoDB เพื่อทำการ join ข้อมูลและจัดการข้อมูลในรูปแบบที่หลากหลายมากขึ้น โดยเราได้เรียนรู้การใช้งานของ Lookup ในการ join ข้อมูลจาก Collection อื่น ๆ และการใช้งานของ Map และ MergeObjects เพื่อจัดการข้อมูลใน Array ของข้อมูล นอกจากนี้ยังมีการสรุป Pipeline ทั้งหมดที่ใช้ในหน้านี้เพื่อให้เข้าใจง่ายขึ้น ท้ายที่สุดเราได้สรุปว่าการใช้ aggregation pipeline จะช่วยให้เราสามารถจัดการข้อมูลได้หลากหลายมากขึ้น และเข้าใจการทำงานของ MongoDB ได้มากขึ้น

AI Generated Summary

General

Project 1 Familiar with React

Project 2 Tailwind CSS, Component and Layouting

Project 3 useState, useEffect, React Hook and API Call

Project 4 Basic Express, API and Database Access

Project 5 Frontend Backend Integration

Project 6 Routing

Project 7 Search with react

Project 8 Pushing the Array

Project 9 More complex data structure

Project 10 Mongo Aggregate Pipeline

Project 11 Create Pagination and Search

Clone this wiki locally