Recursive Cte in Presence of Circular References

Recursive CTE in presence of circular references

You will need to put your WHERE-filter in the CTE part of your query, like so:

WITH recursive_cte (root_id, id) AS (
SELECT parent_id, id
FROM test_cte
WHERE id=0 -- Restrict your recursion to start from the item with id = 0, instead of considdering all items.
UNION ALL
SELECT t.parent_id, r.id
FROM test_cte t
INNER JOIN recursive_cte r
ON (r.root_id=t.id)
)
SELECT *
FROM recursive_cte

TSQL CTE: How to avoid circular traversal?

You need to accumulate a sentinel string within your recursion. In the following example I have a circular relationship from A,B,C,D, and then back to A, and I avoid a loop with the sentinel string:

DECLARE @MyTable TABLE(Parent CHAR(1), Child CHAR(1));

INSERT @MyTable VALUES('A', 'B');
INSERT @MyTable VALUES('B', 'C');
INSERT @MyTable VALUES('C', 'D');
INSERT @MyTable VALUES('D', 'A');

; WITH CTE (Parent, Child, Sentinel) AS (
SELECT Parent, Child, Sentinel = CAST(Parent AS VARCHAR(MAX))
FROM @MyTable
WHERE Parent = 'A'
UNION ALL
SELECT CTE.Child, t.Child, Sentinel + '|' + CTE.Child
FROM CTE
JOIN @MyTable t ON t.Parent = CTE.Child
WHERE CHARINDEX(CTE.Child,Sentinel)=0
)
SELECT * FROM CTE;

Result:

Parent Child Sentinel
------ ----- --------
A B A
B C A|B
C D A|B|C
D A A|B|C|D

Detecting circular references in SQL

To check for circular references i have used a trigger and recursive CTE:

CREATE TRIGGER trgIU_X_CheckCircularReferences
ON dbo.X
AFTER INSERT, UPDATE
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Results TABLE ([Exists] BIT);

WITH CteHierarchy
AS
(
SELECT x.A, x.B, X.C, 1 AS [Type]
FROM inserted i
JOIN X x ON i.A = x.A AND i.C = x.B
UNION ALL
SELECT x.A, x.B, X.C, 2 AS [Type]
FROM CteHierarchy i
JOIN X x ON i.A = x.A AND i.C = x.B
WHERE NOT EXISTS
(
SELECT *
FROM inserted a
WHERE a.A = x.A AND a.B = x.B
)
)
INSERT @Results ([Exists])
SELECT TOP(1) 1
FROM CteHierarchy h
JOIN X x ON h.A = x.A AND h.C = x.B
OPTION(MAXRECURSION 1000);

IF EXISTS(SELECT * FROM @Results)
BEGIN
ROLLBACK;
RAISERROR('Circular references detected', 16, 1);
END
END
GO

Now, we can run some tests:

--Test 1 - OK
PRINT '*****Test 1 - OK*****';
SELECT * FROM X;

BEGIN TRANSACTION;

UPDATE X
SET C = 'B1'
WHERE B = 'B4';

SELECT * FROM X;

--This transaction can be commited without problems
--but I will cancel all modification so we can run the second test
ROLLBACK TRANSACTION;
PRINT '*****End of test 1*****';
GO

--Test 2 - NOT OK
PRINT '*****Test 2 - NOT OK*****';
SELECT * FROM X;

BEGIN TRANSACTION;

UPDATE X
SET C = 'B1'
WHERE B = 'B1';

--Useless in this case (test 2 & test 3)
--Read section [If a ROLLBACK TRANSACTION is issued in a trigger] from http://msdn.microsoft.com/en-us/library/ms181299.aspx
SELECT * FROM X;
--Useless
ROLLBACK TRANSACTION;
--Useless
PRINT '*****End of test 2*****';
GO

PRINT '*****Test 3 - NOT OK*****';
SELECT * FROM X;

BEGIN TRANSACTION;

UPDATE X
SET C = 'B4'
WHERE B = 'B1';
GO

Results:

*****Test 1 - OK*****

(4 row(s) affected)

(0 row(s) affected)

(1 row(s) affected)

(4 row(s) affected)
*****End of test 1*****
*****Test 2 - NOT OK*****

(4 row(s) affected)

(1 row(s) affected)
Msg 50000, Level 16, State 1, Procedure trgIU_X_CheckCircularReferences, Line 34
Circular references detected
Msg 3609, Level 16, State 1, Line 8
The transaction ended in the trigger. The batch has been aborted.
*****Test 3 - NOT OK*****

(4 row(s) affected)

(1 row(s) affected)
Msg 50000, Level 16, State 1, Procedure trgIU_X_CheckCircularReferences, Line 34
Circular references detected
Msg 3609, Level 16, State 1, Line 7
The transaction ended in the trigger. The batch has been aborted.

For the second test, you can see how this trigger has canceled (ROLLBACK TRANSACTION) the transaction and, after UPDATE, nothing has been executed (in current batch).

Recursive CTE to populate latest available data across many quarters?

One solution is to get from the UholdingsPE table all combinations of all quarters between the minimum and maximum values ​​of the Reference_Date column and all the different values ​​of the Investment and Holding columns. All the rows are returned from the cih CTE. After that, you need to extract the value of the Price column of the row of the UholdingsPE table that is the closest by of the value of the Reference_Date column to the value of the Reference_Date column of the current row of the cih CTE.

with
cc as (
select
min(Reference_Date) as Reference_Date,
max(Reference_Date) as xq
from UholdingsPE
union all
select eomonth(dateadd(qq, 1, q)), xq
from cc
where q < xq
),
ii as (select distinct Investment from UholdingsPE),
hh as (select distinct Holding from UholdingsPE),
cih as (
select Reference_Date, Investment, Holding
from cc
cross join ii
cross join hh
)
select *
from cih
cross apply (
select a.Price
from UholdingsPE as a
where
a.Reference_Date <= cih.Reference_Date and
a.Investment = cih.Investment and
a.Holding = cih.Holding
order by a.Reference_Date desc
offset 0 rows
fetch first 1 row only
) as ca
order by Reference_Date, Investment, Holding
option (maxrecursion 0);

Output:

+----+---------------------+----------------+-----------+-------+
| | Reference_Date | Investment | Holding | Price |
+----+---------------------+----------------+-----------+-------+
| 1 | 31.03.2017 00:00:00 | Example Fund 1 | Apple | 1 |
| 2 | 31.03.2017 00:00:00 | Example Fund 1 | Microsoft | 1 |
| 3 | 30.06.2017 00:00:00 | Example Fund 1 | Apple | 1 |
| 4 | 30.06.2017 00:00:00 | Example Fund 1 | Microsoft | 1 |
| 5 | 30.09.2017 00:00:00 | Example Fund 1 | Apple | 1 |
| 6 | 30.09.2017 00:00:00 | Example Fund 1 | Microsoft | 1 |
| 7 | 31.12.2017 00:00:00 | Example Fund 1 | Apple | 1 |
| 8 | 31.12.2017 00:00:00 | Example Fund 1 | Microsoft | 1 |
| 9 | 31.03.2018 00:00:00 | Example Fund 1 | Apple | 1 |
| 10 | 31.03.2018 00:00:00 | Example Fund 1 | Microsoft | 1 |
| 11 | 30.06.2018 00:00:00 | Example Fund 1 | Apple | 22 |
| 12 | 30.06.2018 00:00:00 | Example Fund 1 | Microsoft | 22 |
| 13 | 30.09.2018 00:00:00 | Example Fund 1 | Apple | 22 |
| 14 | 30.09.2018 00:00:00 | Example Fund 1 | Microsoft | 22 |
| 15 | 31.12.2018 00:00:00 | Example Fund 1 | Apple | 33 |
| 16 | 31.12.2018 00:00:00 | Example Fund 1 | Microsoft | 33 |
| 17 | 31.03.2019 00:00:00 | Example Fund 1 | Apple | 33 |
| 18 | 31.03.2019 00:00:00 | Example Fund 1 | Microsoft | 33 |
| 19 | 30.06.2019 00:00:00 | Example Fund 1 | Apple | 44 |
| 20 | 30.06.2019 00:00:00 | Example Fund 1 | Microsoft | 44 |
| 21 | 30.09.2019 00:00:00 | Example Fund 1 | Apple | 44 |
| 22 | 30.09.2019 00:00:00 | Example Fund 1 | Microsoft | 44 |
| 23 | 31.12.2019 00:00:00 | Example Fund 1 | Apple | 55 |
| 24 | 31.12.2019 00:00:00 | Example Fund 1 | Microsoft | 55 |
+----+---------------------+----------------+-----------+-------+

Demo.

Let's try the cursor-based solution.

declare
@prev_inv varchar(14), @Investment varchar(14),
@prev_hold varchar(9), @Holding varchar(9),
@prev_ref_date date, @Reference_date date,
@prev_price integer, @Price integer, @qdiff integer;
declare
c cursor forward_only static read_only for
select Investment, Holding, Reference_Date, Price from UholdingsPE
union all
select Investment, Holding, ref_date, -1
from (select max(Reference_Date) as ref_date from UholdingsPE) as a
cross join (select distinct Investment from UholdingsPE) as b
cross join (select distinct Holding from UholdingsPE) as c
order by Investment, Holding, Reference_Date, Price desc;
open c;
fetch next from c into @Investment, @Holding, @Reference_Date, @Price;
select
@prev_inv = @Investment,
@prev_hold = @Holding,
@prev_ref_date = @Reference_date,
@prev_price = @Price;
while @@fetch_status = 0 begin
fetch next from c into @Investment, @Holding, @Reference_Date, @Price;
set @qdiff = datediff(q, @prev_ref_date, @Reference_date);
if @prev_inv = @Investment and
@prev_hold = @Holding and
@qdiff > 1
begin
insert into miss_UholdingsPE(Investment,
Holding,
Price,
Reference_Date)
select
@prev_inv, @prev_hold, @prev_price,
eomonth(dateadd(q, nums.n, @prev_ref_date))
from nums
where nums.n < @qdiff + iif(@Price = -1, 1, 0);
end;
select
@prev_inv = @Investment,
@prev_hold = @Holding,
@prev_ref_date = @Reference_date,
@prev_price = @Price;
end;
close c;
deallocate c;

Cursor-based solution demo.

Another set-based solution:

with
a as (select distinct Investment, Holding from stage_UholdingsPE),
b as (
select Q.Reference_Date, a.Investment, a.Holding
from a
cross join (
select eomonth(dateadd(quarter, n, '20160331'))
from Nums(0, datediff(quarter, '20160331', '20191231'))
) as Q(Reference_Date)
where not exists(select * from stage_UholdingsPE as s
where
s.Reference_Date = Q.Reference_Date and
s.Investment = a.Investment and
s.Holding = a.Holding)
)
insert into stage_UholdingsPE(Reference_Date, Investment, Holding, Price)
select Reference_Date, Investment, Holding, null from b;
go
with
a as (
select
*,
count(Price) over(partition by Investment, Holding
order by Reference_Date
rows unbounded preceding) as c
from stage_UholdingsPE
)
insert into full_UholdingsPE(Reference_Date, Investment, Holding, Price)
select
Reference_Date, Investment, Holding,
max(Price) over(partition by c) as Price
from a;

Demo.

How to prevent insertion of cyclic reference in SQL

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

Prevent circular reference in MS-SQL table

Finally, I have created the scripts after some failures, its working fine for me.

   -- To hold the Account table data
Declare @Accounts table (ID INT, ParentAccountID INT)

-- To be updated
Declare @AccountID int = 4;
Declare @ParentAccountID int = 7;

Declare @NextParentAccountID INT = @ParentAccountID

Declare @IsCircular int = 0

INSERT INTO @Accounts values (1, NULL), (2,1), (3,1) ,(4,3), (5,4), (6,5), (7,6), (8,7)

-- No circular reference value
--Select * from @Accounts

-- Request to update ParentAccountID to 7 for the Account ID 4
update @Accounts
set ParentAccountID = @ParentAccountID
where ID = @AccountID

Select * from @Accounts

WHILE(1=1)
BEGIN
-- Take the ParentAccountID for @NextParentAccountID
SELECT @NextParentAccountID = ParentAccountID from @Accounts WHERE ID = @NextParentAccountID

-- If the @NextParentAccountID is NULL, then it reaches the top level account, no circular reference hence break the loop
IF (@NextParentAccountID IS NULL)
BEGIN
BREAK;
END

-- If the @NextParentAccountID is equal to @AccountID (to which the update was done) then its creating circular reference
-- Then set the @IsCircular to 1 and break the loop
IF (@NextParentAccountID = @AccountID )
BEGIN
SET @IsCircular = 1
BREAK
END
END

IF @IsCircular = 1
BEGIN
select 'CircularReference' as 'ResponseCode'
END


Related Topics



Leave a reply



Submit