Loop through columns in MySQL trigger - mysql

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

Related

Selecting from tables that don't yet exist

I am creating some reporting which summarises user actions (clicks) from an online job board.
The MYSQL tables are structured so that there is one table for each days activities. The table names are standard (except for the date) e.g. 'User_Clicks_DD_MM_YYY'
I want to select from all of the tables (including future tables that have not yet been created) without having to revisit the code each day.
Does anyone know of a way that I can do this?
here is one way to approach this, it is a stored procedure that builds a query
what this does is, loops through a range of dates (you can substitute now() for the last date) and checks if a table exists for a particular date. if it does exist then it adds it to a select statement string. then the string is executed at the end of the proc.
create table tab_01_01_2020 (id int);
create table tab_02_01_2020 (id int);
insert into tab_01_01_2020 values (1);
insert into tab_02_01_2020 values (1);
DELIMITER $$
DROP PROCEDURE IF EXISTS test$$
CREATE PROCEDURE test()
BEGIN
DECLARE count INT DEFAULT 0;
DECLARE mydate DATE;
set mydate = '2020/01/01';
set #qry='select 0 as id where 1 = 0';
WHILE mydate < '2020/02/01' DO
SET count = count + 1;
set #day = (SELECT RIGHT(CONCAT('0',DAY(mydate)), 2));
set #mon = (SELECT RIGHT(CONCAT('0',MONTH(mydate)), 2));
set #tab = CONCAT('tab_',#day,'_',#mon,'_',YEAR(mydate));
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = #tab) = 1
THEN
set #qry = CONCAT(#qry, ' union select id from ', #tab);
END IF;
SET mydate = DATE_ADD(mydate, INTERVAL 1 DAY);
END WHILE;
select #qry;
prepare stmt from #qry;
execute stmt;
deallocate prepare stmt;
END$$
DELIMITER ;
call test();

MySQL select from all tables with a column name

Let's say I query this:
SELECT TABLE_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE COLUMN_NAME like 'coduser';
it returns a list of tables which contains the column "coduser":
users
messages
passwords
photos
It's a 20+ items list.
I need to search on those tables all occurrences where "coduser" is equal to "5OEWP1BPSV".
SELECT * FROM tablenamehere WHERE coduser = "5OEWP1BPSV";
but I'm not using anything other than MySQL to do this.
Basically just search all tables where there is a column called "coduser" and coduser = "5OEWP1BPSV".
You will need to use dynamic sql and given the small numbers involved a cursor would be appropriate
drop table if exists t,t1;
create table t(id int auto_increment primary key , codeuser varchar(20));
create table t1(id int auto_increment primary key , codeuser varchar(20));
insert into t(codeuser) values
('aaa'),('5OEWP1BPSV');
insert into t(codeuser) values
('bbb');
drop procedure if exists p;
delimiter $$
create procedure p()
begin
declare tablename varchar(20);
declare finished int;
DECLARE GRD_CUR CURSOR FOR
SELECT TABLE_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE COLUMN_NAME like 'codeuser';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET FINISHED = 0;
OPEN GRD_CUR;
LOOPROWS:LOOP
FETCH GRD_CUR INTO tablename;
IF FINISHED = 0 THEN
LEAVE LOOPROWS;
END IF;
#build and execute dynamic sql
set #sql = concat('SELECT * FROM ' , tablename, ' WHERE codeuser = "5OEWP1BPSV"');
#select #sql;
prepare sqlstmt from #sql;
execute sqlstmt;
deallocate prepare sqlstmt;
END LOOP;
close grd_cur;
end $$
delimiter ;
call p();
Loop over the returned list of tables, and for each table in that list perform a
SELECT *
WHERE columnnamehere = "valueofcolumnhere";
Bear in mind that this will give you separate lists for each table within that FOR loop, so you would have to concatenate these in some way to get a complete list.

Mysql calling procedure failed when dynamically alter table in it

I want to alter my tables dynamically based on whether the table has specific column.
My database name is summer_cms, and there are over 50 tables in it.
What I want are below:
If a table has a column named add_time, then I would like to add a column add_user_id in it.
Similarly, I would like to add update_user_id in the table if update_time is found.
I know I should get it down in the process of creating the database schemas, but my database has been built and I have to alter it by need.
So I create a procedure to do it:
CREATE PROCEDURE ALTER_SUMMER_TABLE()
BEGIN
DECLARE tableName VARCHAR(64);
DECLARE exitence VARCHAR(64);
DECLARE ntable INT; # number of tables
DECLARE i INT; # index
SET i = 0;
# get the count of table
SELECT COUNT(DISTINCT(TABLE_NAME)) INTO ntable FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'summer_cms';
WHILE i < ntable DO
# select the specific table name into the variable of `tableName`.
SELECT TABLE_NAME INTO tableName
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'summer_cms'
AND COLUMN_NAME = 'add_time'
LIMIT 1 OFFSET i;
# alter table, but I get error in this clause.
ALTER TABLE tableName ADD COLUMN `add_user_id` INT NOT NULL DEFAULT 0 COMMENT 'add user id';
# check if the table has `update_time`
SELECT TABLE_NAME INTO exitence
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'summer_cms'
AND TABLE_NAME = tableName
AND COLUMN_NAME = 'update_time';
# add `update_user_id` if `update_time` be found.
IF exitence THEN
ALTER TABLE tableName ADD COLUMN `update_user_id` INT NOT NULL DEFAULT 0 COMMENT 'update user id';
END IF;
SET i = i + 1;
END WHILE;
END
But I got an error when I call this procedure.
Procedure execution failed
1146 - Table 'summer_cms.tableName' doesn't exist
Dose anyone could tell me what I was missing or wrong? Any help will be appreciated.
There a a few alterations you can make to your procedure to make it more streamlined as well as getting round a few problems.
First using a cursor to select the table names rather than using the two selects your using. Secondly to use a prepared statement to allow you to dynamically set the table name...
DELIMITER $$
CREATE DEFINER=`root`#`localhost` PROCEDURE `ALTER_SUMMER_TABLE`()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE tableName VARCHAR(64);
declare cur cursor for SELECT TABLE_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'summer_cms'
AND COLUMN_NAME = 'add_time';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
open cur;
start_loop: loop
fetch cur into tableName;
if (done = 1 )THEN
LEAVE start_loop;
END IF;
SET #sql = CONCAT('ALTER TABLE ', tableName,' ADD COLUMN `add_user_id` INT NOT NULL DEFAULT 0 ');
PREPARE stmt FROM #sql;
EXECUTE stmt;
end loop;
close cur;
END$$
DELIMITER ;
You could do a few tweaks - only fetch table names where the column doesn't already exist for example.
Here's an example of dynamic sql
drop procedure if exists alter_table;
delimiter //
CREATE DEFINER=`root`#`localhost` procedure alter_table()
begin
declare tablename varchar(20);
set tablename = 'u';
set #sqlstmt = concat('ALTER TABLE ', tableName, ' ADD COLUMN ', char(96), 'add_user_id', char(96), ' INT NOT NULL DEFAULT 0 COMMENT', char(39), 'add user id', char(39),';');
prepare stmt from #sqlstmt;
execute stmt;
deallocate prepare stmt;
end //
delimiter ;
Note I have used ascii backticks and single quotes.

MySql, split a string and insert into table

I have two inputs for my stored procedure. One is the 'RoledID' and second one is the 'MenuIDs'. 'MenusIDs' is a list of comma separated menus ids that need to be inserted with RoledID. RoleId is just an INT and we need to put this RoledID against each MenuID. My table 'RolesMenus' contains two columns one for MenuID and one for RoleID.
Now I need to split MenuIDs and insert each MenuID with RoleID.
How can I write a stored procedure for it?
You can build one INSERT query (because statement allows to insert multiple records) and run it with prepared statements, e.g. -
SET #MenuIDs = '1,2,3';
SET #RoledID = 100;
SET #values = REPLACE(#MenuIDs, ',', CONCAT(', ', #RoledID, '),('));
SET #values = CONCAT('(', #values, ', ', #RoledID, ')'); -- This produces a string like this -> (1, 100),(2, 100),(3, 100)
SET #insert = CONCAT('INSERT INTO RolesMenus VALUES', #values); -- Build INSERT statement like this -> INSERT INTO RolesMenus VALUES(1, 100),(2, 100),(3, 100)
-- Execute INSERT statement
PREPARE stmt FROM #insert;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
As you see, it can be done without stored procedure.
Give this a go. It may need some tweaking if the MenuIDs string does not conform to 'menuId,menuId,menuId'.
Also I do not know what data type the menuId column is in your target table (INT?) so you may have to put some numeric checking in too (in case '1,2,3,banana,4,5' is passed in as the MenuIds input parameter).
DELIMITER $$
DROP PROCEDURE IF EXISTS `insert_role_menuids`$$
CREATE PROCEDURE `insert_role_menuids`(IN RoleID INT,IN MenuIDs varchar(500))
BEGIN
declare idx,prev_idx int;
declare v_id varchar(10);
set idx := locate(',',MenuIDs,1);
set prev_idx := 1;
WHILE idx > 0 DO
set v_id := substr(MenuIDs,prev_idx,idx-prev_idx);
insert into RolesMenus (RoleId,MenuId) values (RoleID,v_id);
set prev_idx := idx+1;
set idx := locate(',',MenuIDs,prev_idx);
END WHILE;
set v_id := substr(MenuIDs,prev_idx);
insert into RolesMenus (RoleId,MenuId) values (RoleID,v_id);
END$$
DELIMITER ;
for this solution, you must create a table with the name split_table, it can have a id(autoincrement) if you need it and must have a column where to store the value (I call it valor)
DELIMITER $$
USE `dbaname`$$
DROP PROCEDURE IF EXISTS `Split`$$
CREATE DEFINER=`root`#`localhost` PROCEDURE `Split`(
IN cadena VARCHAR(8000),
IN delimitador VARCHAR(10)
)
BEGIN
TRUNCATE split_table;
SET #posicion = 1;
SET #ldel = LENGTH(delimitador);
SET #valor = SUBSTRING_INDEX(cadena, delimitador, 1);
WHILE #valor <> '' AND #posicion > 0 DO
SET #valor = SUBSTRING_INDEX(cadena, delimitador, 1);
INSERT INTO split_table(valor) VALUES (#valor);
SET #posicion = POSITION(delimitador IN cadena);
SET #largo = LENGTH(cadena);
IF #largo >= #posicion THEN
SET cadena = SUBSTR(cadena, #posicion + #ldel, #largo - #posicion);
SET #valor = SUBSTRING_INDEX(cadena, delimitador, 1);
ELSE
SET #posicion = 0;
END IF;
END WHILE;
END$$
DELIMITER ;
First create procedure
CREATE DEFINER=`root`#`localhost` PROCEDURE `split_str_save_to_tmp_table`(
IN _str TEXT,
IN _table_name VARCHAR(80)
)
BEGIN
#DROP FIRST OLD TABLE
SET #q = CONCAT('DROP TEMPORARY TABLE IF EXISTS ', _table_name);
PREPARE st FROM #q;
EXECUTE st;
#CREATE TABLE
SET #q = CONCAT('CREATE TEMPORARY TABLE ', _table_name, '(id INT UNSIGNED NOT NULL PRIMARY KEY (id) )' );
PREPARE st FROM #q;
EXECUTE st;
SET #ids = REPLACE(_str, ',', '),(');
SET #ids = CONCAT('(', #ids, ')');
#INSERT INTO TABLE
SET #q = CONCAT('INSERT INTO ' , _table_name ,' VALUES');
SET #q = CONCAT(#q, #ids);
PREPARE st FROM #q;
EXECUTE st;
DEALLOCATE PREPARE st;
END
Then call
call split_str_save_to_tmp_table('1,2,3,4,5', 'tmp_split_product');
SELECT * FROM tmp_split_product
AFAIK MySQL does not have a function to split strings. Here is the MySQL manual for string related functions. In the comments section should be some information about workarounds for splitting string with substring-functions but not really usable:
MySQL manual

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

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;