Enforce Uniqueness of Related Entities - sql-server

In a relational database (SQL), I have a parent entity that can have 0..n related child entities. The parent entity is uniquely identified in part by its collection of related child entities, such that I should not be able to have two similar parents with the same collection of children.
So I could have Parent 1 with Child 1 and Child 2, and Parent 2 with Child 2 and Child 3, but I cannot have another parent with Child 2 and Child 3.
Ideally, I would like to enforce this uniqueness using a database constraint. I've considered storing a hash of all child records with the parent, but was wondering if there was an easier / more standard way of accomplishing this.
Any ideas?

This kind of constraint is tricky because SQL has no relational equality operator, i.e. no simple way of evaluting A=B where A and B are sets of rows. Standard SQL does support nested tables but unfortunately SQL Server does not.
One possible answer is a predicate like the following, which checks for any identical families in a table:
NOT EXISTS (
SELECT 1
FROM family f, family g
WHERE f.child = g.child
AND f.parent <> g.parent
GROUP BY f.parent, g.parent
HAVING COUNT(*) = (SELECT COUNT(*) FROM family WHERE parent = f.parent)
AND COUNT(*) = (SELECT COUNT(*) FROM family WHERE parent = g.parent)
)
Notice that this query doesn't attempt to deal with childless families. In set-theoretic terms two empty sets are necessarily identical. If you want to allow for childless families then you would have to decide whether two childless families should be deemed identical or not.
SQL is not a truly relational language and it falls well short of what a relational language ought to be capable of. Tutorial D is an example of a real relational language that does support relational equality and relation-valued attributes. In Tutorial D you can in principle represent each family as a value of a single attribute in a relvar. That family attribute can also be a key and therefore duplicate families would not be allowed.

Thanks for the help from those who suggested using a trigger. This is roughly what I have and seems to be working.
CREATE TRIGGER [dbo].[trig_Parent_Child_Uniqueness]
ON [dbo].[Parent_Child]
AFTER INSERT, UPDATE
AS
BEGIN
IF EXISTS (
SELECT 1
FROM Parent p1
--Compare each pair of parents
JOIN Parent p2 ON p1.ParentId <> p2.ParentId
WHERE NOT EXISTS (
--Find any children that are different
SELECT 1
FROM (
SELECT ChildId FROM Parent_Child c1
WHERE c1.ParentId = p1.ParentId
) as c1
FULL OUTER JOIN (
SELECT ChildId FROM Parent_Child c2
WHERE c2.ParentId = p2.ParentId
) as c2 ON c2.ChildId = c1.ChildId
WHERE c1.ChildId IS NULL OR c2.ChildId IS NULL
)
) ROLLBACK;
END;
EDIT: Or a better solution, adapted from #sqlvogel
CREATE TRIGGER [dbo].[trig_Parent_Child_Uniqueness]
ON [dbo].[Parent_Child]
AFTER INSERT, UPDATE
AS
BEGIN
IF EXISTS (
SELECT 1
FROM Parent_Child p1
FULL JOIN Parent_Child p2 ON p1.ParentId <> p2.ParentId
AND p1.ChildId = p2.ChildId
GROUP BY p1.ParentId
HAVING COUNT(p1.ParentId) = COUNT(*)
AND COUNT(p2.ParentId) = COUNT(*)
) ROLLBACK;
END;

This is a bit yucky as it includes triggers and cursors :(
It includes a column in the parent table which is based upon the children
Set up:
CREATE TAble Parent
(
Id INT Primary Key,
Name VARCHAR(50),
ChildItems VARCHAR(200) NOT NULL UNIQUE
)
CREATE TABLE Child
(
Id INT Primary Key,
Name VARCHAR(50)
)
CREATE TABLE ParentChild
(
Id INT Identity Primary Key,
ParentId INT,
ChildId Int
)
Triggers
-- This gives the unique colmn a default based upon the id of the parent
CREATE TRIGGER trg_Parent ON Parent
INSTEAD OF Insert
AS
SET NOCOUNT ON
INSERT INTO Parent (Id, Name, ChildItems)
SELECT Id, Name, '/' + CAST(Id As Varchar(10)) + '/'
FROM Inserted
GO
-- This updates the parent with a path based upon child items
-- If a the exact same child items exist for another parent then this fails
-- because of the unique index
CREATE Trigger trg_ParentChild ON ParentChild
AFTER Insert, Update
AS
DECLARE #ParentId INT = 0
DECLARE #ChildItems VARCHAR(8000) = ''
DECLARE parentCursor CURSOR FOR
SELECT DISTINCT ParentId
FROM Inserted
OPEN parentCursor
FETCH NEXT FROM parentCursor INTO #ParentId
WHILE ##FETCH_STATUS = 0
BEGIN
SELECT #ChildItems = COALESCE(#ChildItems + '/ ', '') + CAST(ChildID As Varchar(10))
FROM ParentChild
WHERE ParentId = #ParentId
ORDER BY ChildId
UPDATE Parent
SET ChildItems = #ChildITems
WHERE Id = #ParentId
FETCH NEXT FROM parentCursor INTO #ParentId
SET #ChildItems = ''
END
CLOSE parentCursor
DEALLOCATE parentCursor
GO
Data Setup
INSERT INTO Parent (Id, Name)
VALUES (1, 'Parent1'), (2,'Parent2'), (3, 'Parent3')
INSERT INTO Child (Id, Name)
VALUES (1,'Child1'), (2,'Child2'), (3,'Child3'), (4,'Child4')
Now insert some data
-- This one succeeds
INSERT INTO ParentChild (ParentId, ChildId)
VALUES (1,1),(1,2),(2,2),(2,3)
-- This one Fails
INSERT INTO ParentChild (ParentId, ChildId) VALUES (3,1),(3,2)

Related

How can I use a trigger to allow an incremented, user-assigned ID?

I am moving a small database from MS Access into SQL Server. Each year, the users would create a new Access database and have clean data, but this change will put data across the years into one pot. The users have relied on the autonumber value in Access as a reference for records. That is very inaccurate if, say, 238 records are removed.
So I am trying to accommodate them with an id column they can control (somewhat). They will not see the real primary key in the SQL table, but I want to give them an ID they can edit, but still be unique.
I've been working with this trigger, but it has taken much longer than I expected.
Everything SEEMS TO work fine, except I don't understand why I have the same data in my INSERTED table as the table the trigger is on. (See note in code.)
ALTER TRIGGER [dbo].[trg_tblAppData]
ON [dbo].[tblAppData]
AFTER INSERT,UPDATE
AS
BEGIN
SET NOCOUNT ON;
DECLARE #NewUserEnteredId int = 0;
DECLARE #RowIdForUpdate int = 0;
DECLARE #CurrentUserEnteredId int = 0;
DECLARE #LoopCount int = 0;
--*** Loop through all records to be updated because the values will be incremented.
WHILE (1 = 1)
BEGIN
SET #LoopCount = #LoopCount + 1;
IF (#LoopCount > (SELECT Count(*) FROM INSERTED))
BREAK;
SELECT TOP 1 #RowIdForUpdate = ID, #CurrentUserEnteredId = UserEnteredId FROM INSERTED WHERE ID > #RowIdForUpdate ORDER BY ID DESC;
IF (#RowIdForUpdate IS NULL)
BREAK;
-- WHY IS THERE A MATCH HERE? HAS THE RECORD ALREADY BEEN INSERTED?
IF EXISTS (SELECT UserEnteredId FROM tblAppData WHERE UserEnteredId = #CurrentUserEnteredId)
BEGIN
SET #NewUserEnteredId = (SELECT Max(t1.UserEnteredId) + 1 FROM tblAppData t1);
END
ELSE
SET #NewUserEnteredId = #CurrentUserEnteredId;
UPDATE tblAppData
SET UserEnteredId = #NewUserEnteredId
FROM tblAppData a
WHERE a.ID = #RowIdForUpdate
END
END
Here is what I want to accomplish:
When new record(s) are added, it should increment values from the Max existing
When a user overrides a value, it should check to see the existence of that value. If found restore the existing value, otherwise allow the change.
This trigger allows for multiple rows being added at a time.
It is great for this to be efficient for future use, but in reality, they will only add 1,000 records a year.
I wouldn't use a trigger to accomplish this.
Here is a script you can use to create a sequence (op didn't tag version), create the primary key, use the sequence as your special id, and put a constraint on the column.
create table dbo.test (
testid int identity(1,1) not null primary key clustered
, myid int null constraint UQ_ unique
, somevalue nvarchar(255) null
);
create sequence dbo.myid
as int
start with 1
increment by 1;
alter table dbo.test
add default next value for dbo.myid for myid;
insert into dbo.test (somevalue)
select 'this' union all
select 'that' union all
select 'and' union all
select 'this';
insert into dbo.test (myid, somevalue)
select 33, 'oops';
select *
from dbo.test
insert into dbo.test (somevalue)
select 'oh the fun';
select *
from dbo.test
--| This should error
insert into dbo.test (myid, somevalue)
select 3, 'This is NO fun';
Here is the result set:
testid myid somevalue
1 1 this
2 2 that
3 3 and
4 4 this
5 33 oops
6 5 oh the fun
And at the very end a test, which will error.

EF4 RowCount issue on instead of insert trigger while updating an other table

I have some trouble with entityFramework 4. Here is the thing :
We have a SQL server database. Every table have 3 instead of triggers for insert, update and delete.
We know EntityFramework has some issues to deal with theses triggers, that's why we added the following code at the end of triggers to force the rowCount :
for insert :
DECLARE #Identifier BIGINT;
SET #Identifier = scope_identity()
SELECT #Identifier AS Identifier
for update/delete :
CREATE TABLE #TempTable (temp INT PRIMARY KEY);
INSERT INTO #TempTable VALUES (1);
DROP TABLE #TempTable
It worked fine until now :
From an instead of insert trigger (let's say table A), I try to update a field of an other table (table B)
I know my update code perfectly work since a manual insert does the work. The issue shows up only when I'm using Entity framework.
I have the solution now, let's make a school case of this with a full example. :)
In this example, our application is an addressBook. We want to update the business Activity (IsActive column in Business)
everytime we add, update or delete a contact on this business. The business is considered as active if at least one of the contact
of the business is active. We record every state changements on the business in a table to have the full history.
So, we have 3 tables :
table Business (Identifier (PK Identity), Name, IsActive),
table Contact (Identifier (PK Identity), Name, IsActive, IdentifierBusiness)
table BusinessHistory (Identifier (PK Identity), IsActive, Date, IdentifierBusiness)
Here's are the triggers one we are interested in :
table Contact (trigger IoInsert):
-- inserting the new rows
INSERT INTO Contact
(
Name
,IsActive
,IdentifierBusiness
)
SELECT
t0.Name
,t0.IsActive
,t0.IdentifierBusiness
FROM
inserted AS t0
-- Updating the business
UPDATE
Business
SET
IsActive = CASE WHEN
(
(t0.IsActive = 1 AND Business.IsActive = 1)
OR
(t0.IsActive = 1 AND Business.IsActive = 0)
) THEN 1 ELSE 0
FROM
inserted AS t0
WHERE
Business.Identifier = t0.IdentifierBusiness
AND
t0.IsActive = 1
AND
Business.IsActive = 0
-- Forcing rowCount for EntityFramework
DECLARE #Identifier BIGINT;
SET #Identifier = scope_identity()
SELECT #Identifier AS Identifier
Table Business (trigger IoUpdate)
UPDATE
Business
SET
IsActive = 1
FROM
Contact AS t0
WHERE
Business.Identifier = t0.IdentifierBusiness
AND
t0.IsActive = 1
AND
Business.IsActive = 0
---- Updating BusinessHistory
INSERT INTO BusinessHistory
(
Date
,IsActive
,IdentifierBusiness
)
SELECT
DATE()
,t0.IsActive
,t0.Identifier
FROM
inserted AS t0
INNER JOIN
deleted AS t1 ON t0.Identifier = t1.Identifier
WHERE
(t0.Identifier <> t1.Identifier)
-- Forcing rowCount for EntityFramework
CREATE TABLE #TempTable (temp INT PRIMARY KEY);
INSERT INTO #TempTable VALUES (1);
DROP TABLE #TempTable
Table BusinessHistory :
-- Updating the business
UPDATE
Business
SET
IsActive = CASE WHEN
(
(t0.IsActive = 1 AND Business.IsActive = 1)
OR
(t0.IsActive = 1 AND Business.IsActive = 0)
) THEN 1 ELSE 0
FROM
inserted AS t0
WHERE
Business.Identifier = t0.IdentifierBusiness
AND
t0.IsActive = 1
AND
Business.IsActive = 0
-- inserting the new rows
INSERT INTO BusinessHistory
(
Date
,IsActive
,IdentifierBusiness
)
SELECT
DATE()
,t0.IsActive
,t0.Identifier
FROM
inserted AS t0
-- Forcing rowCount for EntityFramework
DECLARE #Identifier BIGINT;
SET #Identifier = scope_identity()
SELECT #Identifier AS Identifier
So, in a nutshell, what happened ?
We have 2 tables, Business and Contact. Contact is updating table Business on insert and update.
When Business is updated, it does an insert into BusinessHistory, which is storing the history of updates of table Business
,when the field IsActive is updated.
the thing is, even if I don't insert a new row in BusinessHistory, I launch an insert instruction and so, I go inside the instead of insert trigger of the table BusinessHistory. Of course, in the end of this one, there is a scope_identity(). You can use scope_identity only once, and it gives back the last identity inserted.
So, since I did not inserted any BusinessHistory, it was consuming the scope_identity of my newly inserted contact : the scope_identity of the instead of
insert of the contact table was empty !
How to isolate the issue ?
Using the profiler, you figure out that there are insert instruction in BusinessHistory when it should not be any of them.
Using the debugging, you will eventually end in the an insert trigger your are not supposed to be in.
How to fix it ?
Several alternatives here. What I did was to surround in table Business the insert of BusinessHistory by an If condition :
I want the insert to be inserted only if the statut "IsActive" has changed :
IF EXISTS
(
SELECT
1
FROM
inserted AS t0
INNER JOIN
deleted AS t1 ON t0.Identifier = t1.Identifier
WHERE
(t0.IsActive <> t1.IsActive)
)
BEGIN
INSERT INTO BusinessHistory
(
Date
,IsActive
,IdentifierBusiness
)
SELECT
DATE()
,t0.IsActive
,t0.Identifier
FROM
inserted AS t0
INNER JOIN
deleted AS t1 ON t0.Identifier = t1.Identifier
WHERE
(t0.IsActive <> t1.IsActive)
END
An other possibility is, in the trigger instead of insert of the table BusinessHistory, to surround the whole trigger by an IF EXISTS condition
IF EXISTS (SELECT 1 FROM inserted)
BEGIN
----Trigger's code here !
END
How to avoid it ?
Well, use one of these fixes !
Avoiding scope_identity(), ##IDENTITY is more than enough in most of the cases ! In my company, we only use scope_identity because of EF 4 !
I know my english is not perfect, I can edit if it's not good enough, or if someone want to add something on this subject !

Looping table valued parameter values passed as parameter to stored procedure for checking a value

I have a stored procedure that receives a TVP as input. Now, I need to check the received data for a particular ID in a primary key column. If it exists, then I just need to update the table using those new values for other column (sent via TVP). Else, do an insert.
How to do it?
CREATE PROCEDURE ABC
#tvp MyTable READONLY
AS
IF EXISTS (SELECT 1 FROM MYTAB WHERE ID= #tvp.ID)
DO update
ELSE
Create
Just wondering the if exists loop I did is correct. I reckon its wrong as it will only check for first value and then update. What about other values? How should I loop through this?
Looping/CURSOR is the weapon of last resort, always search for solution that is SET based, not ROW based.
You should use MERGE which is designed for this type of operation:
MERGE table_name AS TGT
USING (SELECT * FROM #tvp) AS SRC
ON TGT.id = SRC.ID
WHEN MATCHED THEN
UPDATE SET col = SRC.col
WHEN NOT MATCHED THEN
INSERT (col_name, col_name2, ...)
VALUES (SRC.col_name1, SRC.col_name2, ...)
If you don't like MERGE use INSERT/UPDATE:
UPDATE table_name
SET col = tv.col,
...
FROM table_name AS tab
JOIN #tvp AS tv
ON tab.id = tv.id
INSERT INTO table_name(col1, col2, ...)
SELECT tv.col1, tv.col2, ...
FROM table_name AS tab
RIGHT JOIN #tvp AS tv
ON tab.id = tv.id
WHERE tab.id IS NULL

SQL Update Query with from clause Optimization

I have two tables which are heavily queried by multiple users. Average 100+ (update/select) queries/second requests are made for these tables.
Parent
Child
*GrantParent is not involved in join so, I said only two tables
I need to reorder all children for each parent. There can be 3000-4000 parents and each parent may have around same number of children.
Column Types:
ParentID GUID
ChildIndex int
FileID Varchar
IsDeleted bit
Tables have clustered index on PK and non-clustered index on columns being used in where.
UPDATE C SET C.ChildIndex = T.ReOrderedChildIndex FROM [Child] C INNER JOIN
(
SELECT ROW_NUMBER() OVER (PARTITION BY dbo.Child.[ParentID] ORDER BY [ChildIndex] asc) AS ReOrderedChildIndex,
dbo.Child.ChildIndex,
dbo.Child.FileID,
dbo.Child.ParentID
FROM dbo.Child WITH (NOLOCK) INNER JOIN
dbo.Parent WITH (NOLOCK) ON dbo.Child.ParentID = dbo.Parent.ParentID
WHERE (dbo.Parent.GrandParentID = 1) AND (dbo.Child.IsDeleted = 0)
) T
ON C.FileID =T.FileID AND (C.ParentID=T.ParentID) AND (C.IsDeleted = 0)
It looks above query take longer time and put select queries on wait even I have used WITH (NOLOCK) in all data selection stored procedures.
There is another query which reorder parents in same way as done for childs in above query.
In Activity Monitor the locks are shown for select stored procedures.
What is the best way to reorder perform reordering?
I am having following issues and believe they are stems from these queries:
1- Randomly deadlock occur.
2- Often connection pool time out occurs.
*Database is accessed by a windows application using Entlib 4.0 with connection pooling enabled, pool max size 200.
SQL Server 2008 R2
I'd recommend restructuring your data to a more flexible schema. This schema will allow multiple levels so you can merge GrandParent, Parent, and Child into one logical relationship table and one logical details table. You'll also be able to take advantage of indexes to reduce locks and improve performance.
You'll have to re-build your hierarchy after any relationship changes. The way I wrote the script below should minimize this impact on your system. You will no longer be updating the entire table, just the pieces that have changed.
Schema:
CREATE TABLE dbo.EntityName
(
ID INT IDENTITY(1,1),
ParentID INT -- Todo: Add foreign key back to dbo.EntityName
-- Todo: Add primary key
);
GO
CREATE TABLE dbo.Hierarchy
(
ParentID INT, -- Todo: Add foreign key back to dbo.EntityName
ChildID INT, -- Todo: Add foreign key back to dbo.EntityName
ChildLevel INT
);
GO
Populate script (slightly rough around the edges):
CREATE PROCEDURE [dbo].[uspBuildHierarchy]
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #Hierarchy
(
ParentID INT,
ChildID INT,
ChildLevel INT
);
-- Add the root of your hierarchy
INSERT INTO #Hierarchy VALUES (1, 1, 0);
DECLARE #ChildLevel INT = 1,
#LastCount INT = 1;
WHILE (#LastCount > 0)
BEGIN
INSERT INTO #Hierarchy
SELECT
E.ParentID,
E.ID,
#ChildLevel + 1
FROM dbo.EntityName E
INNER JOIN #Hierarchy H ON H.ChildID = E.ParentID
AND H.ChildLevel = (#ChildLevel - 1)
LEFT JOIN #Hierarchy EH ON EH.ParentID = E.ParentID
AND EH.ChildID = E.ID
WHERE EH.ChildLevel IS NULL;
SET #LastCount = ##ROWCOUNT;
SET #ChildLevel = #ChildLevel + 1;
END
MERGE INTO dbo.Hierarchy OH
USING
(
SELECT
ParentID,
ChildID,
ChildLevel
FROM #Hierarchy
) NH
ON OH.ParentID = NH.ParentID
AND OH.ChildID = NH.ChildID
WHEN MATCHED AND OH.ChildLevel <> NH.ChildLevel THEN
UPDATE
SET ChildLevel = NH.ChildLevel
WHEN NOT MATCHED THEN
INSERT
VALUES
(
NH.ParentID,
NH.ChildID,
NH.ChildLevel
)
WHEN NOT MATCHED BY SOURCE
THEN DELETE;
END
GO
Query for all of an entity's children:
SELECT *
FROM dbo.EntityName E
INNER JOIN dbo.Hierarchy H ON H.ChildID = E.ID
AND H.ParentID = #EntityNameID;

Determine Old primary key in a SQL Trigger

I've done this before somewhere I'm sure of it!
I have a SQL Server 2000 table that I need to log changes to fields on updates and inserts into a second Logging table. A simplified version of the structure I'm using is below:
MainTable
ID varchar(10) PRIMARY KEY
DESCRIPTION varchar(50)
LogTable
OLDID varchar(10)
NEWID varchar(10)
For any other field something like this would work great:
Select i.DESCRIPTION As New, d.DESCRIPTION As Old
From Inserted i
LEFT JOIN Deleted d On i.ID=d.ID
...But obviously the join would fail if ID was changed.
I cannot modify the Tables in way, the only power I have in this database is to create a trigger.
Alternatively is there someone who can teach me time travelling and I'll go back into the past and ask myself back then how I did this? Cheers :)
Edit:
I think I need to clarify a few things here. This is not actually my database, it is a pre-existing system that I have almost no control of, other than writing this trigger.
My question is how can I retrieve the old primary key if said primary key was changed. I don't need to be told that I shouldn't change the primary key or about chasing up foreign keys etc. That's not my problem :)
DECLARE #OldKey int, #NewKey int;
SELECT #Oldkey = [ID] FROM DELETED;
SELECT #NewKey = [ID] FROM INSERTED;
This only works if you have a single row. Otherwise you have no "anchor" to link old and new rows. So check in your trigger for > 1 in INSERTED.
Is it possible to assume that the INSERTED and DELETED tables presented to you in a trigger are guaranteed to be in the same order?
I don't think it's possible. Imagine if you have 4 rows in the table:
1 Val1
2 Val2
3 Val3
4 Val4
Now issue the following update:
UPDATE MainTable SET
ID = CASE ID WHEN 1 THEN 2 WHEN 2 THEN 1 ELSE ID END
Description = CASE ID WHEN 3 THEN 'Val4' WHEN 4 THEN 'Val3' ELSE Description END
Now, how are you going to distinguish between what happened to rows 1 & 2 and what happened to rows 3 & 4. And more importantly, can you describe what's different between them? All of the stuff that tells you which columns have been updated won't help you.
If it's possible in this case that there's an additional key on the table (e.g. Description is UNIQUE), and your update rules allow it, you could write the trigger to prevent simultaneous updates to both keys, and then you can use whichever key hasn't been updated to correlate the two tables.
If you must handle multiple-row inserts/updates, and there's no alternate key that's guaranteed not to change, the only way I can see to do this is to use an INSTEAD OF trigger. For example, in the trigger you could break the original insert/update command into one command per row, grabbing each old id before you insert/update.
Within triggers in SQL Server you have access to two tables: deleted and inserted. Both of these have already been mentioned. Here's how they function depending on what action the trigger is firing on:
INSERT OPERATION
deleted - not used
inserted - contains the new rows being added to the table
DELETE OPERATION
deleted - contains the rows being removed from the table
inserted - not used
UPDATE OPERATION
deleted - contains the rows as they would exist before the UPDATE operation
inserted - contains the rows as they would exist after the UPDATE operation
These function in every way like tables. Therefore, it is entirely possible to use a row based operation such as something like the following (Operation exists only on the audit table, as does DateChanged):
INSERT INTO MyAuditTable
(ID, FirstColumn, SecondColumn, ThirdColumn, Operation, DateChanged)
VALUES
SELECT ID, FirstColumn, SecondColumn, ThirdColumn, 'Update-Before', GETDATE()
FROM deleted
UNION ALL
SELECT ID, FirstColumn, SecondColumn, ThirdColumn, 'Update-After', GETDATE()
FROM inserted
----new----
add an identity column to the table that the application can not change, you can then use that new column to join the inserted to the deleted tables within the trigger:
ALTER TABLE YourTableName ADD
PrivateID int NOT NULL IDENTITY (1, 1)
GO
----old----
Don't ever update/change key values. How can you do this and fix all of your foreign keys?
I wouldn't recommend ever using a trigger that can't handle a set of rows.
If you must change the key, insert a new row with the proper new key and values, use SCOPE_IDENTITY() if that is what your are doing. Delete the old row. Log for the old row that it was changed to the new row's key, which you should now have. I hope there is no foreign key on the changed key in your log...
You can create a new identity column on table MainTable (named for example correlationid) and correlate inserted and deleted tables using this column.
This new column should be transparent for existing code.
INSERT INTO LOG(OLDID, NEWID)
SELECT deleted.id AS OLDID, inserted.id AS NEWID
FROM inserted
INNER JOIN deleted
ON inserted.correlationid = deleted.correlationid
Pay attention, you could insert duplicate records in the log table.
Of course nobody should be changing the primary key on the table -- but that is exactly what triggers are supposed to be for (in part), is to keep people from doing things they shouldn't do. It's a trivial task in Oracle or MySQL to write a trigger that intercepts changes to primary keys and stops them, but not at all easy in SQL Server.
What you of course would love to be able to do would be to simply do something like this:
if exists
(
select *
from inserted changed
join deleted old
where changed.rowID = old.rowID
and changed.id != old.id
)
... [roll it all back]
Which is why people go out googling for the SQL Server equivalent of ROWID. Well, SQL Server doesn't have it; so you have to come up with another approach.
A fast, but sadly not bombproof, version is to write an instead of update trigger that looks to see whether any of the inserted rows have a primary key not found in the updated table or vice versa. This would catch MOST, but not all, of the errors:
if exists
(
select *
from inserted lost
left join updated match
on match.id = lost.id
where match.id is null
union
select *
from deleted new
left join inserted match
on match.id = new.id
where match.id is null
)
-- roll it all back
But this still doesn't catch an update like...
update myTable
set id = case
when id = 1 then 2
when id = 2 then 1
else id
end
Now, I've tried making the assumption that the inserted and deleted tables are ordered in such a way that cursoring through the inserted and deleted tables simultaneously will give you properly matching rows. And this APPEARS to work. In effect you turn the trigger into the equivalent of the for-each-row triggers available in Oracle and mandatory in MySQL...but I would imagine the performance will be bad on massive updates since this is not native behavior to SQL Server. Also it depends upon an assumption that I can't actually find documented anywhere and so am reluctant to depend on. But code structured that way APPEARS to work properly on my SQL Server 2008 R2 installation. The script at the end of this post highlights both the behavior of the fast-but-not-bombproof solution and the behavior of the second, pseudo-Oracle solution.
If anybody could point me to someplace where my assumption is documented and guaranteed by Microsoft I'd be a very grateful guy...
begin try
drop table kpTest;
end try
begin catch
end catch
go
create table kpTest( id int primary key, name nvarchar(10) )
go
begin try
drop trigger kpTest_ioU;
end try
begin catch
end catch
go
create trigger kpTest_ioU on kpTest
instead of update
as
begin
if exists
(
select *
from inserted lost
left join deleted match
on match.id = lost.id
where match.id is null
union
select *
from deleted new
left join inserted match
on match.id = new.id
where match.id is null
)
raisError( 'Changed primary key', 16, 1 )
else
update kpTest
set name = i.name
from kpTest
join inserted i
on i.id = kpTest.id
;
end
go
insert into kpTest( id, name ) values( 0, 'zero' );
insert into kpTest( id, name ) values( 1, 'one' );
insert into kpTest( id, name ) values( 2, 'two' );
insert into kpTest( id, name ) values( 3, 'three' );
select * from kpTest;
/*
0 zero
1 one
2 two
3 three
*/
-- This throws an error, appropriately
update kpTest set id = 5, name = 'FIVE' where id = 1
go
select * from kpTest;
/*
0 zero
1 one
2 two
3 three
*/
-- This allows the change, inappropriately
update kpTest
set id = case
when id = 1 then 2
when id = 2 then 1
else id
end
, name = UPPER( name )
go
select * from kpTest
/*
0 ZERO
1 TWO -- WRONG WRONG WRONG
2 ONE -- WRONG WRONG WRONG
3 THREE
*/
-- Put it back
update kpTest
set id = case
when id = 1 then 2
when id = 2 then 1
else id
end
, name = LOWER( name )
go
select * from kpTest;
/*
0 zero
1 one
2 two
3 three
*/
drop trigger kpTest_ioU
go
create trigger kpTest_ioU on kpTest
instead of update
as
begin
declare newIDs cursor for select id, name from inserted;
declare oldIDs cursor for select id from deleted;
declare #thisOldID int;
declare #thisNewID int;
declare #thisNewName nvarchar(10);
declare #errorFound int;
set #errorFound = 0;
open newIDs;
open oldIDs;
fetch newIDs into #thisNewID, #thisNewName;
fetch oldIDs into #thisOldID;
while ##FETCH_STATUS = 0 and #errorFound = 0
begin
if #thisNewID != #thisOldID
begin
set #errorFound = 1;
close newIDs;
deallocate newIDs;
close oldIDs;
deallocate oldIDs;
raisError( 'Primary key changed', 16, 1 );
end
else
begin
update kpTest
set name = #thisNewName
where id = #thisNewID
;
fetch newIDs into #thisNewID, #thisNewName;
fetch oldIDs into #thisOldID;
end
end;
if #errorFound = 0
begin
close newIDs;
deallocate newIDs;
close oldIDs;
deallocate oldIDs;
end
end
go
-- Succeeds, appropriately
update kpTest
set name = UPPER( name )
go
select * from kpTest;
/*
0 ZERO
1 ONE
2 TWO
3 THREE
*/
-- Succeeds, appropriately
update kpTest
set name = LOWER( name )
go
select * from kpTest;
/*
0 zero
1 one
2 two
3 three
*/
-- Fails, appropriately
update kpTest
set id = case
when id = 1 then 2
when id = 2 then 1
else id
end
go
select * from kpTest;
/*
0 zero
1 one
2 two
3 three
*/
-- Fails, appropriately
update kpTest
set id = id + 1
go
select * from kpTest;
/*
0 zero
1 one
2 two
3 three
*/
-- Succeeds, appropriately
update kpTest
set id = id, name = UPPER( name )
go
select * from kpTest;
/*
0 ZERO
1 ONE
2 TWO
3 THREE
*/
drop table kpTest
go

Resources