MySQL Trigger checking for existing rows with possible NULL values - mysql

I have an InnoDB table with a unique index on performance_id, ticket_rank_id and ticket_type_id.
All of these id's have relations to other tables, but their values can also be NULL.
MySQL allows duplicate rows if one of the column values is NULL so I decided to build a trigger for this problem.
CREATE TRIGGER `before_insert_performance_tickets`
BEFORE INSERT ON `performance_tickets` FOR EACH ROW BEGIN
DECLARE num_rows INTEGER;
SELECT COUNT(*) INTO num_rows FROM performance_tickets
WHERE performance_id = NEW.performance_id
AND ticket_rank_id = NEW.ticket_rank_id
AND ticket_type_id = NEW.ticket_type_id;
IF num_rows > 0 THEN
// Cancel insert action
ELSE
// Perform insert action
END IF;
END
The problem is AND ticket_rank_id = NEW.ticket_rank_id where I have to check if ticket_rank_id is NULL or has a value.
AND ticket_rank_id = NULL does not work, it only works if i do AND ticket_rank_id IS NULL.
Is there any slick solution for this or do I have to write separate queries depending on NEW.ticket_rank_id and NEW.ticket_type_id being NULL or not?

You need to add extra OR condition for NULL values (ticket_rank_id IS NULL OR ticket_rank_id = NEW.ticket_rank_id) because NULL compared with anything return FALSE. Try this query:
SELECT COUNT(*)
INTO num_rows
FROM performance_tickets
WHERE performance_id = NEW.performance_id
AND (ticket_rank_id IS NULL OR ticket_rank_id = NEW.ticket_rank_id)
AND (ticket_type_id IS NULL OR ticket_type_id = NEW.ticket_type_id);

I think I figured it out:
SELECT COUNT(*) INTO num_rows FROM performance_tickets
WHERE performance_id = NEW.performance_id
AND IF(NEW.ticket_rank_id IS NULL, ticket_rank_id IS NULL, ticket_rank_id = NEW.ticket_rank_id)
AND IF(NEW.ticket_type_id IS NULL, ticket_type_id IS NULL, ticket_type_id = NEW.ticket_type_id)
Seems to work, now I have to figure out how to abort the insert if num_rows == 1. But that's a different problem.
Thanks anyways ;)

Related

mysql trigger on update: how to detect NULL values?

I try to update the porcentageComplete profil when users updates his profile:
statut field:
`statut` enum('Marié','En couple','Divorcé') DEFAULT NULL,
Trigger is:
DROP TRIGGER IF EXISTS update_user;
DELIMITER $$
CREATE TRIGGER update_user
BEFORE UPDATE
ON users FOR EACH ROW
BEGIN
DECLARE porcentage int default 0;
IF NEW.statut!=NULL OR OLD.statut!=NULL THEN
set porcentage = porcentage + 1;
END IF;
SET NEW.porcentageCompleted = porcentage;
END$$
This not update correctly the porcentageCompleted : it looks like NULL comparaison does not work correctly
Comparisons with = or <>(or !=) to a NULL value yield NULL themselves, so not true, i.e. false. Use IS NULL and IS NOT NULL to check for NULLs.
...
IF NEW.statut IS NOT NULL
OR OLD.statut IS NOT NULL THEN
...
Worth a read: 3.3.4.6 Working with NULL Values

Why does MySQL not always use index merge here?

Consider this table:
CREATE TABLE `Alarms` (
`AlarmId` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`DeviceId` BINARY(16) NOT NULL,
`Code` BIGINT(20) UNSIGNED NOT NULL,
`Ended` TINYINT(1) NOT NULL DEFAULT '0',
`NaturalEnd` TINYINT(1) NOT NULL DEFAULT '0',
`Pinned` TINYINT(1) NOT NULL DEFAULT '0',
`Acknowledged` TINYINT(1) NOT NULL DEFAULT '0',
`StartedAt` TIMESTAMP NOT NULL DEFAULT '0000-00-00 00:00:00',
`EndedAt` TIMESTAMP NULL DEFAULT NULL,
`MarkedForDeletion` TINYINT(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`AlarmId`),
KEY `Key1` (`Ended`,`Acknowledged`),
KEY `Key2` (`Pinned`),
KEY `Key3` (`DeviceId`,`Pinned`),
KEY `Key4` (`DeviceId`,`StartedAt`,`EndedAt`),
KEY `Key5` (`DeviceId`,`Ended`,`EndedAt`),
KEY `Key6` (`MarkedForDeletion`)
) ENGINE=INNODB;
And, for this test, populate it like so:
-- Populate some dummy data; 500 alarms for each
-- of 1000 one-second periods
SET #testDevice = UNHEX('00030000000000000000000000000000');
DROP PROCEDURE IF EXISTS `injectAlarms`;
DELIMITER ;;
CREATE PROCEDURE injectAlarms()
BEGIN
SET #fromdate = '2018-02-18 00:00:00';
SET #numdates = 1000;
SET #todate = DATE_ADD(#fromdate, INTERVAL #numdates SECOND);
-- Create table of alarm codes to join on
DROP TABLE IF EXISTS `__codes`;
CREATE TEMPORARY TABLE `__codes` (
`Code` BIGINT NOT NULL PRIMARY KEY
);
SET #startcode = 0;
SET #endcode = 499;
REPEAT
INSERT INTO `__codes` VALUES(#startcode);
SET #startcode = #startcode + 1;
UNTIL #startcode > #endcode END REPEAT;
-- Add an alarm for each code, for each second in range
REPEAT
INSERT INTO `Alarms`
(`DeviceId`, `Code`, `Ended`, `NaturalEnd`, `Pinned`, `Acknowledged`, `StartedAt`, `EndedAt`)
SELECT
#testDevice,
`Code`,
TRUE, FALSE, FALSE, FALSE,
#fromdate, #fromdate
FROM `__codes`;
SET #fromdate = DATE_ADD(#fromdate, INTERVAL 1 SECOND);
UNTIL #fromdate > #todate END REPEAT;
END;;
DELIMITER ;
CALL injectAlarms();
Now, for some datasets the following query works quite well:
SELECT * FROM `Alarms`
WHERE
((`Alarms`.`Ended` = FALSE AND `Alarms`.`Acknowledged` = FALSE) OR `Alarms`.`Pinned` = TRUE) AND
`MarkedForDeletion` = FALSE AND
`DeviceId` = #testDevice
;
This is because MariaDB is clever enough to use index merges, e.g.:
id select_type table type possible_keys
1 SIMPLE Alarms index_merge Key1,Key2,Key3,Key4,Key5,Key6
key key_len ref rows Extra
Key1,Key2,Key3 2,1,17 (NULL) 2 Using union(Key1,intersect(Key2,Key3)); Using where
However if I use the dataset as populated by the procedure above, and flip the query around a bit (which is another view I need, but in this case will return many more rows):
SELECT * FROM `Alarms`
WHERE
((`Alarms`.`Ended` = TRUE OR `Alarms`.`Acknowledged` = TRUE) AND `Alarms`.`Pinned` = FALSE) AND
`MarkedForDeletion` = FALSE AND
`DeviceId` = #testDevice
;
… it doesn't:
id select_type table type possible_keys
1 SIMPLE Alarms ref Key1,Key2,Key3,Key4,Key5,Key6
key key_len ref rows Extra
Key2 1 const 144706 Using where
I would rather like the index merges to happen more often. As it is, given the ref=const, this query plan doesn't look too scary … however, the query takes almost a second to run. That in itself isn't the end of the world, but the poorly-scaling nature of my design shows when trying a more exotic query, which takes a very long time:
-- Create a temporary table that we'll join against in a mo
DROP TABLE IF EXISTS `_ranges`;
CREATE TEMPORARY TABLE `_ranges` (
`Start` TIMESTAMP NOT NULL DEFAULT 0,
`End` TIMESTAMP NOT NULL DEFAULT 0,
PRIMARY KEY(`Start`, `End`)
);
-- Populate it (in reality this is performed by my application layer)
SET #endtime = 1518992216;
SET #starttime = #endtime - 86400;
SET #inter = 900;
DROP PROCEDURE IF EXISTS `populateRanges`;
DELIMITER ;;
CREATE PROCEDURE populateRanges()
BEGIN
REPEAT
INSERT IGNORE INTO `_ranges` VALUES(FROM_UNIXTIME(#starttime),FROM_UNIXTIME(#starttime + #inter));
SET #starttime = #starttime + #inter;
UNTIL #starttime > #endtime END REPEAT;
END;;
DELIMITER ;
CALL populateRanges();
-- Actual query
SELECT UNIX_TIMESTAMP(`_ranges`.`Start`) AS `Start_TS`,
COUNT(`Alarms`.`AlarmId`) AS `n`
FROM `_ranges`
LEFT JOIN `Alarms`
ON `Alarms`.`StartedAt` < `_ranges`.`End`
AND (`Alarms`.`EndedAt` IS NULL OR `Alarms`.`EndedAt` >= `_ranges`.`Start`)
AND ((`Alarms`.`EndedAt` IS NULL AND `Alarms`.`Acknowledged` = FALSE) OR `Alarms`.`Pinned` = TRUE)
-- Again, the above condition is sometimes replaced by:
-- AND ((`Alarms`.`EndedAt` IS NOT NULL OR `Alarms`.`Acknowledged` = TRUE) AND `Alarms`.`Pinned` = FALSE)
AND `DeviceId` = #testDevice
AND `MarkedForDeletion` = FALSE
GROUP BY `_ranges`.`Start`
(This query is supposed to gather a list of counts per time slice, each count indicating how many alarms' [StartedAt,EndedAt] range intersects that time slice. The result populates a line graph.)
Again, when I designed these tables and there weren't many rows in them, index merges seemed to make everything whiz along. But now not so: with the dataset as given in injectAlarms(), this takes 40 seconds to complete!
I noticed this when adding the MarkedForDeletion column and performing some of my first large-dataset scale tests. This is why my choice of indexes doesn't make a big deal out of the presence of MarkedForDeletion, though the results described above are the same if I remove AND MarkedForDeletion = FALSE from my queries; however, I've kept the condition in, as ultimately I will need it to be there.
I've tried a few USE INDEX/FORCE INDEX combinations, but it never seems to use index merge as a result.
What indexes can I define to make this table behave quickly in the given cases? Or how can I restructure my queries to achieve the same goal?
(Above query plans obtained on MariaDB 5.5.56/CentOS 7, but solution must also work on MySQL 5.1.73/CentOS 6.)
Wow! That's the most complicated "index merge" I have seen.
Usually (perhaps always), you can make a 'composite' index to replace an index-merge-intersect, and perform better. Change key2 from just (pinned) to (pinned, DeviceId). This may get rid of the 'intersect' and speed it up.
In general, the Optimizer uses index merge only in desperation. (I think this is the answer to the title question.) Any slight changes to the query or the values involved, and the Optimizer will perform the query without index merge.
An improvement on the temp table __codes is to build a permanent table with a large range of values, then use a range of values from that table inside your Proc. If you are using MariaDB, then use the dynamically built "sequence" table. For example the 'table' seq_1_to_100 is effectively a table of one column with numbers 1..100. No need to declare it or populate it.
You can get rid of the other REPEAT loop by computing the time from Code.
Avoiding LOOPs will be the biggest performance benefit.
Get all that done, then I may have other tips.

Set update row id to OUT parameter in MySQL

The table tbtable contains the following columns.
The procedure to create or update an entry in tbtable is the following.
CREATE PROCEDURE `createOrUpdateTbTable` (
IN `this_pid` INT UNSIGNED,
IN `this_sid` INT UNSIGNED,
IN `this_ri` LONGBLOB,
IN `this_defaults` TINYINT,
IN `this_approved` TINYINT,
OUT `id` INT UNSIGNED
)
BEGIN
UPDATE `tbtable` SET
`ri` = this_ri, `defaults` = this_defaults, `approved` = this_approved
WHERE `pid` = this_pid AND `sid` = this_sid;
IF ROW_COUNT() = 0
THEN
INSERT INTO `tbtable` (`pid`, `sid`, `ri`, `defaults`, `approved`)
VALUES (this_pid, this_sid, this_ri, this_defaults, this_approved);
SET id = LAST_INSERT_ID();
END IF;
END
Right now I don't have any way to get the id of an entry when an update occurs. To what script should I change my current createOrUpdate method so that I can also retrieve the id when an update happens?
I checked other similar questions but they don't have any OUT parameter, so not applicable for my case.
Thanks.
EDIT:
BEGIN
IF EXISTS (SELECT*FROM `tbtable` WHERE `pid` = this_pid AND `sid` = this_sid)
THEN
UPDATE `tbtable`
SET
`ri` = this_ri, `defaults` = this_defaults, `approved` = this_approved
WHERE `pid` = this_pid AND `sid` = this_sid;
SET id = `id` ;
ELSE
INSERT INTO `tbtable` (`pid`, `sid`, `ri`, `defaults`, `approved`)
VALUES (this_pid, this_sid, this_ri, this_defaults, this_approved);
SET id = LAST_INSERT_ID();
END IF;
END
I tried this approach as well, but the id is null when there is an update.
We could run a SELECT t.myid INTO v_id FROM t WHERE ... statement to store a value into a local procedure variable.
Or, we could set a user-defined variable.
Note that the same identifier might be used for a routine parameter, a local variable and a column. A routine parameter takes precedence over a table column.
In the general case, an UPDATE statement can affect more than one row, so we could have multiple rows. The procedure argument is a scalar, so we would need to decide which of the rows we want to return the id from.
Assuming that id column is guaranteed to be non-NULL in the (unfortunately named) tbtable table...
BEGIN
DECLARE lv_id BIGINT DEFAULT NULL;
-- test if row(s) exist, and fetch lowest id value of from matching rows
SELECT t.id
INTO lv_id -- save retrieved id value into procedure variable
FROM tbtable t
WHERE t.pid = this_pid
AND t.sid = this_sid
ORDER BY t.id
LIMIT 1
;
-- if we got a non-NULL value returned
IF lv_id IS NOT NULL THEN
-- do the update
UPDATE `tbtable` t
SET t.ri = this_ri
, t.defaults = this_defaults
, t.approved = this_approved
WHERE t.pid = this_pid
AND t.sid = this_sid
;
ELSE
INSERT INTO `tbtable` (`pid`, `sid`, `ri`, `defaults`, `approved`)
VALUES (this_pid, this_sid, this_ri, this_defaults, this_approved)
;
SET lv_id = LAST_INSERT_ID();
END IF;
-- set OUT parameter
SET id = lv_id ;
END$$
Note that this procedure is subject to a race condition, with a simultaneous DELETE operation from another session. Our SELECT statement could return an id for a matching row, and another session could DELETE that row, and then our update runs, and doesn't find the row. Timing here is pretty tight, it would be difficult to demonstrate this without adding a delay into the procedure, like a SELECT WAIT(15); right before the UPDATE (to give us fifteen seconds to run a delete from another session.)
You try to return a single value but your update statement could be executed in multiple rows. So when you return the id from that type of updated statement , you need to loop through the updated rows and return any one of those updated row values (because you expect that the combination of pid and sid is unique). Here is sample code without the rid columns as i do not want to create a temporary database with that :)
CREATE PROCEDURE createOrUpdateTbTable (
IN this_pid INT UNSIGNED,
IN this_sid INT UNSIGNED,
IN this_ri LONGBLOB,
IN this_defaults TINYINT,
IN this_approved TINYINT,
OUT id INT UNSIGNED
)
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE updated_id INT;
DECLARE updatedIds CURSOR FOR SELECT tbtable.id FROM tbtableWHERE pid = this_pid AND sid = this_sid;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
IF EXISTS (SELECT*FROM `tbtable` WHERE `pid` = this_pid AND `sid` = this_sid)
THEN
UPDATE `tbtable`
SET
`defaults` = this_defaults, `approved` = this_approved
WHERE `pid` = this_pid AND `sid` = this_sid;
OPEN updatedIds;
read_loop: LOOP
FETCH updatedIds INTO updated_id;
SET id = updated_id;
IF done THEN
LEAVE read_loop;
END IF;
END LOOP;
CLOSE updatedIds;
ELSE
INSERT INTO `tbtable` (`pid`, `sid`, `defaults`, `approved`)
VALUES (this_pid, this_sid, this_defaults, this_approved);
SET id = LAST_INSERT_ID();
END IF;END
You need explicit return the value at the end:
IF ROW_COUNT() = 0
THEN
INSERT INTO `tbplanhassurface` (`planid`, `surfaceid`, `roi`, `defaultsurface`, `approved`)
VALUES (this_planid, this_surfaceid, this_roi, this_defaultsurface, this_approved);
SET id = LAST_INSERT_ID();
ELSE
SELECT #id = your_id_field
FROM `tbplanhassurface`
WHERE `planid` = this_planid
AND `surfaceid` = this_surfaceid;
END IF;
SELECT #id;
END

Check if a column is null or not before inserting another record in SQL

I need to check first if the EndTime column in my table is null or not before I can insert another record. If the Endtime column is not null than a new record can be inserted else an error must be thrown. I'm not sure how to create the error in SQL.
This is what I tried but it doesn't work
ALTER PROCEDURE [dbo].[AddDowntimeEventStartByDepartmentID]
(#DepartmentId int,
#CategoryId int,
#StartTime datetime,
#Comment varchar(100) = NULL)
AS
BEGIN TRY
PRINT N'Starting execution'
SET #StartTime = COALESCE(#StartTime, CURRENT_TIMESTAMP);
INSERT INTO DowntimeEvent(DepartmentId, CategoryId, StartTime, EndTime, Comment)
WHERE EndTime = NULL
OUTPUT
inserted.EventId, inserted.DepartmentId,
inserted.CategoryId, inserted.StartTime,
inserted.EndTime, inserted.Comment
VALUES(#DepartmentId, #CategoryId, #StartTime, NULL, #Comment)
END TRY
BEGIN CATCH
SELECT ERROR_NUMBER(),ERROR_MESSAGE()
END CATCH
Here is my table:
CREATE TABLE [dbo].[DowntimeEvent](
[EventId] [int] IDENTITY(0,1) NOT NULL,
[DepartmentId] [int] NOT NULL,
[CategoryId] [int] NOT NULL,
[StartTime] [datetime] NOT NULL,
[EndTime] [datetime] NULL,
[Comment] [varchar](100) NULL,
)
You could use the INSERT...SELECT syntax instead of INSERT...VALUES to be able to use a WHERE clause (with a different condition to the one you tried to use, see below), then check the number of affected rows and raise an error if it is 0:
...
BEGIN TRY
...
INSERT INTO DowntimeEvent
...
SELECT #DepartmentId, #CategoryId, #StartTime, NULL, #Comment
WHERE NOT EXISTS (
SELECT *
FROM dbo.DowntimeEvent
WHERE DepartmentId = #DepartmentId
AND CategoryId = #CategoryId
AND EndTime IS NULL
);
IF ##ROWCOUNT = 0
RAISERROR ('A NULL row already exists!', 16, 1)
;
END TRY
BEGIN CATCH
...
END CATCH;
(Of course, you will need to omit your WHERE clause as invalid Transact-SQL.)
If you want a prevention mechanism at the database level rather than just in your stored procedure, so as to be able to prevent invalid additions from any caller, you may want to consider a trigger.
A FOR INSERT trigger like this would check if new rows violate the rule "Do not add rows newer than the existing NULL row" (as well as "Do not add older rows with empty EndTime") and roll back the transaction if they do:
CREATE TRIGGER DowntimeEvent_CheckNew
ON dbo.DowntimeEvent
FOR INSERT, UPDATE
-- do nothing if EndTime is not affected
IF NOT UPDATE(EndTime)
RETURN
;
-- raise an error if there is an inserted NULL row
-- older than another existing or inserted row
IF EXISTS (
SELECT *
FROM dbo.DowntimeEvent AS t
WHERE t.EndTime IS NULL
AND EXISTS (
SELECT *
FROM inserted AS i
WHERE i.DepartmentId = t.DepartmentId
AND i.CategoryId = t.CategoryId
AND i.StartTime >= t.StartTime
)
)
BEGIN
RAISERROR ("An attempt to insert an older NULL row!", 16, 1);
ROLLBACK TRANSACTION;
END;
-- raise an error if there is an inserted row newer
-- than the existing NULL row or an inserted NULL row
IF EXISTS (
SELECT *
FROM inserted AS i
WHERE i.EndTime IS NULL
AND EXISTS (
SELECT *
FROM dbo.DowntimeEvent AS t
WHERE t.DepartmentId = i.DepartmentId
AND t.CategoryId = i.CategoryId
AND t.StartTime >= i.StartTime
)
)
BEGIN
RAISERROR ("An older NULL row exists!", 16, 1);
ROLLBACK TRANSACTION;
END;
Note that merely issuing ROLLBACK TRANSACTION in a trigger already implies raising a level 16 error like this:
Msg 3609, Level 16, State 1, Line nnn
The transaction ended in the trigger. The batch has been aborted.
so, you may not need your own. There would be a difference in the meaning of Line nnn between the message above and the one brought by your own RAISERROR, however: the line number in the former would refer to the location of the triggering statement, whereas the line number in the latter would refer to a position in your trigger.

IF ELSE with NULL values mysql

im having problems creating a function that must return the amount of goals of a team as a home and away
First it sums all local goals, and then it sums all visitor goals, and saves into variables, but the problem is, i want to know what can i do if there is NO data, i mean, what can i do when it returns NULL, i tried with IF ELSE but still not working, here is the code:
CREATE DEFINER=`root`#`localhost` FUNCTION `vgoles`(`veq` int) RETURNS int(11)
BEGIN
#Routine body goes here...
DECLARE vgloc INT;
DECLARE vgvis INT;
DECLARE vgoles INT;
SELECT SUM(gloc) INTO #vgloc FROM partidos WHERE eqloc=#veq;
SELECT SUM(gvis) INTO #vgvis FROM partidos WHERE eqvis=#veq;
IF #vgloc = NULL THEN
SET #vgloc = 0;
END IF;
IF #vgvis = NULL THEN
SET #vgvis = 0;
END IF;
SET #vgoles=#vgloc+#vgvis;
RETURN #vgoles;
END
Thanks and have a nice day
The IF #vgloc = NULL THEN does not work as you expect because you can't check NULL with equality (=). NULL is a special value that is not equal to anything, not even to itself.
SELECT (3 = NULL) --yields--> NULL
SELECT NOT (3 = NULL) --yields--> NULL
SELECT (NULL = NULL) --yields--> NULL
SELECT (NULL <> NULL) --yields--> NULL
SELECT (NULL IS NULL) --yields--> TRUE
To check for NULL value, you need: IF #vgloc IS NULL THEN.
But you can also use COALESCE() function for further simplicity:
SELECT COALESCE(SUM(gloc),0) INTO #vgloc FROM partidos WHERE eqloc=#veq;
SELECT COALESCE(SUM(gvis),0) INTO #vgvis FROM partidos WHERE eqvis=#veq;