Why does this CASE based update cause replication skew? - mysql

Using MySQL 5.6 with statement based replication between a single master and slave, the following scenario creates replication skew:
Create this table:
CREATE TABLE `ReplicationTest` (
`TestId` int(11) NOT NULL,
`Tokens` int(11) NOT NULL,
PRIMARY KEY (`TestId`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1$$
Insert this data:
Insert into ReplicationTest (TestId, Tokens) VALUES
(1, 0),
(2, 0),
(3, 0),
(4, 0);
Create this proc:
CREATE PROCEDURE `MyDatabase`.`ReplicationTestProc` (vTestId int, pSuccessful BIT, pAmount DECIMAL(19, 4))
BEGIN
START TRANSACTION;
Update MyDatabase.ReplicationTest Set Tokens = CASE WHEN pSuccessful THEN Tokens - (pAmount * 100) ELSE Tokens END
where TestId = vTestId;
COMMIT;
END
Run these 4 statements in a single execution
call `MyDatabase`.`ReplicationTestProc`(1, 1, 1);
call `MyDatabase`.`ReplicationTestProc`(2, 1, 1);
call `MyDatabase`.`ReplicationTestProc`(3, 1, 1);
call `MyDatabase`.`ReplicationTestProc`(4, 0, 1);
You will now have different values in the ReplicationTest table between master and replication. It looks like the pSuccessful variable is being treated as a global, with the last value set the one that is being used on the replication server.

You can get a clue what's happening by observing what statements actually get logged in the binary log. View the binlog with:
$ mysqlbinlog <datadir>/mysql-bin.000001
(Of course the filename containing these statements is probably different on your system.)
The statements inside the called procedure are logged to the binary log, but constant values that are based one procedure variables are slightly modified. This is explained in http://dev.mysql.com/doc/refman/5.6/en/stored-programs-logging.html:
A statement to be logged might contain references to local procedure variables. These variables do not exist outside of stored procedure context, so a statement that refers to such a variable cannot be logged literally. Instead, each reference to a local variable is replaced by this construct for logging purposes.
Here's what we see in the binlog for your test. The references to local variables have been replaced with a string representation of the BIT values:
. . .
Update test.ReplicationTest Set Tokens = CASE
WHEN NAME_CONST('pSuccessful',_binary'' COLLATE 'binary')
THEN Tokens - ( NAME_CONST('pAmount',1.0000) * 100) ELSE Tokens END
where TestId = NAME_CONST('vTestId',1)
. . .
Update test.ReplicationTest Set Tokens = CASE
WHEN NAME_CONST('pSuccessful',_binary'' COLLATE 'binary')
THEN Tokens - ( NAME_CONST('pAmount',1.0000) * 100) ELSE Tokens END
where TestId = NAME_CONST('vTestId',2)
. . .
Update test.ReplicationTest Set Tokens = CASE
WHEN NAME_CONST('pSuccessful',_binary'' COLLATE 'binary')
THEN Tokens - ( NAME_CONST('pAmount',1.0000) * 100) ELSE Tokens END
where TestId = NAME_CONST('vTestId',3)
. . .
Update test.ReplicationTest Set Tokens = CASE
WHEN NAME_CONST('pSuccessful',_binary'\0' COLLATE 'binary')
THEN Tokens - ( NAME_CONST('pAmount',1.0000) * 100) ELSE Tokens END
where TestId = NAME_CONST('vTestId',4)
. . .
Notice how the BIT values 1 is replaced with the string literal _binary''.
When your expression evaluates that for true/false, the empty string is equivalent to false, which is why the statement doesn't update your column on the slave:
mysql> select false = _binary'';
+-------------------+
| false = _binary'' |
+-------------------+
| 1 |
+-------------------+
Why is a BIT value of 1 replaced with an empty string? BIT values are sort of strings by nature, as if using the BINARY data type. Here's an example of a BIT value corresponding to the ASCII code for 'Z' coming out as a 'Z':
mysql> select b'01011010';
+-------------+
| b'01011010' |
+-------------+
| Z |
+-------------+
How does BIT treat the value of b'1'? It's just like an ASCII value 0000001, which prints as an unprintable, zero-length string:
mysql> select b'1';
+------+
| b'1' |
+------+
| |
+------+
You can force this value to be converted to an integer by referencing it in an integer context:
mysql> select b'1'+0;
+--------+
| b'1'+0 |
+--------+
| 1 |
+--------+
But even if we were to change your stored procedure to use this trick, it's too late. The value has been converted in the binary log to an empty string _binary'' and that string can't be converted to anything but 0 in an integer context.
mysql> select _binary''+0;
+-------------+
| _binary''+0 |
+-------------+
| 0 |
+-------------+
Why isn't the BIT value converted to b'1' in the binary log like a real BIT literal? This appears to be deliberate. There's even a comment in the binary logging code for stored procedures (file sql/sp.c, function sp_get_item_value()).
/* Bit type is handled as binary string */
What I would recommend to you is to forget about using BIT for booleans in MySQL. There are too many cases of unexpected behavior of the MySQL BIT data type. For more on this, see Why you should not use BIT columns in MySQL.
Instead, use BOOLEAN or TINYINT (BOOLEAN or BOOL are really just aliases for TINYINT(1) in MySQL). I tested your procedure with the parameter changed to TINYINT, and it works as expected:
slave1 [localhost] {msandbox} (test) > select * from ReplicationTest;
+--------+--------+
| TestId | Tokens |
+--------+--------+
| 1 | -100 |
| 2 | -100 |
| 3 | -100 |
| 4 | 0 |
+--------+--------+

Related

How to use a MySQL stored procedure that generates a random 8-char long key to encrypt data again each time a the user logs in

I am working on a stored procedure in MySQL that is used to Login in a user for my polling application. I am trying to implement an algorithm that generates a new random 8-char long string that is being used as a new passphrase to re-encrypt data again.
In my particular case I am re-encrypting given answer-ids (aid) using this passphrase combined with AES. To re-encrypt the data that is already stored encrypted inside my database, the user provides his/her password to decrypt the 8-char long value that has already been stored inside the database. In other words the password is used to decrypt the old encryption-key so this key can be used to decrypt the answer-id of the question the user has voted on. Then the procedure generates a new 8-char long key to re-encrypt these answer-ids again each time the user has successfully logged in.
Unfortunately when I test my procedure, and I check the "vote" table I get NULL as the new encrypted answer id. I have done many attempts to correct this error and probably it has to do something with semantics but I haven't been able to solve it so far and I am puzzling very hard to find out what went wrong. The Login procedure calls another procedure that is responsible for extracting and decrypting already encrypted answer-ids, therefore it is using a while-loop that inserts data into a temporary table. Could you guys please help me out ?
In my opinion it is a slightly advanced procedure, maybe too advanced for my purposes. That's why I provided some comments that will lead you through all the steps being executed. This is my code:
LoginUser procedure
--------------------
-- LoginUser --
-- uname is stored encrypted by passwordhash,
-- pw is stored encrypted by unamehash,
-- key is encrypted by passwordhash,
-- aid is encrypted by key
--------------------
DROP PROCEDURE IF EXISTS LoginUser;
DELIMITER //
CREATE PROCEDURE LoginUser(IN uname CHAR(64), IN pw CHAR(64))
BEGIN
-- declare variables
DECLARE validUser, validLogin, validCombination BOOLEAN default 0;
DECLARE encryptedUsername, oldEncryptedKey, newEncryptedKey, encryptedPassword VARBINARY(32);
DECLARE plainOldKey, plainNewKey CHAR(8);
-- set encrypted username
SET encryptedUsername = (SELECT AES_ENCRYPT(uname,SHA2(pw,256)));
-- check if username exists - returns 1 for true, 0 for false
SET validUser = (SELECT EXISTS(SELECT username FROM user WHERE username = encryptedUsername));
-- logic when valid user
IF (validUser = 1) THEN
-- get pkey from user
SET oldEncryptedKey = (SELECT pkey FROM user WHERE username = encryptedUsername);
-- decrypt pkey using passwordhash
SET plainOldKey = (SELECT AES_DECRYPT(oldEncryptedKey, SHA2(pw,256)));
-- encrypt pw using plainOldKey
SET encryptedPassword = (SELECT AES_ENCRYPT(pw,SHA2(uname,256)));
-- check combination
SET validCombination = (SELECT EXISTS(SELECT * FROM user WHERE username = encryptedUsername AND `password` = encryptedPassword));
-- logic when valid combination
IF(validCombination = 1) THEN
-- set valid login to true
SET validLogin = 1;
-- generate a plainNewKey
SET plainNewKey = (SELECT SUBSTRING(MD5(RAND()) FROM 1 FOR 8));
-- set new encrypted key
SET newEncryptedKey = (SELECT AES_ENCRYPT(plainNewKey,SHA2(pw,256)));
-- call procedure to re-encrypt answer ids.
CALL EncryptAidsAgain(plainOldKey,plainNewKey,encryptedUsername);
-- update pkey from user table by newKey
UPDATE user SET pkey = newEncryptedKey WHERE username = encryptedUsername;
END IF;
END IF;
SELECT validLogin;
END //
DELIMITER ;
EncryptAidsAgain procedure
DROP PROCEDURE IF EXISTS EncryptAidsAgain;
DELIMITER //
CREATE PROCEDURE EncryptAidsAgain(IN oldKeyValue CHAR(8), IN newKeyValue CHAR(8), IN encryptedUsername VARBINARY(32))
BEGIN
DECLARE i, answered INT DEFAULT 0;
DECLARE encryptedAid, newEncryptedAid VARBINARY(32);
DECLARE plainAid CHAR(7);
-- create temporary table to store aids
CREATE TEMPORARY TABLE aids(oldEncryptedAid VARBINARY(32));
-- get amount of given answers
SET answered = (SELECT COUNT(*) FROM vote WHERE username = encryptedUsername);
-- perform while loop
WHILE i < answered DO
-- insert single aid in temporary table aids
INSERT INTO aids (oldEncryptedAid) SELECT aid FROM vote WHERE username = encryptedUsername LIMIT i, 1;
-- retrieve stored aid from aids
SET encryptedAid = (SELECT oldEncryptedAid FROM aids LIMIT i, 1);
-- decrypt stored aid from aids using oldKeyValue
SET plainAid = (SELECT AES_DECRYPT(encryptedAid, SHA2(oldKeyValue,256)));
-- set new encrypted aid
SET newEncryptedAid = (SELECT AES_ENCRYPT(plainAid,SHA2(newKeyValue,256)));
-- update table vote (aid)
UPDATE vote SET aid = newEncryptedAid WHERE username = encryptedUsername AND aid = encryptedAid;
-- increase i
SET i = i + 1;
END WHILE;
DROP TABLE aids;
END //
DELIMITER ;
test results
When call the LoginUser procedure multiple times each time the key updates well:
mysql> select * from user;
+------------------+------------------+------------------+------------------------------------------------------------------+
| username | password | pkey | ip |
+------------------+------------------+------------------+------------------------------------------------------------------+
| EÞ¯k·\â¥çJ¤EÃîP | EÞ¯k·\â¥çJ¤EÃîP | 6░ÛõaǪhpK║ÔäV | 26b92be7c4ad202f842d1755c3db56f25b39c54c51cc56642b8f14ba4bda793d |
+------------------+------------------+------------------+------------------------------------------------------------------+
4 rows in set (0.00 sec)
mysql> call LoginUser("test4","test4");
+------------+
| validLogin |
+------------+
| 1 |
+------------+
1 row in set (0.02 sec)
Query OK, 0 rows affected (0.02 sec)
mysql> select * from user;
+------------------+------------------+------------------+------------------------------------------------------------------+
| username | password | pkey | ip |
+------------------+------------------+------------------+------------------------------------------------------------------+
| EÞ¯k·\â¥çJ¤EÃîP | EÞ¯k·\â¥çJ¤EÃîP | ┴O¥╝Îìöãñ#='¹ | 26b92be7c4ad202f842d1755c3db56f25b39c54c51cc56642b8f14ba4bda793d |
+------------------+------------------+------------------+------------------------------------------------------------------+
4 rows in set (0.00 sec)
But when I login the user multiple times, the new encrypted answer-ids go NULL
mysql> select * from vote;
+-----+------------------+------+------------------+
| vid | username | qid | aid |
+-----+------------------+------+------------------+
| 3 | EÞ¯k·\â¥çJ¤EÃîP | 2 | ðlözaò3OîÙW▓B═Ï |
| 4 | EÞ¯k·\â¥çJ¤EÃîP | 1 | z)ı╝├║%~╝V&7#"─V |
+-----+------------------+------+------------------+
2 rows in set (0.00 sec)
mysql> call LoginUser("test4","test4");
+------------+
| validLogin |
+------------+
| 1 |
+------------+
1 row in set (0.03 sec)
Query OK, 0 rows affected (0.03 sec)
mysql> select * from vote;
+-----+------------------+------+------+
| vid | username | qid | aid |
+-----+------------------+------+------+
| 3 | EÞ¯k·\â¥çJ¤EÃîP | 2 | NULL |
| 4 | EÞ¯k·\â¥çJ¤EÃîP | 1 | NULL |
+-----+------------------+------+------+
2 rows in set (0.00 sec)
Thank you very much in advance, if you have any tips for a better encryption policy they are very welcome!

mysql use IF NOT NULL as part of transaction

I am using a MySQL transaction. If the initial where myHistoryNum result (match name+rowActive) is not NULL, then continue the transaction.
I have tried various combinations, however cannot get the insert statement to trigger when myHistoryNum is set.
history | uuid | name | rowActive
24 | abcd | Art | 1 <<< is match, trigger an insert
999 | zxcv | Art | 0 <<< no match, do not trigger insert
start transaction;
select #myHistNum := max(history), #olduuid := uuid
from mytable WHERE (name=art AND rowActive=1);
# START IS NOT NULL test
CASE WHEN #myHistNum IS NOT NULL THEN
set #histNum = #histNum + 1;
INSERT INTO mytable
.....
END; <<<ALT, tried END AS;
commit;
also tried
IF #myHistNum IS NOT NULL
THEN
.....
END IF;
commit;
If I'm not mistaken, control flow statements (like IF, WHEN) can only be used within stored programs in MySQL

MySQL duplicate data removal with loop

I have a table called Positions which has data like this:
Id PositionId
1 'a'
2 'a '
3 'b '
4 'b'
Some of them has spaces so my idea is to remove those spaces, this is not actual table just an example of a table which has much more data.
So i created procedure to iterate over PositionIds and compare them if trimed they match remove one of them:
CREATE PROCEDURE remove_double_positions()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE current VARCHAR(255);
DECLARE previous VARCHAR(255) DEFAULT NULL;
DECLARE positionCur CURSOR FOR SELECT PositionId FROM Positions ORDER BY PositionId;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN positionCur;
clean_duplicates: LOOP
FETCH positionCur INTO current;
IF done THEN
LEAVE clean_duplicates;
END IF;
IF previous LIKE current THEN
DELETE FROM Positions WHERE PositionId = current;
END IF;
SET previous = current;
END LOOP clean_duplicates;
CLOSE positionCur;
END
For some reason it shows that 2 rows were affected but actually deletes all 4 of them and i don't know the reason why, could you help me.
From the manual https://dev.mysql.com/doc/refman/5.7/en/string-comparison-functions.html#operator_like under the like operator - Per the SQL standard, LIKE performs matching on a per-character basis, thus it can produce results different from the = comparison operator:...In particular, trailing spaces are significant, which is not true for CHAR or VARCHAR comparisons performed with the = operator:
mysql> SELECT 'a' = 'a ', 'a' LIKE 'a ';
+------------+---------------+
| 'a' = 'a ' | 'a' LIKE 'a ' |
+------------+---------------+
| 1 | 0 |
+------------+---------------+
1 row in set (0.00 sec)
This is true when = or like is used in where or case.
Your procedure would work as desired if you amended the delete bit to
IF trim(previous) = trim(current) THEN
DELETE FROM Positions WHERE PositionId like current;
END IF;
Just some other solution without cursor and procedure. I've check it on ORACLE. Hope it helps.
DELETE FROM positions
WHERE id IN ( SELECT t1.id
FROM positions t1,
positions t2
WHERE t1.positionId = TRIM(t2.positionId)
AND t1.positionId != t2.positionId
);
UPDATE
There are some crasy things are going on with mysql. Some problem with blank at the end of a strong and this error 1093 error.
Now my solution checked with MySQL 5.5.9
CREATE TABLE positions (
id INT NOT NULL,
positionid VARCHAR(2) NOT NULL
);
INSERT INTO positions VALUES
( 1, 'a'),
( 2, 'a '),
( 3, 'b'),
( 4, 'b ');
DELETE FROM positions
WHERE id IN ( SELECT t3.id FROM
(SELECT t2.id
FROM positions t1,
positions t2
WHERE t1.positionid = t2.positionid
AND LENGTH(t1.positionid) = 1
AND length(t2.positionid) = 2
) t3
);
mysql> SELECT * from positions;
+----+------------+
| id | positionid |
+----+------------+
| 1 | a |
| 3 | b |
+----+------------+
2 rows in set (0.00 sec)
mysql>
This "double" from delete SQL will fix this error 1093
Hope this helps.

How to convert a MySQL 5.7 JSON NULL to native MySQL NULL?

This code:
SELECT JSON_EXTRACT('{"hello": null}', '$.hello')
Returns null.
However, this is not the MySQL native NULL. For example, this:
SELECT COALESCE(JSON_EXTRACT('{"hello": null}', '$.hello'), 'world')
also yields null.
How do I convert this JSON null to MySQL native NULL?
I suppose that I could use IF with some comparisons but it doesn't seem like the proper way to do it...
Unfortunately CAST('{}' AS JSON) will not work is this case,
but NULLIF works:
Full methods:
SELECT NULLIF(JSON_UNQUOTE(JSON_EXTRACT('{"hello": null}', '$.hello')), 'null') IS NULL;
Shorted:
SELECT NULLIF(helloColumn ->> '$.hello', 'null') IS NULL IS NULL;
This question is about MySQL 5.7, but I would like to add the solution for MySQL 8.0.
With MySQL 8.0.21 we have now the possibility to use the JSON_VALUE function (part of SQL Standard) to extract and optionally convert the value to a (MySQL) type.
mysql> SET #doc = '{"a": null}';
mysql> SELECT JSON_VALUE(#doc, '$.a' RETURNING SIGNED);
+------------------------------------------+
| JSON_VALUE(#doc, '$.a' RETURNING SIGNED) |
+------------------------------------------+
| NULL |
+------------------------------------------+
mysql> SET #doc = '{"a": 2}';
mysql> SELECT JSON_VALUE(#doc, '$.a' RETURNING SIGNED);
+------------------------------------------+
| JSON_VALUE(#doc, '$.a' RETURNING SIGNED) |
+------------------------------------------+
| 2 |
+------------------------------------------+
Assume you have a table called 'mytable' that contains a json column called 'data'. I usually have to use a construct like this:
SELECT
CASE WHEN data->>'$.myfield' = 'null' THEN NULL ELSE data->>'$.myfield' END myfield
FROM mytable
Or create a virtual field as follows:
ALTER TABLE mytable ADD myfield VARCHAR(200) GENERATED ALWAYS AS (
CASE WHEN data->>'$.myfield' = 'null' THEN NULL ELSE data->>'$.myfield' END
) VIRTUAL;
Looks official discussion is under progress but not much active.
Here is one more jury rigging:
mysql> SELECT CAST('null' AS JSON), JSON_TYPE(CAST('null' AS JSON)) = 'NULL';
+----------------------+------------------------------------------+
| CAST('null' AS JSON) | JSON_TYPE(CAST('null' AS JSON)) = 'NULL' |
+----------------------+------------------------------------------+
| null | 1 |
+----------------------+------------------------------------------+
1 row in set (0.00 sec)
mysql> select ##version;
+---------------+
| ##version |
+---------------+
| 5.7.21-20-log |
+---------------+
1 row in set (0.00 sec)
SELECT COALESCE(IF(JSON_TYPE(JSON_EXTRACT('{"hello":null}', '$.hello')) = 'NULL', NULL, JSON_EXTRACT('{"hello":null}', '$.hello')), 'world');
You can create a custom defined function TO_NULL by running:
-- To check if JSON value is null, if so return SQL null, and if not return original JSON value.
-- There are two nulls, SQL null and JSON null, they are not equal.
-- More information: https://stackoverflow.com/questions/37795654
DROP FUNCTION IF EXISTS TO_NULL;
CREATE FUNCTION TO_NULL(a JSON) RETURNS JSON
BEGIN
RETURN IF(JSON_TYPE(a) = 'NULL', NULL, a);
END;
And than use it like so:
SELECT TO_NULL(JSON_EXTRACT('null', '$')) IS NULL;

MySQL output masking (i.e. phone number, SSN, etc. display formatting)

Is there a built-in SQL function that will mask output data?
Let's say I have an integer column, that represents a phone number (for any country). Is there a better way to display the numbers than sub-stringing them apart, loading hashes, dashes and dot, then concatenating them back together?
I know several languages have a feature to simply mask the data as it is displayed instead of restructuring it. Does MySQL have something similar?
Here's what I came up with, if you have any modifications or improvements please leave them as comments and I will update the code. Otherwise if you like it, don't for get to bump it. Enjoy!
DELIMITER //
CREATE FUNCTION mask (unformatted_value BIGINT, format_string CHAR(32))
RETURNS CHAR(32) DETERMINISTIC
BEGIN
# Declare variables
DECLARE input_len TINYINT;
DECLARE output_len TINYINT;
DECLARE temp_char CHAR;
# Initialize variables
SET input_len = LENGTH(unformatted_value);
SET output_len = LENGTH(format_string);
# Construct formated string
WHILE ( output_len > 0 ) DO
SET temp_char = SUBSTR(format_string, output_len, 1);
IF ( temp_char = '#' ) THEN
IF ( input_len > 0 ) THEN
SET format_string = INSERT(format_string, output_len, 1, SUBSTR(unformatted_value, input_len, 1));
SET input_len = input_len - 1;
ELSE
SET format_string = INSERT(format_string, output_len, 1, '0');
END IF;
END IF;
SET output_len = output_len - 1;
END WHILE;
RETURN format_string;
END //
DELIMITER ;
Here's how to use it... It only works for integers (i.e. SSN Ph# etc.)
mysql> select mask(123456789,'###-##-####');
+-------------------------------+
| mask(123456789,'###-##-####') |
+-------------------------------+
| 123-45-6789 |
+-------------------------------+
1 row in set (0.00 sec)
mysql> select mask(123456789,'(###) ###-####');
+----------------------------------+
| mask(123456789,'(###) ###-####') |
+----------------------------------+
| (012) 345-6789 |
+----------------------------------+
1 row in set (0.00 sec)
mysql> select mask(123456789,'###-#!###(###)');
+----------------------------------+
| mask(123456789,'###-#!###(###)') |
+----------------------------------+
| 123-4!56#(789) |
+----------------------------------+
1 row in set (0.00 sec)
Let's say your People table has namelast, namefirst, and phone fields.
SELECT p.namelast, p.namefirst,
CONCAT(SUBSTR(p.phone,1,3), '-', SUBSTR(p.phone,4,3), '-', SUBSTR(p.phone,7,4)) AS Telephone,
FROM People p
The CONCAT(SUBSTR()) uses the field name for the first position, character position for the second, and number of digits for the third. Using "AS Telephone" keeps your column heading clean in your query output.
One note of caution: I'd recommend against using integers for phone numbers, social security numbers, etc. The reason is that you'll probably never perform numeric computations with these numbers. While there is the odd chance you'd use them as a primary key, there's always the possibility of losing digits from numbers with leading zeros.
Yes, mySQL does have formatting power:
http://dev.mysql.com/doc/refman/5.1/en/string-functions.html#function_format
But you've also almost answered your own question. MySQL doesn't have the same amount of power to be able to easily write a formatter based on other criteria. It can, with if statements, but the logic makes the SQL difficult to read at best. So, if you were trying to have a formatter based on country code, for example, you would have to write a lot of logic into the SQL statement which isn't the best place for it, if you have the option of another language to parse the results.