Database design with Loan and LoanLines - sql

I have not been able to come up with a viable solution for days.
I am developing a system to maintain items and lending out these items.
Loan contains IEnumerable<LoanLine> which each points at an Item:
So far so good.
The tricky part comes to light when each item can't be lent out in the same period. And that period is defined by LoanLine.PickedUp ?? Loan.DateFrom > LoanLine.Returned ?? Loan.DateTo. This means that if LoanLine.PickedUp is null, then Loan.DateFrom should be used to compare, and if LoanLine.Returned is null, then Loan.DateTo should be used.
An item can be picked up and returned outside the loans boundaries. So these scenarioes can occur:
It should also be possible to "go back", ie. set LoanLine.Returned to null, in which case Loan.DateTo is used to compare again. The same goes with LoanLine.PickedUp.
It should also be possible to update both Loan.DateFrom and Loan.DateTo, with the berforementioned constraints still in effect. That means that if an update to Loan results in one of the lines, with either DateTime set to null, is overlapping, then the constraint shall throw an error.
This is the create-script:
create table loan
(
id int primary key identity(1, 1),
datefrom date not null,
dateto date not null,
employee_id int references employee(id) not null,
recipient_id int references employee(id) null,
note nvarchar(max) not null,
constraint c_loan_chkdates check (datefrom <= dateto)
);
create table loanlineitem
(
id int primary key identity(1, 1),
loan_id int references loan(id) on delete cascade not null,
item_id int references item(id) not null,
pickedup datetime null,
returned datetime null,
constraint uq_loanlineitem unique (loan_id, item_id),
constraint c_loanlineitem_chkdates check (returned is null or pickedup <= returned)
);
And this is the constraint:
create function checkLoanLineItem(#itemId int, #loanId int, #pickedup datetime, #returned datetime)
returns bit
as
begin
declare #result bit = 0;
declare #from date = #pickedup;
declare #to date = #returned;
--If either #from or #to is null, fill the ones with null from loan-table
if (isnull(#from, #to) is null)
begin
select #from = isnull(#from, datefrom),
#to = isnull(#to, dateadd(d, 1, dateto))
from loan
where id = #loanId;
end
if not exists (select top 1 lli.id from loanlineitem lli
inner join loan l on lli.loan_id = l.id
where l.id <> #loanId
and lli.item_id = #itemId
and ((isnull(lli.pickedup, l.datefrom) >= #from and isnull(lli.pickedup, l.datefrom) < #to)
--When comparing datetime with date, the date's time is 00:00:00
--so one day is added to account for this
or (isnull(lli.returned, dateadd(d, 1, l.dateto)) >= #from and isnull(lli.returned, dateadd(d, 1, l.dateto)) < #to))
)
begin
set #result = 1;
end
return #result;
end;
go;
alter table loanlineitem
add constraint c_loanlineitem_checkoverlap check (dbo.checkLoanLineItem(item_id, loan_id, pickedup, returned) = 1)
go;
I could make a similar constraint on Loan-table but then I would have similar code two places, which I would prefer to avoid, if possible.
So what I'm asking is; should I rethink my schema to accomplish this, or is it possible with some constraints which I'm not familiar with?

For this we will need two things:
A way to track the status of an item with respect to a loan
Only allow one active loan at a point in time
The first item can be addressed through the data model (see below) but the second will require any changes to the database MUST occur through stored procedures and those stored procedures will have to contain logic to keep the database in a consistent state. Otherwise you'll have a real mess on your hands (or rely on triggers, which is another headache).
We'll track the physical state of the item through an item status based on a timestamp, and, if desired, reservations through another mechanism based on a future date.
This query will return the current status and loan of all items, as well as the next reservation. From this you can also determine which items are past due.
SELECT
Item.ItemId
,ItemStatus.UpdateDtm
,ItemStatus.StatusCd
,ItemStatus.LoanNumber
,Loan.StartDt
,Loan.EndDt
,Reservation.StartDt
,Reservation.EndDt
FROM
Item Item
LEFT JOIN
LoanItemStatus ItemStatus
ON ItemStatus.ItemId = Item.ItemId
AND ItemStatus.UpdateDtm =
(
SELECT
MAX(UpdateDtm)
FROM
LoanItemStatus
WHERE
ItemId = Item.ItemId
)
LEFT JOIN
Loan Loan
ON Loan.LoanNumber = ItemStatus.LoanNumber
LEFT JOIN
ItemReservation Reservation
ON Reservation.ItemId = Item.ItemId
AND Reservation.StartDt =
(
SELECT
MIN(StartDt)
FROM
ItemReservation
WHERE
ItemId = Item.ItemId
AND StartDt >= GetDate()
)
It will probably make sense to harden this logic into a view.
To see if an item is reserved during a given timeframe:
SELECT
Item.ItemId
,CASE
WHEN COALESCE(PriorReservation.EndDt,GETDATE()) <= #ReservationStartDt AND #ReservationEndDt <= COALESCE(NextReservation.StartDt,'9999-12-31') THEN 'Y'
ELSE 'N'
END AS ReservationAvailableInd
FROM
Item Item
LEFT JOIN
ItemReservation PriorReservation
ON PriorReservation.ItemId = Item.ItemId
AND PriorReservation.StartDt =
(
SELECT
MAX(StartDt)
FROM
ItemReservation
WHERE
ItemId = Item.ItemId
AND StartDt <= #ReservationStartDt
)
LEFT JOIN
ItemReservation NextReservation
ON NextReservation.ItemId = Item.ItemId
AND NextReservation.StartDt =
(
SELECT
MIN(StartDt)
FROM
ItemReservation
WHERE
ItemId = Item.ItemId
AND StartDt > #ReservationStartDt
)
So you'll need to roll all of this into your stored procedures so:
When an item is loaned, it is available for the time period specified
When the loan date range is changed it does not conflict with the existing items or future reservations
When new reservations are made they do not conflict with existing procedures reservations
State transitions make sense (Not loaned/Returned -> Awaiting pickup -> Picked Up -> Returned/Lost)
You cannot delete loans with items that have been picked up or items that have been picked up

Alright, I found the solution, although it isn't the most elegant or DRY one.
First a view with occupations (thanks bbaird for the suggestion, which made it easier to figure out the logic):
create view vw_loanlineitem_occupations
as
select lli.id, loan_id, item_id, isnull(lli.pickedup, l.datefrom) as [from], isnull(lli.returned, dateadd(d, 1, l.dateto)) as [to] from loanlineitem lli inner join loan l on lli.loan_id = l.id
then a general check overlap udf:
create function udf_isOverlapping(#span1Start datetime, #span1End datetime, #span2Start datetime, #span2End datetime)
returns bit
as
begin
return iif((#span1Start <= #span2End and #span1End >= #span2Start), 1, 0);
end
then a udf and constraint on loan:
create function udf_isLoanValid(#loanId int, #dateFrom date, #dateTo date)
returns bit
as
begin
declare #result bit = 0;
--When type 'date' is compared to 'datetime' the time-part is 00:00:00, so add one day
set #dateTo = dateadd(d, 1, #dateTo)
if not exists (
select top 1 lli.id from loanlineitem lli
inner join loan l on lli.loan_id = l.id
--Only check items that are in this loan
where lli.item_id in (select item_id from loanlineitem where loan_id = #loanId)
--Check if this span is overlapping with other lines/loans
--When type 'date' is compared to 'datetime' the time-part is 00:00:00, so add one day
and (dbo.udf_isOverlapping(
#dateFrom,
#dateTo,
isnull(lli.pickedup, iif(l.id = #loanId, #dateFrom, l.datefrom)),
isnull(lli.returned, iif(l.id = #loanId, #dateTo, dateadd(d, 1, l.dateto)))
) = 1
)
)
begin
set #result = 1
end
return #result;
end;
go;
alter table loan
add constraint c_loan_datecheck check (dbo.udf_isLoanValid(id, dateFrom, dateTo) = 1);
and a separate constraint on loanlineitem, which unfortunately repeats some of the code from loan's constraint:
create function udf_isLineValid(#itemId int, #loanId int, #pickedup datetime, #returned datetime)
returns bit
as
begin
declare #result bit = 0;
declare #from date = #pickedup;
declare #to date = #returned;
--If either #from or #to is null, fill the ones with null from loan-table
if (#from is null or #to is null)
begin
select #from = isnull(#from, datefrom),
#to = isnull(#to, dateadd(d, 1, dateto))
from loan
where id = #loanId;
end
--If no lines with overlap exists, this line is valid, so set result to 1
if not exists (
select top 1 id from vw_loanlineitem_occupations
where item_id = #itemId
and loan_id <> #loanId
and dbo.udf_isOverlapping(#from, #to, [from], [to]) = 1
)
begin
set #result = 1;
end
return #result;
end;
go;
alter table loanlineitem
add constraint c_loanlineitem_checkoverlap check (dbo.udf_isLineValid(item_id, loan_id, pickedup, returned) = 1)
It works, which is the most important part. I am not sure about how performance is, but data integrity is more important.

Related

While Loop SQL not populating complete results

Question: the iteration happens only till record 131 and gives accurate value, after that the parameter #ADE_END_DATE returns a NULL value, why would that be? Below is my code.
Additionally I noticed the column Leave_Date has NULL values and the iteration stops and returns NULL value for the parameter #ADE_END_DATE where the NULL value starts.
Thanks for your help.
BEGIN
DECLARE #HIREDATEPlus1Yr DATETIME
DECLARE #ADE_Leave_Date DATETIME
DECLARE #ADE_End_Date DATETIME
DECLARE #ADE_Start_Date DATETIME
DECLARE #DATECAL DATETIME
DECLARE #i INT
DECLARE #j INT
DECLARE #Loop_length INT
DECLARE #ID VARCHAR(18)
-- start of loop
SET #j = 1
-- Loop length will equal to the list of all ADRs
SET #Loop_Length = (SELECT COUNT([AD_ID])
FROM [DS_ADHOC_MOPs].[ADE].[List]
WHERE Status NOT IN ('MANAGER', 'TBH', 'FROZEN'))
-- Loop through each ADRs
WHILE (#j <= #Loop_length)
BEGIN
-- Loop through each ADRs
SET #i = 0
-- Find AD ID
SET #ID = (SELECT TOP 1 [AD_ID] FROM [DS_ADHOC_MOPs].[ADE].[List]
WHERE [AD_ID] NOT IN (SELECT TOP (#j-1) [AD_ID]
FROM [DS_ADHOC_MOPs].[ADE].[List]
WHERE ([AD_ID] IS NOT NULL
AND Status NOT IN ('MANAGER', 'TBH', 'FROZEN'))))
-- Find the start date of the ADR
SET #ADE_Start_Date = (SELECT TOP 1 [Hire_Date]
FROM [DS_ADHOC_MOPs].[ADE].[List]
WHERE [AD_ID] NOT IN (SELECT TOP (#j-1) [AD_ID]
FROM [DS_ADHOC_MOPs].[ADE].[List]
WHERE ([AD_ID] IS NOT NULL
AND Status NOT IN ('MANAGER', 'TBH', 'FROZEN'))))
-- Hire date plus 1 year
SET #HIREDATEPlus1Yr = DATEADD(YEAR, 1, #ADE_Start_Date)
--Adding Leave Date
SET #ADE_Leave_Date = (SELECT TOP 1 [LEAVE_DATE]
FROM [DS_ADHOC_MOPs].[ADE].[List]
WHERE [AD_ID] NOT IN (SELECT TOP (#j-1) [AD_ID]
FROM [DS_ADHOC_MOPs].[ADE].[List]
WHERE ([AD_ID] IS NOT NULL
AND Status NOT IN ('MANAGER', 'TBH', 'FROZEN'))))
-- Set a temporary variable which will be 1 year from now. Use the Date ADD formulae to start date, if they are leaver before one year then add leave date (Use IF): DONE
-- Put everything inside the while loop and add opportunity selecting to it.
IF #ADE_Leave_Date IS NULL
SET #ADE_End_Date = DATEADD(YEAR, 1, #ADE_Start_Date)
ELSE IF #HIREDATEPlus1Yr < #ADE_Leave_Date
SET #ADE_End_Date = DATEADD(YEAR, 1, #ADE_Start_Date)
ELSE
SET #ADE_End_Date = #ADE_Leave_Date
SET #DATECAL = datediff(DAY, #ADE_Start_Date, #ADE_End_Date)
SET #j = #j + 1
UPDATE #TEMPTABLEEEE
SET [#ADE_End_Date] = #ADE_End_Date
WHERE #ID = AD_ID
END
SELECT * FROM #TEMPTABLEEEE
END
I'm not sure why you are using a WHILE loop. It looks like this code could be much simplified. SQL is a set based language. Whenever possible, you should try to handle your data as a whole set instead of breaking it down into row by row evaluations.
Does this give you what you need? If the table has more than one row for each AD_ID, you will need to get the MAX() or MIN() Hire_Date/LEAVE_DATE. To improve the answer, consider providing sample data.
UPDATE t
SET [#ADE_End_Date] = ed.ADE_EndDate
FROM #TEMPTABLEEEE AS t
INNER JOIN (
SELECT AD_ID
,CASE
WHEN LEAVE_DATE IS NULL THEN DATEADD(YEAR,1,Hire_Date)
WHEN DATEADD(YEAR,1,Hire_Date) < LEAVE_DATE THEN DATEADD(YEAR,1,Hire_Date)
ELSE LEAVE_DATE
END AS ADE_EndDate
FROM DS_ADHOC_MOPs.ADE.List
WHERE Status NOT IN ('MANAGER', 'TBH', 'FROZEN')
) AS ed ON t.AD_ID = ed.AD_ID

How to improve while loop insert performance in sql server?

Here is my SQL Query. It's insert almost 6500+ row from temp table. But its takes 15+ mins! . How can i improve this ? Thanks
ALTER proc [dbo].[Process_bill]
#userid varchar(10),
#remark nvarchar(500),
#tdate date ,
#pdate date
as
BEGIN
IF OBJECT_ID('tempdb.dbo..#temptbl_bill', 'U') IS NOT NULL
DROP TABLE #temptbl_bill;
CREATE TABLE #temptbl_bill (
RowID int IDENTITY(1, 1),
------------
)
// instert into temp table
DECLARE #NumberRecords int, #RowCounter int
DECLARE #batch INT
SET #batch = 300
SET #NumberRecords = (SELECT COUNT(*) FROM #temptbl_bill)
SET #RowCounter = 1
SET NOCOUNT ON
BEGIN TRANSACTION
WHILE #RowCounter <= #NumberRecords
BEGIN
declare #clid int
declare #hlid int
declare #holdinNo nvarchar(150)
declare #clientid nvarchar(100)
declare #clientName nvarchar(50)
declare #floor int
declare #radius nvarchar(50)
declare #bill money
declare #others money
declare #frate int
declare #due money
DECLARE #fine money
DECLARE #rebate money
IF #RowCounter > 0 AND ((#RowCounter % #batch = 0) OR (#RowCounter = #NumberRecords))
BEGIN
COMMIT TRANSACTION
PRINT CONCAT('Transaction #', CEILING(#RowCounter/ CAST(#batch AS FLOAT)), ' committed (', #RowCounter,' rows)');
BEGIN TRANSACTION
END;
// multiple select
// insert to destination table
Print 'RowCount -' +cast(#RowCounter as varchar(20)) + 'batch -' + cast(#batch as varchar(20))
SET #RowCounter = #RowCounter + 1;
END
COMMIT TRANSACTION
PRINT CONCAT('Transaction #', CEILING(#RowCounter/ CAST(#batch AS FLOAT)), ' committed (',
#RowCounter,' rows)');
SET NOCOUNT OFF
DROP TABLE #temptbl_bill
END
GO
As has been said in comments, the loop is completely unnecessary. The way to improve the performance of any loop is to remove it completely. Loops are a last resort in SQL.
As far as I can tell your insert can be written with a single statement:
INSERT tbl_bill(clid, hlid, holdingNo,ClientID, ClientName, billno, date_month, unit, others, fine, due, bill, rebate, remark, payment_date, inserted_by, inserted_date)
SELECT clid = c.id,
hlid = h.id,
h.holdinNo ,
c.cliendID,
clientName = CAST(c.clientName AS NVARCHAR(50)),
BillNo = CONCAT(h.holdinNo, MONTH(#tdate), YEAR(#tdate)),
date_month = #tDate,
unit = 0,
others = CASE WHEN h.hfloor = 0 THEN rs.frate * (h.hfloor - 1) ELSE 0 END,
fine = bs.FineRate * b.Due / 100,
due = b.Due,
bill = #bill, -- This is declared but never assigned
rebate = bs.rebate,
remark = #remark,
payment_date = #pdate,
inserted_by = #userid,
inserted_date = GETDATE()
FROM ( SELECT id, clientdID, ClientName
FROM tbl_client
WHERE status = 1
) AS c
INNER JOIN
( SELECT id, holdinNo, [floor], connect_radius
FROM tx_holding
WHERE status = 1
AND connect_radius <> '0'
AND type = 'Residential'
) AS h
ON c.id = h.clid
LEFT JOIN tbl_radius_setting AS rs
ON rs.radius= CONVERT(real,h.connect_radius)
AND rs.status = 1
AND rs.type = 'Non-Govt.'
LEFT JOIN tbl_bill_setting AS bs
ON bs.Status = 1
LEFT JOIN
( SELECT hlid,
SUM(netbill) AS Due
FROM tbl_bill AS b
WHERE date_month < #tdate
AND (b.ispay = 0 OR b.ispay IS NULL)
GROUP BY hlid
) AS b
ON b.hlid = h.id
WHERE NOT EXISTS
( SELECT 1
FROM tbl_bill AS tb
WHERE EOMONTH(#tdate) = EOMONTH(date_month)
AND tb.holdingNo = h.holdinNo
AND (tb.update_by IS NOT NULL OR tb.ispay=1)
);
Please take this with a pinch of salt, it was quite hard work trying to piece together the logic, so it may need some minor tweaks and corrections
As well as adapting this to work as a single statement, I have made a number of modifications to your existing code:
Swapped NOT IN for NOT EXISTS to avoid any issues with null records. If holdingNo is nullable, they are equivalent, if holdingNo is nullable, NOT EXISTS is safer - Not Exists Vs Not IN
The join syntax you are using was replaced 27 years ago, so I switched from ANSI-89 join syntax to ANSI-92. - Bad habits to kick : using old-style JOINs
Changed predicates of YEAR(date_month) = YEAR(#tDate) AND MONTH(date_month) = MONTH(#tDate) to become EOMONTH(#tdate) = EOMONTH(date_month). These are syntactically the same, but EOMONTH is Sargable, whereas MONTH and YEAR are not.
Then a few further links/suggestions that are directly related to changes I have made
Although I removed the while lopp, don't fall into the trap of thinking this is better than a cursor. A properly declared cursor will out perform a while loop like yours - Bad Habits to Kick : Thinking a WHILE loop isn't a CURSOR
The general consensus is that prefixing object names is not a good idea. It should either be obvious from the context if an object is a table/view or function/procedure, or it should be irrelevant - i.e. There is no need to distinguish between a table or a view, and in fact, we may wish to change from one to the other, so having the prefix makes things worse, not better.
The average ratio of time spent reading code to time spent writing code is around 10:1 - It is therefore worth the effort to format your code when you are writing it so that it is easy to read. This is hugely subjective with SQL, and I would not recommend any particular conventions, but I cannot believe for a second you find your original code free flowing and easy to read. It took me about 10 minutes just unravel the first insert statement.
EDIT
The above is not correct, EOMONTH() is not sargable, so does not perform any better than YEAR(x) = YEAR(y) AND MONTH(x) = MONTH(y), although it is still a bit simpler. If you want a truly sargable predicate you will need to create a start and end date using #tdate, so you can use:
DATEADD(MONTH, DATEDIFF(MONTH, '19000101', #tdate), '19000101')
to get the first day of the month for #tdate, then almost the same forumla, but add months to 1st February 1900 rather than 1st January to get the start of the next month:
DATEADD(MONTH, DATEDIFF(MONTH, '19000201', #tdate), '19000201')
So the following:
DECLARE #Tdate DATE = '2019-10-11';
SELECT DATEADD(MONTH, DATEDIFF(MONTH, '19000101', #tdate), '19000101'),
DATEADD(MONTH, DATEDIFF(MONTH, '19000201', #tdate), '19000201');
Will return 1st October and 1st November respectively. Putting this back in your original query would give:
WHERE NOT EXISTS
( SELECT 1
FROM tbl_bill AS tb
WHERE date_month >= DATEADD(MONTH, DATEDIFF(MONTH, '19000101', #tdate), '19000101'),
AND date_month < DATEADD(MONTH, DATEDIFF(MONTH, '19000201', #tdate), '19000201')
AND tb.holdingNo = h.holdinNo
AND (tb.update_by IS NOT NULL OR tb.ispay=1)
);

New to SQL - Why is my Insert into trying to insert NULL into primary key?

What I want to do is insert a range of dates into multiple rows for customerID=1. I have and insert for dbo.Customer(Dates), specifying my that I want to insert a record into the Dates column for my Customer table, right? I am getting error:
Cannot insert the value NULL into column 'CustomerId', table 'dbo.Customers'
Sorry if I am way off track here. I have looked at similar threads to find out what I am missing, but I'm not piecing this together. I am thinking it wants to overwrite the existing customer ID as NULL, but I am unsure why exactly since I'm specifying dbo.Customer(Dates) and not the existing customerID for that record.
declare #date_Start datetime = '03/01/2011'
declare #date_End datetime = '10/30/2011'
declare #date datetime = #date_Start
while #date <= #date_End
begin
insert into dbo.Customer(Dates) select #date
if DATEPART(dd,#date) = 0
set #date = DATEADD(dd, -1, DATEADD(mm,1,#date))
else
set #date = DATEADD(dd,1,#date)
end
select * from dbo.Customer
The primary key is customerId, but you are not inserting a value.
My guess is that you declared it as a primary key with something like this:
customerId int primary key,
You want it to be an identity column, so the database assigns a value:
customerId int identity(1, 1) primary key
Then, you don't need to assign a value into the column when you insert a new row -- the database does it for you.
Your Customer table has a column named CustomerId and which column is NOT Nullable so you have to provide that column value as well. If your column type is Int try the bellow code:
declare #date_Start datetime = '03/01/2011'
declare #date_End datetime = '10/30/2011'
declare #date datetime = #date_Start
DECLARE #cusId INT
SET #cusId = 1
while #date <= #date_End
begin
insert into dbo.Customer(CustomerId, Dates) select #cusId, #date
if DATEPART(dd,#date) = 0
set #date = DATEADD(dd, -1, DATEADD(mm,1,#date))
else
set #date = DATEADD(dd,1,#date)
SET #cusId = #cusId + 1;
end
select * from dbo.Customer
thank you for the feedback. I think I'm scrapping this and going to go with creating a separate table to JOIN. Not sure why I didn't start doing that before

How can this SQL check constraint for time ranges fail, when table is empty?

I have implemented a time range validation, as a check constraint, using a function in SQL, using this guide, almost to the letter.
Creating the function first:
create function dbo.ValidateStatusPeriodInfoTimeRange
(
#btf_id VARCHAR(32),
#start_time BIGINT,
#end_time BIGINT
)
returns bit
as
begin
declare #Valid bit = 1;
if exists( select *
from dbo.StatusPeriodInfoOccurrence o
where o.btf_id = #btf_id
and #start_time <= o.end_time and o.start_time <= #end_time )
set #Valid = 0;
return #Valid;
end
And then the constraint, using the function:
alter table dbo.StatusPeriodInfoOccurrence with nocheck add constraint
CK_StatusPeriodInfoOccurrence_ValidateTimeRange
check (dbo.ValidateStatusPeriodInfoTimeRange(btf_id, start_time, end_time) = 1);
When I try to insert an element into a completely empty table, I get:
The INSERT statement conflicted with the CHECK constraint
"CK_StatusPeriodInfoOccurrence_ValidateTimeRange". The conflict occurred in
database "D600600TD01_BSM_Surveillance", table "dbo.StatusPeriodInfoOccurrence".
I tried to figure out if I did something wrong in the function itself, and created this query to check it's return value:
DECLARE #ReturnValue INT
EXEC #ReturnValue = ValidateStatusPeriodInfoTimeRange
#btf_id = 'a596933eff9143bceda5fc5d269827cd',
#start_time = 2432432,
#end_time = 432432423
SELECT #ReturnValue
But this returns 1, as it should.
I am at a loss on how to continue debugging this. All parts seem to work, but the whole does not. Any ideas on how the insert statement can conflict with the check constraint?
Edit: Here is my insert statement for completion:
INSERT INTO StatusPeriodInfoOccurrence (btf_id, start_time, end_time) VALUES ('a596933eff9143bceda5fc5d269827cd',2432432,432432423);
There is an additional primary key comlumn with identity auto increment.
CHECK constraints happen after the row is inserted, so in its current form, the constraint fails because the very row that was inserted matches the constraint. In order for this to work as a constraint (not a trigger) there must be a way to distinguish the row we're checking from all other rows. MichaƂ's answer shows how to do this without relying on an IDENTITY, but if you do have that explicitly excluding the row may be simpler:
create function dbo.ValidateStatusPeriodInfoTimeRange
(
#id INT,
#btf_id VARCHAR(32),
#start_time BIGINT,
#end_time BIGINT
)
returns bit
as
begin
declare #Valid bit = 1;
if exists( select *
from dbo.StatusPeriodInfoOccurrence o
where o.id <> #id AND o.btf_id = #btf_id
and #start_time <= o.end_time and o.start_time <= #end_time )
set #Valid = 0;
return #Valid;
end;
with the constraint defined as
check (dbo.ValidateStatusPeriodInfoTimeRange(id, btf_id, start_time, end_time) = 1)
Regardless of the approach, indexes on (btf_id, start_time) and (btf_id, end_time) are a good idea to keep this scalable, otherwise a full table scan is necessary on every insert.
As was mentioned in comments, constraint is checked after the record is inserted into a table, then the transaction is commited or rolled back, depending on result of a check, which in your example will always fails, as query:
select *
from dbo.StatusPeriodInfoOccurrence o
where o.btf_id = #btf_id
and #start_time <= o.end_time and o.start_time <= #end_time
will return always at least one row (the one being inserted).
So, knowing that, you should check if the query returns more than one record, so the condition in if statement should become:
if (select count(*)
from dbo.StatusPeriodInfoOccurrence o
where o.btf_id = #btf_id
and #start_time <= o.end_time and o.start_time <= #end_time ) > 1
This solution works fine (tested on my DB).

SQL Server 2005 - writing an insert and update trigger for validation

I'm pretty bad at SQL, so I need someone to check my trigger query and tell me if it solves the problem and how acceptable it is. The requirements are a bit convoluted, so please bear with me.
Suppose I have a table declared like this:
CREATE TABLE Documents
(
id int identity primary key,
number1 nvarchar(32),
date1 datetime,
number2 nvarchar(32),
date2 datetime
);
For this table, the following constraints must be observed:
At least one of the number-date pairs should be filled (both the number and the date field not null).
If both number1 and date1 are not null, a record is uniquely identified by this pair. There cannot be two records with the same number1 and date1 if both fields are not null.
If either number1 or date1 is null, a record is uniquely identified by the number2-date2 pair.
Yes, there is a problem of poor normalization, but I cannot do anything about that.
As far as I know, I cannot write unique indexes on the number-date pairs that check whether some of the values are null in SQL Server 2005. Thus, I tried validating the constraints with a trigger.
One last requirement - the trigger should have no inserts of its own, only validation checks. Here's what I came up with:
CREATE TRIGGER validate_requisite_uniqueness
ON [Documents]
FOR INSERT, UPDATE
AS
BEGIN
DECLARE #NUMBER1 NVARCHAR (32)
DECLARE #DATE1 DATETIME
DECLARE #NUMBER2 NVARCHAR (32)
DECLARE #DATE2 DATETIME
DECLARE #DATETEXT VARCHAR(10)
DECLARE inserted_cursor CURSOR FAST_FORWARD FOR SELECT number1, date1, number2, date2 FROM Inserted
IF NOT EXISTS (SELECT * FROM INSERTED)
RETURN
OPEN inserted_cursor
FETCH NEXT FROM inserted_cursor into #NUMBER1, #DATE1, #NUMBER2, #DATE2
WHILE ##FETCH_STATUS = 0
BEGIN
IF (#NUMBER1 IS NULL OR #DATE1 IS NULL)
BEGIN
IF (#NUMBER2 IS NULL OR #DATE2 IS NULL)
BEGIN
ROLLBACK TRANSACTION
RAISERROR ('Either the first or the second number-date pair should be filled.', 10, 1)## Heading ##
END
END
IF (#NUMBER1 IS NOT NULL AND #DATE1 IS NOT NULL)
BEGIN
IF ((SELECT COUNT(*) FROM Documents WHERE number1 = #NUMBER1 AND date1 = #DATE1) > 1)
BEGIN
ROLLBACK TRANSACTION
SET #DATETEXT = CONVERT(VARCHAR(10), #DATE1, 104)
RAISERROR ('A document with the number1 ''%s'' and date1 ''%s'' already exists.', 10, 1, #NUMBER1, #DATETEXT)
END
END
ELSE IF (#NUMBER2 IS NOT NULL AND #DATE2 IS NOT NULL) /*the DATE2 check is redundant*/
BEGIN
IF ((SELECT COUNT(*) FROM Documents WHERE number2 = #NUMBER2 AND date2 = #DATE2) > 1)
BEGIN
ROLLBACK TRANSACTION
SET #DATETEXT = CONVERT(VARCHAR(10), #DATE2, 104)
RAISERROR ('A document with the number2 ''%s'' and date2 ''%s'' already exists.', 10, 1, #NUMBER2, #DATETEXT)
END
END
FETCH NEXT FROM inserted_cursor
END
CLOSE inserted_cursor
DEALLOCATE inserted_cursor
END
Please tell me how well-written and efficient this solution is.
A couple of questions I can come up with:
Will this trigger validate correctly against existing rows and newly inserted/updated rows in case of bulk modifications? It should, because the modifications are already applied to the table in the scope of this transaction, right?
Are the constraint violations handled correctly? Meaning, was I right to use the rollback transaction and raiserror pair?
Is the "IF NOT EXISTS (SELECT * FROM INSERTED) RETURN" statement used correctly?
Is the use of COUNT to check the constraints acceptable, or should I use some other way of checking the uniqueness of number-date pairs?
Can this solution be optimized in terms of execution speed? Should I add non-unique indexes on both number-date pairs?
Thanks.
EDIT:
A solution using a check constraint and an indexed view, based on Damien_The_Unbeliever's answer:
CREATE TABLE dbo.Documents
(
id int identity primary key,
number1 nvarchar(32),
date1 datetime,
number2 nvarchar(32),
date2 datetime,
constraint CK_Documents_AtLestOneNotNull CHECK (
(number1 is not null and date1 is not null) or
(number2 is not null and date2 is not null)
)
);
go
create view dbo.UniqueDocuments
with schemabinding
as
select
CASE WHEN (number1 is not null and date1 is not null)
THEN CAST(1 AS BIT)
ELSE CAST(0 AS BIT)
END as first_pair_filled,
CASE WHEN (number1 is not null and date1 is not null)
THEN number1
ELSE number2
END as number,
CASE WHEN (number1 is not null and date1 is not null)
THEN date1
ELSE date2
END as [date]
from
dbo.Documents
go
create unique clustered index IX_UniqueDocuments on dbo.UniqueDocuments(first_pair_filled,number,[date])
go
I would avoid the trigger, and use a check constraint and an indexed view:
CREATE TABLE dbo.Documents
(
id int identity primary key,
number1 nvarchar(32),
date1 datetime,
number2 nvarchar(32),
date2 datetime,
constraint CK_Documents_AtLestOneNotNull CHECK (
(number1 is not null and date1 is not null) or
(number2 is not null and date2 is not null)
)
);
go
create view dbo.UniqueDocuments
with schemabinding
as
select
COALESCE(number1,number2) as number,
COALESCE(date1,date2) as [date]
from
dbo.Documents
go
create unique clustered index IX_UniqueDocuments on dbo.UniqueDocuments(number,[date])
go
Which has the advantage that, although there is some "trigger-like" behaviour because of the indexed view, it's well-tested code that's already been deeply integrated into SQL Server.
I would use this logic instead (I didn't type it all as it takes ages), and definitely use SELECT 1 FROM ... in the IF EXISTS() statement as it helps performance. Also remove the cursors like marc_s said.
CREATE TRIGGER trg_validate_requisite_uniqueness
ON dbo.[Documents]
AFTER INSERT, UPDATE
AS
DECLARE #Number1 NVARCHAR(100) = (SELECT TOP 1 number1 FROM dbo.Documents ORDER BY Id DESC)
DECLARE #Date1 DATETIME = (SELECT TOP 1 date1 FROM dbo.Documents ORDER BY Id DESC)
DECLARE #Number2 NVARCHAR(100) = (SELECT TOP 1 number2 FROM dbo.Documents ORDER BY Id DESC)
DECLARE #Date2 DATETIME = (SELECT TOP 1 date2 FROM dbo.Documents ORDER BY Id DESC)
DECLARE #DateText NVARCHAR(100)
IF EXISTS (SELECT 1 FROM dbo.Documents AS D
INNER JOIN INSERTED AS I ON D.id = I.id WHERE I.Number1 IS NULL AND I.number2 IS NULL)
BEGIN
ROLLBACK TRANSACTION
RAISERROR ('Either the first or the second number pair should be filled.', 10, 1)
END
ELSE IF EXISTS (SELECT 1 FROM dbo.Documents AS D
INNER JOIN INSERTED AS I ON D.id = I.id WHERE I.Date1 IS NULL AND I.Date2 IS NULL)
BEGIN
ROLLBACK TRANSACTION
RAISERROR ('Either the first or the second date pair should be filled.', 10, 1)
END
ELSE IF EXISTS (SELECT 1 FROM dbo.Documents AS D
GROUP BY D.number1, D.date1 HAVING COUNT(*) >1
)
BEGIN
ROLLBACK TRANSACTION
SET #DateText = (SELECT CONVERT(VARCHAR(10), #Date1, 104))
RAISERROR ('Cannot have duplicate values', 10, 1, #Number1, #DateText )
END