diff --git a/src/lib/PnP.Framework.Modernization.Test/Extensions/ListItemExtensionsTests.cs b/src/lib/PnP.Framework.Modernization.Test/Extensions/ListItemExtensionsTests.cs new file mode 100644 index 000000000..2e110bb6c --- /dev/null +++ b/src/lib/PnP.Framework.Modernization.Test/Extensions/ListItemExtensionsTests.cs @@ -0,0 +1,468 @@ +using Microsoft.SharePoint.Client; +using Microsoft.SharePoint.Client.Taxonomy; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; + +namespace PnP.Framework.Modernization.Tests.Extensions +{ + [TestClass] + public class ListItemExtensionsTests + { + [TestMethod] + public void GetDifferences_WithIdenticalValues_ReturnsEmptyList() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["Title"] = "Test Title", + ["Description"] = "Test Description" + }); + + var newValues = new Dictionary + { + ["Title"] = "Test Title", + ["Description"] = "Test Description" + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(0, differences.Count); + } + + [TestMethod] + public void GetDifferences_WithDifferentValues_ReturnsExpectedChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["Title"] = "Old Title", + ["Description"] = "Old Description" + }); + + var newValues = new Dictionary + { + ["Title"] = "New Title", + ["Description"] = "Old Description" + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("Title", differences[0].FieldInternalName); + Assert.AreEqual("New Title", differences[0].NewValue); + Assert.AreEqual("Old Title", differences[0].CurrentValue); + } + + [TestMethod] + public void GetDifferences_WithEmptyStringTreatAsNull_True_HandlesEmptyStringsAsNull() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["Title"] = "", + ["Description"] = null + }); + + var newValues = new Dictionary + { + ["Title"] = null, + ["Description"] = "" + }; + + // Act + var differences = listItem.GetDifferences(newValues, true); + + // Assert + Assert.AreEqual(0, differences.Count, "Empty strings and nulls should be treated as equal when treatEmptyStringAsNull is true"); + } + + [TestMethod] + public void GetDifferences_WithEmptyStringTreatAsNull_False_HandlesEmptyStringsAsDifferent() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["Title"] = "", + ["Description"] = null + }); + + var newValues = new Dictionary + { + ["Title"] = null, + ["Description"] = "" + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(2, differences.Count, "Empty strings and nulls should be treated as different when treatEmptyStringAsNull is false"); + } + + [TestMethod] + public void GetDifferences_WithNullCurrentValue_ReturnsCorrectChange() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["Title"] = null + }); + + var newValues = new Dictionary + { + ["Title"] = "New Title" + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("Title", differences[0].FieldInternalName); + Assert.AreEqual("New Title", differences[0].NewValue); + Assert.IsNull(differences[0].CurrentValue); + } + + [TestMethod] + public void GetDifferences_WithMissingField_TreatsAsNull() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary()); + + var newValues = new Dictionary + { + ["Title"] = "New Title" + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("Title", differences[0].FieldInternalName); + Assert.AreEqual("New Title", differences[0].NewValue); + Assert.IsNull(differences[0].CurrentValue); + } + + [TestMethod] + public void GetDifferences_WithVariousDataTypes_HandlesCorrectly() + { + // Arrange + var currentDate = DateTime.UtcNow.AddDays(-1); + var newDate = DateTime.UtcNow; + + var listItem = CreateMockListItem(new Dictionary + { + ["Title"] = "Old Title", + ["NumberField"] = 100, + ["DateField"] = currentDate, + ["UserField"] = new FieldUserValue { LookupId = 1}, + ["MultiLookupField"] = new FieldLookupValue[] + { + new FieldLookupValue { LookupId = 1} + }, + ["TaxonomyField"] = new TaxonomyFieldValue { TermGuid = "old-guid", Label = "OldTerm" }, + ["GeoLocationField"] = new FieldGeolocationValue { Latitude = 40.0, Longitude = -70.0, Altitude = 100.0, Measure = 0.0 } + }); + + var newValues = new Dictionary + { + ["Title"] = "New Title", + ["NumberField"] = 123, + ["DateField"] = newDate, + ["UserField"] = new FieldUserValue {LookupId = 5 }, + ["MultiLookupField"] = new FieldLookupValue[] + { + new FieldLookupValue { LookupId = 2 }, + new FieldLookupValue { LookupId = 3 } + }, + ["TaxonomyField"] = new TaxonomyFieldValue { TermGuid = "abcd-efgh-ijkl-mnop", Label = "Term1" }, + ["GeoLocationField"] = new FieldGeolocationValue { Latitude = 47.6097, Longitude = -122.3331, Altitude = 0, Measure = 0 } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(7, differences.Count, "All fields should show differences"); + + // Verify each field change + var titleChange = differences.FirstOrDefault(d => d.FieldInternalName == "Title"); + Assert.IsNotNull(titleChange); + Assert.AreEqual("New Title", titleChange.NewValue); + Assert.AreEqual("Old Title", titleChange.CurrentValue); + + var numberChange = differences.FirstOrDefault(d => d.FieldInternalName == "NumberField"); + Assert.IsNotNull(numberChange); + Assert.AreEqual(123, numberChange.NewValue); + Assert.AreEqual(100, numberChange.CurrentValue); + + var dateChange = differences.FirstOrDefault(d => d.FieldInternalName == "DateField"); + Assert.IsNotNull(dateChange); + Assert.AreEqual(newDate, dateChange.NewValue); + Assert.AreEqual(currentDate, dateChange.CurrentValue); + } + + [TestMethod] + public void GetDifferences_WithIdenticalComplexTypes_ReturnsEmptyList() + { + // Arrange + var userValue = new FieldUserValue {LookupId = 5 }; + var lookupValues = new FieldLookupValue[] + { + new FieldLookupValue { LookupId = 2 }, + new FieldLookupValue { LookupId = 3 } + }; + var taxonomyValue = new TaxonomyFieldValue { TermGuid = "abcd-efgh-ijkl-mnop", Label = "Term1" }; + var geoValue = new FieldGeolocationValue { Latitude = 47.6097, Longitude = -122.3331, Altitude = 0, Measure = 0 }; + + var listItem = CreateMockListItem(new Dictionary + { + ["UserField"] = userValue, + ["MultiLookupField"] = lookupValues, + ["TaxonomyField"] = taxonomyValue, + ["GeoLocationField"] = geoValue + }); + + var newValues = new Dictionary + { + ["UserField"] = new FieldUserValue {LookupId = 5 }, + ["MultiLookupField"] = new FieldLookupValue[] + { + new FieldLookupValue { LookupId = 2 }, + new FieldLookupValue { LookupId = 3 } + }, + ["TaxonomyField"] = new TaxonomyFieldValue { TermGuid = "abcd-efgh-ijkl-mnop", Label = "Term1" }, + ["GeoLocationField"] = new FieldGeolocationValue { Latitude = 47.6097, Longitude = -122.3331, Altitude = 0, Measure = 0 } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(0, differences.Count, "Identical complex types should not show differences"); + } + + [TestMethod] + public void GetDifferences_WithDifferentUserFields_ReturnsCorrectChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["UserField"] = new FieldUserValue {LookupId = 1 } + }); + + var newValues = new Dictionary + { + ["UserField"] = new FieldUserValue {LookupId = 5 } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("UserField", differences[0].FieldInternalName); + + var newUserValue = differences[0].NewValue as FieldUserValue; + var currentUserValue = differences[0].CurrentValue as FieldUserValue; + + Assert.IsNotNull(newUserValue); + Assert.IsNotNull(currentUserValue); + Assert.AreEqual(5, newUserValue.LookupId); + Assert.AreEqual(1, currentUserValue.LookupId); + } + + [TestMethod] + public void GetDifferences_WithDifferentMultiLookupFields_ReturnsCorrectChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["MultiLookupField"] = new FieldLookupValue[] + { + new FieldLookupValue { LookupId = 1 } + } + }); + + var newValues = new Dictionary + { + ["MultiLookupField"] = new FieldLookupValue[] + { + new FieldLookupValue { LookupId = 2 }, + new FieldLookupValue { LookupId = 3 } + } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("MultiLookupField", differences[0].FieldInternalName); + + var newLookupValues = differences[0].NewValue as FieldLookupValue[]; + var currentLookupValues = differences[0].CurrentValue as FieldLookupValue[]; + + Assert.IsNotNull(newLookupValues); + Assert.IsNotNull(currentLookupValues); + Assert.AreEqual(2, newLookupValues.Length); + Assert.AreEqual(1, currentLookupValues.Length); + } + + [TestMethod] + public void GetDifferences_WithDifferentTaxonomyFields_ReturnsCorrectChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["TaxonomyField"] = new TaxonomyFieldValue { TermGuid = "old-guid", Label = "OldTerm" } + }); + + var newValues = new Dictionary + { + ["TaxonomyField"] = new TaxonomyFieldValue { TermGuid = "new-guid", Label = "NewTerm" } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("TaxonomyField", differences[0].FieldInternalName); + + var newTaxValue = differences[0].NewValue as TaxonomyFieldValue; + var currentTaxValue = differences[0].CurrentValue as TaxonomyFieldValue; + + Assert.IsNotNull(newTaxValue); + Assert.IsNotNull(currentTaxValue); + Assert.AreEqual("new-guid", newTaxValue.TermGuid); + Assert.AreEqual("old-guid", currentTaxValue.TermGuid); + } + + [TestMethod] + public void GetDifferences_WithDifferentGeolocationFields_ReturnsCorrectChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["GeoLocationField"] = new FieldGeolocationValue { Latitude = 40.0, Longitude = -70.0, Altitude = 100.0, Measure = 0.0 } + }); + + var newValues = new Dictionary + { + ["GeoLocationField"] = new FieldGeolocationValue { Latitude = 47.6097, Longitude = -122.3331, Altitude = 0, Measure = 0 } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("GeoLocationField", differences[0].FieldInternalName); + + var newGeoValue = differences[0].NewValue as FieldGeolocationValue; + var currentGeoValue = differences[0].CurrentValue as FieldGeolocationValue; + + Assert.IsNotNull(newGeoValue); + Assert.IsNotNull(currentGeoValue); + Assert.AreEqual(47.6097, newGeoValue.Latitude, 0.0001); + Assert.AreEqual(40.0, currentGeoValue.Latitude, 0.0001); + } + + [TestMethod] + public void GetDifferences_WithFieldUrlValue_ReturnsCorrectChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["UrlField"] = new FieldUrlValue { Url = "https://old.com", Description = "Old Site" } + }); + + var newValues = new Dictionary + { + ["UrlField"] = new FieldUrlValue { Url = "https://new.com", Description = "New Site" } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("UrlField", differences[0].FieldInternalName); + + var newUrlValue = differences[0].NewValue as FieldUrlValue; + var currentUrlValue = differences[0].CurrentValue as FieldUrlValue; + + Assert.IsNotNull(newUrlValue); + Assert.IsNotNull(currentUrlValue); + Assert.AreEqual("https://new.com", newUrlValue.Url); + Assert.AreEqual("https://old.com", currentUrlValue.Url); + } + + [TestMethod] + public void GetDifferences_WithStringArrayFields_ReturnsCorrectChanges() + { + // Arrange + var listItem = CreateMockListItem(new Dictionary + { + ["StringArrayField"] = new string[] { "Option1", "Option2" } + }); + + var newValues = new Dictionary + { + ["StringArrayField"] = new string[] { "Option3", "Option4", "Option5" } + }; + + // Act + var differences = listItem.GetDifferences(newValues, false); + + // Assert + Assert.AreEqual(1, differences.Count); + Assert.AreEqual("StringArrayField", differences[0].FieldInternalName); + + var newStringArray = differences[0].NewValue as string[]; + var currentStringArray = differences[0].CurrentValue as string[]; + + Assert.IsNotNull(newStringArray); + Assert.IsNotNull(currentStringArray); + Assert.AreEqual(3, newStringArray.Length); + Assert.AreEqual(2, currentStringArray.Length); + } + + private ListItem CreateMockListItem(Dictionary fieldValues) + { + return new SimpleTestableListItem(fieldValues); + } + } + + #region ListItem Test Object + + public class SimpleTestableListItem : ListItem + { + public SimpleTestableListItem(Dictionary fieldValues) + : base(CreateMockContext(), null) + { + var actualFieldValues = base.FieldValues; + + foreach (var kvp in fieldValues) + { + actualFieldValues[kvp.Key] = kvp.Value; + } + } + + private static ClientRuntimeContext CreateMockContext() + { + return (ClientRuntimeContext)FormatterServices.GetUninitializedObject(typeof(ClientRuntimeContext)); + } + } + + #endregion +} \ No newline at end of file diff --git a/src/lib/PnP.Framework/Extensions/ObjectExtensions.cs b/src/lib/PnP.Framework/Extensions/ObjectExtensions.cs index 306eac01d..75f9bcec8 100644 --- a/src/lib/PnP.Framework/Extensions/ObjectExtensions.cs +++ b/src/lib/PnP.Framework/Extensions/ObjectExtensions.cs @@ -1,5 +1,10 @@ -using System; +using Microsoft.SharePoint.Client; +using Microsoft.SharePoint.Client.Taxonomy; +using System; +using System.Collections; using System.Collections.Generic; +using System.Globalization; +using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -175,5 +180,132 @@ public static void SetPublicInstancePropertyValue(this object source, string pro System.Reflection.BindingFlags.IgnoreCase)? .SetValue(source, value); } + + /// + /// Compares two values for equality with special handling for SharePoint field types and null/empty string normalization + /// + /// First value to compare + /// Second value to compare + /// Whether to treat empty/whitespace strings as null + /// True if values are considered equal + public static bool ValuesEqual(object a, object b, bool treatEmptyStringAsNull = false) + { + if (ReferenceEquals(a, b)) + { + return true; + } + + if (a is null || b is null) + { + if (treatEmptyStringAsNull) + { + if ((a is null && IsEmptyStringLike(b)) || (b is null && IsEmptyStringLike(a))) + { + return true; + } + } + return false; + } + + a = Normalize(a, treatEmptyStringAsNull); + b = Normalize(b, treatEmptyStringAsNull); + + if (a is IStructuralEquatable seA && b is IStructuralEquatable seB) + { + return StructuralComparisons.StructuralEqualityComparer.Equals(seA, seB); + } + + return Equals(a, b); + } + + /// + /// Checks whether the provided object is an empty or whitespace string + /// + /// The object to check + /// True if the object is a string that is empty or consists only of whitespace + private static bool IsEmptyStringLike(object x) + { + return x is string s && string.IsNullOrWhiteSpace(s); + } + + /// + /// Normalizes a value for comparison + /// + /// The value to normalize + /// Whether to treat empty/whitespace strings as null + /// The normalized value + private static object Normalize(object v, bool treatEmptyStringAsNull) + { + if (v == null) + { + return null; + } + + switch (v) + { + case string s: + return (treatEmptyStringAsNull && string.IsNullOrWhiteSpace(s)) ? null : s; + + case bool b: + return b; + + case byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal: + return Convert.ToDecimal(v, CultureInfo.InvariantCulture); + + case DateTime dt: + var utc = (dt.Kind == DateTimeKind.Utc ? dt : DateTime.SpecifyKind(dt, DateTimeKind.Unspecified)).ToUniversalTime(); + return new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, utc.Second, DateTimeKind.Utc); + + case FieldUserValue u: + return u.LookupId; + + case FieldLookupValue l: + return l.LookupId; + + case IEnumerable multiLookup: + return multiLookup.Select(x => x?.LookupId ?? 0).OrderBy(x => x).ToArray(); + + case TaxonomyFieldValue tx: + return tx.TermGuid?.Trim().ToLowerInvariant(); + + case TaxonomyFieldValueCollection txc: + return txc + .Where(x => x != null && !string.IsNullOrEmpty(x.TermGuid)) + .Select(x => x.TermGuid.Trim().ToLowerInvariant()) + .OrderBy(g => g) + .ToArray(); + + case FieldGeolocationValue geo: + return new ValueTuple( + Math.Round(geo.Latitude, 6), + Math.Round(geo.Longitude, 6), + Math.Round(geo.Altitude, 2), + Math.Round(geo.Measure, 2)); + + case FieldUrlValue url: + return new ValueTuple( + url.Url?.Trim() ?? string.Empty, + url.Description?.Trim() ?? string.Empty); + + case string[] ss: + return ss + .Select(x => treatEmptyStringAsNull && string.IsNullOrWhiteSpace(x) ? null : x) + .OrderBy(x => x, StringComparer.Ordinal) + .ToArray(); + + case IEnumerable enumerable when v is not string: + { + var list = new List(); + foreach (var e in enumerable) + { + list.Add(Normalize(e, treatEmptyStringAsNull)); + } + return list.ToArray(); + } + + default: + return v; + } + } } } diff --git a/src/lib/PnP.Framework/Modernization/Entities/FieldChange.cs b/src/lib/PnP.Framework/Modernization/Entities/FieldChange.cs new file mode 100644 index 000000000..cadca511d --- /dev/null +++ b/src/lib/PnP.Framework/Modernization/Entities/FieldChange.cs @@ -0,0 +1,57 @@ +using System; + +namespace PnP.Framework.Modernization.Entities +{ + /// + /// Represents a change detected in a SharePoint list item field value + /// + [Serializable] + public class FieldChange + { + /// + /// Gets or sets the internal name of the field that has changed + /// + public string FieldInternalName { get; set; } + + /// + /// Gets or sets the new value for the field + /// + public object NewValue { get; set; } + + /// + /// Gets or sets the current value of the field (optional, for context) + /// + public object CurrentValue { get; set; } + + /// + /// Initializes a new instance of the FieldChange class + /// + public FieldChange() + { + } + + /// + /// Initializes a new instance of the FieldChange class + /// + /// Internal name of the field + /// New value for the field + public FieldChange(string fieldInternalName, object newValue) + { + FieldInternalName = fieldInternalName; + NewValue = newValue; + } + + /// + /// Initializes a new instance of the FieldChange class + /// + /// Internal name of the field + /// New value for the field + /// Current value of the field + public FieldChange(string fieldInternalName, object newValue, object currentValue) + { + FieldInternalName = fieldInternalName; + NewValue = newValue; + CurrentValue = currentValue; + } + } +} \ No newline at end of file diff --git a/src/lib/PnP.Framework/Modernization/Extensions/ListItemExtensions.cs b/src/lib/PnP.Framework/Modernization/Extensions/ListItemExtensions.cs index 731a84d00..c105237d4 100644 --- a/src/lib/PnP.Framework/Modernization/Extensions/ListItemExtensions.cs +++ b/src/lib/PnP.Framework/Modernization/Extensions/ListItemExtensions.cs @@ -1,4 +1,5 @@ -using PnP.Framework.Modernization; +using PnP.Framework.Extensions; +using PnP.Framework.Modernization; using PnP.Framework.Modernization.Entities; using PnP.Framework.Modernization.Pages; using PnP.Framework.Modernization.Transform; @@ -304,6 +305,38 @@ public static bool FieldExists(this ListItem item, string fieldName) { return item.FieldValues.ContainsKey(fieldName); } + + /// + /// Gets the differences between the current field values and the provided new values + /// + /// List item to check + /// Dictionary of field internal names and their new values + /// Whether to treat empty string as null when comparing values + /// List of field changes that need to be applied + public static List GetDifferences(this ListItem item, IDictionary newValues, bool treatEmptyStringAsNull) + { + var diffs = new List(); + + foreach (var kvp in newValues) + { + var name = kvp.Key; + var newVal = kvp.Value; + + object currentVal = null; + if (item.FieldValues != null && item.FieldValues.TryGetValue(name, out var cv)) + { + currentVal = cv; + } + + if (!ObjectExtensions.ValuesEqual(currentVal, newVal, treatEmptyStringAsNull)) + { + diffs.Add(new FieldChange(name, newVal, currentVal)); + } + } + + return diffs; + } + #endregion }