Reputation: 2319
Let's say that I have a table with the following columns
dbo.Orders (
customerNumber INT,
orderNumber INT,
cost FLOAT, -- its just an example, there are other columns
...
)
I want the order number to autoincrement but only for the given customer. So for example, if I was to execute the following
INSERT INTO
dbo.Orders (customerNumber, cost)
VALUES
(1, 2500), -- would take orderNumber: 1
(1, 3500), -- would take orderNumber: 2
(1, 1500), -- would take orderNumber: 3
(2, 650), -- would take orderNumber: 1, because the customer is 2
(2, 50), -- would take orderNumber: 2
(1, 100), -- would take orderNumber: 4, because customers 1 last order was 3
And so on, such that there will be always an unique combination of customerNumber and orderNumber
I know I can enforce the uniqueness of the pair of values using an UNIQUE KEY with both columns, but I could not find anything about an IDENTITY using two columns. I found you can get something similar to an IDENTITY creating a sequence, but I could not find something that would allow me to have many sequences one per customer.
The only thing I can think off is to read the current value, then add one and then insert it, like so
SELECT @lastOrderNumber = orderNumber
FROM dbo.Orders
WHERE customerNumber = @Customer
SET @newOrderNumber = ISNULL(@lastOrderNumber,0) + 1;
INSERT INTO dbo.Orders (customerNumber, cost, orderNumber)
VALUES (@Customer, 2500, @newOrderNumber)
But I think it becomes kind of cumbersome, its more difficult to do multiple inserts at once, and also becomes succeptible to racing conditions such as two insertions on the same customer nearly at the same time, something identities are very good at handling. I don't know if there is a better way
Thank you so much in advance
Upvotes: 2
Views: 84
Reputation: 6781
I think this way of creating the schema is going to bite you in the long run. You're essentially forcing a PK to be two columns wide, so any FKs to that table are going to need to be two columns, which is wider than necessary. This looks like a fact table as opposed to dimension, so it may not be that bad, but it still seems unnecessary.
All that said, in addition to the solutions above, you could create a helper table to hold the next Order ID per customer. This would require some finesse because the INSERTS need to read and update the next number in a single transaction to prevent duplicates. This could lead to concurrency/blocking issues, but as long as the INSERT is not long/complex, it would take a mighty busy system to bog down.
CREATE TABLE #Orders
(
customerNumber INT
, orderNumber INT
, cost DECIMAL(38, 2)
, CONSTRAINT PK_Orders PRIMARY KEY ( customerNumber, orderNumber )
);
CREATE TABLE #CustomerOrderNumberCurrentValue
(
customerNumber INT
, LastUsedOrderNumber INT NOT NULL
, CONSTRAINT PK_CustomerOrderNumberCurrentValue PRIMARY KEY (customerNumber)
);
--New customers would need to be added to this table
INSERT INTO #CustomerOrderNumberCurrentValue
(
customerNumber
, LastUsedOrderNumber
)
VALUES
( 1 , 0 ), ( 2, 0)
BEGIN TRAN
DECLARE @NextOrderNumber INT
SELECT @NextOrderNumber = LastUsedOrderNumber + 1 FROM #CustomerOrderNumberCurrentValue WHERE customerNumber = 1;
INSERT INTO #Orders
(
customerNumber
, orderNumber
, cost
)
VALUES
( 1 , @NextOrderNumber , 2500 );
UPDATE #CustomerOrderNumberCurrentValue
SET LastUsedOrderNumber = @NextOrderNumber
WHERE customerNumber = 1;
COMMIT
BEGIN TRAN
SELECT @NextOrderNumber = LastUsedOrderNumber + 1 FROM #CustomerOrderNumberCurrentValue WHERE customerNumber = 1;
INSERT INTO #Orders
(
customerNumber
, orderNumber
, cost
)
VALUES
( 1 , @NextOrderNumber , 3500 );
UPDATE #CustomerOrderNumberCurrentValue
SET LastUsedOrderNumber = @NextOrderNumber
WHERE customerNumber = 1;
COMMIT
BEGIN TRAN
SELECT @NextOrderNumber = LastUsedOrderNumber + 1 FROM #CustomerOrderNumberCurrentValue WHERE customerNumber = 1;
INSERT INTO #Orders
(
customerNumber
, orderNumber
, cost
)
VALUES
( 1 , @NextOrderNumber , 1500 );
UPDATE #CustomerOrderNumberCurrentValue
SET LastUsedOrderNumber = @NextOrderNumber
WHERE customerNumber = 1;
COMMIT
BEGIN TRAN
SELECT @NextOrderNumber = LastUsedOrderNumber + 1 FROM #CustomerOrderNumberCurrentValue WHERE customerNumber = 2;
INSERT INTO #Orders
(
customerNumber
, orderNumber
, cost
)
VALUES
( 2 , @NextOrderNumber , 650 );
UPDATE #CustomerOrderNumberCurrentValue
SET LastUsedOrderNumber = @NextOrderNumber
WHERE customerNumber = 2;
COMMIT
BEGIN TRAN
SELECT @NextOrderNumber = LastUsedOrderNumber + 1 FROM #CustomerOrderNumberCurrentValue WHERE customerNumber = 2;
INSERT INTO #Orders
(
customerNumber
, orderNumber
, cost
)
VALUES
( 2 , @NextOrderNumber , 50 );
UPDATE #CustomerOrderNumberCurrentValue
SET LastUsedOrderNumber = @NextOrderNumber
WHERE customerNumber = 2;
COMMIT
BEGIN TRAN
SELECT @NextOrderNumber = LastUsedOrderNumber + 1 FROM #CustomerOrderNumberCurrentValue WHERE customerNumber = 1;
INSERT INTO #Orders
(
customerNumber
, orderNumber
, cost
)
VALUES
( 1 , @NextOrderNumber , 100 );
UPDATE #CustomerOrderNumberCurrentValue
SET LastUsedOrderNumber = @NextOrderNumber
WHERE customerNumber = 1;
COMMIT
SELECT *
FROM #Orders;
DROP TABLE #Orders;
DROP TABLE #CustomerOrderNumberCurrentValue;
Note in the code above that each INSERT is a separate transaction that includes grabbing the next order ID and updating the table that holds it. Very important!
Upvotes: 1
Reputation: 72194
If you want to keep this simple and just use basic locking, it's not that hard. You don't need intermediate variables, but you do need correct locking hints. The locking hints used below, along with the index, will be all you need to ensure correctness.
INSERT INTO
dbo.Orders (customerNumber, cost, orderNumber)
SELECT v.*,
ISNULL(o.orderNumber, 0) + ROW_NUMBER() OVER (ORDER BY (SELECT NULL))
FROM (VALUES
(1, 2500),
(1, 3500),
(1, 1500),
(2, 650),
(2, 50),
(1, 100)
) AS v(customerNumber, cost)
OUTER APPLY (
SELECT TOP (1)
o.orderNumber
FROM dbo.Orders o WITH (SERIALIZABLE, UPDLOCK) -- the UPDLOCK is absolutely essential!!
WHERE o.customerNumber = v.customerNumber
ORDER BY
o.orderNumber DESC
) AS o;
You must put an index on the table (customerNumber ASC, orderNumber DESC)
otherwise you'll get huge locking problems and slow query plans.
If you are set on using sequences, then given the relatively low numbers of possible customerNumber
s, you could create a sequence per value. You can automate this using triggers.
I'd advise shoving them into another schema for easy management sake.
CREATE OR ALTER TRIGGER tr_CreateCustomerSequence ON dbo.Customer
AFTER INSERT, DELETE
AS
SET NOCOUNT ON;
DECLARE @sql nvarchar(max);
SELECT @sql = STRING_AGG(CONCAT(N'
CREATE SEQUENCE Sequences.Customer_', i.customerNumber, ' AS int MINVALUE 1 NOCACHE'), '')
FROM inserted i
WHERE NOT EXISTS (SELECT 1
FROM sys.sequences seq
WHERE seq.name = CONCAT(N'Customer_', i.customerNumber)
);
EXEC @sql;
SELECT @sql = STRING_AGG(CONCAT(N'
DROP SEQUENCE Sequences.Customer_', i.customerNumber), '')
FROM deleted;
EXEC @sql;
Then you can use them pretty simply, using sp_sequence_get_range
to get the values dynamically. The only complication is that you need to split the insert batch per customer, not once for all customer orders.
DECLARE @customerNumber int = 1; -- or a parameter
DECLARE @numberOfInsertRows bigint = 3;
DECLARE @sequence_name nvarchar(776) = CONCAT(N'Sequences.Customer_', @customerNumber);
DECLARE @range_first_value int;
EXEC sp_sequence_get_range
@sequence_name = @sequence_name,
@range_size = @range_size,
@range_first_value = @range_first_value OUT;
INSERT INTO
dbo.Orders (customerNumber, cost, orderNumber)
SELECT v.*,
@range_first_value + ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) - 1
FROM (VALUES
(1, 2500),
(1, 3500),
(1, 1500)
) AS v(customerNumber, cost);
You can alternatively call sp_sequence_get_range
from client-side app code, and then pass the correct values in.
Upvotes: 3