SQL Server: Making JSON relational
Working on a task to create a relational ordered set of prompts from rules with dynamic model binding. This is one way to do it with JSON
and SQL
. In this case the prompts may change, but it is a rare event. The goal is to make the SQL server which has relational information in tables create and do the dynamic binding and make it do type checking. How?
Start with Business
table and then a related BusinessSteps
table like so
DROP TABLE IF EXISTS [dbo].[BusinessSteps]
DROP TABLE IF EXISTS [dbo].[Business]
CREATE TABLE [dbo].[Business](
[Id] [int] IDENTITY(1,1) NOT NULL,
[Name] [nvarchar](100) NOT NULL,
CONSTRAINT [PK_Business] PRIMARY KEY CLUSTERED
(
[Id] ASC
))
GO
CREATE TABLE [dbo].[BusinessSteps](
[Id] [int] IDENTITY(1,1) NOT NULL,
[BusinessID] [int] NOT NULL,
[TemplateText] [nvarchar](max) NOT NULL,
PRIMARY KEY CLUSTERED
(
[Id] ASC
))
GO
ALTER TABLE [dbo].[BusinessSteps] WITH CHECK ADD CONSTRAINT [FK_BusinessSteps_Business] FOREIGN KEY([BusinessID])
REFERENCES [dbo].[Business] ([Id])
ON DELETE CASCADE
GO
ALTER TABLE [dbo].[BusinessSteps] CHECK CONSTRAINT [FK_BusinessSteps_Business]
GO
Load some initial data, in the JSON
steps don't worry about the formatting yet. It's to allow for dynamic formatting by letting the SQL
engine take care of the binding and presentation
DECLARE @businessName NVARCHAR(200) = 'Jasmine''s Jewels'
MERGE INTO [dbo].[Business] AS [target]
USING
(
VALUES (@businessName)
) AS [source] ([Name])
ON [target].[Name] = [source].[Name]
WHEN NOT MATCHED BY TARGET THEN
INSERT ([Name])
VALUES ([Name]);
DECLARE @business_id INT;
SELECT @business_id = [Id]
FROM [dbo].[Business]
WHERE [Name] = @businessName;
MERGE INTO [dbo].[BusinessSteps] AS [target]
USING
(
VALUES
(@business_id, '{
"steps": [
{
"order": 1,
"text": "Dial: '' + @phoneNumber + ''"
},
{
"order": 2,
"text": "Enter: '' + @firstName + '' '' + @lastName + ''"
},
{
"order": 3,
"text": "Enter Date of Birth: '' + FORMAT(@dateOfBirth, ''MM/dd/yyyy'') + ''"
},
{
"order": 4,
"text": "Enter the requested status: '' + @status + ''"
}
]
}')
) AS [source] ([BusinessID], [TemplateText])
ON [target].[BusinessID] = [source].[BusinessID]
WHEN NOT MATCHED BY TARGET THEN
INSERT ([BusinessID], [TemplateText])
VALUES ([BusinessID], [TemplateText]);
Now when you do a select you should get something like this
Id | BusinessID | TemplateText |
---|---|---|
22 | 2 | { "steps": [ { "order": 1, "text": "Dial: ' + @phoneNumber + '" }, { "order": 2, "text": "Enter: ' + @firstName + ' ' + @lastName + '" }, { "order": 3, "text": "Enter Date of Birth: ' + FORMAT(@dateOfBirth, 'MM/dd/yyyy') + '" }, { "order": 4, "text": "Enter the requested status: ' + @status + '" } ] } |
Each business entity has a corresponding row that contains steps
within these steps are objects that are composed of the elements {order, text}
the idea is that each step has a specific order and that step is then composed of some text
that text
is template text that can be dynamically executed to input parameters in a safe manner with dynamic sql and the use of SELECT
to do the dynamic binding. In this case the data, binding, and formatting all can occur in SQL
. Remember this is to keep modeling and display consistent so let it be SQL
all the way up and down.
If you run the following query to find the BusinessSteps
you entered above
DECLARE @businessName NVARCHAR(MAX) = 'Jasmine''s Jewels'
DECLARE @json NVARCHAR(MAX)
SELECT TOP 1 @json = [TemplateText]
FROM [dbo].[BusinessSteps] [bs]
INNER JOIN [dbo].[Business] [b]
ON [b].[Id] = [bs].[BusinessID]
WHERE [b].[Name] = @businessName
SELECT JSON_VALUE(value, '$.text')
FROM OPENJSON(@json, '$.steps')
ORDER BY CAST(JSON_VALUE(value, '$.order') AS INT)
It would give you the following
(No column name) |
---|
Dial: ' + @phoneNumber + ' |
Enter: ' + @firstName + ' ' + @lastName + ' |
Enter Date of Birth: ' + FORMAT(@dateOfBirth, 'MM/dd/yyyy') + ' |
Enter the requested status: ' + @status + ' |
So what you want to do is have those SQL
variables dynamically bind and run like so for the first statement like so
DECLARE @phoneNumber NVARCHAR(100) = '123-456-7890'
SELECT 'Dial: ' + @phoneNumber + ''
Results
(No column name) |
---|
Dial: 123-456-7890 |
How to do that dynamically for every step is to have the variables passed in and evaluated for each statement using sp_executesql
It's a bit of a mess so let's create the stored procedure to extract, bind, and return the intended data dynamically, so let me give you the stored procedure to do this
CREATE OR ALTER PROCEDURE [dbo].[Get_PromptSteps]
(
@businessName NVARCHAR(100),
@phoneNumber NVARCHAR(20),
@lastName NVARCHAR(100),
@firstName NVARCHAR(100),
@dateOfBirth DATETIME,
@status NVARCHAR(100)
) AS
BEGIN
SET NOCOUNT ON
DECLARE @response NVARCHAR(MAX)
DECLARE @json NVARCHAR(MAX);
DECLARE @params NVARCHAR(MAX) = '
@phoneNumber NVARCHAR(20),
@lastName NVARCHAR(100),
@firstName NVARCHAR(100),
@dateOfBirth DATETIME,
@status NVARCHAR(100)
'
SELECT TOP 1 @json = [TemplateText]
FROM [dbo].[BusinessSteps] [bs]
INNER JOIN [dbo].[Business] [b]
ON [b].[Id] = [bs].[BusinessID]
WHERE [b].[Name] = @businessName
DECLARE @totalSteps INT
SELECT @totalSteps = COUNT(JSON_VALUE(value, '$.order'))
FROM OPENJSON(@json, '$.steps')
DECLARE @currentStep INT = 1
DECLARE @tab AS TABLE (response NVARCHAR(MAX))
WHILE @currentStep <= @totalSteps
BEGIN
DECLARE @stmt NVARCHAR(MAX);
SELECT TOP 1 @stmt = JSON_VALUE(value, '$.text')
FROM OPENJSON(@json, '$.steps')
WHERE CAST(JSON_VALUE(value, '$.order') AS INT) = @currentStep
IF CHARINDEX('@', @stmt) < 0
BEGIN
SET @stmt = 'SELECT ' + @stmt
END
ELSE
BEGIN
SET @stmt = 'SELECT ''' + @stmt + ''''
END
PRINT @stmt
INSERT INTO @tab
EXECUTE @response = sp_executesql @stmt,
@params,
@phoneNumber = @phoneNumber,
@lastName = @lastName,
@firstName = @firstName,
@dateOfBirth = @dateOfBirth,
@status = @status
SET @currentStep = @currentStep + 1
END
SELECT [response] FROM @tab
END
Now you can simply run the stored procedure as so
DECLARE @RC int
DECLARE @businessName NVARCHAR(100) = 'Jasmine''s Jewels'
DECLARE @phoneNumber nvarchar(100) = '123-456-7890'
DECLARE @lastName nvarchar(100) = 'Zero'
DECLARE @firstName nvarchar(100) = 'Kandy'
DECLARE @dateOfBirth datetime = '1999-01-06'
DECLARE @status nvarchar(100) = 'Ok for now'
EXECUTE @RC = [dbo].[Get_PromptSteps]
@businessName
,@phoneNumber
,@lastName
,@firstName
,@dateOfBirth
,@status
GO
Gives you the output of
response |
---|
Dial: 123-456-7890 |
Enter: Kandy Zero |
Enter Date of Birth: 01/06/1999 |
Enter the requested status: Ok for now |
Then to call it from C# using Dapper like so
var sqlConnection = new SqlConnectionStringBuilder
{
DataSource = "localhost",
InitialCatalog = "DbCache",
IntegratedSecurity = true
};
var businessName = "Jasmine's Jewels";
var phoneNumber = "123-456-7890";
var lastName = "Zero";
var firstName = "Kandy";
var status = "Ok for now";
var dateOfBirth = new DateTime(1999, 1, 6);
using var conn = new SqlConnection(sqlConnection.ConnectionString);
var steps = (await conn.QueryAsync<string>("Get_PromptSteps", new
{
businessName,
phoneNumber,
lastName,
firstName,
dateOfBirth,
status
}, null, null, CommandType.StoredProcedure)).ToList();
Now to make a change to an step you will need to access the step and modify it's template, say I want to reverse the name step from firstName lastName
to lastName, firstName
. I would do the following. I already know this step is 2
so that simplifies my query
I need to use a JSON path query
and the JSON_MODIFY
to change the output as needed.
DECLARE @businessName NVARCHAR(MAX) = 'Jasmine''s Jewels'
DECLARE @json NVARCHAR(MAX)
SELECT TOP 1 @json = [TemplateText]
FROM [dbo].[BusinessSteps] [bs]
INNER JOIN [dbo].[Business] [b]
ON [b].[Id] = [bs].[BusinessID]
WHERE [b].[Name] = @businessName
DECLARE @stepOrder INT = 2
SELECT JSON_VALUE(value, '$.text')
FROM OPENJSON(@json, '$.steps')
WHERE CAST(JSON_VALUE(value, '$.order') AS INT) = @stepOrder
UPDATE [BusinessSteps]
SET [TemplateText] = JSON_MODIFY(@json, '$.steps[' + CAST((@stepOrder - 1) as NVARCHAR(100)) +'].text', 'Enter: '' + @lastName + '','' + @firstName + ''')
WHERE [BusinessID] =
(
SELECT [Id]
FROM [Business]
WHERE [Name] = @businessName
)
SELECT TOP 1 @json = [TemplateText]
FROM [dbo].[BusinessSteps] [bs]
INNER JOIN [dbo].[Business] [b]
ON [b].[Id] = [bs].[BusinessID]
WHERE [b].[Name] = @businessName
SELECT JSON_VALUE(value, '$.text')
FROM OPENJSON(@json, '$.steps')
WHERE CAST(JSON_VALUE(value, '$.order') AS INT) = @stepOrder
First half of the query gives current value for the step
(No column name) |
---|
Enter: ' + @firstName + ' ' + @lastName + ' |
And second half shows the updated step
(No column name) |
---|
Enter: ' + @lastName + ',' + @firstName + ' |
Now run the stored procedure again with above parameters
DECLARE @RC int
DECLARE @businessName NVARCHAR(100) = 'Jasmine''s Jewels'
DECLARE @phoneNumber nvarchar(100) = '123-456-7890'
DECLARE @lastName nvarchar(100) = 'Zero'
DECLARE @firstName nvarchar(100) = 'Kandy'
DECLARE @dateOfBirth datetime = '1999-01-06'
DECLARE @status nvarchar(100) = 'Ok for now'
EXECUTE @RC = [dbo].[Get_PromptSteps]
@businessName
,@phoneNumber
,@lastName
,@firstName
,@dateOfBirth
,@status
GO
And you get
response |
---|
Dial: 123-456-7890 |
Enter: Zero,Kandy |
Enter Date of Birth: 01/06/1999 |
Enter the requested status: Ok for now |
Successful modification of the step