跨多个列实现相互唯一性

Dou*_*las 22 sql t-sql sql-server

我试图找到一种直观的方法来强制表中两列的相互唯一性.我不是在寻找复合唯一性,其中不允许重复的密钥组合 ; 相反,我想要一个规则,其中任何一个键都不能再出现在任一列中.请看以下示例:

CREATE TABLE Rooms
(
    Id INT NOT NULL PRIMARY KEY,
)

CREATE TABLE Occupants
(
    PersonName VARCHAR(20),
    LivingRoomId INT NULL REFERENCES Rooms (Id),
    DiningRoomId INT NULL REFERENCES Rooms (Id),
)
Run Code Online (Sandbox Code Playgroud)

一个人可以选择任何房间作为他们的起居室,任何其他房间作为他们的餐厅.一旦房间被分配给乘客,它就不能再被分配给另一个人(无论是作为起居室还是作为餐厅).

我知道这个问题可以通过数据规范化来解决; 但是,我无法更改架构,对架构进行重大更改.

更新:回应提议的答案:

两个独特的约束(或两个唯一索引)不会阻止重复横跨两列.同样的,一个简单的LivingRoomId != DiningRoomId检查约束不会阻止跨重复.例如,我希望禁止以下数据:

INSERT INTO Rooms VALUES (1), (2), (3), (4)
INSERT INTO Occupants VALUES ('Alex',    1, 2)
INSERT INTO Occupants VALUES ('Lincoln', 2, 3)
Run Code Online (Sandbox Code Playgroud)

2号房间由Alex(作为起居室)和林肯(作为餐厅)同时占用; 这不应该被允许.

更新2:我对三个主要提议的解决方案进行了一些测试,计算将500,000行插入Occupants表中需要多长时间,每行有一对随机的唯一房间ID.

Occupants使用唯一索引和检查约束(调用标量函数)扩展表会导致插入大约需要三倍的时间.标量函数的实现是不完整的,只检查新居住者的起居室是否与现有居住者的餐厅没有冲突.如果执行反向检查,我无法在合理的时间内完成插入.

添加将每个占用者的房间作为新行插入另一个表的触发器会使性能降低48%.同样,索引视图需要43%的时间.在我看来,使用索引视图更简洁,因为它避免了创建另一个表的需要,并允许SQL Server自动处理更新和删除.

完整的脚本和测试结果如下:

SET STATISTICS TIME OFF
SET NOCOUNT ON

CREATE TABLE Rooms
(
    Id INT NOT NULL PRIMARY KEY IDENTITY(1,1),
    RoomName VARCHAR(10),
)

CREATE TABLE Occupants
(
    Id INT NOT NULL PRIMARY KEY IDENTITY(1,1),
    PersonName VARCHAR(10),
    LivingRoomId INT NOT NULL REFERENCES Rooms (Id),
    DiningRoomId INT NOT NULL REFERENCES Rooms (Id)
)

GO

DECLARE @Iterator INT = 0
WHILE (@Iterator < 10)
BEGIN
    INSERT INTO Rooms
    SELECT TOP (1000000) 'ABC'
    FROM sys.all_objects s1 WITH (NOLOCK)
        CROSS JOIN sys.all_objects s2 WITH (NOLOCK)
        CROSS JOIN sys.all_objects s3 WITH (NOLOCK);
    SET @Iterator = @Iterator + 1
END;

DECLARE @RoomsCount INT = (SELECT COUNT(*) FROM Rooms);

SELECT TOP 1000000 RoomId
INTO ##RandomRooms
FROM 
(
    SELECT DISTINCT
        CAST(RAND(CHECKSUM(NEWID())) * @RoomsCount AS INT) + 1 AS RoomId
    FROM sys.all_objects s1 WITH (NOLOCK)
        CROSS JOIN sys.all_objects s2 WITH (NOLOCK)

) s

ALTER TABLE ##RandomRooms
ADD Id INT IDENTITY(1,1)

SELECT
    'XYZ' AS PersonName,
    R1.RoomId AS LivingRoomId,
    R2.RoomId AS DiningRoomId
INTO ##RandomOccupants
FROM ##RandomRooms R1
    JOIN ##RandomRooms R2
        ON  R2.Id % 2 = 0
        AND R2.Id = R1.Id + 1

GO

PRINT CHAR(10) + 'Test 1: No integrity check'

CHECKPOINT;
DBCC FREEPROCCACHE WITH NO_INFOMSGS;
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS;
SET NOCOUNT OFF
SET STATISTICS TIME ON

INSERT INTO Occupants
SELECT *
FROM ##RandomOccupants

SET STATISTICS TIME OFF
SET NOCOUNT ON

TRUNCATE TABLE Occupants

PRINT CHAR(10) + 'Test 2: Unique indexes and check constraint'

CREATE UNIQUE INDEX UQ_LivingRoomId
ON Occupants (LivingRoomId)

CREATE UNIQUE INDEX UQ_DiningRoomId
ON Occupants (DiningRoomId)

GO

CREATE FUNCTION CheckExclusiveRoom(@occupantId INT)
RETURNS BIT AS
BEGIN
RETURN 
(
    SELECT CASE WHEN EXISTS
    (
        SELECT *
        FROM Occupants O1
            JOIN Occupants O2
                ON O1.LivingRoomId = O2.DiningRoomId
             -- OR O1.DiningRoomId = O2.LivingRoomId
        WHERE O1.Id = @occupantId
    )
    THEN 0
    ELSE 1
    END
)
END

GO

ALTER TABLE Occupants
ADD CONSTRAINT ExclusiveRoom 
CHECK (dbo.CheckExclusiveRoom(Id) = 1)

CHECKPOINT;
DBCC FREEPROCCACHE WITH NO_INFOMSGS;
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS;
SET NOCOUNT OFF
SET STATISTICS TIME ON

INSERT INTO Occupants
SELECT *
FROM ##RandomOccupants

SET STATISTICS TIME OFF
SET NOCOUNT ON

ALTER TABLE Occupants DROP CONSTRAINT ExclusiveRoom
DROP INDEX UQ_LivingRoomId ON Occupants
DROP INDEX UQ_DiningRoomId ON Occupants
DROP FUNCTION CheckExclusiveRoom

TRUNCATE TABLE Occupants

PRINT CHAR(10) + 'Test 3: Insert trigger'

CREATE TABLE RoomTaken 
(
    RoomId INT NOT NULL PRIMARY KEY REFERENCES Rooms (Id) 
)

GO

CREATE TRIGGER UpdateRoomTaken
ON Occupants
AFTER INSERT
AS 
    INSERT INTO RoomTaken
    SELECT RoomId
    FROM
    (
        SELECT LivingRoomId AS RoomId
        FROM INSERTED
            UNION ALL
        SELECT DiningRoomId AS RoomId
        FROM INSERTED
    ) s

GO  

CHECKPOINT;
DBCC FREEPROCCACHE WITH NO_INFOMSGS;
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS;
SET NOCOUNT OFF
SET STATISTICS TIME ON

INSERT INTO Occupants
SELECT *
FROM ##RandomOccupants

SET STATISTICS TIME OFF
SET NOCOUNT ON

DROP TRIGGER UpdateRoomTaken
DROP TABLE RoomTaken

TRUNCATE TABLE Occupants

PRINT CHAR(10) + 'Test 4: Indexed view with unique index'

CREATE TABLE TwoRows
(
    Id INT NOT NULL PRIMARY KEY
)

INSERT INTO TwoRows VALUES (1), (2)

GO

CREATE VIEW OccupiedRooms
WITH SCHEMABINDING
AS
    SELECT RoomId = CASE R.Id WHEN 1 
                    THEN O.LivingRoomId 
                    ELSE O.DiningRoomId 
                    END
    FROM dbo.Occupants O
        CROSS JOIN dbo.TwoRows R

GO

CREATE UNIQUE CLUSTERED INDEX UQ_OccupiedRooms
ON OccupiedRooms (RoomId);

CHECKPOINT;
DBCC FREEPROCCACHE WITH NO_INFOMSGS;
DBCC DROPCLEANBUFFERS WITH NO_INFOMSGS;
SET NOCOUNT OFF
SET STATISTICS TIME ON

INSERT INTO Occupants
SELECT *
FROM ##RandomOccupants

SET STATISTICS TIME OFF
SET NOCOUNT ON

DROP INDEX UQ_OccupiedRooms ON OccupiedRooms
DROP VIEW OccupiedRooms
DROP TABLE TwoRows

TRUNCATE TABLE Occupants

DROP TABLE ##RandomRooms
DROP TABLE ##RandomOccupants

DROP TABLE Occupants
DROP TABLE Rooms


/* Results:

Test 1: No integrity check

 SQL Server Execution Times:
   CPU time = 5210 ms,  elapsed time = 10853 ms.

(500000 row(s) affected)

Test 2: Unique indexes and check constraint

 SQL Server Execution Times:
   CPU time = 21996 ms,  elapsed time = 27019 ms.

(500000 row(s) affected)

Test 3: Insert trigger
SQL Server parse and compile time: 
   CPU time = 5663 ms, elapsed time = 11192 ms.

 SQL Server Execution Times:
   CPU time = 4914 ms,  elapsed time = 4913 ms.

(1000000 row(s) affected)

 SQL Server Execution Times:
   CPU time = 10577 ms,  elapsed time = 16105 ms.

(500000 row(s) affected)

Test 4: Indexed view with unique index

 SQL Server Execution Times:
   CPU time = 10171 ms,  elapsed time = 15777 ms.

(500000 row(s) affected)

*/
Run Code Online (Sandbox Code Playgroud)

Vla*_*sak 10

我认为唯一的方法是使用约束和函数.

伪代码(很长时间没有这样做):

CREATE FUNCTION CheckExlusiveRoom
RETURNS bit
declare @retval bit
set @retval = 0
    select retval = 1 
      from Occupants as Primary
      join Occupants as Secondary
        on Primary.LivingRoomId = Secondary.DiningRoomId
     where Primary.ID <> Secondary.ID
        or (   Primary.DiningRoomId= Secondary.DiningRoomId
            or Primary.LivingRoomId = Secondary.LivingRoomID)
return @retval
GO
Run Code Online (Sandbox Code Playgroud)

然后,在检查约束中使用此函数....

替代方法是使用一个中间表OccupiedRoom,在那里你总是会插入使用的房间(例如通过触发器?)和FK而不是Room表

对评论的反应:

您是否需要直接在表上强制执行,或者是否因为插入/更新足够而发生约束违规?因为那时我想是这样的:

  1. 创建一个简单的表:

    create table RoomTaken (RoomID int primary key references Room (Id) )
    
    Run Code Online (Sandbox Code Playgroud)
  2. 在插入/更新/删除时创建一个触发器,以确保在Occupants中使用的任何Room也保留在RoomID中.

  3. 如果您尝试复制房间使用,RoomTaken表将抛出PK违规

不确定这是否足够和/或它与UDF的速度比较(我认为它会更好).

是的,我看到RoomTaken在使用者身上不会使用FK的问题,但是......实际上,你在一些限制条件下工作并且没有完美的解决方案 - 在我看来,速度(UDF)与100%完整性执行相比.


And*_*y M 6

您可以以索引视图的形式创建"外部"约束:

CREATE VIEW dbo.OccupiedRooms
WITH SCHEMABINDING
AS
SELECT r.Id
FROM   dbo.Occupants AS o
INNER JOIN dbo.Rooms AS r ON r.Id IN (o.LivingRoomId, o.DiningRoomId)
;
GO

CREATE UNIQUE CLUSTERED INDEX UQ_1 ON dbo.OccupiedRooms (Id);
Run Code Online (Sandbox Code Playgroud)

该视图基本上是对已占用房间的ID进行取消,将它们全部放在一列中.该列的唯一索引确保它没有重复项.

以下是此方法的工作原理演示:

UPDATE

正如hvd已正确评论的那样,上述解决方案并未捕获尝试插入相同的内容LivingRoomId以及DiningRoomId何时将它们放在同一行上.这是因为dbo.Rooms在这种情况下表只匹配一次,因此,连接生成只为该对引用生成一行.

修复它的一种方法是在同一注释中建议:除索引视图外,在dbo.OccupiedRooms表上使用CHECK约束禁止具有相同房间ID的行.LivingRoomId <> DiningRoomId但是,建议的条件不适用于两列都为NULL的情况.为了解释这种情况,条件可以扩展到这个:

LivingRoomId <> DinindRoomId AND (LivingRoomId IS NOT NULL OR DinindRoomId IS NOT NULL)
Run Code Online (Sandbox Code Playgroud)

或者,您可以更改视图的SELECT语句以捕获所有情况.如果LivingRoomIdDinindRoomIdNOT NULL列,你能避免加盟dbo.Rooms和UNPIVOT使用交叉连接到一个虚拟的2行表中的列:

SELECT  Id = CASE x.r WHEN 1 THEN o.LivingRoomId ELSE o.DiningRoomId END
FROM    dbo.Occupants AS o
CROSS
JOIN    (SELECT 1 UNION ALL SELECT 2) AS x (r)
Run Code Online (Sandbox Code Playgroud)

但是,由于这些列允许使用NULL,因此此方法不允许您插入多个单引用行.为了使它适用于您的情况,您需要过滤掉NULL条目,但前提是它们来自其他引用不为NULL的行.我相信在上面的查询中添加以下WHERE子句就足够了:

WHERE o.LivingRoomId IS NULL AND o.DinindRoomId IS NULL
   OR x.r = 1 AND o.LivingRoomId IS NOT NULL
   OR x.r = 2 AND o.DinindRoomId IS NOT NULL
Run Code Online (Sandbox Code Playgroud)

  • 这需要与`Occupants`上的`CHECK`约束相结合,即`LivingRoomId <> DiningRoomId`:这是该视图未涵盖的一件事. (2认同)
  • @Douglas如果使用`LEFT JOIN`执行此操作,视图确实会给出正确的结果,但SQL Server不允许在使用`LEFT JOIN`的视图上创建索引.您可以使用`INNER JOIN`来使其工作,但您需要将连接条件更改为类似于`TwoRows.N = 1 AND r.Id = o.LivingRoomId OR TwoRows.N = 2 AND r.Id = o .DiningRoomId`(未经测试).请注意,在这种情况下,您也可以删除`o`和`r`之间的连接条件,因为它现在是多余的. (2认同)
  • @AndriyM`CHECK`约束检查条件是否为false,如果是,则导致错误.`NULL`值不会导致条件变为false,它们会导致条件变为未知,因此我的检查已经正确处理了`NULL`值.[SQL小提琴](http://www.sqlfiddle.com/#!6/44a6b/2) (2认同)