How to prevent insertion of cyclic reference in SQL - sql-server

I have the following table:
create table dbo.Link
(
FromNodeId int not null,
ToNodeId int not null
)
Rows in this table represent links between nodes.
I want to prevent inserts or updates to this table from creating a cyclic relationship between nodes.
So if the table contains:
(1,2)
(2,3)
it should not be allowed to contain any of the following:
(1,1)
(2,1)
(3,1)
I'm happy to treat (1,1) separately (e.g. using a CHECK CONSTRAINT) if it makes the solution more straightforward.
I was thinking of creating an AFTER INSERT trigger with a recursive CTE (though there may be an easier way to do it).
Assuming this is the way to go, what would the trigger definition be? If there is a more elegant way, what is it?

Note first that it is preferable to detect cycles in another environment as recursive CTEs aren't known for their good performance and neither is a trigger that would run for each insert statement. For large graphs, a solution based on the solution below will likely be inefficient.
Suppose you create the table as follows:
CREATE TABLE dbo.lnk (
node_from INT NOT NULL,
node_to INT NOT NULL,
CONSTRAINT CHK_self_link CHECK (node_from<>node_to),
CONSTRAINT PK_lnk_node_from_node_to PRIMARY KEY(node_from,node_to)
);
That would block inserts with node_from equal to node_to, and for rows that already exist.
The following trigger should detect cyclic references by throwing an exception if a cyclic reference is detected:
CREATE TRIGGER TRG_no_circulars_on_lnk ON dbo.lnk AFTER INSERT
AS
BEGIN
DECLARE #cd INT;
WITH det_path AS (
SELECT
anchor=i.node_from,
node_to=l.node_to,
is_cycle=CASE WHEN i.node_from/*anchor*/=l.node_to THEN 1 ELSE 0 END
FROM
inserted AS i
INNER JOIN dbo.lnk AS l ON
l.node_from=i.node_to
UNION ALL
SELECT
dp.anchor,
node_to=l.node_to,
is_cycle=CASE WHEN dp.anchor=l.node_to THEN 1 ELSE 0 END
FROM
det_path AS dp
INNER JOIN dbo.lnk AS l ON
l.node_from=dp.node_to
WHERE
dp.is_cycle=0
)
SELECT TOP 1
#cd=is_cycle
FROM
det_path
WHERE
is_cycle=1
OPTION
(MAXRECURSION 0);
IF #cd IS NOT NULL
THROW 67890, 'Insert would cause cyclic reference', 1;
END
I tested this for a limited number of inserts.
INSERT INTO dbo.lnk(node_from,node_to)VALUES(1,2); -- OK
INSERT INTO dbo.lnk(node_from,node_to)VALUES(2,3); -- OK
INSERT INTO dbo.lnk(node_from,node_to)VALUES(3,4); -- OK
And
INSERT INTO dbo.lnk(node_from,node_to)VALUES(2,3); -- PK violation
INSERT INTO dbo.lnk(node_from,node_to)VALUES(1,1); -- Check constraint violation
INSERT INTO dbo.lnk(node_from,node_to)VALUES(3,2); -- Exception: Insert would cause cyclic reference
INSERT INTO dbo.lnk(node_from,node_to)VALUES(3,1); -- Exception: Insert would cause cyclic reference
INSERT INTO dbo.lnk(node_from,node_to)VALUES(4,1); -- Exception: Insert would cause cyclic reference
It also detects cyclic references already present in the inserted rows if inserting more than one row at once, or if a path longer than one edge would be introduced in the graph. Going off on the same initial inserts:
INSERT INTO dbo.lnk(node_from,node_to)VALUES(8,9),(9,8); -- Exception: Insert would cause cyclic reference
INSERT INTO dbo.lnk(node_from,node_to)VALUES(4,5),(5,6),(6,1); -- Exception: Insert would cause cyclic reference

EDIT: handle multi-record inserts, moved logic in separate function
I have considered a procedural approach, it is very fast and almost independent from number of records in link table and graph "density"
I have tested it on a table with 10'000 links with nodes values from 1 to 1000.
It is really really fast an do not suffer of link table dimension or "density"
In addition, the function could be used to test values before insert or (for example) if you don't want to use a trigger at all an move test logic to the client.
Consideration on Recursive CTE: BE CAREFUL!
I have tested the accepted answer on my test table (10k rows) but after 25 minutes, I have cancelled the insert operation of one single row because the query was hung with no result...
Downsizing the table to 5k rows the insert of a single record can last up to 2-3 minutes.
It is very dependant from the "population" of the graph. If you insert a new path, or you are adding a node to a path with low "ramification" it is quite fast but you have no control over that.
When the graph will be more "dense" this solution will blow up in your face.
Consider your needs very carefully.
So, let's see how to..
First of all I have set the PK of table to both columns and added an index on second column for full coverage. (the CHECK on FromNodeId<>ToNodeId is not needed cause the algorithm already cover this case).
CREATE TABLE [dbo].[Link](
[FromNodeId] [int] NOT NULL,
[ToNodeId] [int] NOT NULL,
CONSTRAINT [PK_Link] PRIMARY KEY CLUSTERED ([FromNodeId],[ToNodeId])
)
GO
CREATE NONCLUSTERED INDEX [ToNodeId] ON [dbo].[Link] ([ToNodeId])
GO
Then I have built a function to test the validity of a single link:
drop function fn_test_link
go
create function fn_test_link(#f int, #t int)
returns int
as
begin
--SET NOCOUNT ON
declare #p table (id int identity primary key, l int, t int, unique (l,t,id))
declare #r int = 0
declare #i int = 0
-- link is not self-referencing
if #f<>#t begin
-- there are links that starts from where new link wants to end (possible cycle)
if exists(select 1 from link where fromnodeid=#t) begin
-- PAY ATTENTION.. HERE LINK TABLE ALREADY HAVE ALL RECORDS ADDED (ALSO NEW ONES IF PROCEDURE IS CALLED FROM A TRIGGER AFTER INSERT)
-- LOAD ALL THE PATHS TOUCHED BY DESTINATION OF TEST NODE
set #i = 0
insert into #p
select distinct #i, ToNodeId
from link
where fromnodeid=#t
set #i = 1
-- THERE IS AT LEAST A STEP TO FOLLOW DOWN THE PATHS
while exists(select 1 from #p where l=#i-1) begin
-- LOAD THE NEXT STEP FOR ALL THE PATHS TOUCHED
insert into #p
select distinct #i, l.ToNodeId
from link l
join #p p on p.l = #i-1 and p.t = l.fromnodeid
-- CHECK IF THIS STEP HAVE REACHED THE TEST NODE START
if exists(select 1 from #p where l=#i and t=#f) begin
-- WE ARE EATING OUR OWN TAIL! CIRCULAR REFERENCE FOUND
set #r = -1
break
end
-- THE NODE IS STILL GOOD
-- DELETE FROM LIST DUPLICATED ALREADY TESTED PATHS
-- (THIS IS A BIG OPTIMIZATION, WHEN PATHS CROSSES EACH OTHER YOU RISK TO TEST MANY TIMES SAME PATHS)
delete p
from #p p
where l = #i
and (exists(select 1 from #p px where px.l < p.l and px.t = p.t))
set #i = #i + 1
end
if #r<0
-- a circular reference was found
set #r = 0
else
-- no circular reference was found
set #r = 1
end else begin
-- THERE ARE NO LINKS THAT STARTS FROM TESTED NODE DESTINATIO (CIRCULAR REFERENCE NOT POSSIBLE)
set #r = 1
end
end; -- link is not self-referencing
--select * from #p
return #r
end
GO
Now let's call it from a trigger.
If more than a row will be inserted the trigger will test each link against the whole insert (old table + new recs), if all are valid and the final table will be consistent the insert will complete, if one of them is not valid the insert will abort.
DROP TRIGGER tr_test_circular_reference
GO
CREATE TRIGGER tr_test_circular_reference ON link AFTER INSERT
AS
BEGIN
SET NOCOUNT ON
declare #p table (id int identity primary key, l int, f int, t int)
declare #f int = 0
declare #t int = 0
declare #n int = 0
declare #i int = 1
declare #ins table (id int identity primary key, f int, t int)
insert into #ins select * from inserted
set #n = ##ROWCOUNT;
-- there are links to insert
while #i<=#n begin
-- load link
select #f=f, #t=t from #ins where id = #i
if dbo.fn_test_link(#f, #t)=0 begin
declare #m nvarchar(255)
set #m = formatmessage('Insertion of link (%d,%d) would cause circular reference (n.%d)', #f, #t, #i);
THROW 50000, #m, 1
end
set #i = #i + 1
end
END
GO
I hope this will help

Related

How to optimize cursor in a stored procedure

I'm having problems with a stored procedure that iterates over a table, it works fine with a few hundred rows however when the table is over the thousands it saturates the memory and crashes.
The procedure should iterate row by row and fill a column with a value which is calculated from another column in the row. I suspect it is the cursor that crashes the procedure and in other questions I've read to use a while loop but I'm no expert in sql and the examples I tried from those answers didn't work.
CREATE PROCEDURE [dbo].[GenerateNewHashes]
AS
BEGIN
SET NOCOUNT ON;
DECLARE #module BIGINT = 382449983
IF EXISTS(SELECT 1 FROM dbo.telephoneSource WHERE Hash IS NULL)
BEGIN
DECLARE hash_cursor CURSOR FOR
SELECT a.telephone, a.Hash
FROM dbo.telephoneSource AS a
OPEN hash_cursor
FETCH FROM hash_cursor
WHILE ##FETCH_STATUS = 0
BEGIN
UPDATE dbo.telephoneSource
SET Hash = CAST(telephone AS BIGINT) % #module
WHERE CURRENT OF hash_cursor
FETCH NEXT FROM hash_cursor
END
CLOSE hash_cursor
DEALLOCATE hash_cursor
END
END
Basically the stored procedure is intended to fill a new column called Hash that was added to the existing table, when the script that updates the table ends the new column is filled with NULL values and then this stored procedure is supposed to fill each null value with the operation telephone number (which is a bigint) % module variable (big int as well).
Is there anything besides changing to a while loop that I can do to make it use less memory or just don't crash? Thanks in advance.
You could do the following:
WHILE 1=1
BEGIN
UPDATE TOP (10000) dbo.telephoneSource
SET Hash = CAST(telephone AS BIGINT)%#module
WHERE Hash IS NULL;
IF ##ROWCOUNT = 0
BEGIN
BREAK;
END;
END;
This will update Hash as long as there are NULL values and will exit once there have been no records updated.
Adding a filtered index could be useful as well:
CREATE NONCLUSTERED INDEX IX_telephoneSource_Hash_telephone
ON dbo.telephoneSource (Hash)
INCLUDE (telephone)
WHERE Hash IS NULL;
It will speed up lookups in order to update it. But this might be not needed.
Here is example of code to do it in loops from my comment above with out using a cursor, and if you add where your field you are updating IS NOT NULL into the inner loop it wont update ones that were already done (in case you need to restart the process or something.
I didnt include your specific tables in there but if you need me to I can add it in there.
DECLARE #PerBatchCount as int
DECLARE #MAXID as bigint
DECLARE #WorkingOnID as bigint
Set #PerBatchCount = 1000
--Find range of IDs to process using yoru tablename
SELECT #WorkingOnID = MIN(ID), #MAXID = MAX(ID)
FROM YouTableHere WITH (NOLOCK)
WHILE #WorkingOnID <= #MAXID
BEGIN
-- do an update on all the ones that exist in the offer table NOW
--DO YOUR UPDATE HERE
-- include this where clause where ID is your PK you are looping through
WHERE ID BETWEEN #WorkingOnID AND (#WorkingOnID + #PerBatchCount -1)
set #WorkingOnID = #WorkingOnID + #PerBatchCount
END
SET NOCOUNT OFF;
I would simply add computed column:
ALTER TABLE dbo.telephoneSource
ADD Hash AS (CAST(telephone AS BIGINT)%382449983) PERSISTED;

SQL Server check constraint failing on correct input

I'm using a check constraint on a table to restrict what values are inserted in the table..
Here's an explanation of what I'm trying to do
If any Product(sedan) is associated to a specific ObjLevel (Toyota) then the same Product cannot be associated to another specific ObjLevel (Lexus)
After I apply the check constraint on the table, any insert containing ObjLevel "toyota" or "lexus" fails..
create table ObjLevel(
OLID int identity,
Name varchar(50) not null
)
insert into ObjLevel values('Ford')
insert into ObjLevel values('Toyota')
insert into ObjLevel values('Lexus')
insert into ObjLevel values('GM')
insert into ObjLevel values('Infiniti')
create table ObjInstance(
OLIID int identity (20,1),
OLID int
)
insert into ObjInstance values(1)
insert into ObjInstance values(2)
insert into ObjInstance values(3)
insert into ObjInstance values(4)
insert into ObjInstance values(5)
create table Product(
PID int identity(50,1),
Name varchar(20)
)
insert into Product values ('sedan')
insert into Product values ('coupe')
insert into Product values ('hatchback')
create table ObjInstanceProd(
OLIID int,
PID int
)
create FUNCTION [dbo].[fnObjProd] (#Pid int) RETURNS bit WITH EXECUTE AS CALLER AS
BEGIN
DECLARE #rv bit
DECLARE #cnt int
SET #cnt = 0
SET #rv = 0
SET #cnt=
(Select Count(*) from ObjInstanceProd olip
join ObjInstance oli
on olip.OLIID = oli.OLIID
join ObjLevel ol
on ol.OLID = oli.OLID
where ol.Name in ('Toyota','Lexus')
and PID = #Pid)
if(#cnt>0)
SET #rv = 1
RETURN #rv
END
ALTER TABLE [dbo].[ObjInstanceProd] WITH CHECK ADD CONSTRAINT [CK_OLIP] CHECK ([dbo].[fnObjProd]([PID])=0)
--Insert Statement
insert into ObjInstanceProd(OLIID,PID) values (22,51)
Msg 547, Level 16, State 0, Line 1
The INSERT statement conflicted with the CHECK constraint "CK_OLIP". The conflict occurred in database "tmp", table "dbo.ObjInstanceProd", column 'PID'.
The statement has been terminated.
--Execute Function
select [dbo].[fnObjProd] (51)
0
Initially the Table ObjInstanceProd is empty.. So, no matter what value I put in the table, as long as the function in the constraint returns a 0, it should accept it.. But it does not..
The function is correctly returning a 0 (when executed independently), but for some reason, the check constraint returns a 1
When the CHECK constraint fires, the row is already in the table. Therefore, the function is called, and since there is a row returned by the query, the function returns 1, not 0. Try this. Drop the constraint, insert your row successfully, and then run this query:
SELECT OLIID, PID, dbo.fnObjProd([PID]) FROM dbo.ObjInstanceProd;
It should return 1 for every value of PID. Try to add the constraint now. It will fail for the same reason.
Have you considered using a trigger for this? If you use a check constraint, this will turn any multi-row insert or update into a cursor behind the scenes. This can absolutely kill performance and concurrency depending on how you touch your tables. Here is a simple INSTEAD OF INSERT trigger to prevent bad values going in with a single operation, even for a multi-row insert:
CREATE TRIGGER dbo.trObjProd
ON dbo.ObjInstanceProd
INSTEAD OF INSERT AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS
(
SELECT 1 FROM inserted
WHERE EXISTS
(
SELECT 1
FROM dbo.ObjInstanceProd AS olip
INNER JOIN dbo.ObjInstance AS oli
ON olip.OLIID = oli.OLIID
INNER JOIN dbo.ObjLevel AS ol
ON ol.OLID = oli.OLID
WHERE
ol.Name in ('Toyota','Lexus')
AND olip.PID = inserted.PID
)
)
BEGIN
INSERT ObjInstanceProd(OLIID, PID)
SELECT OLIID, PID FROM inserted;
END
ELSE
BEGIN
RAISERROR('At least one value was not good.', 11, 1);
SELECT OLIID, PID FROM inserted;
END
END
GO
If you're going to stick with a function, this is a much more efficient approach, however you need to define a way to determine that the current row being inserted is excluded from the check - I couldn't determine how to do that because there are no constraints on dbo.ObjInstanceProd. Is OLIID, PID unique?
ALTER FUNCTION [dbo].[fnObjProd]
(
#Pid INT
)
RETURNS BIT
WITH EXECUTE AS CALLER
AS
BEGIN
RETURN
(
SELECT CASE WHEN EXISTS
(
SELECT 1
FROM dbo.ObjInstanceProd AS olip
INNER JOIN dbo.ObjInstance AS oli
ON olip.OLIID = oli.OLIID
INNER JOIN dbo.ObjLevel AS ol
ON ol.OLID = oli.OLID
WHERE
ol.Name in ('Toyota','Lexus')
AND olip.PID = #Pid
) THEN 1 ELSE 0 END
);
END
GO

Why is the natural ID generation in this SQL Stored Proc creating duplicates?

I am incrementing the alphanumeric value by 1 for the productid using stored procedure. My procedure incrementing the values up to 10 records, once its reaching to 10th say for PRD0010...no more its incrementing... however, the problem is it is repeating
the same values PRD0010.. for each SP call.
What could be the cause of this?
create table tblProduct
(
id varchar(15)
)
insert into tblProduct(id)values('PRD00')
create procedure spInsertInProduct
AS
Begin
DECLARE #PId VARCHAR(15)
DECLARE #NId INT
DECLARE #COUNTER INT
SET #PId = 'PRD00'
SET #COUNTER = 0
SELECT #NId = cast(substring(MAX(id), 4, len(MAX(id))) as int)
FROM tblProduct group by left(id, 3) order by left(id, 3)
--here increse the vlaue to numeric id by 1
SET #NId = #NId + 1
--GENERATE ACTUAL APHANUMERIC ID HERE
SET #PId = #PId + cast(#NId AS VARCHAR)
INSERT INTO tblProduct(id)values (#PId)
END
Change
SELECT #NId = cast(substring(MAX(id), 4, len(MAX(id))) as int)
FROM tblProduct group by left(id, 3) order by left(id, 3)
To
SELECT TOP 1
#NId = cast(substring(id, 4, len(id)) as int)
FROM tblProduct order by LEN(id) DESC, ID DESC
You have to remember that
PRD009
is always greater than
PRD0010
or
PRD001
All in all, I think your approach is incorrect.
Your values will be
PRD00
PRD001
...
PRD009
PRD0010
PRD0011
...
PRD0099
PRD00100
This will make sorting a complete nightmare.
In addition to astander's analysis, you also have a concurrency issue.
The simple fix would be to add this at the beginning of your proc:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE
BEGIN TRANSACTION
And add a COMMIT at the end. Otherwise, two callers of this stored proc will get the same MAX/TOP 1 value from your table, and insert the same value.
Also, you can and should prevent these duplicates from existing by adding a key to your table, for this column. If you already have a PRIMARY KEY on this table, you can add an additional key using a UNIQUE constraint. This will prevent duplicates occurring in the future, no matter what programming errors occur. E.g.
ALTER TABLE tblProduct ADD CONSTRAINT UQ_Product_ID UNIQUE (ID)

SQL Server - Auto-incrementation that allows UPDATE statements

When adding an item in my database, I need it to auto-determine the value for the field DisplayOrder. Identity (auto-increment) would be an ideal solution, but I need to be able to programmatically change (UPDATE) the values of the DisplayOrder column, and Identity doesn't seem to allow that. For the moment, I use this code:
CREATE PROCEDURE [dbo].[AddItem]
AS
DECLARE #DisplayOrder INT
SET #DisplayOrder = (SELECT MAX(DisplayOrder) FROM [dbo].[MyTable]) + 1
INSERT INTO [dbo].[MyTable] ( DisplayOrder ) VALUES ( #DisplayOrder )
Is it the good way to do it or is there a better/simpler way?
A solution to this issue from "Inside Microsoft SQL Server 2008: T-SQL Querying"
CREATE TABLE dbo.Sequence(
val int IDENTITY (10000, 1) /*Seed this at whatever your current max value is*/
)
GO
CREATE PROC dbo.GetSequence
#val AS int OUTPUT
AS
BEGIN TRAN
SAVE TRAN S1
INSERT INTO dbo.Sequence DEFAULT VALUES
SET #val=SCOPE_IDENTITY()
ROLLBACK TRAN S1 /*Rolls back just as far as the save point to prevent the
sequence table filling up. The id allocated won't be reused*/
COMMIT TRAN
Or another alternative from the same book that allocates ranges easier. (You would need to consider whether to call this from inside or outside your transaction - inside would block other concurrent transactions until the first one commits)
CREATE TABLE dbo.Sequence2(
val int
)
GO
INSERT INTO dbo.Sequence2 VALUES(10000);
GO
CREATE PROC dbo.GetSequence2
#val AS int OUTPUT,
#n as int =1
AS
UPDATE dbo.Sequence2
SET #val = val = val + #n;
SET #val = #val - #n + 1;
You can set your incrementing column to use the identity property. Then, in processes that need to insert values into the column you can use the SET IDENITY_INSERT command in your batch.
For inserts where you want to use the identity property, you exclude the identity column from the list of columns in your insert statement:
INSERT INTO [dbo].[MyTable] ( MyData ) VALUES ( #MyData )
When you want to insert rows where you are providing the value for the identity column, use the following:
SET IDENTITY_INSERT MyTable ON
INSERT INTO [dbo].[MyTable] ( DisplayOrder, MyData )
VALUES ( #DisplayOrder, #MyData )
SET IDENTITY_INSERT MyTable OFF
You should be able to UPDATE the column without any other steps.
You may also want to look into the DBCC CHECKIDENT command. This command will set your next identity value. If you are inserting rows where the next identity value might not be appropriate, you can use the command to set a new value.
DECLARE #DisplayOrder INT
SET #DisplayOrder = (SELECT MAX(DisplayOrder) FROM [dbo].[MyTable]) + 1
DBCC CHECKIDENT (MyTable, RESEED, #DisplayOrder)
Here's the solution that I kept:
CREATE PROCEDURE [dbo].[AddItem]
AS
DECLARE #DisplayOrder INT
BEGIN TRANSACTION
SET #DisplayOrder = (SELECT ISNULL(MAX(DisplayOrder), 0) FROM [dbo].[MyTable]) + 1
INSERT INTO [dbo].[MyTable] ( DisplayOrder ) VALUES ( #DisplayOrder )
COMMIT TRANSACTION
One thing you should do is to add commands so that your procedure's run as a transaction, otherwise two inserts running at the same time could produce two rows with the same value in DisplayOrder.
This is easy enough to achieve: add
begin transaction
at the start of your procedure, and
commit transaction
at the end.
You way works fine (with a little modification) and is simple. I would wrap it in a transaction like #David Knell said. This would result in code like:
CREATE PROCEDURE [dbo].[AddItem]
AS
DECLARE #DisplayOrder INT
BEGIN TRANSACTION
SET #DisplayOrder = (SELECT MAX(DisplayOrder) FROM [dbo].[MyTable]) + 1
INSERT INTO [dbo].[MyTable] ( DisplayOrder ) VALUES ( #DisplayOrder )
COMMIT TRANSACTION
Wrapping your SELECT & INSERT in a transaction guarantees that your DisplayOrder values won't be duplicated by AddItem. If you are doing a lot of simultaneous adding (many per second), there may be contention on MyTable but for occasional inserts it won't be a problem.

How to handle multiple rows in this Sql Server Trigger?

I have the following trigger, but because a trigger needs to handle multiple records, I'm not sure how to correctly handle this, in my trigger code.
Can someone please suggest how I can change the TSql below to correctly handle multiple records, instead of just a single record (as is listed, below).
Table Schema and defaults.
CREATE TABLE [dbo].[tblArticle](
[IdArticle] [int] IDENTITY(1,1) NOT NULL,
[IdArticleStatus] [tinyint] NOT NULL,
[Title] [nvarchar](200) NOT NULL,
[CleanTitle] [nvarchar](300) NOT NULL,
[UniqueTitle] [nvarchar](300) NOT NULL,
[Content] [nvarchar](max) NOT NULL
GO
ALTER TABLE [dbo].[tblArticle] ADD CONSTRAINT [DF_tblArticle_CleanTitle]
DEFAULT (newid()) FOR [CleanTitle]
GO
ALTER TABLE [dbo].[tblArticle] ADD CONSTRAINT [DF_tblArticle_UniqueTitle]
DEFAULT (newid()) FOR [UniqueTitle]
GO
Trigger, which only handles a single record ... not multiple.
ALTER TRIGGER [dbo].[ArticlesAfterInsertOrUpdate]
ON [dbo].[tblArticle]
AFTER INSERT,UPDATE
AS
BEGIN
SET NOCOUNT ON
DECLARE #IdArticle INTEGER,
#Title NVARCHAR(300),
#CleanTitle NVARCHAR(300),
#UniqueTitle NVARCHAR(300),
#NewCleanTitle NVARCHAR(300),
#CleanTitleCount INTEGER
-- Only Update the CleanTitle and UniqueTitle if *required*
-- This means, create a unique subject of the title, then check if this clean value
-- is different to the current clean value. If so, then update both clean and unique.
-- Otherwise, don't do anything (because it will include this row in the count check, below).
IF UPDATE(Title) BEGIN
-- TODO: How will this handle multiple records???
SELECT #IdArticle = IdArticle, #Title = Title, #CleanTitle = CleanTitle
FROM INSERTED
-- Create the 'Slugs'.
SET #NewCleanTitle = dbo.CreateUniqueSubject(#Title)
SET #UniqueTitle = #NewCleanTitle
IF #NewCleanTitle != #CleanTitle BEGIN
-- We need to update the clean and unique, so lets get started...
-- Grab the count :: eg. how many other _clean_ titles already exist?
-- Note: this is the _only_ reason why we have this
-- column - because it has an index on it.
SELECT #CleanTitleCount = COUNT(IdArticle)
FROM [dbo].[tblArticle]
WHERE CleanTitle = #NewCleanTitle
-- If we have some previous titles, then we need to append a number
-- to the end of the current slug.
IF #CleanTitleCount > 0
SET #UniqueTitle = #NewCleanTitle + CAST((#CleanTitleCount + 1) AS VARCHAR(10))
-- Now update the unique subject field.
UPDATE [dbo].[tblArticle]
SET CleanTitle = #NewCleanTitle,
UniqueTitle = #UniqueTitle
WHERE IdArticle = #IdArticle
END
END
END
GO
Please help!
Don't really need to know what the custom function does, just that it returns the same value for each given input (i.e. the Title). It gets a bit complicated to perform this type of logic in a trigger, but you can certainly make it happen. There are definitely other ways of making it work as well, best approach would depend entirely on your environment, however the following logic will get you what you're looking for as a starting point:
ALTER TRIGGER [dbo].[ArticlesAfterInsertOrUpdate]
ON [dbo].[tblArticle]
AFTER INSERT,UPDATE
AS
BEGIN
SET NOCOUNT ON
-- Only Update the CleanTitle and UniqueTitle if *required*
-- This means, create a unique subject of the title, then check if this clean value
-- is different to the current clean value. If so, then update both clean and unique.
-- Otherwise, don't do anything (because it will include this row in the count check, below).
IF UPDATE(Title) BEGIN
-- Materialize with the newCleanTitle value for simplicity sake, could
-- do this inline below, not sure which would work better in your environment
if object_id('tempdb..#tempIData') > 0
drop table #tempIData;
select *,
dbo.CreateUniqueSubject(i.Title) as newCleanTitle
into #tempIData
from inserted i
where i.CleanTitle <> dbo.CreateUniqueSubject(i.Title);
with iData as
( -- Get the data inserted along with a running tally of any duplicate
-- newCleanTitle values
select i.IdArticle as IdArticle,
i.CleanTitle, i.newCleanTitle,
-- Need to get the count here as well to account for cases where
-- we insert multiple records with the same resulting cleanTitle
cast(row_number() over(partition by i.newCleanTitle order by i.IdArticle) as bigint) as cntCleanTitle
from #tempIData i
),
srcData as
( -- Get the existing count of data by CleanTitle value for each
-- newCleanTitle included in the inserted data
select t.CleanTitle as CleanTitle,
cast(coalesce(count(*),0) as bigint) as cntCleanTitle
from dbo.tblArticle t
join
( -- Need a distinct list of newCleanTitle values
select a.newCleanTitle
from iData a
group by a.newCleanTitle
) i
-- Join on CleanTitle as we need to get the existing running
-- count for each distinct CleanTitle values
on t.CleanTitle = i.newCleanTitle
group by t.CleanTitle
)
-- Do the update...
update a
set a.CleanTitle = i.newCleanTitle,
a.UniqueTitle =
case
when i.cntCleanTitle + coalesce(s.cntCleanTitle,0) > 1
then i.newCleanTitle + cast((cast(i.cntCleanTitle as bigint) + cast(coalesce(s.cntCleanTitle,0) as bigint)) as nvarchar(10))
else
i.newCleanTitle
end
from dbo.tblArticle a
join iData i
on a.IdArticle = i.IdArticle
left join srcData s
on i.newCleanTitle = s.CleanTitle;
if object_id('tempdb..#tempIData') > 0
drop table #tempIData;
END
END

Resources