keyboard_tabSkip to content
arrow_back All Posts

Sep 13, 2023

รู้จักกับ ORM ตัวช่วย query ฐานข้อมูลกัน

พามาลองเล่นตัว ORM หรือ Object Relational Mapping เทคนิคสำหรับการคุยกับ Database ผ่าน Object กัน

สารบัญ

orm-express สามารถดู video ของหัวข้อนี้ก่อนได้ ดู video

ORM คืออะไร ?

ORM ย่อมาจาก Object-Relational Mapping เป็น technique อย่างหนึ่งในการเปลี่ยนการสื่อสารระหว่าง Relational database จากแต่เดิมที่อยู่ในรูปแบบของ SQL Query มาเป็น Object แทน โดยการมองตารางออกมาเป็น object ที่สามารถ insert, update, delete และ get data ออกมาได้ (เป็นตัวกลางในการแปลงเป็น SQL ออกมาแทน)

orm-image ภาพจาก: https://medium.com/@emccul13/object-relational-mapping-9d84807f5536

ซึ่งข้อดีของพวก ORM คือ

  1. Abstraction ทุกคนในทีมสามารถทำงานผ่าน Object แทนได้ (แทนที่จะต้องมาปวดหัวกับ SQL แทน) ซึ่งจะช่วยทำให้ code อ่านง่ายขึ้นมาก
  2. Database Agnostic สามารถเปลี่ยนไปมาระหว่าง SQL Database ในหลายๆประเภทได้ (ตามที่ ORM support) โดยการปรับเพียงแค่ config เล็กน้อย
  3. Maintainability ง่ายต่อการอ่าน code มากขึ้น ทำให้ maintain และเข้าใจได้ง่ายขึ้น
  4. Security ORM ทุกตัวส่วนใหญ่จะทำการเพิ่มการป้องกันผ่านการโจมตี SQL injection ไว้แล้ว ทำให้เราไม่ต้องกังวลกับเรื่องนี้

จุดพิจารณา

  1. Performance ที่ถ้าเกิดเขียนไม่ดี อาจจะเสีย query ที่เปลืองกว่าการไม่ใช้ ORM ได้ (โดยเฉพาะพวก JOIN และ GROUP query)
  2. Learning Curve ต้องมาเรียนรู้ ORM Tools เพิ่ม (จากแต่เดิมที่รู้แค่ SQL ก็ทำได้เลย)

ซึ่งในหัวข้อนี้ เราจะมาลองใช้ Sequelize Node.js ORM ที่ support พวก relational database หลากหลายตัวมากอย่าง PostgreSQL, MySQL, SQLite, MSSQL และสามารถใช้ได้ทั้ง Javascript และ Typescript ด้วย

https://sequelize.org/

เราจะมาลองกันผ่าน Session นี้กัน

setting project

  • สำหรับ project นี้เราจะ setup docker 2 ตัวคือ mysql และ phpmyadmin ไว้ก่อน (เดี๋ยวเราจะมีเพิ่มบางอย่างตามมาทีหลัง)

docker-compose.yml

plain
version: "3.7"

services:
  db:
    image: mysql:latest
    container_name: mysql_db
    command: --default-authentication-plugin=mysql_native_password
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: tutorial
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
    networks:
      - my_network

  phpmyadmin:
    image: phpmyadmin/phpmyadmin:latest
    container_name: phpmyadmin
    environment:
      PMA_HOST: db
      PMA_PORT: 3306
      PMA_USER: root
      PMA_PASSWORD: root
    ports:
      - "8080:80"
    depends_on:
      - db
    networks:
      - my_network

networks:
  my_network:
    driver: bridge

volumes:
  mysql_data:
    driver: local

Structure project จะเป็นตามนี้

├── docker-compose.yml
├── index.js -- ไฟล์หลักที่เราจะทำกัน
├── package.json

ที่ package.json จะทำการลง package mysql2 และ sequelize เอาไว้เพื่อให้เห็นภาพว่า หากใช้ท่า ORM และ SQL query มีความแตกต่างกันยังไงบ้าง

json
{
  "name": "orm-express-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "python3 -m http.server --directory src 8888",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.18.2",
    "mysql2": "^3.6.0",
    "sequelize": "^6.32.1"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  }
}

สุดท้าย ที่ index.js เดี๋ยวเราจะกลับมาเพิ่มกันหลังเราทำความเข้าใจโจทย์กันแล้ว

โจทย์ของหัวข้อนี้

ขอ reference กลับไปยัง หัวข้อที่ 10 ใน web development 101 link นี้

  • ในหัวข้อนี้เราจะมีการใช้ SQL command ทั้งหมด ตั้งแต่ CREATE, VIEW, UPDATE, DELETE

เราจะมาลองปรับกันโดย

  • เราจะปรับทุก API มาใช้ ORM แทน

และเราจะลองเพิ่มการ relation database เข้ามาโดยเพิ่มตาราง Address สำหรับเก็บ address ของ users คู่กันไว้ผ่าน userId แทน (เป็น Foreign Key)

Loading diagram…

config เริ่มต้น ที่ index.js โดยเราจะ import package มาทั้ง 2 ตัวเลยคือ

  • mysql2 ท่าที่เราใช้ SQL query
  • sequelize ท่าที่ใช้ ORM (เป็นตัวแทนของ SQL query)
javascript
const express = require("express");
const mysql = require("mysql2/promise");
const { Sequelize, DataTypes } = require("sequelize");

const app = express();
app.use(express.json());

const port = 8000;

let conn = null;

// function init connection mysql
const initMySQL = async () => {
  conn = await mysql.createConnection({
    host: "localhost",
    user: "root",
    password: "root",
    database: "tutorial",
  });
};

// use sequenlize
const sequelize = new Sequelize("tutorial", "root", "root", {
  host: "localhost",
  dialect: "mysql",
});

/* เราจะเพิ่ม code ส่วนนี้กัน */

// Listen
app.listen(port, async () => {
  await initMySQL();
  // await sequelize.sync()

  console.log("Server started at port 8000");
});

เริ่มต้น set schema ก่อน

จาก Database design ด้านบน เราจะทำการสร้าง table ผ่าน sequealize (เพื่อให้ sequealize เก็บ model เป็น version ไว้ได้)

  • สร้าง Model Users โดยเป็นตัวแทนของ table users
  • สร้าง Model Addresses โดยเป็นตัวแทนของ table addresses
  • และทำการบอก relation ระหว่าง Users และ Addresses
javascript
// ทั้งสร้างและ validation
const User = sequelize.define(
  "users",
  {
    name: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    email: {
      type: DataTypes.STRING,
      allowNull: false,
      unique: true,
      valipublishDate: {
        isEmail: true,
      },
    },
  },
  {},
);

// เพิ่ม table address เข้ามา
const Address = sequelize.define(
  "addresses",
  {
    address1: {
      type: DataTypes.STRING,
      allowNull: false,
    },
  },
  {},
);

// ประกาศ relation แบบปกติ
// User.hasMany(Address)

// relation แบบผูกติด (จะสามารถลบไปพร้อมกันได้) = พิจารณาเป็น case by case ไป
User.hasMany(Address, { onDelete: "CASCADE" });

Address.belongsTo(User);

เมื่อเราเพิ่ม Schema เสร็จให้ปลด comment ตรง sequelize.sync() ออก เมื่อเราปลดเสร็จและลอง run ดู เราจะเจอตารางที่สร้างเสร็จเรียบร้อยใน phpmyadmin

javascript
// Listen
app.listen(port, async () => {
  await initMySQL();
  await sequelize.sync(); // ปลด comment บรรทัดนี้ออก

  console.log("Server started at port 8000");
});

ผลลัพธ์จาก phpmyadmin phpmyadmin-example

มาลองทำ CRUD ผ่าน ORM กัน

เรามาทำ API ทั้ง 5 เส้นนี้กันคือ

  1. GET /users สำหรับ get users ทั้งหมดที่บันทึกเข้าไปออกมา
  2. POST /users สำหรับการสร้าง users ใหม่บันทึกเข้าไป
  3. GET /users/:id/address สำหรับการ get address ทั้งหมด รายคนออกมา
  4. PUT /users/:id สำหรับการแก้ไข users รายคนและเพิ่ม address เข้าไป
  5. DELETE /users/:id สำหรับการลบ users รายคน (ตาม id ที่บันทึกเข้าไป)

1. GET /users สำหรับ get users ทั้งหมดที่บันทึกเข้าไปออกมา

  • เปลี่ยนจากการใช้ query select ตรงๆเป็นทำผ่าน model User (ที่เป็นตัวแทนของ table user ออกมาแทน)
  • ผ่านคำสั่ง User.findAll()
javascript
app.get("/api/users", async (req, res) => {
  try {
    // แบบ Query แบบเก่า
    // const [result] = await conn.query('SELECT * from users')

    // query ผ่าน model แทน
    const users = await User.findAll();
    res.json(users);
  } catch (err) {
    console.error(err);
  }
});

2. POST /users สำหรับการสร้าง users ใหม่บันทึกเข้าไป

  • ใช้คำสั่ง User.create ทำการ insert ข้อมูลเข้าไปได้เลย (จะเหมือนกับ SQL INSERT)
javascript
app.post("/api/users", async (req, res) => {
  try {
    const data = req.body;
    // แบบ Query แบบเก่า
    // const [result] = await conn.query('INSERT INTO users SET ?', data)

    // ท่า Model
    const users = await User.create(data);
    res.json(users);
  } catch (err) {
    console.error(err);
    res.json({
      message: "something went wrong",
      error: err.errors.map((e) => e.message),
    });
  }
});

3. GET /users/:id/address สำหรับการ get address ทั้งหมด รายคนออกมา

  • ใช้คำสั่ง findAll หรือ findOne ในการค้นหาโดยใช้ { where: ... } เข้ามาเป็น condition เพิ่ม
  • และใช้ { include: ... } เข้ามาสำหรับทำ JOIN table (ดึง table ที่เป็น relation มา)
  • หากอย่าง breakdown แต่ละอันออกมา (เป็นเหมือน LEFT JOIN) ให้ใส่ raw: true เข้ามา
javascript
app.get("/api/users/:id/address", async (req, res) => {
  try {
    const userId = req.params.id;
    // แบบ Query แบบเก่า
    // const [result] = await conn.query('SELECT users.*, addresses.address1 FROM users LEFT JOIN addresses on users.id = addresses.userId WHERE users.id = ?', userId)

    const result = await User.findAll({
      where: { id: userId },
      include: {
        model: Address,
      },
      raw: true,
    });

    res.json(result);
  } catch (err) {
    console.error(err);
    res.json(err);
  }
});

4. PUT /users/:id สำหรับการแก้ไข users รายคน (ตาม id ที่บันทึกเข้าไป) และเพิ่ม address เข้าไป (ถ้ามี field address นั้น)

  • ใช้ท่า upsert ของ SQL สำหรับการใส่ข้อมูล
  • upsert คือท่าที่จะเช็ค primary key ของข้อมูลก่อน insert เข้าไปว่า
    • ถ้ามีอยู่แล้ว = update กลับผ่าน primary key ตัวเดิม
    • ถ้าไม่มี = insert เป็นค่าใหม่เข้าไป

upsert Ref: https://sqlperformance.com/2020/09/locking/upsert-anti-pattern

javascript
app.put("/api/users/:id", async (req, res) => {
  try {
    const data = req.body;
    const userId = req.params.id;
    // แบบ Query แบบเก่า
    //  const result = await conn.query('UPDATE users SET ? WHERE id = ?', [data, id])

    // ท่า Model
    const users = await User.update(
      {
        name: data.name,
        email: data.email,
      },
      {
        where: { id: userId },
      },
    );

    // ใช้ upsert (insert or update) สำหรับการใส่ข้อมูล
    for (let i = 0; i < data.addresses.length; i++) {
      let cAddressData = data.addresses[i][i];
      cAddressData.userId = userId;
      const address = await Address.upsert(cAddressData);
    }

    // [2] ใช้ if, else แยกเคส ซึ่ง upsert มีค่าเทียบเท่ากับท่าล่างนี้
    // for (let i = 0; i < data.addresses.length; i++) {
    //   const updateAddress = data.addresses[i]
    //   const updateObj = {}

    //   if (updateAddress.id) {
    //     await Address.update({
    //       address1: updateAddress.address1
    //     }, {
    //       where: {
    //         id: updateAddress.id,
    //         userId // userId: userId
    //       }
    //     })
    //   } else {
    //     await Address.create({
    //       ...updateAddress,
    //       userId
    //     })
    //   }
    // }

    res.json({
      message: "update complete!",
      users,
    });
  } catch (err) {
    console.error(err);
    res.json({
      message: "something went wrong",
      error: err.errors.map((e) => e.message),
    });
  }
});

5. DELETE /users/:id สำหรับการลบ users รายคน (ตาม id ที่บันทึกเข้าไป)

  • ใช้คำสั่ง User.destroy ในการลบข้อมูลได้
  • หาก schema ด้านบน เป็น User.hasMany(Address, { onDelete: 'CASCADE' }) = ตอนลบข้อมูลออกจาก User, Address (ที่มี relation กับ User ผ่าน userId) หากข้อมูลใดมี userId เดียวกัน = โดนลบด้วย (จะลบคู่กันไปเลย)
  • หากไม่ใส่ = ข้อมูลไม่หาย และ userId ใน Address จะเป็น null แทน
javascript
app.delete("/api/users/:id", async (req, res) => {
  try {
    const userId = req.params.id;
    // แบบ Query แบบเก่า
    // const [result] = await conn.query('DELETE FROM users WHERE id = ?', [id])

    // query ผ่าน model แทน
    const result = await User.destroy({
      where: { id: userId },
    });

    res.json({
      message: "delete successful",
      result,
    });
  } catch (err) {
    console.error(err);
    res.json(err);
  }
});

เปลี่ยนมาลองกับ PostgresSQL

ข้อดีของการใช้ ORM อีกอย่างหนึ่งคือ มันเป็นตัวกลางที่สามารถเปลี่ยน Database เป็นตัวอื่นก็ได้ (ไม่จำเป็นต้องเป็น MySQL อย่างเดียว) เช่นเคสนี้ เราจะลองเอามาต่อกับ PostgresSQL แทน

plain
version: "3"

services:
  postgres:
    image: postgres:latest
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: mydatabase
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

  pgadmin:
    image: dpage/pgadmin4:latest
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@example.com
      PGADMIN_DEFAULT_PASSWORD: adminpassword
    ports:
      - "5050:80"
    depends_on:
      - postgres
    volumes:
      - pgadmin_data:/var/lib/pgadmin

volumes:
  postgres_data:
  pgadmin_data:

ที่ express ลองเปลี่ยนมาต่อเป็น sequelize ก็จะได้ผลลัพธ์เหมือนเดิมออกมาได้ผ่านการยิง API

javascript
const sequelize = new Sequelize("mydatabase", "user", "password", {
  host: "localhost",
  dialect: "postgres",
  logging: false, // Set true if you want to see SQL query logs
});

ผลลัพธ์จากการลองสร้าง user (เปิดผ่าน pgadmin)

image-01

Github source code

https://github.com/mikelopster/orm-express-example

forumComments (0)

lockLog in to join the conversation.

Log In

Loading comments…