diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd7571..ebeb585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.4.1 + +- Added a `List` equality extension, `equals`. +- Fixed an issue with the `List` extension `unique` that may have caused null-safety issues. + ## 1.4.0 - [BREAKING] JWT-related extensions have been reworked. diff --git a/README.md b/README.md index 44cf311..8f033fb 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,10 @@ providing utility functions and extensions that simplify common tasks. common transformations and checks. - **JWT Utilities**: Provides getters to parse a JWT token from a `String`, then get common properties from it. -- **List Extensions**: Adds a new `unique` operator for filtering `List` items. +- **List Extensions**: Adds a new `unique` operator for filtering `List` items, + as well as getters for `isNullOrEmpty`, `isEmptyOrNull`, `isNotNullOrEmpty`, + and `isNotEmptyOrNull`. Furthermore, an `equals` extension has been introduced + which can be used to compare two lists. ## Getting Started @@ -80,7 +83,7 @@ converted from a `String?` to an `int?`: ```dart @freezed -class MyFreezedClass with _$MyFreezedClass { +abstract class MyFreezedClass with _$MyFreezedClass { const factory MyFreezedClass({ @DecimalConverter() double? valueIsMaybeNull, @DecimalConverter() double? valueIsDouble, @@ -411,6 +414,41 @@ The following extensions have been added to the `List` object: print(nullList.isNullOrEmpty); // Output: true ``` +- `equals`: Compares two lists to see if they are equal. + + ```dart + List? list1 = [1, 2, null, 4]; + List? list2 = [1, 2, null, 4]; + List? list3 = [1, 2, 3, 4]; + List? list4 = null; + List? list5 = [1, 2, 3, null]; + + print(list1.equals(list2)); // Output: true + print(list1.equals(list3)); // Output: false + print(list1.equals(list4)); // Output: false + print(list4.equals(null)); // Output: true + print(list5.equals([1,2,3,null])); // Output: true + + // Example with ignoreSorting: + List? list6 = [1, 2, 3]; + List? list7 = [3, 1, 2]; + + // Output: true (order doesn't matter) + print(list6.equals(list7, ignoreSorting: true)); + + // Output: false (order matters) + print(list6.equals(list7, ignoreSorting: false)); + + List? list8 = ["apple", "banana", "cherry"]; + List? list9 = ["cherry", "apple", "banana"]; + + // Output: true + print(list8.equals(list9, ignoreSorting: true)); + + // Output: false + print(list8.equals(list9, ignoreSorting: false)); + ``` + ## Contributing Contributions are welcome! Feel free to fork the repository and submit pull diff --git a/lib/src/extensions/list.dart b/lib/src/extensions/list.dart index dd5d11e..5436453 100644 --- a/lib/src/extensions/list.dart +++ b/lib/src/extensions/list.dart @@ -37,10 +37,10 @@ extension Unique on List { /// print(uniquePeople.map((p) => p.name)); // Output: ['Alice', 'Bob'] /// ``` /// - List unique([Id Function(E element)? id, bool inplace = true]) { - final Set ids = {}; + List unique([Object? Function(E element)? id, bool inplace = true]) { + final Set ids = {}; final List list = inplace ? this : List.from(this); - list.retainWhere((x) => ids.add(id != null ? id(x) : x as Id)); + list.retainWhere((x) => ids.add(id != null ? id(x) : x)); return list; } } @@ -122,3 +122,112 @@ extension ListNullability on List? { /// This is identical to [isEmptyOrNull]. bool get isEmptyOrNull => isNullOrEmpty; } + +/// Extension on nullable lists of nullable elements to provide a custom equality check. +extension ListEquality on List? { + /// Checks if this list is equal to another list. + /// + /// Two lists are considered equal if: + /// - Both are null, or + /// - Both have the same length, and + /// - Elements at the same index are equal. + /// + /// Nullable list elements are handled as follows: + /// - If both elements at a given index are null, they are considered equal. + /// - If one element is null and the other is not, they are considered unequal. + /// + /// The type parameter `T` represents the type of elements in the list. + /// The elements can be nullable (`T?`). + /// + /// Example: + /// + /// ```dart + /// List? list1 = [1, 2, null, 4]; + /// List? list2 = [1, 2, null, 4]; + /// List? list3 = [1, 2, 3, 4]; + /// List? list4 = null; + /// List? list5 = [1, 2, 3, null]; + /// + /// print(list1.equals(list2)); // Output: true + /// print(list1.equals(list3)); // Output: false + /// print(list1.equals(list4)); // Output: false + /// print(list4.equals(null)); // Output: true + /// print(list5.equals([1,2,3,null])); //Output: true + /// + /// // Example with ignoreSorting: + /// List? list6 = [1, 2, 3]; + /// List? list7 = [3, 1, 2]; + /// print(list6.equals(list7, ignoreSorting: true)); // Output: true (order doesn't matter) + /// print(list6.equals(list7, ignoreSorting: false)); // Output: false (order matters) + /// + /// List? list8 = ["apple", "banana", "cherry"]; + /// List? list9 = ["cherry", "apple", "banana"]; + /// print(list8.equals(list9, ignoreSorting: true)); // Output: true + /// print(list8.equals(list9, ignoreSorting: false)); // Output: false + /// ``` + /// + /// Returns `true` if the lists are equal, `false` otherwise. + /// + /// The [ignoreSorting] parameter, if set to true, will compare the lists + /// after sorting them. This defaults to false. + bool equals( + List? a, { + bool ignoreSorting = false, + }) { + if (this == null) return a == null; + if (a == null || this?.length != a.length) return false; + if (this.runtimeType != a.runtimeType) return false; + + if (ignoreSorting) { + // Create copies to avoid modifying the original lists. + final List sortedThis = this?.toList() ?? []; + final List sortedA = a.toList(); + + // If the lists contain non-comparable elements, we can't rely on sorting to determine equality. + if (T is! Comparable) { + // Instead, we check if the lists contain the same elements, regardless of order. + // This is an O(n^2) operation, but it's the only reliable way to compare non-comparable lists. + for (final itemThis in sortedThis) { + final int indexA = sortedA.indexOf(itemThis); + if (indexA == -1) { + // itemThis is not in sortedA, so the lists are not equal. + return false; + } + // Remove the item from sortedA to avoid double-counting. + sortedA.removeAt(indexA); + } + // If sortedA is empty, all elements in sortedThis were found in sortedA. + return sortedA.isEmpty; + } + // Sort if comparable + sortedThis.sort((a, b) { + if (a == null && b == null) return 0; + if (a == null) return -1; + if (b == null) return 1; + return (a as Comparable).compareTo(b as Comparable); + }); + sortedA.sort((a, b) { + if (a == null && b == null) return 0; + if (a == null) return -1; + if (b == null) return 1; + return (a as Comparable).compareTo(b as Comparable); + }); + + // Compare sorted lists + for (int i = 0; i < sortedThis.length; i++) { + if (sortedThis[i] != sortedA[i]) { + return false; + } + } + return true; + } else { + // Original comparison + for (int i = 0; i < (this?.length ?? 0); i++) { + if (this?[i] != a[i]) { + return false; + } + } + return true; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 1fb8857..92d1c43 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: arcane_helper_utils description: Provides a variety of helpful utilities and extensions for Flutter and Dart. -version: 1.4.0 +version: 1.4.1 repository: https://github.com/hanskokx/arcane_helper_utils issue_tracker: https://github.com/hanskokx/arcane_helper_utils/issues diff --git a/test/extensions/list_test.dart b/test/extensions/list_test.dart index f094fc1..79c57d8 100644 --- a/test/extensions/list_test.dart +++ b/test/extensions/list_test.dart @@ -63,4 +63,150 @@ void main() { expect(nonEmptyList.isNullOrEmpty, !nonEmptyList.isNotNullOrEmpty); }); }); + + group("ListEquality", () { + // Helper function to make tests more concise + void testEquality( + List? list1, + List? list2, + bool expected, { + bool ignoreSorting = false, + }) { + expect( + list1.equals(list2, ignoreSorting: ignoreSorting), + expected, + reason: + 'Expected ${list1?.toString()} and ${list2?.toString()} to be ${expected ? "equal" : "unequal"} when ignoreSorting is $ignoreSorting', + ); + } + + test("Both lists are null", () { + testEquality(null, null, true); + }); + + test("One list is null, the other is not", () { + testEquality([1, 2, 3], null, false); + testEquality(null, [1, 2, 3], false); + }); + + test("Lists have different lengths", () { + testEquality([1, 2, 3], [1, 2], false); + testEquality([1, 2], [1, 2, 3], false); + }); + + test("Lists have the same elements in the same order", () { + testEquality([1, 2, 3], [1, 2, 3], true); + testEquality(["a", "b", "c"], ["a", "b", "c"], true); + testEquality([true, false, true], [true, false, true], true); + }); + + test( + "Lists have the same elements in a different order - without ignoreSorting", + () { + testEquality([1, 2, 3], [3, 2, 1], false); + }); + + test( + "Lists have the same elements in a different order - with ignoreSorting", + () { + testEquality([1, 2, 3], [3, 2, 1], true, ignoreSorting: true); + }); + + test("Lists have different elements", () { + testEquality([1, 2, 3], [1, 2, 4], false); + testEquality(["a", "b", "c"], ["a", "b", "d"], false); + }); + + test("Lists with null elements - both null at same index", () { + testEquality([1, null, 3], [1, null, 3], true); + }); + + test("Lists with null elements - one null, one not null at same index", () { + testEquality([1, null, 3], [1, 2, 3], false); + testEquality([1, 2, 3], [1, null, 3], false); + }); + + test("Lists with multiple null elements", () { + testEquality([null, null, null], [null, null, null], true); + testEquality([null, 1, null], [null, 1, null], true); + testEquality([null, 1, null], [1, null, 1], false); + }); + + test("Empty lists", () { + testEquality([], [], true); + }); + test("List of different types", () { + testEquality([], [], false); + }); + + test("Lists of comparable items with different order - ignoreSorting true", + () { + testEquality([3, 1, 2], [1, 2, 3], true, ignoreSorting: true); + testEquality( + ["c", "a", "b"], + ["b", "c", "a"], + true, + ignoreSorting: true, + ); + }); + + test("Lists of comparable items with different order - ignoreSorting false", + () { + testEquality([3, 1, 2], [1, 2, 3], false, ignoreSorting: false); + testEquality( + ["c", "a", "b"], + ["b", "c", "a"], + false, + ignoreSorting: false, + ); + }); + + test("Lists with nulls and different order - ignoreSorting true", () { + testEquality([null, 3, 1, 2], [1, 2, 3, null], true, ignoreSorting: true); + testEquality([3, 1, 2, null], [null, 1, 2, 3], true, ignoreSorting: true); + }); + + test("Lists with nulls and different order - ignoreSorting false", () { + testEquality( + [null, 3, 1, 2], + [1, 2, 3, null], + false, + ignoreSorting: false, + ); + }); + + test( + "Lists of non-comparable items with different order - ignoreSorting true", + () { + final List list1 = [ + NonComparable(1), + NonComparable(2), + NonComparable(3), + ]; + final List list2 = [ + NonComparable(3), + NonComparable(1), + NonComparable(2), + ]; + testEquality(list1, list2, true, ignoreSorting: true); + }); + }); +} + +class NonComparable { + final int value; + NonComparable(this.value); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NonComparable && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + String toString() => "NonComparable($value)"; }