-
Notifications
You must be signed in to change notification settings - Fork 204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Sorting order in lists with equal sorting criteria #3419
base: develop
Are you sure you want to change the base?
Conversation
SDK Size
|
SDK Performance
|
a087116
to
20c8f56
Compare
SDK Size
|
20c8f56
to
2e5c54e
Compare
@@ -303,6 +305,26 @@ protocol ChannelDatabaseSession { | |||
func removeChannels(cids: Set<ChannelId>) | |||
} | |||
|
|||
extension ChannelDatabaseSession { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Without it, there are so many errors because of missing the offset
argument (most of it is in tests). If we want to avoid this extension, I can take the time to fill it in.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can't we provide a default value for the offset?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
or maybe move these to the test target?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately can't set a default value in protocol but moving the extension to test target is a good idea.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moved it
func isAnyNil(_ value: Any) -> Bool { | ||
let mirror = Mirror(reflecting: value) | ||
return mirror.displayStyle == .optional && mirror.children.isEmpty | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a better way?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can't we do something like this instead:
func isAnyNil(_ value: Any) -> Bool {
return (value as Any?) == nil
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This also does not work.
func test() {
struct Item {
let index: Int
let date: Date?
}
let item1 = Item(index: 1, date: nil)
let item2 = Item(index: 2, date: Date())
let keyPath: PartialKeyPath<Item> = \.date
let lhsValue = item1[keyPath: keyPath]
let rhsValue = item2[keyPath: keyPath]
print(lhsValue, rhsValue)
print(isAnyNil(lhsValue), isAnyNil(rhsValue))
print((lhsValue as Any?) == nil, (rhsValue as Any?) == nil)
}
func isAnyNil(_ value: Any) -> Bool {
let mirror = Mirror(reflecting: value)
return mirror.displayStyle == .optional && mirror.children.isEmpty
}
gives
nil Optional(2024-09-17 10:18:17 +0000)
true false
false false
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe String(describing: value) == "nil"
? 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@laevandus You can check DebugObjectViewController
, we do nil comparison there.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I want to avoid reflection though, this will be run for every channel so it might impact performance.
extension Collection { | ||
extension Sequence { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Needed it for sorting .enumerated()
@@ -10,49 +10,54 @@ struct SortValue<T> { | |||
} | |||
|
|||
extension Array { | |||
func sort(using sorting: [SortValue<Element>]) -> [Element] { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Renamed it to sorted
because sort
means in-place in the Foundation APIs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tested it also on SwiftUI and I still get an occasional jump. Have you tried it there?
@@ -303,6 +305,26 @@ protocol ChannelDatabaseSession { | |||
func removeChannels(cids: Set<ChannelId>) | |||
} | |||
|
|||
extension ChannelDatabaseSession { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can't we provide a default value for the offset?
// Note: When the channel is part of multiple channel list controllers, | ||
// it might still cause some movement because the property is shared. | ||
// Recommendation: use at least one sorting parameter with unique values. | ||
@NSManaged var sortIndex: Int |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
another option can be to keep a map of the queries and the indexes, but I guess it's not worth it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I considered it but it felt like it gets too complex and also sort descriptor wants a key-path.
@@ -303,6 +305,26 @@ protocol ChannelDatabaseSession { | |||
func removeChannels(cids: Set<ChannelId>) | |||
} | |||
|
|||
extension ChannelDatabaseSession { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
or maybe move these to the test target?
func isAnyNil(_ value: Any) -> Bool { | ||
let mirror = Mirror(reflecting: value) | ||
return mirror.displayStyle == .optional && mirror.children.isEmpty | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can't we do something like this instead:
func isAnyNil(_ value: Any) -> Bool {
return (value as Any?) == nil
}
2820a2a
to
d56904c
Compare
Generated by 🚫 Danger |
b12a9be
to
25db257
Compare
/// The first `SortValue` is the primary key and subsequent `SortValue`s are for breaking the tie. | ||
func sorted(using sortValues: [SortValue<Element>]) -> [Element] { | ||
guard !sortValues.isEmpty else { return self } | ||
return sorted { lhs, rhs in | ||
for sortValue in sortValues { | ||
let lhsValue = lhs[keyPath: sortValue.keyPath] | ||
let rhsValue = rhs[keyPath: sortValue.keyPath] | ||
let isAscending = sortValue.isAscending | ||
// These are type-erased, therefore we need to manually cast them | ||
if let result = nilComparison(lhs: lhsValue, rhs: rhsValue, isAscending: isAscending) { | ||
return result | ||
} else if let result = areInIncreasingOrder(lhs: lhsValue, rhs: rhsValue, type: Date.self, isAscending: isAscending) { | ||
return result | ||
} else if let result = areInIncreasingOrder(lhs: lhsValue, rhs: rhsValue, type: String.self, isAscending: isAscending) { | ||
return result | ||
} else if let result = areInIncreasingOrder(lhs: lhsValue, rhs: rhsValue, type: Int.self, isAscending: isAscending) { | ||
return result | ||
} else if let result = areInIncreasingOrder(lhs: lhsValue, rhs: rhsValue, type: Double.self, isAscending: isAscending) { | ||
return result | ||
} else if let lBool = lhsValue as? Bool, let rBool = rhsValue as? Bool, lBool != rBool { | ||
// Backend considers boolean sorting in reversed order. | ||
return isAscending ? lBool && !rBool : !lBool && rBool |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we still need these changes, after we changed the strategy to use an additional sort descriptor for the tie breaker?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We still need, this code-path is triggered when someone uses a custom sorting key which can't be represented by the FRC's sort descriptors. Most of the time, this code never runs, only these specific cases when someone goes for custom sorting keys.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorting keys what have localKey nil. Nil means we can't use sort descriptor and need to resort post-FRC sorting.
public static let hasUnread = Self(
keyPath: \.hasUnread,
localKey: nil,
remoteKey: "has_unread"
)
public static func custom<T>(keyPath: KeyPath<ChatChannel, T>, key: String) -> Self {
.init(keyPath: keyPath, localKey: nil, remoteKey: key)
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new approach sounds much better 👍 Just added minor comments but overall LGTM!
… with NSSortDescriptor order
…as API responses for breaking ties in the fetched results controller
…ey for sorting by user id, and add sorting tie breakers to controllers
@@ -62,7 +62,7 @@ public struct ChannelListSortingKey: SortingKey, Equatable { | |||
|
|||
/// Sort channels by their unread count. | |||
public static let unreadCount = Self( | |||
keyPath: \.unreadCount, | |||
keyPath: \.unreadCount.messages, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unreadCount is a struct, not Int, this is used for runtime sorting
guard let index = string.lastIndex(of: ".") else { return date } | ||
let range = string.suffix(from: index) | ||
.dropFirst(4) // . and ms part | ||
.dropLast() // Z | ||
var fractionWithoutMilliseconds = String(range) | ||
if fractionWithoutMilliseconds.count < 3 { | ||
fractionWithoutMilliseconds = fractionWithoutMilliseconds.padding(toLength: 3, withPad: "0", startingAt: 0) | ||
} | ||
guard let microseconds = TimeInterval("0.000".appending(fractionWithoutMilliseconds)) else { return date } | ||
return date.addingTimeInterval(microseconds) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe not the most beautiful but it needs to handle cases like
2024-09-18T13:49:11.3241Z
2024-09-18T13:49:11.32412Z
2024-09-18T13:49:11.324123Z
2024-09-18T13:49:11.3241234Z
2024-09-18T13:49:11.32412345Z
2024-09-18T13:49:11.324123456Z
418cc33
to
c11edf4
Compare
Quality Gate passedIssues Measures |
🔗 Issue Links
Resolves: PBE-5993
🎯 Goal
When sorting by key(s) with equal values the sort order in SDK models is kept unchanged
📝 Summary
lastMessageAt
timestamp (triggers active reordering)unreadCount
or other Int or Bool keys led to unexpected reordering in the channel list (many channels with equal sorting value). Break this by addingupdatedAt
sorting key internally.ChannelMemberListSortingKey.userId
for sorting channel members by id🛠 Implementation
Microseconds parsing
DateFormatter supports milliseconds and ignores microseconds and nanoseconds. This led to date properties to lose precision and causing many equal values when sorting the list.
Equal values and sorting
Our SDK returns items sorted by Core Data sort descriptors (or by runtime sorting if custom sorting keys are used). When we sort over non-unique values and there are many items with the same value, the order returned by the SDK is inconsistent:
channel1
,channel2
,channel3
can change tochannel2
,channel3
,channel1
when DB changes happen. API responses, on the other hand, return consistent order even in cases like these. If we paginate, then we also do not want to see any reordering happening when the next page of channels is inserted into the DB. One way is to internally add a tie breaker.The issue with runtime sorting
Runtime sorting is used when we can't sort on the Core Data model layer (DTO does not have a key for that field). I found that runtime sorting does not match with how NSSortDescriptor sorts items (nil values, multiple sort criteria). This PR fixes these issues.
🎨 Showcase
Channel list sorted by unread count
🧪 Manual Testing Notes
Case: sort channel list by unread message count
Prerequisites
Open
DemoAppCoordinator+DemoApp.swift:L105
and change default sorting to sort by unread count (gives many equal values)Steps
Result: Channels do not reorder
☑️ Contributor Checklist
🎁 Meme
Provide a funny gif or image that relates to your work on this pull request. (Optional)