For a client project I was recently asked how to efficiently handle translations in SQL Server. As, in this particular case, the client application was very proficient in processing XML data I chose in cooperation with the development team to use XML as the datatype to handle this specific case. While XML has been suitable as a SQL Server native datatype since SQL 2005, for one reason or the other it’s not often used – with only a very few working examples. This blog post is meant for T-SQL teams that are looking for a working XML example on SQL Server, including schema binding and XML indexes. To make this an interactive playground I’ve chosen to create everything in tempdb. This allows you to really play and experiment with it. Without any further ado, let us start!
A simple clean up
USE tempdb
go
------------------------------------------------------------------
-- Cleanup
------------------------------------------------------------------
BEGIN TRY
DROP TABLE dbo.dimName
DROP XML SCHEMA COLLECTION translationsSchemaCollectionSchema
END TRY
BEGIN CATCH
END CATCH
GO
Creating a schema (XSD)
Creating a schema has two mayor advantages and one disadvantage. Let’s walk through them separately.
Advantages:
SQL Server provides native storage of XML data through the xml data type. The XML Schema Collection stores imported XML schemas, which are then used to do the following:
XML that is stored in a column or variable that a schema is associated with, is referred to as typed XML, because the schema provides the necessary data type information for the instance data. SQL Server uses this information type to optimize data storage. Evenly important, the query processing engine also uses the schema for type checking and optimizing queries and data modification. The biggest reason for this is that, by default, data stored in the typed xml column are stored as XML content, and not as XML documents. This might seem like a trivial detail, but it really makes a difference in the context of a RDBMS. This, however, doesn’t change the fact that you can still use a simple SQL select statement to retrieve the whole dataset as one XML document.
Disadvantage:
The only drawback, that nevertheless isn’t to be understimated, is that you need maintain both the schema as well as the data. This can become complex when you have to take versioning into account, as it might be a lot of work for dynamically changing documents that you just want to store and retrieve as one XML document without filtering. Ok, let’s create the XSD!
GO
------------------------------------------------------------------
-- SCHEMA COLLECTION translationsSchema
------------------------------------------------------------------
USE tempdb
go
CREATE XML SCHEMA COLLECTION TranslationSchema AS
N'<?xml version="1.0" encoding="UTF-16"?>
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="translations">
<xs:complexType>
<xs:sequence>
<xs:element name="language" maxOccurs="unbounded" minOccurs="0">
<xs:complexType>
<xs:sequence>
<xs:element type="xs:string" name="languageCode"/>
<xs:element type="xs:string" name="textValue"/>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
' ;
GO
The Table
For this blog I’ve created a very simple table object which listens to the glorious name dimName. I also used the previously created XSD.
USE tempdb
go
create table dbo.dimName
(id integer identity(1,1) Not null PRIMARY KEY,
name XML (TranslationSchema) NOT NULL
)
As you can see, I’ve added a clustered primary key. Not only is this a best practice, it is also required when you want to create XML indexes.
Creating XML Indexes
Just like on many other column types, the XML data type allows you to create XML indexes on xml data when the database options are set correctly for working with the xml data type. XML instances are stored in xml type columns as large binary objects (BLOBs). These XML instances can be large, and the stored binary representation of xml data type instances go up to 2GB. Without an index, these binary large objects are shredded at run time to evaluate a query. This shredding can be time-consuming, so in most cases you’ll want to add an index from the moment that you filter your XML data. The default disclaimer is still valid, though: “Index maintenance cost during data modification must always be considered when these columns experience frequent updates and/or inserts”. By design, an XML Index falls into one of two categories and has three subtypes. Let’s walk through them:
The Primary XML Index
The first index on the xml type column must be the primary XML index. This index effectively contains all tags, values, and paths within the XML instances in an XML column. SQL Server then XXXX the clustered primary key to correlate rows in the primary XML index with rows in the table that contains the XML column. The number of index rows is approximately equal to the number of nodes in the XML binary large object. What’s important is that the primary XML index isn’t used when a full XML instance is retrieved.
Creating the Primary XML Index:
CREATE PRIMARY XML INDEX PXML_dbo_dimName
ON dbo.dimName (Name);
GO
The Secondary XML Index
As the primary XML index can become quite large, it can be interesting to create a secondary index on your XML data. You can see this as a filtered index of the XML data containing only one of the 3 types.
In this demo we’ll use both a Value and a Path index.
Creating the Value Secondary Index:
CREATE XML INDEX IXML_dbo_dimName_path
ON dbo.dimName(name)
USING XML INDEX PXML_dbo_dimName FOR VALUE;
Ok now let’s fill this table with some (meaningless) XML data:
INSERT INTO [dbo].[dimName]
([name] )
VALUES
('<translations>
<language><languageCode>NL</languageCode><textValue>Klant</textValue></language>
<language><languageCode>FR</languageCode><textValue>Client</textValue></language>
<language><languageCode>IT</languageCode><textValue>Cliente</textValue></language>
</translations>'),
('<translations>
<language><languageCode>NL</languageCode><textValue>Wagen</textValue></language>
<language><languageCode>FR</languageCode><textValue>Voiture</textValue></language>
<language><languageCode>EN</languageCode><textValue>Car</textValue></language>
</translations>'),
('<translations>
<language><languageCode>NL</languageCode><textValue>Tomaat</textValue></language>
<language><languageCode>FR</languageCode><textValue>Tomate</textValue></language>
<language><languageCode>IT</languageCode><textValue>Pomodoro</textValue></language>
</translations>'),
('<translations>
<language><languageCode>NL</languageCode><textValue>Voetbal</textValue></language>
<language><languageCode>FR</languageCode><textValue>Le Foot</textValue></language>
<language><languageCode>GB</languageCode><textValue>Soccer</textValue></language>
</translations>'),
('<translations>
<language><languageCode>NL</languageCode><textValue>Bier</textValue></language>
<language><languageCode>FR</languageCode><textValue>Biere</textValue></language>
<language><languageCode>SP</languageCode><textValue>Cervessa</textValue></language>
</translations>'),
('<translations>
<language><languageCode>VL</languageCode><textValue>Subiet</textValue></language>
</translations>')
GO
Let the querying begin and see if we can find all the keys that contain a specific string (using the primary index):
SELECT distinct
d.id, fd.v.value('(.)[1]', 'varchar(10)') as XmlValue
FROM
[dbo].[dimName] d
CROSS APPLY
name.nodes('/translations/language/textValue') AS fd(v)
Where fd.v.value('(.)[1]', 'varchar(10)') in ('Tomaat','Subiet')
GO
Let’s see if we can query the data in another way:
SELECT b.id,
x.XmlCol.value('(languageCode)[1]','NVARCHAR(10)') AS LanguageCode,
x.XmlCol.value('(textValue)[1]','NVARCHAR(100)') AS Vertaling
FROM dbo.dimName b
CROSS APPLY b.name.nodes('/translations/language') x(XmlCol)
where x.XmlCol.value('(languageCode)[1]','NVARCHAR(10)') in ('VL','NL')
Let’s search for a value now:
SELECT b.id,
x.XmlCol.value('(languageCode)[1]','NVARCHAR(10)') AS LanguageCode,
x.XmlCol.value('(textValue)[1]','NVARCHAR(100)') AS Vertaling
FROM dbo.dimName b
CROSS APPLY b.name.nodes('/translations/language') x(XmlCol)
where x.XmlCol.value('(textValue)[1]','NVARCHAR(100)') like ('%ie%')
Now we’ll be using an exist function, so we’ll be swapping to a Path Index:
DROP INDEX IXML_dbo_dimName_path ON dbo.dimName
CREATE XML INDEX IXML_dbo_dimName_path
ON dbo.dimName(name)
USING XML INDEX PXML_dbo_dimName FOR PATH;
GO
Let the query begin:
SELECT *
FROM dbo.dimName
WHERE name.exist('(/translations/language/languageCode[.="GB"])')=1
OR name.exist('(/translations/language/languageCode[.="EN"])')=1
GO
Bringing it all together:
SELECT b.id,
x.XmlCol.value('(languageCode)[1]','NVARCHAR(10)') AS LanguageCode,
x.XmlCol.value('(textValue)[1]','NVARCHAR(100)') AS Vertaling
FROM dbo.dimName b
CROSS APPLY b.name.nodes('/translations/language') x(XmlCol)
where name.exist('(/translations/language/languageCode[.="EN"])') = 1
If you check the query plan, you will now see that these queries use the secondary path index ????. What happens if we use contains and exists together:
SELECT d.id,
x.XmlCol.value('(languageCode)[1]','NVARCHAR(10)') AS LanguageCode,
x.XmlCol.value('(textValue)[1]','NVARCHAR(100)') AS Vertaling
FROM dbo.dimName d
CROSS APPLY d.name.nodes('/translations/language') x(XmlCol)
WHERE
name.exist('(/translations/language/textValue[contains(.,"oo")])')=1
Enjoy!
Karel
© 2023 Kohera
Crafted by
© 2022 Kohera
Crafted by
Cookie | Duration | Description |
---|---|---|
ARRAffinity | session | ARRAffinity cookie is set by Azure app service, and allows the service to choose the right instance established by a user to deliver subsequent requests made by that user. |
ARRAffinitySameSite | session | This cookie is set by Windows Azure cloud, and is used for load balancing to make sure the visitor page requests are routed to the same server in any browsing session. |
cookielawinfo-checkbox-advertisement | 1 year | Set by the GDPR Cookie Consent plugin, this cookie records the user consent for the cookies in the "Advertisement" category. |
cookielawinfo-checkbox-analytics | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Analytics". |
cookielawinfo-checkbox-functional | 11 months | The cookie is set by GDPR cookie consent to record the user consent for the cookies in the category "Functional". |
cookielawinfo-checkbox-necessary | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookies is used to store the user consent for the cookies in the category "Necessary". |
cookielawinfo-checkbox-others | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Other. |
cookielawinfo-checkbox-performance | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Performance". |
CookieLawInfoConsent | 1 year | CookieYes sets this cookie to record the default button state of the corresponding category and the status of CCPA. It works only in coordination with the primary cookie. |
elementor | never | The website's WordPress theme uses this cookie. It allows the website owner to implement or change the website's content in real-time. |
viewed_cookie_policy | 11 months | The cookie is set by the GDPR Cookie Consent plugin and is used to store whether or not user has consented to the use of cookies. It does not store any personal data. |
Cookie | Duration | Description |
---|---|---|
__cf_bm | 30 minutes | Cloudflare set the cookie to support Cloudflare Bot Management. |
pll_language | 1 year | Polylang sets this cookie to remember the language the user selects when returning to the website and get the language information when unavailable in another way. |
Cookie | Duration | Description |
---|---|---|
_ga | 1 year 1 month 4 days | Google Analytics sets this cookie to calculate visitor, session and campaign data and track site usage for the site's analytics report. The cookie stores information anonymously and assigns a randomly generated number to recognise unique visitors. |
_ga_* | 1 year 1 month 4 days | Google Analytics sets this cookie to store and count page views. |
_gat_gtag_UA_* | 1 minute | Google Analytics sets this cookie to store a unique user ID. |
_gid | 1 day | Google Analytics sets this cookie to store information on how visitors use a website while also creating an analytics report of the website's performance. Some of the collected data includes the number of visitors, their source, and the pages they visit anonymously. |
ai_session | 30 minutes | This is a unique anonymous session identifier cookie set by Microsoft Application Insights software to gather statistical usage and telemetry data for apps built on the Azure cloud platform. |
CONSENT | 2 years | YouTube sets this cookie via embedded YouTube videos and registers anonymous statistical data. |
vuid | 1 year 1 month 4 days | Vimeo installs this cookie to collect tracking information by setting a unique ID to embed videos on the website. |
Cookie | Duration | Description |
---|---|---|
ai_user | 1 year | Microsoft Azure sets this cookie as a unique user identifier cookie, enabling counting of the number of users accessing the application over time. |
VISITOR_INFO1_LIVE | 5 months 27 days | YouTube sets this cookie to measure bandwidth, determining whether the user gets the new or old player interface. |
YSC | session | Youtube sets this cookie to track the views of embedded videos on Youtube pages. |
yt-remote-connected-devices | never | YouTube sets this cookie to store the user's video preferences using embedded YouTube videos. |
yt-remote-device-id | never | YouTube sets this cookie to store the user's video preferences using embedded YouTube videos. |
yt.innertube::nextId | never | YouTube sets this cookie to register a unique ID to store data on what videos from YouTube the user has seen. |
yt.innertube::requests | never | YouTube sets this cookie to register a unique ID to store data on what videos from YouTube the user has seen. |
Cookie | Duration | Description |
---|---|---|
WFESessionId | session | No description available. |