Flyway fail on error in Transact-SQL migration - tsql

When using Flyway in combination with a Microsoft SQL Server, we are observing the issue described on this question.
Basically, a migration script like this one does not rollback the successful GO-delimited batches when another part failed:
BEGIN TRANSACTION
-- Create a table with two nullable columns
CREATE TABLE [dbo].[t1](
[id] [nvarchar](36) NULL,
[name] [nvarchar](36) NULL
)
-- add one row having one NULL column
INSERT INTO [dbo].[t1] VALUES(NEWID(), NULL)
-- set one column as NOT NULLABLE
-- this fails because of the previous insert
ALTER TABLE [dbo].[t1] ALTER COLUMN [name] [nvarchar](36) NOT NULL
GO
-- create a table as next action, so that we can test whether the rollback happened properly
CREATE TABLE [dbo].[t2](
[id] [nvarchar](36) NOT NULL
)
GO
COMMIT TRANSACTION
In the above example, the table t2 is being created even though the preceding ALTER TABLE statement fails.
On the linked question, the following approaches (outside of the flyway context) were suggested:
A multi-batch script should have a single error handler scope that rolls back the transaction on error, and commits at the end. In TSQL you can do this with dynamic sql
Dynamic SQL makes for hard-to-read script and would be very inconvenient
With SQLCMD you can use the -b option to abort the script on error
Is this available in flyway?
Or roll your own script runner
Is this maybe the case in flyway? Is there a flyway-specific configuration to enable proper failing on errors?
EDIT: alternative example
Given: simple database
BEGIN TRANSACTION
CREATE TABLE [a] (
[a_id] [nvarchar](36) NOT NULL,
[a_name] [nvarchar](100) NOT NULL
);
CREATE TABLE [b] (
[b_id] [nvarchar](36) NOT NULL,
[a_name] [nvarchar](100) NOT NULL
);
INSERT INTO [a] VALUES (NEWID(), 'name-1');
INSERT INTO [b] VALUES (NEWID(), 'name-1'), (NEWID(), 'name-2');
COMMIT TRANSACTION
Migration Script 1 (failing, without GO)
BEGIN TRANSACTION
ALTER TABLE [b] ADD [a_id] [nvarchar](36) NULL;
UPDATE [b] SET [a_id] = [a].[a_id] FROM [a] WHERE [a].[a_name] = [b].[a_name];
ALTER TABLE [b] ALTER COLUMN [a_id] [nvarchar](36) NOT NULL;
ALTER TABLE [b] DROP COLUMN [a_name];
COMMIT TRANSACTION
This results in the error message Invalid column name 'a_id'. for the UPDATE statement.
Possible solution: introduce GO between statements
Migration Script 2 (with GO: working for "happy case" but only partial rollback when there's an error)
BEGIN TRANSACTION
SET XACT_ABORT ON
GO
ALTER TABLE [b] ADD [a_id] [nvarchar](36) NULL;
GO
UPDATE [b] SET [a_id] = [a].[a_id] FROM [a] WHERE [a].[a_name] = [b].[a_name];
GO
ALTER TABLE [b] ALTER COLUMN [a_id] [nvarchar](36) NOT NULL;
GO
ALTER TABLE [b] DROP COLUMN [a_name];
GO
COMMIT TRANSACTION
This performs the desired migration as long as all values in table [b] have a matching entry in table [a].
In the given example, that's not the case. I.e. we get two errors:
expected: Cannot insert the value NULL into column 'a_id', table 'test.dbo.b'; column does not allow nulls. UPDATE fails.
unexpected: The COMMIT TRANSACTION request has no corresponding BEGIN TRANSACTION.
Horrifyingly: the last ALTER TABLE [b] DROP COLUMN [a_name] statement was actually executed, committed and not rolled back. I.e. one cannot fix this up afterwards as the linking column is lost.
This behaviour is actually independent of flyway and can be reproduced directly via SSMS.

The problem is fundamental to the GO command. It's not a part of the T-SQL language. It's a construct in use within SQL Server Management Studio, sqlcmd, and Azure Data Studio. Flyway is simply passing the commands on to your SQL Server instance through the JDBC connection. It's not going to be dealing with those GO commands like the Microsoft tools do, separating them into independent batches. That's why you won't see individual rollbacks on errors, but instead see a total rollback.
The only way to get around this that I'm aware of would be to break apart the batches into individual migration scripts. Name them in such a way so it's clear, V3.1.1, V3.1.2, etc. so that everything is under the V3.1* version (or something similar). Then, each individual migration will pass or fail instead of all going or all failing.

Edited 20201102 -- learned a lot more about this and largely rewrote it! So far have been testing in SSMS, do plan to test in Flyway as well and write up a blog post. For brevity in migrations, I believe you could put the ##trancount check / error handling into a stored procedure if you prefer, that's also on my list to test.
Ingredients in the fix
For error handling and transaction management in SQL Server, there are three things which may be of great help:
Set XACT_ABORT to ON (it is off by default). This setting "specifies whether SQL Server automatically rolls back the current transaction when a Transact-SQL statement raises a runtime error" docs
Check ##TRANCOUNT state after each batch delimiter you send and using this to "bail out" with RAISERROR / RETURN if needed
Try/catch/throw -- I'm using RAISERROR in these examples, Microsoft recommends you use THROW if it's available to you (it's available SQL Server 2016+ I think) - docs
Working on the original sample code
Two changes:
Set XACT_ABORT ON;
Perform a check on ##TRANCOUNT after each batch delimiter is sent to see if the next batch should be run. The key here is that if an error has occurred, ##TRANCOUNT will be 0. If an error hasn't occurred, it will be 1. (Note: if you explicitly open multiple "nested" transactions you'd need to adjust trancount checks as it can be higher than 1)
In this case the ##TRANCOUNT check clause will work even if XACT_ABORT is off, but I believe you want it on for other cases. (Need to read up more on this, but I haven't come across a downside to having it ON yet.)
BEGIN TRANSACTION;
SET XACT_ABORT ON;
GO
-- Create a table with two nullable columns
CREATE TABLE [dbo].[t1](
[id] [nvarchar](36) NULL,
[name] [nvarchar](36) NULL
)
-- add one row having one NULL column
INSERT INTO [dbo].[t1] VALUES(NEWID(), NULL)
-- set one column as NOT NULLABLE
-- this fails because of the previous insert
ALTER TABLE [dbo].[t1] ALTER COLUMN [name] [nvarchar](36) NOT NULL
GO
IF ##TRANCOUNT <> 1
BEGIN
DECLARE #ErrorMessage AS NVARCHAR(4000);
SET #ErrorMessage
= N'Transaction in an invalid or closed state (##TRANCOUNT=' + CAST(##TRANCOUNT AS NVARCHAR(10))
+ N'). Exactly 1 transaction should be open at this point. Rolling-back any pending transactions.';
RAISERROR(#ErrorMessage, 16, 127);
RETURN;
END;
-- create a table as next action, so that we can test whether the rollback happened properly
CREATE TABLE [dbo].[t2](
[id] [nvarchar](36) NOT NULL
)
GO
COMMIT TRANSACTION;
Alternative example
I added a bit of code at the top to be able to reset the test database. I repeated the pattern of using XACT_ABORT ON and checking ##TRANCOUNT after each batch terminator (GO) is sent.
/* Reset database */
USE master;
GO
IF DB_ID('transactionlearning') IS NOT NULL
BEGIN
ALTER DATABASE transactionlearning
SET SINGLE_USER
WITH ROLLBACK IMMEDIATE;
DROP DATABASE transactionlearning;
END;
GO
CREATE DATABASE transactionlearning;
GO
/* set up simple schema */
USE transactionlearning;
GO
BEGIN TRANSACTION;
CREATE TABLE [a]
(
[a_id] [NVARCHAR](36) NOT NULL,
[a_name] [NVARCHAR](100) NOT NULL
);
CREATE TABLE [b]
(
[b_id] [NVARCHAR](36) NOT NULL,
[a_name] [NVARCHAR](100) NOT NULL
);
INSERT INTO [a]
VALUES
(NEWID(), 'name-1');
INSERT INTO [b]
VALUES
(NEWID(), 'name-1'),
(NEWID(), 'name-2');
COMMIT TRANSACTION;
GO
/*******************************************************/
/* Test transaction error handling starts here */
/*******************************************************/
USE transactionlearning;
GO
BEGIN TRANSACTION;
SET XACT_ABORT ON;
GO
IF ##TRANCOUNT <> 1
BEGIN
DECLARE #ErrorMessage AS NVARCHAR(4000);
SET #ErrorMessage
= N'Check 1: Transaction in an invalid or closed state (##TRANCOUNT=' + CAST(##TRANCOUNT AS NVARCHAR(10))
+ N'). Exactly 1 transaction should be open at this point. Rolling-back any pending transactions.';
RAISERROR(#ErrorMessage, 16, 127);
RETURN;
END;
ALTER TABLE [b] ADD [a_id] [NVARCHAR](36) NULL;
GO
IF ##TRANCOUNT <> 1
BEGIN
DECLARE #ErrorMessage AS NVARCHAR(4000);
SET #ErrorMessage
= N'Check 2: Transaction in an invalid or closed state (##TRANCOUNT=' + CAST(##TRANCOUNT AS NVARCHAR(10))
+ N'). Exactly 1 transaction should be open at this point. Rolling-back any pending transactions.';
RAISERROR(#ErrorMessage, 16, 127);
RETURN;
END;
UPDATE [b]
SET [a_id] = [a].[a_id]
FROM [a]
WHERE [a].[a_name] = [b].[a_name];
GO
IF ##TRANCOUNT <> 1
BEGIN
DECLARE #ErrorMessage AS NVARCHAR(4000);
SET #ErrorMessage
= N'Check 3: Transaction in an invalid or closed state (##TRANCOUNT=' + CAST(##TRANCOUNT AS NVARCHAR(10))
+ N'). Exactly 1 transaction should be open at this point. Rolling-back any pending transactions.';
RAISERROR(#ErrorMessage, 16, 127);
RETURN;
END;
ALTER TABLE [b] ALTER COLUMN [a_id] [NVARCHAR](36) NOT NULL;
GO
IF ##TRANCOUNT <> 1
BEGIN
DECLARE #ErrorMessage AS NVARCHAR(4000);
SET #ErrorMessage
= N'Check 4: Transaction in an invalid or closed state (##TRANCOUNT=' + CAST(##TRANCOUNT AS NVARCHAR(10))
+ N'). Exactly 1 transaction should be open at this point. Rolling-back any pending transactions.';
RAISERROR(#ErrorMessage, 16, 127);
RETURN;
END;
ALTER TABLE [b] DROP COLUMN [a_name];
GO
COMMIT TRANSACTION;
My fave references on this topic
There is a wonderful free resource online which digs into error and transaction handling in great detail. It is written and maintained by Erland Sommarskog:
Part One – Jumpstart Error Handling
Part Two – Commands and Mechanisms
Part Three – Implementation
One common question is why XACT_ABORT is still needed/ if it is entirely replaced by TRY/CATCH. Unfortunately it is not entirely replaced, and Erland has some examples of this in his paper, this is a good place to start on that.

Related

IF statement is not accepted in MySQL trigger, issuing Code 1064 but no reference into the statement

I'm using MariaDB (10.3.29-MariaDB-0+deb10u1) and DbVisualizer (Pro 12.1.1 [Build #3237]) as an IDE.
I want to manage the primary key of a table by having MariaDB set the key to a UUID, I started with this
CREATE TRIGGER before_pkey_maintenance
BEFORE INSERT ON maintenance
FOR EACH ROW
SET NEW.pkey = uuid();
which worked perfectly, except that I started providing my own UUIDs, which the trigger over-wrote, of course. I tried the following to create a UUID only if one was not provided:
CREATE TRIGGER before_pkey_maintenance
BEFORE INSERT ON maintenance
FOR EACH ROW
IF NEW.pkey IS NULL THEN
SET NEW.pkey = uuid();
END IF;
The problem is, when I execute the create, I get the following error:
[Code: 1064, SQL State: 42000] (conn=1002) You have an error in your SQL syntax;
check the manual that corresponds to your MariaDB server version for the right syntax
to use near '' at line 5 [Script position: 132 - 168]
Error 1064 is documented (https://riptutorial.com/mysql/example/2995/error-code-1064--syntax-error) as not correctly using back-ticks, which I can deal with ... except that the message is referring to "syntax to use near '' at line 5", the empty string in the message is less than helpful.
I found found MySQL Trigger with IF statement returning #1064, which adds the use of "delimiter" and tried "|", "//", and "$$" as delimiters. I've tried MANY variations, all similar to the following:
delimiter $$
CREATE TRIGGER before_pkey_maintenance
BEFORE INSERT ON maintenance
FOR EACH ROW
IF NEW.pkey IS null THEN
SET NEW.pkey = uuid();
END IF;
END
$$
delimiter ;
... but none of them worked.
I've looked through DbVisualizer's docs and found nothing that indicates it performs "magic" behind the scenes, but I suspect that if any of the above is correct that DbVis is executing these separately.
Basically You have nor error. Copy the code from the example and use it and you will get any error anymore, you might have some kind of invisible character so delete every thing. and use theis code
CREATE tABLE maintenance (pkey VARCHAR(36))
CREATE TRIGGER before_pkey_maintenance
BEFORE INSERT ON maintenance
FOR EACH ROW
IF NEW.pkey IS null THEN
SET NEW.pkey = uuid();
END IF;
END
INSERT INTO maintenance VALUES (NULL)
✓
SELECT * FROM maintenance
| pkey |
| :----------------------------------- |
| c973e92c-3a6f-11ec-93bc-00163e55bd17 |
db<>fiddle here
here is a Version with DELIMITER
delimiter $$
CREATE TRIGGER before_pkey_maintenance
BEFORE INSERT ON maintenance
FOR EACH ROW
IF NEW.pkey IS null THEN
SET NEW.pkey = uuid();
END IF;
END
$$
delimiter ;
I found this https://mariadb.com/kb/en/trigger-keeps-creating-error/, which led to my solution:
CREATE TRIGGER before_pkey_maintenance
BEFORE INSERT ON maintenance
FOR EACH ROW
SET new.pkey = IF (NEW.pkey is null or NEW.pkey = '', uuid(), NEW.pkey);
Even though this works for this situation, it is not a complete answer because it is not suitable if the trigger needs to execute more complex code.
This is, in fact, a "feature" of dbvisualizer.
The page https://confluence.dbvis.com/display/UG121/Executing+Complex+Statements
describes how to deal with it, the key point is added an '#' to the delimiter statement and the semicolon:
#delimiter $$;
CREATE TRIGGER before_pkey_maintenance
BEFORE INSERT ON maintenance
FOR EACH ROW
IF NEW.pkey IS null THEN
SET NEW.pkey = uuid();
END IF;
$$
#delimiter ;
The DbVis page basically states that it is a level of abstraction above SQL and needs the extended syntax so that it can delimit parts of a more complex script.

Trigger before insert in MariaDB

I'm having some problem to create a new trigger before insert a new row.
It should act before insert to stop an insert of a new row that has a value that is already referenced from another row in the same table.
I tried to use this trigger but it is not compatible with mariaDB, in fact it gives me asyntax error on referencing.
CREATE TRIGGER BadgeAlreadyUsed
BEFORE INSERT ON User
REFERENCING NEW AS N
FOR EACH ROW
WHEN (EXISTS ( SELECT IDBadge FROM User WHERE N.IDBadge = User.IDBadge ))
SIGNAL SQLSTATE '70002' ('Badge already used!!');
How i can do the same thing with the new syntax?
thanks.
Each database (DB2, MariaDB, etc) has hundreds of differences. Don't assume anything!
This might be closer:
CREATE TRIGGER BadgeAlreadyUsed
BEFORE INSERT ON User
FOR EACH ROW
BEGIN
IF (EXISTS ( SELECT IDBadge FROM User
WHERE NEW.IDBadge = User.IDBadge ))
THEN
SIGNAL SQLSTATE '70002'
SET MESSAGE_TEXT = 'Badge already used!!';
END IF;
END;
Notice there there are at least 3 syntax changes (WHEN, NEW, SET).

DB2 trigger syntax

I an learning to write triggers in DB2. I need one and only 1 record in mytable have ENABLED_IND= 'Y'.
CREATE TABLE MYTABLE(
OBJECTID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1, INCREMENT BY 1, NO CACHE, NO CYCLE),
MYDATA VARCHAR(8) NOT NULL,
....
ENABLED_IND CHAR(1) NOT NULL
) IN "TS_PROF_D" INDEX IN "TS_PROF_IX" ;
when ever I insert into mytable I need all existing ENABLED_IND to be set to "N". I came up with the following, a oracle EE dba says it should work, but does not
CREATE TRIGGER MYSCHEMA.MYTRIGGER BEFORE INSERT ON MYSCHEMA.MYTABLE
FOR EACH ROW MODE DB2SQL
BEGIN ATOMIC
UPDATE MYSCHEMA.MYTABLE SET ENABLED_IND = 'N';
END
All db2 is telling me "illegal character" We don't see an illegal character. The web examples of db2 triggers are very confusing.

PL/pgSQL query in PostgreSQL returns result for new, empty table

I am learning to use triggers in PostgreSQL but run into an issue with this code:
CREATE OR REPLACE FUNCTION checkAdressen() RETURNS TRIGGER AS $$
DECLARE
adrCnt int = 0;
BEGIN
SELECT INTO adrCnt count(*) FROM Adresse
WHERE gehoert_zu = NEW.kundenId;
IF adrCnt < 1 OR adrCnt > 3 THEN
RAISE EXCEPTION 'Customer must have 1 to 3 addresses.';
ELSE
RAISE EXCEPTION 'No exception';
END IF;
END;
$$ LANGUAGE plpgsql;
I create a trigger with this procedure after freshly creating all my tables so they are all empty. However the count(*) function in the above code returns 1.
When I run SELECT count(*) FROM adresse; outside of PL/pgSQL, I get 0.
I tried using the FOUND variable but it is always true.
Even more strangely, when I insert some values into my tables and then delete them again so that they are empty again, the code works as intended and count(*) returns 0.
Also if I leave out the WHERE gehoert_zu = NEW.kundenId, count(*) returns 0 which means I get more results with the WHERE clause than without.
--Edit:
Here is an example of how I use the procedure:
CREATE TABLE kunde (
kundenId int PRIMARY KEY
);
CREATE TABLE adresse (
id int PRIMARY KEY,
gehoert_zu int REFERENCES kunde
);
CREATE CONSTRAINT TRIGGER adressenKonsistenzTrigger AFTER INSERT ON Kunde
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE PROCEDURE checkAdressen();
INSERT INTO kunde VALUES (1);
INSERT INTO adresse VALUES (1,1);
It looks like I am getting the DEFERRABLE INITIALLY DEFERRED part wrong. I assumed the trigger would be executed after the first INSERT statement but it happens after the second one, although the inserts are not inside a BEGIN; - COMMIT; - Block.
According to the PostgreSQL Documentation inserts are commited automatically every time if not inside such a block and thus there shouldn't be an entry in adresse when the first INSERT statement is commited.
Can anyone point out my mistake?
--Edit:
The trigger and DEFERRABLE INITIALLY DEFERRED seem to be working all right.
My mistake was to assume that since I am not using a BEGIN-COMMIT-Block each insert would be executed in an own transaction with the trigger being executed afterwards every time.
However even without the BEGIN-COMMIT all inserts get bundled into one transaction and the trigger is executed afterwards.
Given this behaviour, what is the point in using BEGIN-COMMIT?
You need a transaction plus the "DEFERRABLE INITIALLY DEFERRED" because of the chicken and egg problem.
starting with two empty tables:
you cannot insert a single row into the person table, because the it needs at least one address.
you cannot insert a single row into the address table, because the FK constraint needs a corresponding row on the person table to exist
This is why you need to bundle the two inserts into one operation: the transaction. You need the BEGIN+ COMMIT, and the DEFERRABLE allows transient forbidden database states to exists: it causes the check to be evaluated at commit time.
This may seem a bit silly, but the answer is you need to stop deferring the trigger and run it BEFORE the insert. If you run it after the insert, of course there is data in the table.
As far as I can tell this is working as expected.
One further note, you probably dont mean:
RAISE EXCEPTION 'No Exception';
You probably want
RAISE INFO 'No Exception';
Then you can change your settings and run queries in transactions to test that the trigger does what you want it to do. As it is, every insert is going to fail and you have no way to move this into production without editing your procedure.

Cannot use text, ntext, or image in a trigger

Using SQL server 2008 R2, I'm getting the error:
Msg 311, Level 16, State 1, Procedure ad_user, Line 28
Cannot use text, ntext, or image columns in the 'inserted' and 'deleted' tables.
The purpose of the trigger is to update a user-group table when a new user is inserted. I've only included the SQL up to where the error occurs.). What confuses me is if I remove one of the integer declarations, I don't get the same error (just errors about not having declared the variable).
CREATE trigger [dbo].[ad_user] on [dbo].[tps_user]
FOR INSERT
AS
DECLARE #UserGuid uniqueidentifier
DECLARE #EndUserTypeGuid uniqueidentifier
DECLARE #UserTypeGuid uniqueidentifier
DECLARE #saGuid uniqueidentifier
DECLARE #GroupGuid uniqueidentifier
DECLARE #NewUser VarChar(250)
DECLARE #deptnum VarChar(250)
DECLARE #locnum VarChar(250)
DECLARE #CN VarChar(250)
DECLARE #NewOU VarChar(250)
DECLARE #pos1 integer
DECLARE #pos2 integer
BEGIN
SELECT #EndUserTypeGuid=tps_guid FROM tps_user_type WHERE tps_name='EndUser'
SELECT #saGuid = tps_guid FROM tps_user WHERE tps_title = 'SA'
SELECT #UserGuid=tps_guid,
#UserTypeGuid=tps_user_type_guid,
#NewUser=tps_title,
#deptnum=usr_departmentnumber,
#locnum=usr_locationnumber,
#CN=usr_ou
FROM inserted
IF #UserTypeGuid=#EndUserTypeGuid
BEGIN
SELECT #GroupGuid=tps_guid FROM tps_group WHERE usr_departmentnumber=#deptnum
IF #GroupGuid IS NOT NULL
BEGIN
IF #UserGuid NOT IN (SELECT tps_user_id
FROM tps_user_group WHERE tps_group_id = #GroupGuid)
BEGIN
-- Remove the user from other groups
DELETE FROM tps_user_group WHERE tps_user_id = #UserGuid;
-- Create Customer Group Membership from department
INSERT INTO tps_user_group(tps_user_id, tps_group_id, tps_creation_user_guid,
tps_last_update_user_guid, tps_creation_date, tps_last_update)
VALUES(#UserGuid, #GroupGuid, #saGuid, #saGuid, GetDate(), GetDate());
END
END
END
END
I have tested this and this error message will come at compile time, not at runtime, when you explicitly reference such a column in the inserted / deleted pseudo-tables. Unfortunately I think you will have to correct these columns in the underlying table in order to use them, since you can't just apply conversions against the columns in inserted.
What is blocking the client from upgrading these columns to use proper, first-class data types that haven't been deprecated since SQL Server 2005 for many good reasons (including this one)?
You'll need to re-write your trigger anyway. It currently is not multi-row safe. The trigger won't break once you have the data type corrected, it will just pick an arbitrary row from inserted and ignore the rest. So it needs to treat inserted as a set, not as a single row, since triggers in SQL Server fire per statement, not per row.