James Mertz
James Mertz

Reputation: 8789

How do I remove a child element based on attributes on multiple parents?

I have an XML file that will be similar to the structure below:

<?xml version="1.0" encoding="utf-8"?>
<Root Attr1="Foo" Name="MyName" Attr2="Bar" >
    <Parent1 Name="IS">
        <Child1 Name="Kronos1">
            <GrandChild1 Name="Word_1"/>
            <GrandChild2 Name="Word_2"/>
            <GrandChild3 Name="Word_3"/>
            <GrandChild4 Name="Word_4"/>
        </Child1>
        <Child2 Name="Kronos2">
            <GrandChild1 Name="Word_1"/>
            <GrandChild2 Name="Word_2"/>
            <GrandChild3 Name="Word_3"/>
            <GrandChild4 Name="Word_4"/>
        </Child2>
    </Parent1>
</Root>

The elements are not defined in that they can have different values than other files. What I do know is the "Name" attribute of each element before hand, which will always be defined. I need to be able to manipulate, and/or delete data within a selected element based on that name. For Example: removeElement("MyName.IS.Kronos1.Word_1") would delete the GrandChild1 element underneath the Child1 Parent.

My issues is that while using LINQ to XML queries I'm not able to select that element properly. Using this:

private IEnumerable<XElement> findElements(IEnumerable<XElement> docElements, string[] names)
{
    // the string[] is an array from the desired element to be removed.
    // i.e. My.Name.IS ==> array[ "My, "Name", "IS"]
    IEnumerable<XElement> currentSelection = docElements.Descendants();

    foreach (string name in names)
    {
        currentSelection =
            from el in currentSelection
            where el.Attribute("Name").Value == name
            select el;
    }

    return currentSelection;

}

To find where I need to remove the elements yields this result:

<?xml version="1.0" encoding="utf-8"?>
<Root Attr1="Foo" Name="MyName" Attr2="Bar" >
    <Parent1 Name="IS">
        <Child1 Name="Kronos1">
            <GrandChild2 Name="Word_2"/>
            <GrandChild3 Name="Word_3"/>
            <GrandChild4 Name="Word_4"/>
        </Child1>
        <Child2 Name="Kronos2">
            <GrandChild2 Name="Word_2"/>
            <GrandChild3 Name="Word_3"/>
            <GrandChild4 Name="Word_4"/>
        </Child2>
    </Parent1>
</Root>

After debugging it appears that all I'm doing is searching for the same document over again, but for different names each time. How do I search and select a specific element based on multiple parent attribute Names?

It should be noted, that the size of the XML (meaning levels of elements) are also variable. Meaning that there can as little as 2 levels (Parents) or up to 6 levels (Great-Great-GrandChildren). However, I NEED to be able to look at the root node's Name Attribute as well.

Upvotes: 3

Views: 2675

Answers (4)

Tamara Wijsman
Tamara Wijsman

Reputation: 12348

This should work:

if (doc.Root.Attribute("Name").Value != names.First())
    throw new InvalidOperationException("Sequence contains no matching element.");

var selection = doc.Root;

foreach (var next in names.Skip(1))
    selection = selection.Elements().First(x => x.Attribute("Name").Value == next);

return selection;

You can replace the latest lines by the following if you want to:

var selection = names.Skip(1).Aggregate(doc.Root, (current, next) => current.Elements().First(x => x.Attribute("Name").Value == next));

The .First() method throws an exception if no matching element is found in source.


The cleanest approach would be to add a new function:

XElement SelectChildElement(XElement current, string child)
{
    if (current == null)
        return null;        

    var elements = current.Elements();
    return elements.FirstOrDefault(x => x.Attribute("Name").Value == child);
}

Such that you can simply use it as following:

if (doc.Root.Attribute("Name").Value != names.First())
    return null;

return names.Skip(1).Aggregate(doc.Root, SelectChildElement);

And then, if you ever need to select one child, you have a handy SelectChildElement() avaialble. If you want to do myElement.SelectChild(child) instead, you can call it from an extension.

Also, as you use FirstOrDefault here, you don't get an exception but get null returned instead.

This way, it doesn't have to keep track of exceptions which is often more costly...

Upvotes: 1

James Mertz
James Mertz

Reputation: 8789

If you take a recursive approach you can do this:

private XElement findElement(IEnumerable<XElement> docElements, List<string> names)
{


    IEnumerable<XElement> currentElements = docElements;
    XElement returnElem = null;
    // WE HAVE TO DO THIS, otherwise we lose the name when we remove it from the list
    string searchName =  String.Copy(names[0]);

    // look for elements that matchs the first name
    currentElements =
        from el in currentElements
        where el.Attribute("Name").Value == searchName
        select el;

    // as long as there's elements in the List AND there are still names to look for: 
    if (currentElements.Any() && names.Count > 1)
    {
        // remove the name from the list (we found it above) and recursively look for the next
        // element in the XML
        names.Remove(names[0]);
        returnElem = findElement(currentElements.Elements(), names);
    }

    // If we still have elements to look for, AND we're at the last name:
    else if (currentElements.Any() && names.Count == 1)
    {
        // one last search for the final element
        currentElements =
        from el in currentElements
        where el.Attribute("Name").Value == searchName
        select el;

        // we return the the first elements which happens to be the only one (if found) or null if not
        returnElem = currentElements.First();
    }
    else
        // we do this if we don't find the correct elements
        returnElem = null;

    // if we don't find the Element, return null and handle appropriately
    // otherwise we return the result
    return returnElem;
}

Note that I'm passing a list instead of an array. This is easily done through the following:

List<string> elemNames= new List<string>("This.is.a.test".Split('.')); // or whatever your string is that you need to split

Finally, I'm reading the document, splitting it up into elements, and calling the function as follows:

XDocument doc = XDocument.Load(loadLocation);
IEnumerable<XElement> currentSelection = doc.Elements();
XElement foundElement = findElement(currentSelection, elemNames);

Upvotes: 1

david.s
david.s

Reputation: 11403

You need to search the descendants of the currently selected elements in each new step:

private IEnumerable<XElement> findElements(IEnumerable<XElement> docElements, string[] names)
{
    IEnumerable<XElement> currentSelection = docElements;
    IEnumerable<XElement> elements = currentSelection;

    foreach (string name in names)
    {
        currentSelection =
            from el in elements
            where el.Attribute("Name").Value == name
            select el;
        elements = currentSelection.Elements();
    }

    return currentSelection;
}

I tested the following code in LinqPad and everything works as you want. You can see all the intermediate steps too. By the way, LinqPad is a great tool to test out your linq queries.

string xml =
"<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
"<Root Attr1=\"Foo\" Name=\"MyName\" Attr2=\"Bar\" >" +
"    <Parent1 Name=\"IS\">" +
"        <Child1 Name=\"Kronos1\">" +
"            <GrandChild1 Name=\"Word_1\"/>" +
"            <GrandChild2 Name=\"Word_2\"/>" +
"            <GrandChild3 Name=\"Word_3\"/>" +
"            <GrandChild4 Name=\"Word_4\"/>" +
"        </Child1>" +
"        <Child2 Name=\"Kronos2\">" +
"            <GrandChild1 Name=\"Word_1\"/>" +
"            <GrandChild2 Name=\"Word_2\"/>" +
"            <GrandChild3 Name=\"Word_3\"/>" +
"            <GrandChild4 Name=\"Word_4\"/>" +
"        </Child2>" +
"    </Parent1>" +
"</Root>";

string search = "MyName.IS.Kronos1.Word_1";
string[] names = search.Split('.');

IEnumerable<XElement> currentSelection = XElement.Parse(xml).AncestorsAndSelf();
IEnumerable<XElement> elements = currentSelection;
currentSelection.Dump();

foreach (string name in names)
{
    currentSelection =
        from el in elements
        where el.Attribute("Name").Value == name
        select el;
    elements = currentSelection.Elements();
    currentSelection.Dump();
}

Upvotes: 0

Chuck Savage
Chuck Savage

Reputation: 11955

Using this library to use XPath: https://github.com/ChuckSavage/XmlLib/

string search = "MyName.IS.Kronos1.Word_1";
XElement node, root = node = XElement.Load(file);
// Skip(1) is to skip the root, because we start there and there can only ever be one root
foreach (string name in search.Split('.').Skip(1))
    node = node.XPathElement("*[@Name={0}]", name);
node.Remove();
root.Save(file);

Upvotes: 0

Related Questions