รู้จักกับ ORM ตัวช่วย query ฐานข้อมูลกัน
/ 7 min read
สามารถดู video ของหัวข้อนี้ก่อนได้ ดู video
ORM คืออะไร ?
ORM ย่อมาจาก Object-Relational Mapping เป็น technique อย่างหนึ่งในการเปลี่ยนการสื่อสารระหว่าง Relational database จากแต่เดิมที่อยู่ในรูปแบบของ SQL Query มาเป็น Object แทน โดยการมองตารางออกมาเป็น object ที่สามารถ insert, update, delete และ get data ออกมาได้ (เป็นตัวกลางในการแปลงเป็น SQL ออกมาแทน)
ภาพจาก: https://medium.com/@emccul13/object-relational-mapping-9d84807f5536
ซึ่งข้อดีของพวก ORM คือ
- Abstraction ทุกคนในทีมสามารถทำงานผ่าน Object แทนได้ (แทนที่จะต้องมาปวดหัวกับ SQL แทน) ซึ่งจะช่วยทำให้ code อ่านง่ายขึ้นมาก
- Database Agnostic สามารถเปลี่ยนไปมาระหว่าง SQL Database ในหลายๆประเภทได้ (ตามที่ ORM support) โดยการปรับเพียงแค่ config เล็กน้อย
- Maintainability ง่ายต่อการอ่าน code มากขึ้น ทำให้ maintain และเข้าใจได้ง่ายขึ้น
- Security ORM ทุกตัวส่วนใหญ่จะทำการเพิ่มการป้องกันผ่านการโจมตี SQL injection ไว้แล้ว ทำให้เราไม่ต้องกังวลกับเรื่องนี้
จุดพิจารณา
- Performance ที่ถ้าเกิดเขียนไม่ดี อาจจะเสีย query ที่เปลืองกว่าการไม่ใช้ ORM ได้ (โดยเฉพาะพวก JOIN และ GROUP query)
- Learning Curve ต้องมาเรียนรู้ ORM Tools เพิ่ม (จากแต่เดิมที่รู้แค่ SQL ก็ทำได้เลย)
ซึ่งในหัวข้อนี้ เราจะมาลองใช้ Sequelize Node.js ORM ที่ support พวก relational database หลากหลายตัวมากอย่าง PostgreSQL, MySQL, SQLite, MSSQL และสามารถใช้ได้ทั้ง Javascript และ Typescript ด้วย
เราจะมาลองกันผ่าน Session นี้กัน
setting project
- สำหรับ project นี้เราจะ setup docker 2 ตัวคือ mysql และ phpmyadmin ไว้ก่อน (เดี๋ยวเราจะมีเพิ่มบางอย่างตามมาทีหลัง)
docker-compose.yml
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 มีความแตกต่างกันยังไงบ้าง
{ "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)
config เริ่มต้น ที่ index.js โดยเราจะ import package มาทั้ง 2 ตัวเลยคือ
mysql2
ท่าที่เราใช้ SQL querysequelize
ท่าที่ใช้ ORM (เป็นตัวแทนของ SQL query)
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 mysqlconst initMySQL = async () => { conn = await mysql.createConnection({ host: "localhost", user: "root", password: "root", database: "tutorial", });};
// use sequenlizeconst sequelize = new Sequelize("tutorial", "root", "root", { host: "localhost", dialect: "mysql",});
/* เราจะเพิ่ม code ส่วนนี้กัน */
// Listenapp.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
โดยเป็นตัวแทนของ tableusers
- สร้าง Model
Addresses
โดยเป็นตัวแทนของ tableaddresses
- และทำการบอก relation ระหว่าง
Users
และAddresses
// ทั้งสร้างและ validationconst 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
// Listenapp.listen(port, async () => { await initMySQL(); await sequelize.sync(); // ปลด comment บรรทัดนี้ออก
console.log("Server started at port 8000");});
ผลลัพธ์จาก phpmyadmin
มาลองทำ CRUD ผ่าน ORM กัน
เรามาทำ API ทั้ง 5 เส้นนี้กันคือ
GET /users
สำหรับ get users ทั้งหมดที่บันทึกเข้าไปออกมาPOST /users
สำหรับการสร้าง users ใหม่บันทึกเข้าไปGET /users/:id/address
สำหรับการ get address ทั้งหมด รายคนออกมาPUT /users/:id
สำหรับการแก้ไข users รายคนและเพิ่ม address เข้าไปDELETE /users/:id
สำหรับการลบ users รายคน (ตาม id ที่บันทึกเข้าไป)
1. GET /users
สำหรับ get users ทั้งหมดที่บันทึกเข้าไปออกมา
- เปลี่ยนจากการใช้ query select ตรงๆเป็นทำผ่าน model
User
(ที่เป็นตัวแทนของ table user ออกมาแทน) - ผ่านคำสั่ง
User.findAll()
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)
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
เข้ามา
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
เป็นค่าใหม่เข้าไป
- ถ้ามีอยู่แล้ว =
Ref: https://sqlperformance.com/2020/09/locking/upsert-anti-pattern
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 แทน
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 แทน
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_PASSWORD: adminpassword ports: - "5050:80" depends_on: - postgres volumes: - pgadmin_data:/var/lib/pgadmin
volumes: postgres_data: pgadmin_data:
ที่ express ลองเปลี่ยนมาต่อเป็น sequelize ก็จะได้ผลลัพธ์เหมือนเดิมออกมาได้ผ่านการยิง API
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)

Github source code
https://github.com/mikelopster/orm-express-example
- NestJS และ Mongoมี Video
เรียนรู้การผสานพลังระหว่าง NestJS framework ยอดนิยมฝั่ง Node.js กับ MongoDB ฐานข้อมูล NoSQL สุดทรงพลังกัน
- มาเรียนรู้การทำ Frontend Testing ผ่าน React กันมี Video
แนะนำพื้นฐานการทำ Component Testing สำหรับการทำ Unit testing ฝั่ง Frontend
- รู้จักกับ Kafka distribution system สำหรับ Realtime กันมี Video มี Github
มาทำความรู้จัก Kafka กันว่า Kafka มันคืออะไร ใช้ทำอะไรบ้าง มี use case แบบไหน และลองมาละเลงผ่าน code กัน
- Rabbit MQ และการใช้ Message Queueมี Video
มาทำความรู้จักกับ Message Queue ว่ามันคืออะไร มีหลักการยังไงบ้าง และมาลองเล่นกันผ่าน software อย่าง RabbitMQ กัน