MySQL UPDATE trigger: INSERTing the values of the columns that actually changed - mysql

I'm working on a fairly simple ticket managment system. I want to keep a log for stuff that gets added, deleted, and changed.
I created three triggers, AFTER INSERT, AFTER DELETE, and AFTER UPDATE. The INSERT/DELETE triggers are straightforward, it's theUPDATE trigger I'm having problems with.
I would like to add which columns has changed in the table with their old & new values, i.e. colname changed from X to Y
The trigger I have now "works", except of course that it doesn't insert the actual values I'd like.
How do I get the value from OLD and NEW using the col_name variable?
I'm also not sure if this is the best possible way of doing this ... So if anyone has ideas on that, they're welcome too ... This trigger started out a lot simpler ...
BEGIN
DECLARE num_rows, i int default 1;
DECLARE col_name CHAR(255);
DECLARE updated TEXT;
DECLARE col_names CURSOR FOR
SELECT column_name FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'storing'
ORDER BY ordinal_position;
OPEN col_names;
SELECT FOUND_ROWS() INTO num_rows;
SET i = 1;
SET #updated = 'Updated columns: ';
the_loop: LOOP
IF i > num_rows THEN
LEAVE the_loop;
END IF;
FETCH col_names INTO col_name;
/* So, how do I get the proper values? */
/* IF NEW.#col_name != OLD.#col_name THEN */
/*SET #updated = CONCAT(#updated, OLD.#col_name, ' changed into ', NEW.#col_name, ' ');*/
SET #updated = CONCAT(#updated, 'OLD', ' changed into ', 'NEW', ' ');
/* END IF;*/
SET i = i + 1;
END LOOP the_loop;
CLOSE col_names;
INSERT INTO `log` (`storing`, `medewerker`, `actie`, `data`)
VALUES (NEW.`id`, NEW.`medewerker`, "Storing aangepast", #updated);
END

Since usage of prepared statements here is impossible, I would suggest you to call some INSERT statements, e.g. -
IF NEW.column1 <> OLD.column1 THEN
INSERT INTO...
END IF;
IF NEW.column2 <> OLD.column2 THEN
INSERT INTO...
END IF;
...
Or try to copy all fields you need into another table.
In these cases you will avoid using cursor.

Try to use prepared statements
Something like this:
SET #s = CONCAT('SELECT new.', #col_name, ', old.', #col_name, ' FROM ', /*here is the query details like inner joins etc.*/, ' where ', 'NEW.', #col_name, '!= OLD.', #col_name )
PREPARE stmt FROM #s;
EXECUTE stmt;

Related

Stored Procedure prepared statement - MySQL - use prepared statement result as variable [duplicate]

I have a two tables:
people_en: id, name
people_es: id, name
(please, dont bother about normalization. the design is normalized. the tables are much more complex than this
but this is just a way to simplify my problem).
I then have a stored procedure:
CREATE PROCEDURE myproc(lang char(2))
BEGIN
set #select = concat('SELECT * FROM ', lang, ' limit 3');
PREPARE stm FROM #select;
EXECUTE stm;
DEALLOCATE PREPARE stm;
SET #cnt = FOUND_ROWS();
SELECT #cnt;
IF #cnt = 3 THEN
//Here I need to loop through the rows
ELSE
//Do something else
END IF;
END$$
More or less, the logic in the procedure is:
If the select gives 3 rows, then we have to loop through the rows and do something with the value in each row.
Otherwise somwthing else (not important what, but I put this to make you understand that I need to have
an if statement before looping.
I have seen and read about cursors, but couldnt find much for selects created by concat (does it matter?)
and especially created with a prepared statement.
How can I iterate through the result list and use the values from each row?
Thanks.
I have some bad and good news for you.
First the bad news.
MySQL manual says a cursor cannot be used for a dynamic statement that
is prepared and executed with PREPARE and EXECUTE. The statement for a
cursor is checked at cursor creation time, so the statement cannot be
dynamic.
So there are no dynamical cursors so far... Here you would need something like this.
But now the good news: there are at least two ways to bypass it - using vw or tbl.
Below I rewrote your code and applied view to make 'dynamical' cursor.
DELIMITER //
DROP PROCEDURE IF EXISTS myproc;
CREATE PROCEDURE myproc(IN lang VARCHAR(400))
BEGIN
DECLARE c VARCHAR(400);
DECLARE done BOOLEAN DEFAULT FALSE;
DECLARE cur CURSOR FOR SELECT name FROM vw_myproc;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
SET #select = concat('CREATE VIEW vw_myproc as SELECT * FROM ', lang, ' limit 3');
PREPARE stm FROM #select;
EXECUTE stm;
DEALLOCATE PREPARE stm;
SET #select = concat('SELECT * FROM ', lang, ' limit 3');
PREPARE stm FROM #select;
EXECUTE stm;
DEALLOCATE PREPARE stm;
SET #cnt = FOUND_ROWS();
SELECT #cnt;
IF #cnt = 3 THEN
OPEN cur;
read_loop: LOOP
FETCH cur INTO c;
IF done THEN
LEAVE read_loop;
END IF;
#HERE YOU CAN DO STH WITH EACH ROW e.g. UPDATE; INSERT; DELETE etc
SELECT c;
END LOOP read_loop;
CLOSE cur;
DROP VIEW vw_myproc;
ELSE
SET c = '';
END IF;
END//
DELIMITER ;
And to test the procedure:
CALL myproc('people_en');
#clickstefan, you will have problems with two or more users trying to execute your script at the same time. The second user will get error message 'View vw_myproc already exists' for the line:
SET #select = concat('CREATE VIEW vw_myproc as SELECT * FROM ', lang, ' limit 3');
The solution is temporary table - it exists for the lifetime of current connection only, and users may simultaneously create temporary tables with the same name. So, code may looks like:
DROP TABLE IF EXISTS vw_myproc;
SET #select = concat('CREATE TEMPORARY TABLE vw_myproc AS SELECT * FROM ', lang, ' limit 3');

MySQL stored procedure cursor for prepared statements

I have a two tables:
people_en: id, name
people_es: id, name
(please, dont bother about normalization. the design is normalized. the tables are much more complex than this
but this is just a way to simplify my problem).
I then have a stored procedure:
CREATE PROCEDURE myproc(lang char(2))
BEGIN
set #select = concat('SELECT * FROM ', lang, ' limit 3');
PREPARE stm FROM #select;
EXECUTE stm;
DEALLOCATE PREPARE stm;
SET #cnt = FOUND_ROWS();
SELECT #cnt;
IF #cnt = 3 THEN
//Here I need to loop through the rows
ELSE
//Do something else
END IF;
END$$
More or less, the logic in the procedure is:
If the select gives 3 rows, then we have to loop through the rows and do something with the value in each row.
Otherwise somwthing else (not important what, but I put this to make you understand that I need to have
an if statement before looping.
I have seen and read about cursors, but couldnt find much for selects created by concat (does it matter?)
and especially created with a prepared statement.
How can I iterate through the result list and use the values from each row?
Thanks.
I have some bad and good news for you.
First the bad news.
MySQL manual says a cursor cannot be used for a dynamic statement that
is prepared and executed with PREPARE and EXECUTE. The statement for a
cursor is checked at cursor creation time, so the statement cannot be
dynamic.
So there are no dynamical cursors so far... Here you would need something like this.
But now the good news: there are at least two ways to bypass it - using vw or tbl.
Below I rewrote your code and applied view to make 'dynamical' cursor.
DELIMITER //
DROP PROCEDURE IF EXISTS myproc;
CREATE PROCEDURE myproc(IN lang VARCHAR(400))
BEGIN
DECLARE c VARCHAR(400);
DECLARE done BOOLEAN DEFAULT FALSE;
DECLARE cur CURSOR FOR SELECT name FROM vw_myproc;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
SET #select = concat('CREATE VIEW vw_myproc as SELECT * FROM ', lang, ' limit 3');
PREPARE stm FROM #select;
EXECUTE stm;
DEALLOCATE PREPARE stm;
SET #select = concat('SELECT * FROM ', lang, ' limit 3');
PREPARE stm FROM #select;
EXECUTE stm;
DEALLOCATE PREPARE stm;
SET #cnt = FOUND_ROWS();
SELECT #cnt;
IF #cnt = 3 THEN
OPEN cur;
read_loop: LOOP
FETCH cur INTO c;
IF done THEN
LEAVE read_loop;
END IF;
#HERE YOU CAN DO STH WITH EACH ROW e.g. UPDATE; INSERT; DELETE etc
SELECT c;
END LOOP read_loop;
CLOSE cur;
DROP VIEW vw_myproc;
ELSE
SET c = '';
END IF;
END//
DELIMITER ;
And to test the procedure:
CALL myproc('people_en');
#clickstefan, you will have problems with two or more users trying to execute your script at the same time. The second user will get error message 'View vw_myproc already exists' for the line:
SET #select = concat('CREATE VIEW vw_myproc as SELECT * FROM ', lang, ' limit 3');
The solution is temporary table - it exists for the lifetime of current connection only, and users may simultaneously create temporary tables with the same name. So, code may looks like:
DROP TABLE IF EXISTS vw_myproc;
SET #select = concat('CREATE TEMPORARY TABLE vw_myproc AS SELECT * FROM ', lang, ' limit 3');

Loop through columns in MySQL trigger

Is it possible to loop through the all column names while inside a trigger?
Scenario:
To log all the columns of a table that have been modified.
If some values did not change, do not log those ones.
DROP TRIGGER IF EXISTS t_before_update_test;
DELIMITER $$
CREATE TRIGGER t_before_update_test
BEFORE UPDATE ON test
FOR EACH ROW
BEGIN
-- Loop here for all columns, not just col1
IF OLD.col1 <> NEW.col1 THEN
INSERT INTO change_logs(
log_on, user_id,
table_name, colum_name,
old_data, new_data
) VALUES (
UNIX_TIMESTAMP(NOW()), '0',
'test', 'col1',
OLD.col1, NEW.col1
);
END IF;
-- process looping all columns
-- col1, col2, ... should be dynamic per loop
END $$
This is working copy example, where I now need to loop through all columns available in OLD or NEW.
Unfortunately, using dynamic SQL (i.e PREPARED STATEMENT) in MySQL trigger is not allowed.(This can not be bypassed by calling a stored procedure which has dynamic SQL ). Therefore, we have to hardcode the column name in the trigger. However, if the columns are to change, the trigger will break due to the unmatchable columns, which simply stops the UPDATE trasaction. Therefore, we need to check if it's legit to do the logging job in the change_logs table. If legit, then insert into the change_logs table; else just send a warning message into a warning table. Supposing the test table has two columns namely id and datetm. And a warning table with 3 columns (table_name,log_time,log_content) is created beforehand. The change_logs table is identical to the OP's. The rest is creating the trigger (written and tested in workbench):
delimiter //
drop trigger if exists t_before_update_test//
create trigger t_before_update_test before update on test for each row begin
if
'id' not in (select column_name from information_schema.columns where table_name='test')
or 'datetm' not in (select column_name from information_schema.columns where table_name='test')
or (select count(column_name) from information_schema.columns where table_name='test') !=2
then
insert into warning_table values ('test',now(),'Table column structure has been changed!!');
else
IF old.id <> new.id THEN
INSERT INTO change_logs(
log_on, user_id,
`table_name`, colum_name,
old_data, new_data
) VALUES (
UNIX_TIMESTAMP(NOW()), '0',
'test', 'id',
old.id, new.id
);
END IF;
IF old.datetm <> new.datetm THEN
INSERT INTO change_logs(
log_on, user_id,
`table_name`, colum_name,
old_data, new_data
) VALUES (
UNIX_TIMESTAMP(NOW()), '0',
'test', 'datetm',
old.datetm, new.datetm
);
END IF;
end if;
end //
I don't have enough time to finish this right now, but I think that using CONCAT() to prepare a statement and using the result of that for a conditional might enable you to do what you want. Something along these lines:
DECLARE num_rows INT DEFAULT 0;
DECLARE cols CURSOR FOR SELECT column_name FROM information_schema.columns WHERE table_name = 'table_name' ORDER BY ordinal_position;
OPEN cols;
SELECT FOUND_ROWS() INTO num_rows;
SET #i = 1;
cols_loop: LOOP
IF #i > num_rows THEN
CLOSE cols;
LEAVE cols_loop;
END IF;
FETCH cols INTO col;
SET #do_stuff = 0;
SET #s = CONCAT('SELECT IF(NEW.', col, ' <> OLD.', col, ', 1, 0) INTO #do_stuff');
PREPARE stmt1 FROM #s;
EXECUTE stmt1;
DEALLOCATE PREPARE stmt1;
IF #do_stuff = 1 THEN
SET #s2 = CONCAT('INSERT INTO change_logs(log_on, user_id, table_name, colum_name, old_data, new_data )
VALUES (UNIX_TIMESTAMP(NOW()), ''0'', ''test'', ''', col,''', OLD.', col, ', NEW.', col, ');');
PREPARE stmt2 FROM #s2;
EXECUTE stmt2;
DEALLOCATE PREPARE stmt2;
END IF;
SET #i = #i + 1;
END LOOP cols_loop;
CLOSE cols;
Unfortunately you can't do that. You can get the column names by accessing INFORMATION_SCHEMA but it's not possible to access the OLD and NEW values from that column names. I think it make sense because unlike stored procedure you're creating a trigger for a specific table not for the database . Calling a Stored procedure inside the trigger will help you to reduce code up-to some extend.
DROP TRIGGER IF EXISTS t_before_update_test;
DELIMITER $$
CREATE TRIGGER t_before_update_test
BEFORE UPDATE ON test
FOR EACH ROW
BEGIN
IF OLD.col1 <> NEW.col1 THEN
/*pseudo*/
CALL SP_insert_log (
'test',
'colum_name',
'old_value',
''old_value');
ELSEIF OLD.col2 <> NEW.col2 THEN
//call above sp with this column related data
END IF;
END $$
yes, a cursor can be added within a trigger to loop through columns.
here are a couple of links :
mysql, iterate through column names
https://dba.stackexchange.com/questions/22925/mysql-loop-over-cursor-results-ends-ahead-of-schedule
from experience, it might be easier to create a stored procedure that does the looping and inserts and call it from the trigger

PREPARED STATEMENT in a WHILE loop

I have a table autos that has a column name, I want to check first 5 rows in the table and if name value is "toyota", in table mytable write "yes", else write "no".
I write stored procedure, but mysqli_error() returns error in line, where I have EXECUTE ....
If in WHEN I write not PREPARED STATEMENT, but directly the query, the procedure works.
Please see my code and tell me, where is it wrong?
CREATE PROCEDURE proc_auto()
BEGIN
DECLARE start INT;
SET start = 0;
PREPARE stmt FROM ' SELECT name FROM autos ORDER BY id LIMIT ?,1 ';
WHILE start < 5 DO
CASE
WHEN (EXECUTE stmt USING #start ) = 'toyota'
THEN INSERT INTO mytable (log) VALUES('yes');
ELSE
INSERT INTO mytable (log) VALUES('no');
END CASE;
SET start = start + 1;
END WHILE;
END;
(The suggestion about EXECUTE is removed as incorrect and potentially confusing.)
The problem you are trying to solve with a stored procedure could in fact be solved without it, using an entirely different approach: just use a single INSERT ... SELECT statement instead:
INSERT INTO mytable (log)
SELECT
CASE name
WHEN 'toyota' THEN 'yes'
ELSE 'no'
END
FROM autos
ORDER BY id
LIMIT 5
That is, the above statement does the same as your stored procedure: it retrieves first 5 rows from autos and inserts 5 rows into mytable. Depending on the value of name it generates either yeses or nos.
Andriy M's INSERT statement is the most elegant solution, but if you still want to use a procedure, this will work:
CREATE PROCEDURE proc_auto()
BEGIN
DECLARE start INT DEFAULT 0;
PREPARE stmt FROM
'SELECT name INTO #name FROM autos ORDER BY id LIMIT ?,1';
WHILE start < 5 DO
SET #start = start;
EXECUTE stmt USING #start;
IF #name = 'toyota' THEN
INSERT INTO mytable (log) VALUES('yes');
ELSE
INSERT INTO mytable (log) VALUES('no');
END IF;
SET start = start + 1;
END WHILE;
END;
but, in this case, using a CURSOR would yield better performance:
CREATE PROCEDURE proc_auto()
BEGIN
DECLARE start INT DEFAULT 0;
DECLARE b_not_found BOOL DEFAULT FALSE;
DECLARE cur CURSOR FOR
'SELECT name FROM autos ORDER BY id LIMIT 5';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET b_not_found = TRUE;
OPEN cur;
loop1: WHILE start < 5 DO
FETCH cur INTO #name;
IF b_not_found THEN
LEAVE loop1;
END IF;
IF #name = 'toyota' THEN
INSERT INTO mytable (log) VALUES('yes');
ELSE
INSERT INTO mytable (log) VALUES('no');
END IF;
SET start = start + 1;
END WHILE;
CLOSE cur;
END;

Updating empty string to NULL for entire database

I'm performing some database clean up and have noticed that there are a lot of columns that have both empty strings and NULL values in various columns.
Is it possible to write an SQL statement to update the empty strings to NULL for each column of each table in my database, except for the ones that do not allow NULL's?
I've looked at the information_schema.COLUMNS table and think that this might be the place to start.
It's not possible to do this with one simple SQL statement.
But you can do it using one statement for each column.
UPDATE TABLE SET COLUMN = NULL
WHERE LENGTH(COLUMN) = 0
or, if you want to null out the items that also have whitespace:
UPDATE TABLE SET COLUMN = NULL
WHERE LENGTH(TRIM(COLUMN)) = 0
I don't think it's possible within MySQL but certainly with a script language of your choice.
Start by getting all tables SHOW TABLES
Then for each table get the different columns and find out witch ones allow null, either with DESC TABLE, SHOW CREATE TABLE or SELECT * FROM information_schema.COLUMNS, take the one you rather parse
Then for each column that allows null run a normal update that changes "" to null.
Prepare to spend some time waiting :)
I figured out how to do this using a stored procedure. I'd definitely look at using a scripting language next time.
DROP PROCEDURE IF EXISTS settonull;
DELIMITER //
CREATE PROCEDURE settonull()
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE _tablename VARCHAR(255);
DECLARE _columnname VARCHAR(255);
DECLARE cur1 CURSOR FOR SELECT
CONCAT(TABLE_SCHEMA, '.', TABLE_NAME) AS table_name,
COLUMN_NAME AS column_name
FROM information_schema.COLUMNS
WHERE IS_NULLABLE = 'YES'
AND TABLE_SCHEMA IN ('table1', 'table2', 'table3');
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN cur1;
read_loop: LOOP
FETCH cur1 INTO _tablename, _columnname;
IF done THEN
LEAVE read_loop;
END IF;
SET #s = CONCAT('UPDATE ', _tablename, ' SET ', _columnname, ' = NULL WHERE LENGTH(TRIM(', _columnname, ')) = 0' );
PREPARE stmt FROM #s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END LOOP;
CLOSE cur1;
END//
DELIMITER ;
CALL settonull();