Sep 13, 2023
รู้จักกับ ORM ตัวช่วย query ฐานข้อมูลกัน
พามาลองเล่นตัว ORM หรือ Object Relational Mapping เทคนิคสำหรับการคุยกับ Database ผ่าน Object กัน
สารบัญ
สามารถดู 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: localStructure 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 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โดยเป็นตัวแทนของ tableusers - สร้าง Model
Addressesโดยเป็นตัวแทนของ tableaddresses - และทำการบอก relation ระหว่าง
UsersและAddresses
// ทั้งสร้างและ 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
// Listen
app.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_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
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)

