Repository: dotnet/Open-XML-SDK
Pull request #1892: Refactor to clean up CA1851 warnings
Author: login: twsouthwick
Created: 2025-03-10T17:03:05Z | Merged: 2025-03-17T17:37:00Z

Fixes #1325


Linked issue #1325: Clean up CA1851 warnings
After turning on warnings, a number of CA1851 showed up that will take a bit of refactoring to clean up correclty.

Diff:
diff --git a/src/DocumentFormat.OpenXml.Framework/EnumerableExtensions.cs b/src/DocumentFormat.OpenXml.Framework/EnumerableExtensions.cs
new file mode 100644
index 000000000..c13d46d06
--- /dev/null
+++ b/src/DocumentFormat.OpenXml.Framework/EnumerableExtensions.cs
@@ -0,0 +1,33 @@
+﻿// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace DocumentFormat.OpenXml;
+
+internal static class EnumerableExtensions
+{
+    /// <summary>
+    /// Similar to <see cref="Enumerable.FirstOrDefault{TSource}(IEnumerable{TSource})"/> but will also verify that at most there is one.
+    /// </summary>
+    public static T? FirstOrDefaultAndMaxOne<T>(this IEnumerable<T> enumerable, Func<Exception>? exceptionFactory = null)
+    {
+        using var e = enumerable.GetEnumerator();
+
+        if (e.MoveNext())
+        {
+            var first = e.Current;
+
+            if (e.MoveNext())
+            {
+                throw exceptionFactory?.Invoke() ?? throw new InvalidOperationException(ExceptionMessages.FirstOrDefaultMaxOne);
+            }
+
+            return first;
+        }
+
+        return default;
+    }
+}
diff --git a/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.Designer.cs b/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.Designer.cs
index 8419ed6c2..cf1eaed38 100644
--- a/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.Designer.cs
+++ b/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.Designer.cs
@@ -330,6 +330,15 @@ internal static string FileNotFound {
             }
         }
         
+        /// <summary>
+        ///   Looks up a localized string similar to The enumerable contained more than a single element when only zero or one are allowed..
+        /// </summary>
+        internal static string FirstOrDefaultMaxOne {
+            get {
+                return ResourceManager.GetString("FirstOrDefaultMaxOne", resourceCulture);
+            }
+        }
+        
         /// <summary>
         ///   Looks up a localized string similar to The root XML element &quot;{0}&quot; in the part is incorrect. The expected root XML element is: &quot;{1}&quot;..
         /// </summary>
diff --git a/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.resx b/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.resx
index 7ce73d0a8..b0aaa58a4 100644
--- a/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.resx
+++ b/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.resx
@@ -411,4 +411,7 @@
   <data name="FailedToOpenPackage" xml:space="preserve">
     <value>Package could not be opened for stream. See inner exception for details and be aware that there are behavior differences in stream support between .NET Framework and Core.</value>
   </data>
+  <data name="FirstOrDefaultMaxOne" xml:space="preserve">
+    <value>The enumerable contained more than a single element when only zero or one are allowed.</value>
+  </data>
 </root>
\ No newline at end of file
diff --git a/src/DocumentFormat.OpenXml.Framework/Validation/Semantic/RelationshipTypeConstraint.cs b/src/DocumentFormat.OpenXml.Framework/Validation/Semantic/RelationshipTypeConstraint.cs
index 9c63d462e..2e04de7dc 100644
--- a/src/DocumentFormat.OpenXml.Framework/Validation/Semantic/RelationshipTypeConstraint.cs
+++ b/src/DocumentFormat.OpenXml.Framework/Validation/Semantic/RelationshipTypeConstraint.cs
@@ -21,7 +21,6 @@ public RelationshipTypeConstraint(OpenXmlQualifiedName attribute, string type)
             _type = type;
         }
 
-        [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1851:Possible multiple enumerations of 'IEnumerable' collection", Justification = "https://github.com/dotnet/Open-XML-SDK/issues/1325")]
         public override ValidationErrorInfo? ValidateCore(ValidationContext context)
         {
             var current = context.Stack.Current;
@@ -57,20 +56,20 @@ public RelationshipTypeConstraint(OpenXmlQualifiedName attribute, string type)
 
             var rels = current.Part.ExternalRelationships.Where(r => r.Id == attribute.Value.InnerText);
 
-            if (!rels.Any())
+            if (rels.FirstOrDefault() is { } rel)
             {
-                var pairs = current.Part.Parts.Where(p => p.RelationshipId == attribute.Value.InnerText);
-
-                if (pairs.Any())
-                {
-                    Debug.Assert(pairs.Count() == 1);
-                    actualType = pairs.First().OpenXmlPart.RelationshipType;
-                }
+                actualType = rel.RelationshipType;
             }
             else
             {
-                Debug.Assert(rels.Count() == 1);
-                actualType = rels.First().RelationshipType;
+                var pair = current.Part.Parts
+                    .Where(p => p.RelationshipId == attribute.Value.InnerText)
+                    .FirstOrDefaultAndMaxOne();
+
+                if (pair is { })
+                {
+                    actualType = pair.OpenXmlPart.RelationshipType;
+                }
             }
 
             if (actualType == _type)
diff --git a/src/DocumentFormat.OpenXml.Framework/Validation/Semantic/SemanticConstraint.cs b/src/DocumentFormat.OpenXml.Framework/Validation/Semantic/SemanticConstraint.cs
index 4d58f8851..6793e17ec 100644
--- a/src/DocumentFormat.OpenXml.Framework/Validation/Semantic/SemanticConstraint.cs
+++ b/src/DocumentFormat.OpenXml.Framework/Validation/Semantic/SemanticConstraint.cs
@@ -16,7 +16,6 @@ namespace DocumentFormat.OpenXml.Validation.Semantic
     /// <summary>
     /// Base class for each semantic constraint category.
     /// </summary>
-    [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1851:Possible multiple enumerations of 'IEnumerable' collection", Justification = "https://github.com/dotnet/Open-XML-SDK/issues/1325")]
     internal abstract class SemanticConstraint : IValidator
     {
         public SemanticConstraint(SemanticValidationLevel level)
@@ -130,13 +129,10 @@ private static void Get(ValidationContext context, out SemanticValidationLevel l
             }
             else if (parts[0] == "..")
             {
-                var refParts = current.Package
+                return current.Package
                     .GetAllParts()
-                    .Where(p => p.Parts.Any(r => r.OpenXmlPart.PackagePart.Uri == current.Part.PackagePart.Uri));
-
-                Debug.Assert(refParts.Count() == 1);
-
-                return refParts.First();
+                    .Where(p => p.Parts.Any(r => r.OpenXmlPart.PackagePart.Uri == current.Part.PackagePart.Uri))
+                    .First();
             }
             else
             {
@@ -247,29 +243,25 @@ protected static bool GetAttrNumVal(OpenXmlSimpleType attributeValue, out double
 
         private static OpenXmlPart? GetPartThroughPartPath(IEnumerable<IdPartPair> pairs, string[] path)
         {
-            var temp = default(OpenXmlPart);
+            var foundPart = default(OpenXmlPart);
             var parts = pairs;
 
             for (int i = 0; i < path.Length; i++)
             {
-                var s = parts.Where(p => p.OpenXmlPart.GetType().Name == path[i]).Select(t => t.OpenXmlPart);
-                var count = s.Count();
-
-                if (count > 1)
-                {
-                    throw new System.IO.FileFormatException(ValidationResources.MoreThanOnePartForOneUri);
-                }
+                foundPart = parts
+                    .Where(p => p.OpenXmlPart.GetType().Name == path[i])
+                    .Select(t => t.OpenXmlPart)
+                    .FirstOrDefaultAndMaxOne(static () => new System.IO.FileFormatException(ValidationResources.MoreThanOnePartForOneUri));
 
-                if (count == 0)
+                if (foundPart is not { })
                 {
                     return null;
                 }
 
-                temp = s.First();
-                parts = temp.Parts;
+                parts = foundPart.Parts;
             }
 
-            return temp;
+            return foundPart;
         }
 
         protected readonly struct PartHolder<T>


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## PR Overview

This PR refactors code to remove CA1851 warnings by replacing multiple enumerations of IEnumerable with a new extension method and removing suppression attributes.  
- Introduced the FirstOrDefaultAndMaxOne extension method in EnumerableExtensions.cs  
- Removed CA1851 suppression attributes and replaced enumerations with the new extension method in SemanticConstraint.cs and RelationshipTypeConstraint.cs

### Reviewed Changes

| File | Description |
| ---- | ----------- |
| src/DocumentFormat.OpenXml.Framework/EnumerableExtensions.cs | Added extension method to safely return the first (or default) element while ensuring at most one element exists. |
| src/DocumentFormat.OpenXml.Framework/Validation/Semantic/SemanticConstraint.cs | Removed a suppression attribute and refactored part lookup to use LINQ with FirstOrDefaultAndMaxOne. |
| src/DocumentFormat.OpenXml.Framework/Validation/Semantic/RelationshipTypeConstraint.cs | Removed a suppression attribute and refactored relationship lookup to use FirstOrDefaultAndMaxOne. |

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**src/DocumentFormat.OpenXml.Framework/EnumerableExtensions.cs:15**
* [nitpick] Consider renaming the parameter 'exThrow' to 'exceptionFactory' for improved clarity.
```
public static T? FirstOrDefaultAndMaxOne<T>(this IEnumerable<T> enumerable, Func<Exception>? exThrow = null)
```
**src/DocumentFormat.OpenXml.Framework/EnumerableExtensions.cs:25**
* [nitpick] The exception message may be improved for clarity; consider rephrasing it to better indicate that the enumerable contains more than one element.
```
throw exThrow?.Invoke() ?? throw new InvalidOperationException("Max of a single item should be in the enumerable");
```
**src/DocumentFormat.OpenXml.Framework/Validation/Semantic/RelationshipTypeConstraint.cs:67**
* [nitpick] Consider passing a custom exception factory lambda to FirstOrDefaultAndMaxOne to provide a more context-specific error message.
```
.FirstOrDefaultAndMaxOne();
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-03-10T17:13:35Z
user: login: mikeebowen
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-10T17:22:02Z
user: login: lindexi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T06:12:08Z
user: login: twsouthwick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T17:46:12Z
user: login: tomjebo
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-17T17:36:52Z
user: login: tomjebo
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-17T17:36:59Z
user: login: twsouthwick
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T18:00:01Z

Review comments:
diff_hunk: @@ -0,0 +1,33 @@
+﻿// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace DocumentFormat.OpenXml;
+
+internal static class EnumerableExtensions
+{
+    /// <summary>
+    /// Similar to <see cref="Enumerable.FirstOrDefault{TSource}(IEnumerable{TSource})"/> but will also verify that at most there is one.
+    /// </summary>
+    public static T? FirstOrDefaultAndMaxOne<T>(this IEnumerable<T> enumerable, Func<Exception>? exceptionFactory = null)
path: src/DocumentFormat.OpenXml.Framework/EnumerableExtensions.cs
user: login: lindexi
body: Should it name as `SingleOrDefault xxx` ? 

See https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.singleordefault?view=net-9.0
created_at: 2025-03-11T06:12:08Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,33 @@
+﻿// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace DocumentFormat.OpenXml;
+
+internal static class EnumerableExtensions
+{
+    /// <summary>
+    /// Similar to <see cref="Enumerable.FirstOrDefault{TSource}(IEnumerable{TSource})"/> but will also verify that at most there is one.
+    /// </summary>
+    public static T? FirstOrDefaultAndMaxOne<T>(this IEnumerable<T> enumerable, Func<Exception>? exceptionFactory = null)
path: src/DocumentFormat.OpenXml.Framework/EnumerableExtensions.cs
user: login: twsouthwick
body: Good catch! I had forgotten about that method - it's the same thing :)
created_at: 2025-03-12T17:46:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,33 @@
+﻿// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace DocumentFormat.OpenXml;
+
+internal static class EnumerableExtensions
+{
+    /// <summary>
+    /// Similar to <see cref="Enumerable.FirstOrDefault{TSource}(IEnumerable{TSource})"/> but will also verify that at most there is one.
+    /// </summary>
+    public static T? FirstOrDefaultAndMaxOne<T>(this IEnumerable<T> enumerable, Func<Exception>? exceptionFactory = null)
path: src/DocumentFormat.OpenXml.Framework/EnumerableExtensions.cs
user: login: tomjebo
body: @twsouthwick did you want to change this? I'll approve but wait to merge. 
created_at: 2025-03-17T17:36:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,33 @@
+﻿// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace DocumentFormat.OpenXml;
+
+internal static class EnumerableExtensions
+{
+    /// <summary>
+    /// Similar to <see cref="Enumerable.FirstOrDefault{TSource}(IEnumerable{TSource})"/> but will also verify that at most there is one.
+    /// </summary>
+    public static T? FirstOrDefaultAndMaxOne<T>(this IEnumerable<T> enumerable, Func<Exception>? exceptionFactory = null)
path: src/DocumentFormat.OpenXml.Framework/EnumerableExtensions.cs
user: login: twsouthwick
body: haha it automerged. Replaced it in https://github.com/dotnet/Open-XML-SDK/pull/1899
created_at: 2025-03-17T18:00:01Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-10T17:33:52Z
body: ## Test Results
    70 files      70 suites   1h 8m 48s ⏱️
 2 041 tests  2 038 ✅  3 💤 0 ❌
32 418 runs  32 382 ✅ 36 💤 0 ❌

Results for commit 923a0526.

[test-results]:data:application/gzip;base64,H4sIAH4iz2cC/03Myw6DIBCF4VcxrLuAGUDsyzTTURJSLw3CyvTdi8bb8v9OchbhQ9/N4lnV8lGJOYd0VZsjpTCNpbUCV6SMaZ1BanXka87Mm6G77BO+xfAET6EvIE/oYpziLjGP6yuCVm7P4xUBHZy2v9oDbq9b3095GoaQSogGkKQBS51xbzY1W8mKNVFDrfeq0YxWgiHx+wNBkx0KDwEAAA==

author_association: NONE
user: login: twsouthwick
created_at: 2025-03-17T18:00:10Z
body: See https://github.com/dotnet/Open-XML-SDK/pull/1899
author_association: MEMBER