4

I have XML with multiple tags and need to concatenate some values to one string for output.

This for MS SQL 2012

DECLARE @XML AS XML, @hDoc AS INT

SELECT @XML = 
'<offers>
<offer>
    <a>AAA1</a>
    <param name="B">A1B</param>
    <param name="C">A1C</param>

</offer>
<offer>
    <a>AAA2</a>
    <param name="B">A2B</param>
    <param name="C">A2C1&amp;</param>
    <param name="C">A2C2&lt;</param>
</offer>
</offers>';


EXEC sp_xml_preparedocument @hDoc OUTPUT, @XML

SELECT a, ParamB, ParamC
FROM OPENXML(@hDoc, 'offers/offer') 
WITH 
(
a [varchar](50) 'a',
ParamB [varchar](255) 'param[@name="B"]',
ParamC [varchar](255) 'param[@name="C"]'
) as S


EXEC sp_xml_removedocument @hDoc

The output of this code:

a       |ParamB |ParamC
-----------------------------
AAA1    |A1B    |A1C
AAA2    |A2B    |A2C1&

and I need that:

a       |ParamB |ParamC
-----------------------------
AAA1    |A1B    |A1C
AAA2    |A2B    |A2C1& / A2C2<

"/" - any splitter

UPDATE

This query:

DECLARE @XML AS XML, @XML1 AS XML, @hDoc AS INT
DECLARE @delimiter VARCHAR(100)=' / ';

SELECT @XML = 
'<offers>
<offer>
    <a>AAA1</a>
    <param name="B">A1B</param>
    <param name="C">A1C</param>

</offer>
<offer>
    <a>AAA2</a>
    <param name="B">A2B</param>
    <param name="C">A2C1&lt;</param>
    <param name="C">A2C2&amp;</param>
</offer>
</offers>';


SELECT A.o.value('(a/text())[1]','nvarchar(100)') AS Offer_a
          ,A.o.query('param[@name="B"]') B
          ,A.o.query('param[@name="C"]') C
    FROM @XML.nodes('/offers/offer') A(o);

output practically what I need, just remove the xml-tags and insert separators between the parameter values.

This query solve this problem:

--Test XML variable equal col C for AAA2 from prev query
SET @XML1 = 
'<param name="C">A2C1&amp;</param><param name="C">A2C2&lt;</param>';

select (
select @delimiter + p.o.value('.', 'nvarchar(100)') 
          from @XML1.nodes('param') p(o)
          FOR XML PATH(''),TYPE 
     /* Use .value to uncomment XML entities e.g. &gt; &lt; etc*/
    ).value('.','VARCHAR(MAX)')      
    as C_result

But I don't know how to make it as subquery for XML column in previous SQL-query. Look at: dbfiddle

2 Answers 2

3
+100

First of all: FROM OPENXML (together with the stored procedures to prepare and to remove a document) is outdated and should not be used anymore. Rather use the native XML methods provided by the XML data type.

Try it like this:

DECLARE @XML AS XML, @hDoc AS INT

SELECT @XML = 
'<offers>
<offer>
    <a>AAA1</a>
    <param name="B">A1B</param>
    <param name="C">A1C</param>
</offer>
<offer>
    <a>AAA2</a>
    <param name="B">A2B</param>
    <param name="C">A2C1</param>
    <param name="C">A2C2</param>
</offer>
</offers>';

--The query

WITH cte AS
(
    SELECT A.o.value('(a/text())[1]','nvarchar(100)') AS Offer_a
          ,B.p.value('@name','nvarchar(100)') AS Param_Name
          ,A.o.query('.') TheOffer
    FROM @XML.nodes('/offers/offer') A(o)
    CROSS APPLY A.o.nodes('param') B(p)
)
SELECT Offer_a
      ,MAX(CASE WHEN Param_Name='B' THEN REPLACE(concatParams,' ',' / ') END) AS ParamB
      ,MAX(CASE WHEN Param_Name='C' THEN REPLACE(concatParams,' ',' / ') END) AS ParamC
FROM cte
CROSS APPLY(SELECT TheOffer.query('data(offer/param[@name=sql:column("Param_Name")]/text())').value('.','nvarchar(max)')) A(concatParams)
GROUP BY Offer_a;

The idea in short:

The cte will return a set of the text() of <a>, the value of param/@name and the XML-fragment <offer> for the corresponding <a>.
The magic happens in the CROSS APPLY(SELECT ...). This sub-select will fetch the params fitting to the current row's Param_Name in a row-wise call. The XQuery-function sql:column() allows to introduce a value of the current row into a XQuery-expression.
Very important is the XQuery-function data(). This will return all data in this path separated by a blank. Regrettfully this function does not allow for a user defined separator.
Therefore a big warning: If your param values include blanks, you will not know, where to separate them... If you need this, please come back with a comment.
The second piece of magic is the grouped aggregation. We group by Offer_a and use MAX() to allow the usage of non-grouping columns. This is an old-fashioned pivot approach...
The replace will put the slashes instead of the blanks (from data()).

UPDATE: If there are blanks in your param values...

Approach 1

We use XQuery/FLWOR to go through the nodes and do the concatenation within Xquery. We can use sql:variable() to introduce a declared variable into the XPath-expression:

DECLARE @delimiter VARCHAR(100)=' / ';

WITH cte AS
(
    SELECT A.o.value('(a/text())[1]','nvarchar(100)') AS Offer_a
          ,B.p.value('@name','nvarchar(100)') AS Param_Name
          ,A.o.query('.') TheOffer
    FROM @XML.nodes('/offers/offer') A(o)
    CROSS APPLY A.o.nodes('param') B(p)
)
SELECT Offer_a
      ,MAX(CASE WHEN Param_Name='B' THEN STUFF(concatParams,1,LEN(@delimiter),'') END) AS ParamB
      ,MAX(CASE WHEN Param_Name='C' THEN STUFF(concatParams,1,LEN(@delimiter),'') END) AS ParamC
FROM cte
CROSS APPLY(SELECT TheOffer.query('for $p in offer/param[@name=sql:column("Param_Name")]/text()
                                   return <x>{concat(sql:variable("@delimiter"),$p)}</x>
                                  ').value('.','nvarchar(max)')) A(concatParams)
GROUP BY Offer_a;

...or Approach 2

We extract all values to an intermediate set and use a correlated sub query together with the FOR XML approach to get the concatenated parameters.

WITH cte AS
(
    SELECT A.o.value('(a/text())[1]','nvarchar(100)') AS Offer_a
          ,B.p.value('@name','nvarchar(100)') AS Param_Name
          ,A.o.query('.') TheOffer
    FROM @XML.nodes('/offers/offer') A(o)
    CROSS APPLY A.o.nodes('param') B(p)
)
,cte2 AS
(
    SELECT cte.Offer_a
          ,cte.Param_Name
          ,A.relatedParams.value('text()[1]','nvarchar(100)') AS ParamValue
    FROM cte        
    CROSS APPLY TheOffer.nodes('offer/param[@name=sql:column("Param_Name")]') A(relatedParams)
)
SELECT Offer_a
      ,MAX(CASE WHEN Param_Name='B' THEN concatParamValues END) AS paramB
      ,MAX(CASE WHEN Param_Name='C' THEN concatParamValues END) AS paramC
FROM cte2
CROSS APPLY(SELECT STUFF((SELECT DISTINCT CONCAT(@delimiter,ParamValue) 
                          FROM cte2 csq 
                          WHERE csq.Offer_a=cte2.Offer_a
                            AND csq.Param_Name=cte2.Param_Name
                          FOR XML PATH('')),1,LEN(@delimiter),'')) A(concatParamValues)
GROUP BY Offer_a;

... and if your XML might contain forbidden characters use this at the end

CROSS APPLY(SELECT STUFF((SELECT DISTINCT CONCAT(@delimiter,ParamValue) 
                          FROM cte2 csq 
                          WHERE csq.Offer_a=cte2.Offer_a
                            AND csq.Param_Name=cte2.Param_Name
                          FOR XML PATH(''),TYPE).value('.','nvarchar(100)'),1,LEN(@delimiter),'')) A(concatParamValues)

UPDATE 2

You placed an additional question in your comment, but I must admit, I did not really get what you need. If I get this correctly, you are having entities within your values. You did get your result in principles, but these entities remained untranslated? Correct?

In my answer above there is everything you need already. Just to make it clear, a fully working example:

DECLARE @delimiter VARCHAR(100)=' / ';
DECLARE @XML AS XML, @hDoc AS INT

SELECT @XML = 
'<offers>
<offer>
    <a>AAA1</a>
    <param name="B">A1B&amp;</param>         <!-- Some typical entities -->
    <param name="C">A1C&lt;</param>
</offer>
<offer>
    <a>AAA2</a>
    <param name="B">A2B after space</param>   <!-- A case with some spaces -->
    <param name="C">A2C1 &#0065;</param>      <!-- The &#065; is the capital letter A as entity-->
    <param name="C">A2C2</param>
</offer>
</offers>';

WITH cte AS
(
    SELECT A.o.value('(a/text())[1]','nvarchar(100)') AS Offer_a
          ,B.p.value('@name','nvarchar(100)') AS Param_Name
          ,A.o.query('.') TheOffer
    FROM @XML.nodes('/offers/offer') A(o)
    CROSS APPLY A.o.nodes('param') B(p)
)
,cte2 AS
(
    SELECT cte.Offer_a
          ,cte.Param_Name
          ,A.relatedParams.value('text()[1]','nvarchar(100)') AS ParamValue
    FROM cte        
    CROSS APPLY TheOffer.nodes('offer/param[@name=sql:column("Param_Name")]') A(relatedParams)
)
SELECT Offer_a
      ,MAX(CASE WHEN Param_Name='B' THEN concatParamValues END) AS paramB
      ,MAX(CASE WHEN Param_Name='C' THEN concatParamValues END) AS paramC
FROM cte2
CROSS APPLY(SELECT STUFF((SELECT DISTINCT CONCAT(@delimiter,ParamValue) 
                          FROM cte2 csq 
                          WHERE csq.Offer_a=cte2.Offer_a
                            AND csq.Param_Name=cte2.Param_Name
                          FOR XML PATH(''),TYPE).value('.','nvarchar(100)'),1,LEN(@delimiter),'')) A(concatParamValues)
GROUP BY Offer_a;

The result

Offer_a     paramB              paramC
AAA1        A1B&                A1C<
AAA2        A2B after space     A2C2 / A2C1 A

The "approach 1" from above would work to the same result:

WITH cte AS
(
    SELECT A.o.value('(a/text())[1]','nvarchar(100)') AS Offer_a
          ,B.p.value('@name','nvarchar(100)') AS Param_Name
          ,A.o.query('.') TheOffer
    FROM @XML.nodes('/offers/offer') A(o)
    CROSS APPLY A.o.nodes('param') B(p)
)
SELECT Offer_a
      ,MAX(CASE WHEN Param_Name='B' THEN STUFF(concatParams,1,LEN(@delimiter),'') END) AS ParamB
      ,MAX(CASE WHEN Param_Name='C' THEN STUFF(concatParams,1,LEN(@delimiter),'') END) AS ParamC
FROM cte
CROSS APPLY(SELECT TheOffer.query('for $p in offer/param[@name=sql:column("Param_Name")]/text()
                                   return <x>{concat(sql:variable("@delimiter"),$p)}</x>
                                  ').value('.','nvarchar(max)')) A(concatParams)
GROUP BY Offer_a;

Finally I hope, that this solves your issues...

Sign up to request clarification or add additional context in comments.

4 Comments

Love that one 👍. As always XML is truly your pond :) Please remind me two days later when this question will be eligible for bounty.
Shnugo, thanks a lot! In origin params can include spaces. Is it possible to do something about this?
@Shnugo, thanks a lot for your answers! I have one idea and I need your help! Please, look at that: link
@Vovi, sorry don't get it... Is this question solved and you've got a new issue? Or is my suggestion not working? In this case please add to your question (use the edit option) and try to get things clear. I'd ask you - if this question is solved - to close this by accepting the answer and then start a new question.
1

Modern way of parsing XML:

-- this part could be interchanged with original OPENXML approach
WITH cte AS (
  SELECT a = c.s.value('(a/text())[1]', 'NVARCHAR(100)')
      ,param_val = c2.s.value('(./text())[1]', 'NVARCHAR(100)')
      ,param_name = c2.s.value('@name', 'NVARCHAR(100)')
  FROM tab t
  CROSS APPLY t.x.nodes('/offers/offer') AS c(s)
  OUTER APPLY c.s.nodes('param') AS c2(s)
)
SELECT a
 ,paramB=STRING_AGG(CASE WHEN param_name='B' THEN param_val END,'/') WITHIN GROUP(ORDER BY param_name)
 ,paramC=STRING_AGG(CASE WHEN param_name='C' THEN param_val END,'/') WITHIN GROUP(ORDER BY param_name)
FROM cte
GROUP BY a;

db<>fiddle demo

Output:

+-------+---------+-----------+
|  a    | paramB  |  paramC   |
+-------+---------+-----------+
| AAA1  | A1B     | A1C       |
| AAA2  | A2B     | A2C1/A2C2 |
+-------+---------+-----------+

Note: Original question deos not indicate that version is restricted. This code will run starting from SQL Server 2017 due to usage of STRING_AGG function.

3 Comments

Thanks! It looks good! But STRING_AGG is not available in MS SQL 2012. And I'm not sure that execution time with XML contains 20000 records will acceptable. Isn't there a simpler solution?
My version for MS SQL 2017:WITH cte AS ( SELECT A.o.value('(../a/text())[1]','nvarchar(100)') AS Offer_a ,A.o.value('.[@name="B"]','nvarchar(100)') B ,A.o.value('.[@name="C"]','nvarchar(100)') C FROM @XML.nodes('/offers/offer/param') A(o) ) SELECT Offer_a ,STRING_AGG(B, @delimiter) B ,STRING_AGG(C, @delimiter) C FROM cte group by Offer_a
@Vovi Yes, I know that version because it was my first thought too :). The issue is that backtracing in not efficient A.o.value('(../a/text()... here you are forcing to move one level up from nodes that were found in @XML.nodes('/offers/offer/param'). That is why I proposed a solution with CROSS APPLY ... OUTER APPLY

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.