รู้จักกับ ORM ตัวช่วย query ฐานข้อมูลกัน
[ สามารถดู 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:
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 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);
}
});