Tuesday, October 29, 2019

Associating Data Directly with SQL Server Database Objects.

It is easy to attach details and documentation to a SQL Server database using extended properties. In fact, you can add a number of items of information to any database objects such as tables, views, procedures or users. If you use JSON to store the information, then you can, in addition, even monitor trends by storing previous information as a back history of changes. This could include such information as the date of changes, or variables such as the size of the database or table at a particular date, with the object.

I’ll use as an example the applying of version numbers to a database. We’ll end up storing old version numbers and the date when they were applied. We’d want to do this to build up a history of when changes were made to a database. This allows us to find out various items of information as well as the current version: We can, for example, find out when, and how long, a database was at a version number.

Storing a version number for a database in JSON.

Let’s take things in easy steps.

Without a history

Imagine that you have a database called ‘AdventureWorks’ that you need to document. You might have several different facts that you need to store: a description maybe, more likely a version number. There are likely to be other facts you need to document. You might decide to store it a JSON so that you can access just part of the data.

DECLARE @DatabaseInfo NVARCHAR(3750)
SELECT @DatabaseInfo =
  (
  SELECT 'AdventureWorks' AS "Name", '2.45.7' AS "Version",
  'The AdventureWorks Database supports a fictitious, multinational manufacturing company called Adventure Works Cycles.' AS "Description",
    GetDate() AS "Modified",
        SUser_Name() AS "by"
  FOR JSON PATH
  );

IF not EXISTS
  (SELECT name, value  FROM fn_listextendedproperty(
     N'Database_Info',default, default, default, default, default, default) )
    EXEC sys.sp_addextendedproperty @name=N'Database_Info', @value=@DatabaseInfo
ELSE
  EXEC sys.sp_Updateextendedproperty  @name=N'Database_Info', @value=@DatabaseInfo

You can now view this in SSMS, of course

You can access it in SQL via various different techniques depending on your preferences.

You can just access the current version information, or any other value, using JSON_VALUE()

SELECT Json_Value((SELECT Convert(NVARCHAR(3760), value)
  FROM sys.extended_properties AS EP
  WHERE major_id = 0 AND minor_id = 0 
    AND name = 'Database_Info'),'$.Version') AS Version

You can get the data as a result in various forms, including a single row or one row per key. Let’s first get the JSON value from the extended property …

DECLARE @DatabaseInfo NVARCHAR(3750);
SELECT @DatabaseInfo = Convert(NVARCHAR(3760), value)
  FROM sys.extended_properties AS EP
  WHERE major_id = 0 AND minor_id = 0 AND name = 'Database_Info';

.. then you can get the data as a result with a single row …

SELECT *
  FROM
  OpenJson(@DatabaseInfo)
  WITH (Name sysname, Version NVARCHAR(30), Description NVARCHAR(3000), Modified DATETIME2,
[by] NVARCHAR(30)
);

…or you can get the result with one row per key.

SELECT TheProperties.[Key], TheProperties.Value
  FROM OpenJson(@DatabaseInfo) AS TheJson
    OUTER APPLY OpenJson(TheJson.Value) AS TheProperties;

This data isn’t entirely secure. You need CONTROL or ALTER permissions on the object to alter it, but it can be accessed by anyone who has VIEW DEFINITION permission. We can demonstrate this now by creating a ‘headless’ user without a login and assigning just that permission. You can try this out with various permissions to see what works!

USE AdventureWorks2016;
-- create a user
CREATE USER EricBloodaxe WITHOUT LOGIN;
GRANT VIEW DEFINITION ON DATABASE::"AdventureWorks2016" TO EricBloodaxe;
EXECUTE AS USER = 'EricBloodaxe';
PRINT CURRENT_USER;
SELECT Convert(NVARCHAR(3760), value)
  FROM sys.extended_properties AS EP
  WHERE major_id = 0 AND minor_id = 0 AND name = 'Database_Info';
REVERT;
DROP USER EricBloodaxe;
PRINT CURRENT_USER;

Storing a history as well

At this point, you probably decide that you really want more than this. What you really need is to be able to keep track of versions and when they happened, something like this ….

{ 
   "Name":"MyCDCollection",
   "Version":"3.4.05",
   "Description":"Every book on databases for developers used to include one as an example",
   "Modified":"2019-10-21T11:44:53.810",
   "by":"EricBloodaxe",
   "History":[ 
      { 
         "Modified":"2019-10-21T11:44:03.703",
         "by":"dbo",
         "Version":"3.4.00"
      },
      { 
         "Modified":"2019-10-21T11:44:13.717",
         "by":"GenghisKahn",
         "Version":"3.4.01"
      },
      { 
         "Modified":"2019-10-21T11:44:23.733",
         "by":"AtillaTheHun",
         "Version":"3.4.02"
      },
      { 
         "Modified":"2019-10-21T11:44:33.763",
         "by":"VladTheImpaler",
         "Version":"3.4.03"
      },
      { 
         "Modified":"2019-10-21T11:44:43.790",
         "by":"KaiserBull",
         "Version":"3.4.04"
      }
   ]
}

(Taken from one of the test-harnesses. You’ll notice that we are doing very rapid CI!). Here, we have a database that we are continually updating but we have kept a record of our old versions, when they happened and who did the alterations.

We can access the history like this

SELECT * FROM OpenJson(
  (SELECT Json_Query((SELECT Convert(NVARCHAR(3760), value)
    FROM sys.extended_properties AS EP
    WHERE major_id = 0 AND minor_id = 0 
      AND name = 'Database_Info'),'strict $.History')))
  WITH (WhenModified DATETIME2 '$.Modified',
            WhoDidIt sysname '$.by',
                [Version] NVARCHAR(30) '$.Version' )

..but it would be better to add in the current version like this

DECLARE @Info nvarchar(3760)=(SELECT Convert(NVARCHAR(3760), value)
    FROM sys.extended_properties AS EP
    WHERE major_id = 0 AND minor_id = 0 
      AND name = 'Database_Info')
SELECT * FROM OpenJson(
  (SELECT Json_Query(@Info,'strict $.History')))
  WITH (WhenModified DATETIME2 '$.Modified',
            WhoDidIt sysname '$.by',
                [Version] NVARCHAR(30) '$.Version' )
UNION ALL 
SELECT Json_Value(@Info,'strict $.Modified'),
       Json_Value(@Info,'strict $.by'),
           Json_Value(@Info,'strict $.Version')

We maintain the current record where it is easy to get to and simply add an array to hold the history information. Our only headache is that we can only hold an NVARCHAR of 3750 characters (7500 of varchar characters) because extended properties are held as SQL_Variants. They need careful handling! This means that if our JSON data is larger, we have to trim off array elements that make the JSON exceed that number.

There is an error in the JSON_MODIFY() function that means that it doesn’t actually delete an array element, but merely assigns it to NULL. This can only be rectified by removing the NULL because one generally removes the oldest members of an array that you just append to by deleting element[0]. If it is NULL it still exists. Doh!

Once we have this up and running, there is a way of storing all sorts of ring-buffer information for reporting purposes that is in sorted order. Yes, you’re right, you now have a way of estimating database or table growth and performing a host of other monitoring tasks.

Because the code is rather more complicated, we’ll use a stored procedure. I’m making this a temporary procedure because I like to keep ‘utility’ code away from database code.

CREATE OR ALTER  PROCEDURE #ApplyVersionNumberToDatabase
@Version NVARCHAR(30) =  '2.45.7', 
@Name sysname = 'AdventureWorks', --only needed the first time around
@Description NVARCHAR(3000) =     --only needed the first time around
  'The AdventureWorks Database supports a fictitious, multinational 
  manufacturing company called Adventure Works Cycles.'
as

DECLARE 
  @CurrentContents NVARCHAR(4000);
--get the current values if any
SELECT @CurrentContents = Convert(NVARCHAR(3750), value)
  FROM fn_listextendedproperty(
N'Database_Info', DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT, DEFAULT
);
--if there is nothing there yet ...
IF @CurrentContents IS NULL
  BEGIN --just simply write it in
    DECLARE @DatabaseInfo NVARCHAR(3750);
    SELECT @DatabaseInfo =
      N'{"Name":"' + String_Escape(@Name, 'json') + N'","Version":"'
      + String_Escape(@Version, 'json') + N'","Description":"'
      + String_Escape(@Description, 'json') + +N'","Modified":"'
      + Convert(NVARCHAR(28), GetDate(), 126) + N'","by":"'
      + String_Escape(current_user, 'json') + N'","History":[]}'; -- empty history array
    EXEC sys.sp_addextendedproperty @name = N'Database_Info',
@value = @DatabaseInfo;
  END;
ELSE
  BEGIN --place the current values in the history array
-- SQL Prompt formatting off
        SELECT @CurrentContents=
          Json_Modify(@CurrentContents, 'append $.History',Json_query(
           '{"Modified":"'
            +Json_Value(@CurrentContents,'strict $.Modified')
          +'","by":"'
            +Json_Value(@CurrentContents,'strict $.by')
          +'","Version":"'
            +Json_Value(@CurrentContents,'strict $.Version')
        +'"}'))
        --now just overwrite the current values
        SELECT @CurrentContents=Json_Modify(@CurrentContents, 'strict $.Version',@version)
        SELECT @CurrentContents=Json_Modify(@CurrentContents, 'strict $.Modified',
                                              Convert(NVARCHAR(28), GetDate(), 126))
        SELECT @CurrentContents=Json_Modify(@CurrentContents, 'strict $.by',current_user)
-- SQL Prompt formatting on
    --if the json won't fit the space (unlikely) then take out the oldest records
    DECLARE @bug INT = 10; --limit every loop to a sane value just in case...
    WHILE Len(@CurrentContents) > 3750 AND @bug > 0
      BEGIN
        SELECT @CurrentContents =
          Json_Modify(@CurrentContents, 'strict $.History[1]', NULL),
          @bug = @bug - 1;
                  --SQL Server JSON can't delete array elements, it just replaces them
                  --with a null, so we have to remove them manually. 
                  SELECT @CurrentContents=Replace(@CurrentContents,'null,' COLLATE DATABASE_DEFAULT,'') 
      END;
    EXEC sys.sp_updateextendedproperty @name = N'Database_Info',
@value = @CurrentContents;
    PRINT 'updated';
  END;

The way that this works is that you only need to put in the name of the database and the description first time around, or after you’ve deleted it.

Here is how you delete it.

EXEC sys.sp_dropextendedproperty @name = N'Database_Info';

The following code be necessary the first time around, especially if you’ve used different defaults for your temporary stored procedure.

EXECUTE #ApplyVersionNumberToDatabase @Version='3.4.00',@Name='MyCDCollection',
@Description='Every book on databases for developers used to include one as an example'

From then on, it is just a matter of providing the version number

EXECUTE #ApplyVersionNumberToDatabase @Version='3.5.02'

Testing it out

Here is one of the test routines that I used for the stored procedure, but without the checks on the version number, as that would be repetition.

EXEC sys.sp_dropextendedproperty @name = N'Database_Info';
EXECUTE #ApplyVersionNumberToDatabase @Version='3.4.00'
WAITFOR DELAY '00:00:10'
CREATE USER GenghisKahn WITHOUT LOGIN
GRANT alter ON database::"AdventureWorks2016" TO GenghisKahn 
EXECUTE AS USER = 'GenghisKahn' 
EXECUTE #ApplyVersionNumberToDatabase @Version='3.4.01'
REVERT
DROP USER GenghisKahn
WAITFOR DELAY '00:00:10'
-- create a user
CREATE USER AtillaTheHun WITHOUT LOGIN
GRANT alter ON database::"AdventureWorks2016" TO AtillaTheHun 
EXECUTE AS USER = 'AtillaTheHun' 
EXECUTE #ApplyVersionNumberToDatabase @Version='3.4.02'
REVERT
DROP USER AtillaTheHun
WAITFOR DELAY '00:00:10'
-- create a user
CREATE USER VladTheImpaler WITHOUT LOGIN
GRANT alter ON database::"AdventureWorks2016" TO VladTheImpaler 
EXECUTE AS USER = 'VladTheImpaler' 
EXECUTE #ApplyVersionNumberToDatabase @Version='3.4.03'
REVERT
DROP USER VladTheImpaler
WAITFOR DELAY '00:00:10'
-- create a user
CREATE USER KaiserBull WITHOUT LOGIN
GRANT alter ON database::"AdventureWorks2016" TO KaiserBull 
EXECUTE AS USER = 'KaiserBull' 
EXECUTE #ApplyVersionNumberToDatabase @Version='3.4.04'
REVERT
DROP USER KaiserBull
WAITFOR DELAY '00:00:10'
-- create a user
CREATE USER EricBloodaxe WITHOUT LOGIN
GRANT alter ON database::"AdventureWorks2016" TO EricBloodaxe 
EXECUTE AS USER = 'EricBloodaxe' 
EXECUTE #ApplyVersionNumberToDatabase @Version='3.4.05'
REVERT
DROP USER EricBloodaxe

Conclusion

Extended properties are useful for development work but wonderful for reporting on a database or monitoring a trend. If you use JSON to store the data, they can act as miniature tables, or more correctly ring buffers. They have a variety of uses and are easy to create and remove without making any changes to the database whatsoever. This is because Extended properties and their values are either ignored as changes by humans and deployment/comparison tools, or you can easily configure them to be ignored: They do not affect the version)

 

 

The post Associating Data Directly with SQL Server Database Objects. appeared first on Simple Talk.



from Simple Talk https://ift.tt/34mhnkn
via

No comments:

Post a Comment