總整理與實作練習 : MySQL Trigger/Stored Function/Stored Procedure

MySQL 提供了數種進階功能來擴展資料庫的邏輯處理能力,其中 Trigger(觸發器)、Stored Function(儲存函式) 與 Stored Procedure(儲存程序) 是三種常用的工具,分別用於不同的情境,讓資料庫更具靈活性與自動化能力。

關於Trigger (觸發器)、Stored Function (預存函數)、Stored Procedure (預存程序),以下是之前的文章,可以先參考一下 : 

實作練習:MySQL Trigger 觸發器是什麼?如何使用?
實作 : 練習SELECT、INSERT INTO、UPDATE、DELETE FROM以及Trigger的使用
(範例) MySQL Stored Procedure/Stored Function/Trigger
實作練習 : Stored Procedure + Stored Function + Trigger 的錯誤處理
實作 : 工讀生薪資管理系統
實作練習 : PHP + Stored Procedure + Stored Function + Trigger
實作練習 : PHP + Stored Procedure + Stored Function + Trigger (二)
實作練習 : PHP + Stored Procedure + Stored Function + Trigger (三)


Trigger (觸發器) : 

觸發器是在資料庫表格上的程式,在事件發生(對資料列做新增/修改/刪除)時,資料庫會依照觸發條件(事件前/事件後)幫你執行預先儲存好的程式。因此觸發器建立之後,不需要呼叫,而是靠事件來觸發。


關於觸發器,需要知道以下幾個重點 : 

(1) 觸發器作用的資料表,不要於觸發器內對該資料表再使用UPDATE/INSERT/DELETE。
(2) 觸發器只能在特定的表上生效,無法跨表生效。
(3) 每個表格的同一操作只能有一個BEFORE或一個AFTER觸發器。
(4) 觸發器不能再觸發另一個觸發器。
(5) 觸發器中不能執行動態SQL(即使用PREPARE和EXECUTE語句)。
(6) 要避免在觸發器中執行耗時的操作,以免影響系統性能。
(7) InnoDB支援Transaction及行級鎖(Row-level Lock),比其他儲存引擎能夠維持資料一致性。
(8) 觸發器執行時不會返回結果,只能執行 SQL 邏輯。


建立Trigger的語法 

CREATE TRIGGER trigger_name
{BEFORE | AFTER} {INSERT | UPDATE | DELETE} ON table_name
FOR EACH ROW
BEGIN
   -- 觸發器執行的SQL語句
   statement1;
   statement2;
   statement3;
END; 

trigger_name:觸發器的名稱。
BEFORE | AFTER:指定觸發器是在操作前還是操作後執行。
INSERT | UPDATE | DELETE:指定觸發器的觸發事件類型。
table_name:觸發器所關聯的資料表名稱。
FOR EACH ROW:表示觸發器的程式邏輯對受影響的每一筆資料都會執行一次。
BEGIN ... END:在這之間寫入觸發器的執行語句,如果觸發器中只有一條語句,則可以省略BEGIN和END。


範例 : 

-- 建立資料表單
CREATE TABLE my_trigger_test (
id INT AUTO_INCREMENT PRIMARY KEY,
myfield VARCHAR(10) NOT NULL,
last_modified TIMESTAMP);

-- 建立觸發器
DELIMITER //
CREATE TRIGGER before_table_update
BEFORE UPDATE ON my_trigger_test 
FOR EACH ROW
BEGIN
   IF NEW.myfield != OLD.myfield
      THEN
      SET NEW.last_modified = NOW();
   END IF;
END; //
DELIMITER ;

如果Trigger寫成如下 :

-- 以下範例是錯誤的
DELIMITER //
CREATE TRIGGER before_table_update2
BEFORE UPDATE ON my_trigger_test
FOR EACH ROW
UPDATE my_trigger_test SET last_modified = NOW() WHERE id = NEW.id;
END; //
DELIMITER ;

當以上before_table_update2觸發器被執行時,就會發生#1442 錯誤 (如下圖) : 因為表單已經被觸發器使用,不能在觸發器的程式內再執行UPDATE。


當然,要在資料表單更新時紀錄最後的更新時間,也可以不必使用Trigger。只要在欄位屬性上宣告即可,因為表單已經建立,我們使用語法來修正 :

ALTER TABLE my_trigger_test
MODIFY last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;

當然要記得刪除Trigger : 

DROP TRIGGER before_table_update;

DROP TRIGGER before_table_update2;

然後再執行更新看看結果。

UPDATE  my_trigger_test SET myfield = '00000';

就可以看到 last_modified 會自動更新了。


範例 : 

假設有產品資料庫如下 (資料表的設計說明請參考這篇)

CREATE TABLE category (
category_no INT AUTO_INCREMENT PRIMARY KEY,
category_name VARCHAR(100) NOT NULL ); 

CREATE TABLE product (
prod_no INT AUTO_INCREMENT PRIMARY KEY,
category_no INT,
prod_name VARCHAR(100) NOT NULL,
prod_price DECIMAL(10, 2) NOT NULL,
prod_unit VARCHAR(50),
prod_stock INT,
FOREIGN KEY (category_no) REFERENCES category(category_no) );


現在需要產品資料表的 INSERT/UPDATE/DELETE 都記錄在歷史資料

-- 建立一個產品歷史資料
CREATE TABLE product_history (
history_id INT AUTO_INCREMENT PRIMARY KEY,
operation_type ENUM('INSERT', 'UPDATE', 'DELETE') NOT NULL,
operation_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;
prod_no INT NOT NULL,
category_no_old INT,
category_no_new INT,
prod_name_old VARCHAR(100),
prod_name_new VARCHAR(100),
prod_price_old DECIMAL(10, 2),
prod_price_new DECIMAL(10, 2),
prod_unit_old VARCHAR(50),
prod_unit_new VARCHAR(50),
prod_stock_old INT,
prod_stock_new INT );

建立 AFTER UPDATE 觸發器 :

DELIMITER // 
CREATE TRIGGER after_product_update
AFTER UPDATE ON product
FOR EACH ROW 
BEGIN 
INSERT INTO product_history ( operation_type, operation_time, prod_no, category_no_old, category_no_new, prod_name_old, prod_name_new, prod_price_old, prod_price_new, prod_unit_old, prod_unit_new, prod_stock_old, prod_stock_new ) 
VALUES ( 'UPDATE', NOW(), OLD.prod_no, OLD.category_no, NEW.category_no, OLD.prod_name, NEW.prod_name, OLD.prod_price, NEW.prod_price, OLD.prod_unit, NEW.prod_unit, OLD.prod_stock, NEW.prod_stock ); 
END; //
DELIMITER ;


建立 AFTER INSERT 觸發器 : 

DELIMITER //
CREATE TRIGGER after_product_insert
AFTER INSERT ON product
FOR EACH ROW
BEGIN
INSERT INTO product_history ( operation_type, operation_time, prod_no, category_no_new, prod_name_new, prod_price_new, prod_unit_new, prod_stock_new ) VALUES ( 'INSERT', NOW(), NEW.prod_no, NEW.category_no, NEW.prod_name, NEW.prod_price, NEW.prod_unit, NEW.prod_stock );
END; //
DELIMITER ;


建立 AFTER DELETE 觸發器 : 

DELIMITER //
CREATE TRIGGER after_product_delete
AFTER DELETE ON product
FOR EACH ROW
BEGIN
INSERT INTO product_history ( operation_type, operation_time, prod_no, category_no_old, prod_name_old, prod_price_old, prod_unit_old, prod_stock_old ) VALUES ( 'DELETE', NOW(), OLD.prod_no, OLD.category_no, OLD.prod_name, OLD.prod_price, OLD.prod_unit, OLD.prod_stock );
END; //
DELIMITER ;

當我們對product資料表進行新增/修改/刪除,操作紀錄就會寫入 product_history。

如何處理或防止觸發器執行時發生錯誤?

由於觸發器內沒有Error Handler (發生錯誤時的處理機制),因此不要在觸發器內操作太複雜而不可預期的邏輯。如果真的需要複雜邏輯的話,可以由觸發器去呼叫預存程序,範例如下 : 


-- 建立 error_log 資料表單
CREATE TABLE error_log (
error_id INT AUTO_INCREMENT PRIMARY KEY,
error_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
error_message VARCHAR(255)); 


-- 建立stored procedure,包含異常處理邏輯
DELIMITER //

CREATE PROCEDURE log_product_update(
    IN p_prod_no INT,
    IN p_category_no_old INT, IN p_category_no_new INT,
    IN p_prod_name_old VARCHAR(100), IN p_prod_name_new VARCHAR(100),
    IN p_prod_price_old DECIMAL(10, 2), IN p_prod_price_new DECIMAL(10, 2),
    IN p_prod_unit_old VARCHAR(50), IN p_prod_unit_new VARCHAR(50),
    IN p_prod_stock_old INT, IN p_prod_stock_new INT
)

BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN

        -- 錯誤處理邏輯:記錄錯誤到日誌表

        INSERT INTO error_log (error_time, error_message)
        VALUES (NOW(), 'Error occurred in log_product_update procedure.');

        -- 避免中止程序

        RESIGNAL;

    END; 

    -- 插入歷史記錄

    INSERT INTO product_history (
        operation_type, operation_time, prod_no,
        category_no_old, category_no_new,
        prod_name_old, prod_name_new,
        prod_price_old, prod_price_new,
        prod_unit_old, prod_unit_new,
        prod_stock_old, prod_stock_new

    )

    VALUES (

        'UPDATE', NOW(), p_prod_no,
        p_category_no_old, p_category_no_new,
        p_prod_name_old, p_prod_name_new,
        p_prod_price_old, p_prod_price_new,
        p_prod_unit_old, p_prod_unit_new,
        p_prod_stock_old, p_prod_stock_new

    );

END //

DELIMITER ;


-- 建立觸發器 (記得要先刪除前面既有觸發器)
DELIMITER //

CREATE TRIGGER after_product_update
AFTER UPDATE ON product
FOR EACH ROW
BEGIN
    CALL log_product_update(
        OLD.prod_no,
        OLD.category_no, NEW.category_no,
        OLD.prod_name, NEW.prod_name,
        OLD.prod_price, NEW.prod_price,
        OLD.prod_unit, NEW.prod_unit,
        OLD.prod_stock, NEW.prod_stock
    );
END; //

DELIMITER ;


測試error handler

可以用兩個方式來測試上述的 error handler ~ 

測試1:刪除 product_history 資料表

刪除 product_history 資料表,然後執行 UPDATE 語句來觸發觸發器。

DROP TABLE IF EXISTS product_history;

-- 嘗試更新 product 資料表
UPDATE product
SET prod_price = prod_price + 1
WHERE prod_no = 1;

預期結果 : product_history 不存在會導致異常,異常處理邏輯應將錯誤記錄到 error_log 中。

測試2:插入超過欄位限制的文字

插入超過 error_message 欄位限制的內容,導致插入失敗。 修改 log_product_update 程序內的錯誤訊息:

INSERT INTO error_log (error_time, error_message)
VALUES (NOW(), REPEAT('X', 256)); -- 錯誤訊息長度超過 255

預期結果 : 超過 error_message 欄位長度限制會觸發異常。 異常處理邏輯應適當記錄到 error_log 中。

Stored Procedure 預存程序 : 

把一連串的SQL程序步驟儲存起來,最後透過 『call 預儲程序名稱;』來呼叫。

建立Stored Procedure語法 : 

DELIMITER //

CREATE PROCEDURE procedure_name(
    [IN|OUT|INOUT] param_name datatype, -- 傳入、傳出或雙向參數

    ...

)

BEGIN
    -- 程式邏輯
    [DECLARE ...]; -- 宣告區
    [LOOP|IF|CASE|...]; -- 流程控制
    [SQL statements]; -- 執行的 SQL 語句
END; //

DELIMITER ;

通常儲存程序(Stored Procedure)適合執行多步驟邏輯,用來簡化程式或是程式邏輯可以重複使用。

範例 : 新增一個程序,用來新增顧客並回傳該顧客的 ID。

DELIMITER //

CREATE PROCEDURE AddCustomer(
IN customerName VARCHAR(100),
IN customerEmail VARCHAR(100),
OUT customerID INT )
BEGIN
INSERT INTO customer (c_name, c_email) VALUES (customerName, customerEmail);
SET customerID = LAST_INSERT_ID();
END // 

DELIMITER ; 

CALL AddCustomer('John Doe', '[email protected]', @newCustomerID);
SELECT @newCustomerID;


Stored Function 預儲函數

跟預儲程序很像,不過最後會傳回值。 呼叫方式不是 call,而是Select,例如:『select 預儲函數名稱(引數值);』或『select 預儲函數名稱();』。

建立Stored Function語法 : 

DELIMITER //

CREATE FUNCTION function_name(
    param_name datatype, -- 輸入參數
    ...

)

RETURNS datatype -- 函數的返回型別
DETERMINISTIC | NOT DETERMINISTIC -- 決定性標誌(必須指定)
BEGIN
    -- 函數邏輯
    DECLARE ...; -- 宣告變數(可選)
    ...
    RETURN value; -- 返回值
END; //

DELIMITER ;

通常儲存函數(Stored Function)適合處理簡單計算,或是檢查狀態並取得回傳值。

範例 : 計算特定訂單的總金額。

DELIMITER // 

CREATE FUNCTION GetOrderTotal(orderID INT)
RETURNS DECIMAL(10, 2)
NOT DETERMINISTIC
BEGIN
    RETURN ( SELECT SUM(prod_qty * prod_price) FROM order_items WHERE o_no = orderID ); END // 

DELIMITER ; 

SELECT GetOrderTotal(1);

以上的 DETERMINISTIC 是什麼意思? 是指相同輸入會有相同結果,GetOrderTotal(orderID INT) 的結果會因為表格內容變動而改變(例如中間有新的資料加入到 order_items ),因此這個函數應該是 NOT DETERMINISTIC。

但是在 MySQL 中,如果沒有明確宣告 DETERMINISTIC 或 NOT DETERMINISTIC,預設會是 NOT DETERMINISTIC。因此,如果你的函數應該是「非確定性」的,可以選擇不特別宣告 NOT DETERMINISTIC。


Stored procedure 與 Stored function參數類型差異

兩者都支援參數,但用法稍有不同:

儲存程序參數類型:IN:輸入參數(預設),傳遞給程序的值。OUT:輸出參數,程序執行後返回值。INOUT:同時為輸入與輸出參數。

CREATE PROCEDURE example_proc(IN param1 INT, OUT result INT)
BEGIN
    SET result = param1 * 2;
END;

儲存函式參數: 只能使用 IN 參數,必須透過 RETURN 返回結果。

CREATE FUNCTION example_func(param1 INT) RETURNS INT
BEGIN
    RETURN param1 * 2;
END;

Stored procedure 與 Stored function 異常處理

可使用錯誤處理機制:
DECLARE EXIT HANDLER:處理例外情況。
RESIGNAL:傳遞異常至外部程式。

範例 : 

CREATE PROCEDURE error_handler_demo()
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        INSERT INTO error_log (error_time, error_message) VALUES (NOW(), 'Exception occurred.');
    END;

    -- 執行可能會失敗的操作
    UPDATE non_existing_table SET column = 'value';
END;

範例 :

DELIMITER //

CREATE PROCEDURE update_product_stock(
    IN p_prod_no INT,
    IN p_stock_change INT
)
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        -- 錯誤捕獲後記錄到 error_log
        INSERT INTO error_log (error_message)
        VALUES (CONCAT('Error updating stock for product ', p_prod_no));

        -- 使用 RESIGNAL 傳遞異常
        RESIGNAL;
    END;

    -- 驗證產品是否存在
    IF NOT EXISTS (SELECT 1 FROM product WHERE prod_no = p_prod_no) THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = 'Product not found';
    END IF;

    -- 更新庫存,如果結果超過限制則觸發異常
    UPDATE product
    SET prod_stock = prod_stock + p_stock_change
    WHERE prod_no = p_prod_no;

    -- 檢查庫存是否超過範圍
    IF (SELECT prod_stock FROM product WHERE prod_no = p_prod_no) < 0 THEN
        SIGNAL SQLSTATE '45000'
        SET MESSAGE_TEXT = 'Stock cannot be negative';
    END IF;

END //

DELIMITER ;

當 SIGNAL 被執行時,會拋出一個自定義的異常,並中斷執行。 如果有 EXIT HANDLER 捕獲到這個異常,會執行 HANDLER 中的程式碼。 然而如果沒有進一步使用 RESIGNAL,這個異常將被消化在 HANDLER 內,外部無法感知。

在使用Trigger、Stored Procedure、Stored Function時,有些注意事項 : 

(1)儲存程序(Stored Procedure): 
--可以呼叫其他的儲存程序。 
--可以呼叫儲存函數。 
--可以在觸發器內被呼叫。 

(2)儲存函數(Stored Function): 
--可以呼叫其他的儲存函數。 
--不能呼叫儲存程序。 
--可以在觸發器內被呼叫。 

(3)觸發器(Trigger): 
--可以呼叫儲存程序。
--可以呼叫儲存函數。 
--不能包含或呼叫其他觸發器。 
--不能使用Commit或是Rollback。


綜合實作練習

需求描述 :
(1) 當客戶下訂單時:
      -- 更新商品庫存。
      -- 若庫存不足則記錄錯誤訊息。      
(2) 使用儲存函式 (stored function) 計算訂單總金額。
(3) 使用儲存程序  (stored procedure) 執行下訂單的整個過程。
(4) 使用觸發器在下訂單時更新庫存量。

-- 產品資料表
CREATE TABLE product (
    prod_no INT AUTO_INCREMENT PRIMARY KEY,
    prod_name VARCHAR(100),
    prod_stock INT,
    prod_price DECIMAL(10, 2)
);

-- 訂單資料表
CREATE TABLE myorder (
    order_id INT AUTO_INCREMENT PRIMARY KEY,
    order_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 訂單項目資料表
CREATE TABLE order_item (
    item_id INT AUTO_INCREMENT PRIMARY KEY,
    order_id INT,
    prod_no INT,
    quantity INT,
    FOREIGN KEY (order_id) REFERENCES myorder(order_id),
    FOREIGN KEY (prod_no) REFERENCES product(prod_no)
);

-- 錯誤日誌表
CREATE TABLE error_log (
    error_id INT AUTO_INCREMENT PRIMARY KEY,
    error_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    error_message VARCHAR(255)
);

應該如何建置Trigger/Stored Function/Stored Procedure呢?

(1) 計算指定訂單的總金額。

DELIMITER //

CREATE FUNCTION CalculateOrderTotal(order_id INT) 
RETURNS DECIMAL(10, 2)
DETERMINISTIC
BEGIN
    DECLARE total_price DECIMAL(10, 2);
    SELECT SUM(p.prod_price * oi.quantity) INTO total_price
    FROM order_item oi
    JOIN product p ON oi.prod_no = p.prod_no
    WHERE oi.order_id = order_id;
    RETURN IFNULL(total_price, 0);
END //

DELIMITER ;

(2) 以下程序會執行以下功能:新增訂單 + 檢查庫存,若不足則記錄錯誤訊息 + 更新庫存。

DELIMITER //

CREATE PROCEDURE PlaceOrder(
    IN prod_no INT,
    IN quantity INT,
    OUT order_id INT
)
BEGIN
    DECLARE current_stock INT;
    DECLARE error_message VARCHAR(255);

    -- 獲取商品當前庫存
    SELECT prod_stock INTO current_stock FROM product WHERE prod_no = prod_no;

    -- 檢查庫存是否足夠
    IF current_stock IS NULL THEN
        SET error_message = CONCAT('Product not found: ', prod_no);
        INSERT INTO error_log (error_message) VALUES (error_message);
        SET order_id = NULL;
    ELSEIF current_stock < quantity THEN
        SET error_message = CONCAT('Insufficient stock for product: ', prod_no);
        INSERT INTO error_log (error_message) VALUES (error_message);
        SET order_id = NULL;
    ELSE
        -- 新增訂單
        INSERT INTO myorder () VALUES ();
        SET order_id = LAST_INSERT_ID();

        -- 新增訂單項目
        INSERT INTO order_item (order_id, prod_no, quantity) VALUES (order_id, prod_no, quantity);

        -- 更新商品庫存
        UPDATE product SET prod_stock = prod_stock - quantity WHERE prod_no = prod_no;
    END IF;
END //

DELIMITER ;

(3) 觸發器:更新商品庫存

DELIMITER //

CREATE TRIGGER UpdateStockAfterInsert
AFTER INSERT ON order_item
FOR EACH ROW
BEGIN
    UPDATE product
    SET prod_stock = prod_stock - NEW.quantity
    WHERE prod_no = NEW.prod_no;
END //

DELIMITER ;

(4) 測試範例 : 

-- 新增商品資料
INSERT INTO product (prod_name, prod_stock, prod_price) VALUES
('Product A', 100, 10.00),
('Product B', 50, 20.00);

-- 執行下訂單程序
CALL PlaceOrder(1, 5, @order_id); -- 訂購 5 個商品 ID 為 1 的商品
SELECT @order_id;
CALL PlaceOrder(2, 60, @order_id); -- 庫存不足,應記錄錯誤
SELECT @order_id;

-- 計算訂單總金額
SELECT CalculateOrderTotal(1) AS TotalPrice;

-- 檢查更新結果
SELECT * FROM product;          -- 查看庫存更新
SELECT * FROM myorder;          -- 查看訂單資料
SELECT * FROM order_item;       -- 查看訂單項目
SELECT * FROM error_log;        -- 查看錯誤日誌

張貼留言

0 留言