Home > Enterprise >  Find all xml elements by attribute and change their value using t-sql
Find all xml elements by attribute and change their value using t-sql

Time:11-14

How can I update multiple XML elements within a single document?

For example, if I have the XML below and I want to change any elements with an attribute Store_ID="13" to instead have Store_ID="99".

declare @x xml 
select @x = N'
    <Games>
      <Game>
        <Place City="LAS" State="NV" />
        <Place City="ATL" State="GA" />
        <Store Store_ID="12" Price_ID="162" Description="Doom" />
        <Store Store_ID="12" Price_ID="575" Description="Pac-man" />
        <Store Store_ID="13" Price_ID="167" Description="Demons v3" />
        <Store Store_ID="13" Price_ID="123" Description="Whatever" />
      </Game>
    </Games>
'
select @x 

I can find all the elements with SQL like this:

select t.c.query('.') 
from @x.nodes('.//*[@Store_ID="13"]') as t(c)

To update only the first element I could do an update like this (or change '1' to '2' to update the 2nd element, etc):

SET @x.modify('  
  replace value of (.//*[@Store_ID="13"]/@Store_ID)[1]  
  with "99"  
');  
SELECT @x;

The docs for replace value of say I can only update one node at a time:

It must identify only a single node ... When multiple nodes are selected, an error is raised.

So how do I update multiple elements? I can imagine querying first to find how many elements there are, then looping through and calling @x.modify() once for each element, passing an index parameter... but a) that feels wrong and b) when I try it I get an error

-- Find how many elements there are with the attribute to update
declare @numberOfElements int 
select @numberOfElements = count(*) 
from ( 
    select element = t.c.query('.') 
    from @x.nodes('.//*[@Store_ID="13"]') as t(c)
) x 

declare @i int = 1
declare @query nvarchar(max) 

-- loop through and update each one 
while @i <= @numberOfElements begin 

    SET @x.modify('  
      replace value of (.//*[@Store_ID="13"]/@Store_ID)[sql:variable("@i")]  
      with "99"  
    ');  
    set @i = @i   1 ;  
end 

SELECT @x;

Running the sql above gives me the error:

Msg 2337, Level 16, State 1, Line 31
XQuery [modify()]: The target of 'replace' must be at most one node, found 'attribute(Store_ID,xdt:untypedAtomic) *'

Furthermore, if I'm wanting to run this against many rows of a table with XML data stored in a column, it becomes very procedural.

Otherwise I could cast to nvarchar(max) and do string manipulation on it and then cast back to xml. Again, this feels icky, but also means I don't get the power of xml expressions to find the elements to update.

CodePudding user response:

If the XML structure is defined and well-known ahead of time then you could avoid the XML.modify() limitations by just recreating the XML with the edits applied inline.

Example 1: reconstruct the XML with .nodes(), .values() and FOR XML

update dbo.Example
set x = (
    select
        (
            select Place.value(N'@City', N'nvarchar(max)') as [@City],
                Place.value(N'@State', N'nvarchar(max)') as [@State]
            from Game.nodes('Place') as n(Place)
            for xml path(N'Place'), type
        ),
        (
            select
                case when (StoreID = 13) then 99 else StoreID end as [@Store_ID],
                PriceID as [@Price_ID],
                [Description] as [@Description]
            from Game.nodes('Store') as n(Store)
            cross apply (
                select  Store.value(N'@Store_ID', N'int'),
                    Store.value(N'@Price_ID', N'int'),
                    Store.value(N'@Description', N'nvarchar(max)')
            ) attributes(StoreID, PriceID, [Description])
            for xml path('Store'), type
        )
    from x.nodes(N'/Games/Game') Games(Game)
    for xml path(N'Game'), root(N'Games')
);

Example 2: reconstruct the XML with XQuery

update dbo.Example
set x = (
    select  x.query('
<Games>
    <Game>
        {
            for $place in /Games/Game/Place
                return $place
        }
        {
            for $store in /Games/Game/Store
                let $PriceID := $store/@Price_ID
                let $StoreID_Input := xs:integer($store/@Store_ID) 
                let $StoreID := if ($StoreID_Input != 13) then $StoreID_Input else 99
                let $Description := $store/@Description
                return <Store Store_ID="{$StoreID}" Price_ID="{$PriceID}" Description="{$Description}"/>
        }
    </Game>
</Games>')
);

CodePudding user response:

As per this answer it's not possible to update multiple times in a single statement, so to use xml you need to loop and call modify() repeatedly until all values are updated.

My initial attempt to loop was misfounded: simply do the modify() on the first matching element until there's no more:

while @x.exist('.//*[@Store_ID="13"]')=1
begin 

    SET @x.modify('  
      replace value of (.//*[@Store_ID="13"]/@Store_ID)[1]
      with "99"  
    ');  
end 

In my trivial examples I've used hardcoded values "13" and "99" but for real code you can use sql:variable("@variableName") (ref) and sql:column("tableNameOrAlias.colName") (ref), e.g.

declare @oldId int = 13
    ,   @newId int = 99 

while @x.exist('.//*[@Store_ID=sql:variable("@oldId")]')=1
begin 

    SET @x.modify('  
      replace value of (.//*[@Store_ID=sql:variable("@oldId")]/@Store_ID)[1]
      with sql:variable("@newId")  
    ');  
end 
  • Related