In my post about auditing tools in SQL Server I mentioned a few tools we have at our disposal for capturing the change history of data.
One was Change Data Capture which I want into a bit more detail about in Setting up Change Data Capture (CDC)
I also referred to Temporal Tables which are available to us from SQL Server 2016 onward.
Temporal tables aren’t just about monitoring change, they also provide really nice methods for being able to query historical data – to see what the values were at a particular point in time e.g.
SELECT * FROM dbo.SomeData FOR SYSTEM_TIME AS OF '1900-01-01';
This makes them easy to work with, but also opens up other applications for them over and above auditing purposes – for instance being able to maintain configuration data that changes over time, but where we need to reference what the value was historically.
I wanted to look in more detail about the process for changing an existing table so that it becomes a temporal one.
Let’s start by quickly looking at how we would define a temporal table for a fresh deployment:
CREATE TABLE dbo.SomeData( Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_SomeData PRIMARY KEY CLUSTERED, SomeText VARCHAR(1000) NULL, ModifiedBy VARCHAR(128) NULL, StartDate datetime2(7) GENERATED ALWAYS AS ROW START NOT NULL, EndDate datetime2(7) GENERATED ALWAYS AS ROW END NOT NULL, PERIOD FOR SYSTEM_TIME (StartDate, EndDate) ) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.SomeData_History));
There are a few things here additional to a normal table definition. First thing to note is we need two columns to support the Temporal versioning. I’ve called my columns StartDate and EndDate, these both have to be of DATETIME2 datatype to the highest precision (100 nanoseconds). These are specified as GENERATED ALWAYS AS ROW STARTEND so SQL will take care of assigning values when new rows are inserted or updated.
I’ve specified the PERIOD FOR SYSTEM TIME, so SQL knows that these are the fields to use for the temporal versioning.
Finally I’ve specified SYSTEM_VERSIONING for the table and what the history table should be called.
When I execute this you can find the table and corresponding history table displayed in SSMS as below:
We have the table itself, and also the history table has been created for us. This is where the previous versions of rows of data will be stored.
So that’s pretty straightforward – but what if you want to change an existing table?
First of all let’s drop this one:
DROP TABLE dbo.SomeData;
Unfortunately that gives me an error message:
Msg 13552, Level 16, State 1, Line 90
Drop table operation failed on table ‘TestAudit.dbo.SomeData’ because it is not supported operation on system-versioned temporal tables.
As the message says, you can’t just drop Temporal tables. First you must turn system versioning off, then you can go ahead:
ALTER TABLE dbo.SomeData SET (SYSTEM_VERSIONING = OFF); DROP TABLE dbo.SomeData;
Dropping the table though leaves the History table behind – now seen as a normal table:
That’s handy as it means you don’t lose the history when the main table is deleted. For now though let’s just delete that as well:
DROP TABLE dbo.SomeData_History;
Now let’s create a new table that will be our “existing table” we want to change. For this example I’ll create a test table with a trigger that maintains a modified date, and throw in 1000 rows:
CREATE TABLE dbo.SomeData( Id INT IDENTITY(1,1) CONSTRAINT PK_SomeData PRIMARY KEY CLUSTERED NOT NULL, SomeText VARCHAR(1000) NULL, ModifiedBy VARCHAR(128) NULL, ModifiedDate DATETIME NOT NULL CONSTRAINT DF_SomeData_ModifiedDate DEFAULT (GETUTCDATE()) ); GO CREATE TRIGGER dbo.TR_SomeData_ModifiedDate ON dbo.SomeData AFTER UPDATE AS BEGIN UPDATE dbo.SomeData SET ModifiedDate = GETUTCDATE() FROM dbo.SomeData INNER JOIN Inserted ON dbo.SomeData.Id = Inserted.Id; END; GO INSERT INTO dbo.SomeData(SomeText, ModifiedBy) SELECT TOP 1000 'Blah', 'Me' FROM sys.objects a CROSS JOIN sys.objects b;
I’m going to complicate things and say that when I turn this into a temporal table I want to keep the existing ModifiedDate column and use that as the start date for my system versioning.
The first thing you might try is to alter the column:
ALTER TABLE dbo.SomeData ALTER COLUMN ModifiedDate DATETIME GENERATED ALWAYS AS ROW START NOT NULL;
Msg 13589, Level 16, State 1, Line 44
Column ‘ModifiedDate’ in table ‘TestAudit.dbo.SomeData’ cannot be specified as ‘GENERATED ALWAYS’ in ALTER COLUMN statement.
So I can’t do this in an alter statement. What I’ll actually have to do is create a new column specified to work with the system versioning, copy the data over, drop the old column and rename the new one.
First let’s create the additional columns and define the PERIOD FOR SYSTEM_TIME:
ALTER TABLE dbo.SomeData ADD StartDate datetime2 GENERATED ALWAYS AS ROW start NOT NULL CONSTRAINT DF_SomeData_StartDate DEFAULT (GETUTCDATE()), EndDate datetime2 GENERATED ALWAYS AS ROW END NOT NULL CONSTRAINT DF_SomeData_EndDate DEFAULT ('9999-12-31 23:59:59.9999999'), PERIOD FOR SYSTEM_TIME (StartDate, EndDate);
Note that I’ve had to specify defaults for the new columns so that the existing data gets values populated (I didn’t have to do that when I simply defined the Temporal Table from scratch).
For the StartDate I’ve specified the current UTC date – this could be any date and time that’s not in the future, though note it should be a UTC one. If I’d tried to use GETDATE, as I’m currently on British Summer Time, I would get the following error:
Msg 13542, Level 16, State 0, Line 51
ADD PERIOD FOR SYSTEM_TIME on table ‘TestAudit.dbo.SomeData’ failed because there are open records with start of period set to a value in the future.
For the end date I have put the maximum value a DATETIME2 column can hold, this is required and is a way of saying that this record is (at the moment) valid indefinitely. If I try to put in an earlier date I receive this error:
Msg 13575, Level 16, State 0, Line 51
ADD PERIOD FOR SYSTEM_TIME failed because table ‘TestAudit.dbo.SomeData’ contains records where end of period is not equal to MAX datetime.
Now I’ve got the columns I could try copying my data over:
UPDATE dbo.SomeData SET StartDate = ModifiedDate;
I get another error:
Msg 13537, Level 16, State 1, Line 70
Cannot update GENERATED ALWAYS columns in table ‘TestAudit.dbo.SomeData’.
It turns out you can get around this by temporarily dropping the period for system time:
ALTER TABLE dbo.SomeData DROP PERIOD FOR SYSTEM_TIME; UPDATE dbo.SomeData SET StartDate = ModifiedDate; ALTER TABLE dbo.SomeData ADD PERIOD FOR SYSTEM_TIME (StartDate, EndDate);
Your logical thought might be here that I didn’t need to create the period for system time when I altered the table to add the new columns. I could have just added the columns, migrated the data, then only added the period afterwards. Trust me, I tried that too:
Msg 13509, Level 16, State 1, Line 51
Cannot create generated always column when SYSTEM_TIME period is not defined.
From here it turns out to be reasonable clear sailing.
Let’s run the final command to make the table Temporal:
ALTER TABLE dbo.SomeData SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.SomeData_History));
Here’s what it looks like in SSMS:
You can see this is the same as the original fresh creation of a temporal table. The history table has been created for us. Just with an additional column in both tables.
Let’s remove the extra column and do the final rename:
ALTER TABLE dbo.SomeData DROP CONSTRAINT DF_SomeData_ModifiedDate; ALTER TABLE dbo.SomeData DROP COLUMN ModifiedDate; EXEC sp_rename 'dbo.SomeData.StartDate','ModifiedDate','COLUMN';
I was amazed the sp_rename didn’t give me an error. In fact if we look at the table again:
You can see the column has been renamed in both the main table and the history table. Pretty neat!
I have at this stage forgotten to do one thing. Let’s just try to update a record and see what happens:
UPDATE dbo.SomeData SET SomeText = 'Wibble' WHERE Id = 2;
Msg 13537, Level 16, State 1, Procedure TR_SomeData_ModifiedDate, Line 7 [Batch Start Line 185]
Cannot update GENERATED ALWAYS columns in table ‘TestAudit.dbo.SomeData’.
The statement has been terminated.
Yep, don’t forget to remove your trigger!
DROP TRIGGER dbo.TR_SomeData_ModifiedDate;
And now we’re all done.
What if I use SQL Server Data Tools (SSDT) to make my change?
If you’re working with Database Projects in Visual Studio then you will be used to publishing changes straight from there – or deploying them using a DACPAC file.
In that case SSDT handles all this for you – though it does use a slightly different method. Rather than creating a new column, copying that data over, dropping the old column and renaming the new one, it does it at the table level.
That’s the same way some other changes are made with SSDT (or if you do them through the GUI in SSMS). The steps are broadly the same, just with tables instead of columns:
1) Create new temporal table
2) Turn SYSTEM_VERSIONING off
3) Copy across the data from the old table to the new
4) Turn SYSTEM_VERSIONING back on
5) Drop the old table
6) Rename the new table to the original name
If you’re doing this by “hand”, you could do it this way if you prefer, but it is probably a heavier operation that will require more down time. For instance you’ll need the whole table to be locked while the operation progresses as you don’t want to risk data changes once you’ve started copying the data over. The column method will also lock the table, but potentially there is less to copy so it should hopefully be quicker.
As a final point I should note that you may run into problems dropping the trigger with SSDT, it can be a little quirky around doing that. For the moment I’ll leave off from going into that in more detail – but if you get that issue feel free to give me a yell!