From 1fc78b2cceedaae5db516f89fdd3aea17bfa6c71 Mon Sep 17 00:00:00 2001 From: Diana Perez Afanador Date: Mon, 4 Mar 2024 15:52:15 +0100 Subject: [PATCH] Add support for lexicographical comparison for string queries --- CHANGELOG.md | 5 +- Realm/RLMQueryUtil.mm | 8 ++ RealmSwift/Query.swift | 33 ++++++ RealmSwift/Tests/QueryTests.swift | 139 +++++++++++++++++++++++++- RealmSwift/Tests/QueryTests.swift.gyb | 132 +++++++++++++++++++++++- 5 files changed, 312 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b4bad1f7..7b9e4bf0e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,10 @@ x.y.z Release notes (yyyy-MM-dd) ============================================================= ### Enhancements -* None. +* Add support for string comparison queries, which allows building string + queries with the following operators (`>`, `>=`, `<`, `<=`). + This is a case sensitive lexicographical comparison. + ([#8008](https://github.com/realm/realm-swift/issues/8008)). ### Fixed * ([#????](https://github.com/realm/realm-swift/issues/????), since v?.?.?) diff --git a/Realm/RLMQueryUtil.mm b/Realm/RLMQueryUtil.mm index b9957cf317..7a0cc1518e 100644 --- a/Realm/RLMQueryUtil.mm +++ b/Realm/RLMQueryUtil.mm @@ -701,6 +701,14 @@ Query make_diacritic_sensitive_constraint(NSPredicateOperatorType operatorType, return column.not_equal(value, caseSensitive); case NSLikePredicateOperatorType: return column.like(value, caseSensitive); + case NSLessThanPredicateOperatorType: + return column < value; + case NSLessThanOrEqualToPredicateOperatorType: + return column <= value; + case NSGreaterThanPredicateOperatorType: + return column > value; + case NSGreaterThanOrEqualToPredicateOperatorType: + return column >= value; default: { if constexpr (is_any_v, Columns>, Columns>, ColumnDictionaryKeys>) { unsupportedOperator(RLMPropertyTypeString, operatorType); diff --git a/RealmSwift/Query.swift b/RealmSwift/Query.swift index 77219d6025..b1f3873b35 100644 --- a/RealmSwift/Query.swift +++ b/RealmSwift/Query.swift @@ -787,6 +787,39 @@ extension Query where T: _HasPersistedType, T.PersistedType: _QueryBinary { public func notEquals(_ column: Query, options: StringOptions = []) -> Query { .init(.comparison(operator: .notEqual, node, column.node, options: options)) } + + /// :nodoc: + public static func > (_ lhs: Query, _ rhs: T) -> Query { + .init(.comparison(operator: .greaterThan, lhs.node, .constant(rhs), options: [])) + } + /// :nodoc: + public static func > (_ lhs: Query, _ rhs: Query) -> Query { + .init(.comparison(operator: .greaterThan, lhs.node, rhs.node, options: [])) + } + /// :nodoc: + public static func >= (_ lhs: Query, _ rhs: T) -> Query { + .init(.comparison(operator: .greaterThanEqual, lhs.node, .constant(rhs), options: [])) + } + /// :nodoc: + public static func >= (_ lhs: Query, _ rhs: Query) -> Query { + .init(.comparison(operator: .greaterThanEqual, lhs.node, rhs.node, options: [])) + } + /// :nodoc: + public static func < (_ lhs: Query, _ rhs: T) -> Query { + .init(.comparison(operator: .lessThan, lhs.node, .constant(rhs), options: [])) + } + /// :nodoc: + public static func < (_ lhs: Query, _ rhs: Query) -> Query { + .init(.comparison(operator: .lessThan, lhs.node, rhs.node, options: [])) + } + /// :nodoc: + public static func <= (_ lhs: Query, _ rhs: T) -> Query { + .init(.comparison(operator: .lessThanEqual, lhs.node, .constant(rhs), options: [])) + } + /// :nodoc: + public static func <= (_ lhs: Query, _ rhs: Query) -> Query { + .init(.comparison(operator: .lessThanEqual, lhs.node, rhs.node, options: [])) + } } extension Query where T: OptionalProtocol, T.Wrapped: Comparable { diff --git a/RealmSwift/Tests/QueryTests.swift b/RealmSwift/Tests/QueryTests.swift index a96bb029a9..144126f6a9 100644 --- a/RealmSwift/Tests/QueryTests.swift +++ b/RealmSwift/Tests/QueryTests.swift @@ -1738,7 +1738,37 @@ class QueryTests: TestCase, @unchecked Sendable { validateNumericComparisons("optDecimal", \Query.optDecimal, nil, count: 0) } - func testGreaterThanAnyRealmValue() { + func validateStringComparisons( + _ name: String, _ lhs: (Query) -> Query, + _ value: T, letCount: Int = 1, getCount: Int = 1, ltCount: Int = 0, gtCount: Int = 0) where T.PersistedType: _QueryString { + assertQuery(Root.self, "(\(name) > %@)", value, count: gtCount) { + lhs($0) > value + } + assertQuery(Root.self, "(\(name) >= %@)", value, count: getCount) { + lhs($0) >= value + } + assertQuery(Root.self, "(\(name) < %@)", value, count: ltCount) { + lhs($0) < value + } + assertQuery(Root.self, "(\(name) <= %@)", value, count: letCount) { + lhs($0) <= value + } + } + + func testStringComparisons() { + validateStringComparisons("stringCol", \Query.stringCol, "Foó") + validateStringComparisons("stringEnumCol", \Query.stringEnumCol, .value2) + validateStringComparisons("string", \Query.string, StringWrapper(persistedValue: "Foó")) + validateStringComparisons("optStringCol", \Query.optStringCol, "Foó") + validateStringComparisons("optStringEnumCol", \Query.optStringEnumCol, .value2) + validateStringComparisons("optString", \Query.optString, StringWrapper(persistedValue: "Foó")) + + validateStringComparisons("optStringCol", \Query.optStringCol, nil, letCount: 0, gtCount: 1) + validateStringComparisons("optStringEnumCol", \Query.optStringEnumCol, nil, letCount: 0, gtCount: 1) + validateStringComparisons("optString", \Query.optString, nil, letCount: 0, gtCount: 1) + } + + func testGreaterThanNumericAnyRealmValue() { let object = objects()[0] setAnyRealmValueCol(with: .int(123), object: object) assertQuery("(anyCol > %@)", AnyRealmValue.int(123), count: 0) { @@ -1777,7 +1807,18 @@ class QueryTests: TestCase, @unchecked Sendable { } } - func testLessThanAnyRealmValue() { + func testGreaterThanStringAnyRealmValue() { + let object = objects()[0] + setAnyRealmValueCol(with: .string("FooBar"), object: object) + assertQuery("(anyCol > %@)", AnyRealmValue.string("FooBar"), count: 0) { + $0.anyCol > .string("FooBar") + } + assertQuery("(anyCol >= %@)", AnyRealmValue.string("FooBar"), count: 1) { + $0.anyCol >= .string("FooBar") + } + } + + func testLessThanNumericAnyRealmValue() { let object = objects()[0] setAnyRealmValueCol(with: .int(123), object: object) assertQuery("(anyCol < %@)", AnyRealmValue.int(123), count: 0) { @@ -1816,6 +1857,17 @@ class QueryTests: TestCase, @unchecked Sendable { } } + func testLessThanStringAnyRealmValue() { + let object = objects()[0] + setAnyRealmValueCol(with: .string("FooBar"), object: object) + assertQuery("(anyCol < %@)", AnyRealmValue.string("FooBar"), count: 0) { + $0.anyCol < .string("FooBar") + } + assertQuery("(anyCol <= %@)", AnyRealmValue.string("FooBar"), count: 1) { + $0.anyCol <= .string("FooBar") + } + } + private func validateNumericContains( _ name: String, _ lhs: (Query) -> Query) { let values = T.queryValues() @@ -2003,6 +2055,57 @@ class QueryTests: TestCase, @unchecked Sendable { likeStrings.map { (StringWrapper(persistedValue: $0.0), $0.1, $0.2) }, canMatch: true) } + private func validateStringLexicographicalComparision( + _ name: String, _ lhs: (Query) -> Query, _ strings: [(T, Int, Int, Int, Int, Bool)], canMatch: Bool) where T.PersistedType: _QueryString { + for (str, gtCount, getCount, ltCount, letCount, caseSensitive) in strings { + assertQuery(Root.self, "(\(name) > %@)", str, count: (canMatch || caseSensitive) ? gtCount : 1) { + lhs($0) > str + } + assertQuery(Root.self, "(\(name) >= %@)", str, count: (canMatch || caseSensitive) ? getCount : 1) { + lhs($0) >= str + } + assertQuery(Root.self, "(\(name) < %@)", str, count: (canMatch || caseSensitive) ? ltCount : 0) { + lhs($0) < str + } + assertQuery(Root.self, "(\(name) <= %@)", str, count: (canMatch || caseSensitive) ? letCount : 0) { + lhs($0) <= str + } + } + } + + func testLexicographicalComparision() { + let likeStrings: [(String, Int, Int, Int, Int, Bool)] = [ + ("Foó", 0, 1, 0, 1, false), + ("f*", 0, 0, 1, 1, true), + ("*ó", 1, 1, 0, 0, false), + ("f?ó", 0, 0, 1, 1, true), + ("f*ó", 0, 0, 1, 1, true), + ("f??ó", 0, 0, 1, 1, true), + ("*o*", 1, 1, 0, 0, false), + ("*O*", 1, 1, 0, 0, false), + ("?o?", 1, 1, 0, 0, false), + ("?O?", 1, 1, 0, 0, false), + ("Foô", 0, 0, 1, 1, false), + ("Fpó", 0, 0, 1, 1, false), + ("Goó", 0, 0, 1, 1, false), + ("Foò", 1, 1, 0, 0, false), + ("Fnó", 1, 1, 0, 0, false), + ("Eoó", 1, 1, 0, 0, false), + ] + validateStringLexicographicalComparision("stringCol", \Query.stringCol, + likeStrings.map { ($0.0, $0.1, $0.2, $0.3, $0.4, $0.5) }, canMatch: true) + validateStringLexicographicalComparision("stringEnumCol", \Query.stringEnumCol.rawValue, + likeStrings.map { ($0.0, $0.1, $0.2, $0.3, $0.4, $0.5) }, canMatch: false) + validateStringLexicographicalComparision("string", \Query.string, + likeStrings.map { (StringWrapper(persistedValue: $0.0), $0.1, $0.2, $0.3, $0.4, $0.5) }, canMatch: true) + validateStringLexicographicalComparision("optStringCol", \Query.optStringCol, + likeStrings.map { (String?.some($0.0), $0.1, $0.2, $0.3, $0.4, $0.5) }, canMatch: true) + validateStringLexicographicalComparision("optStringEnumCol", \Query.optStringEnumCol.rawValue, + likeStrings.map { (String?.some($0.0), $0.1, $0.2, $0.3, $0.4, $0.5) }, canMatch: false) + validateStringLexicographicalComparision("optString", \Query.optString, + likeStrings.map { (StringWrapper(persistedValue: $0.0), $0.1, $0.2, $0.3, $0.4, $0.5) }, canMatch: true) + } + // MARK: - Data func validateData(_ name: String, _ lhs: (Query) -> Query, @@ -9032,6 +9135,38 @@ class QueryTests: TestCase, @unchecked Sendable { $0.stringEnumCol != $0.stringEnumCol } + assertQuery("(stringEnumCol > stringEnumCol)", count: 0) { + $0.stringEnumCol > $0.stringEnumCol + } + + assertQuery("(stringCol > stringCol)", count: 0) { + $0.stringCol > $0.stringCol + } + + assertQuery("(stringEnumCol >= stringEnumCol)", count: 1) { + $0.stringEnumCol >= $0.stringEnumCol + } + + assertQuery("(stringCol >= stringCol)", count: 1) { + $0.stringCol >= $0.stringCol + } + + assertQuery("(stringEnumCol < stringEnumCol)", count: 0) { + $0.stringEnumCol < $0.stringEnumCol + } + + assertQuery("(stringCol < stringCol)", count: 0) { + $0.stringCol < $0.stringCol + } + + assertQuery("(stringEnumCol <= stringEnumCol)", count: 1) { + $0.stringEnumCol <= $0.stringEnumCol + } + + assertQuery("(stringCol <= stringCol)", count: 1) { + $0.stringCol <= $0.stringCol + } + assertThrows(assertQuery("", count: 1) { $0.arrayCol == $0.arrayCol }, reason: "Comparing two collection columns is not permitted.") diff --git a/RealmSwift/Tests/QueryTests.swift.gyb b/RealmSwift/Tests/QueryTests.swift.gyb index 2eb6fa5480..a977fba06b 100644 --- a/RealmSwift/Tests/QueryTests.swift.gyb +++ b/RealmSwift/Tests/QueryTests.swift.gyb @@ -882,7 +882,34 @@ class QueryTests: TestCase, @unchecked Sendable { % end } - func testGreaterThanAnyRealmValue() { + func validateStringComparisons( + _ name: String, _ lhs: (Query) -> Query, + _ value: T, letCount: Int = 1, getCount: Int = 1, ltCount: Int = 0, gtCount: Int = 0) where T.PersistedType: _QueryString { + assertQuery(Root.self, "(\(name) > %@)", value, count: gtCount) { + lhs($0) > value + } + assertQuery(Root.self, "(\(name) >= %@)", value, count: getCount) { + lhs($0) >= value + } + assertQuery(Root.self, "(\(name) < %@)", value, count: ltCount) { + lhs($0) < value + } + assertQuery(Root.self, "(\(name) <= %@)", value, count: letCount) { + lhs($0) <= value + } + } + + func testStringComparisons() { + % for property in string(properties + optProperties): + validateStringComparisons("${property.colName}", \Query<${property.className}>.${property.colName}, ${property.value(1)}) + % end + + % for property in string(optProperties): + validateStringComparisons("${property.colName}", \Query<${property.className}>.${property.colName}, nil, letCount: 0, gtCount: 1) + % end + } + + func testGreaterThanNumericAnyRealmValue() { let object = objects()[0] % for value in numeric(anyRealmValues): setAnyRealmValueCol(with: ${value.value(0)}, object: object) @@ -895,7 +922,20 @@ class QueryTests: TestCase, @unchecked Sendable { % end } - func testLessThanAnyRealmValue() { + func testGreaterThanStringAnyRealmValue() { + let object = objects()[0] + % for value in string(anyRealmValues): + setAnyRealmValueCol(with: ${value.value(0)}, object: object) + assertQuery("(anyCol > %@)", ${value.enumValue(0)}, count: 0) { + $0.anyCol > ${value.value(0)} + } + assertQuery("(anyCol >= %@)", ${value.enumValue(0)}, count: 1) { + $0.anyCol >= ${value.value(0)} + } + % end + } + + func testLessThanNumericAnyRealmValue() { let object = objects()[0] % for value in numeric(anyRealmValues): setAnyRealmValueCol(with: ${value.value(0)}, object: object) @@ -908,6 +948,19 @@ class QueryTests: TestCase, @unchecked Sendable { % end } + func testLessThanStringAnyRealmValue() { + let object = objects()[0] + % for value in string(anyRealmValues): + setAnyRealmValueCol(with: ${value.value(0)}, object: object) + assertQuery("(anyCol < %@)", ${value.enumValue(0)}, count: 0) { + $0.anyCol < ${value.value(0)} + } + assertQuery("(anyCol <= %@)", ${value.enumValue(0)}, count: 1) { + $0.anyCol <= ${value.value(0)} + } + % end + } + private func validateNumericContains( _ name: String, _ lhs: (Query) -> Query) { let values = T.queryValues() @@ -1040,6 +1093,49 @@ class QueryTests: TestCase, @unchecked Sendable { % end } + private func validateStringLexicographicalComparison( + _ name: String, _ lhs: (Query) -> Query, _ strings: [(T, Int, Int, Int, Int, Bool)], canMatch: Bool) where T.PersistedType: _QueryString { + for (str, gtCount, getCount, ltCount, letCount, caseSensitive) in strings { + assertQuery(Root.self, "(\(name) > %@)", str, count: (canMatch || caseSensitive) ? gtCount : 0) { + lhs($0) > str + } + assertQuery(Root.self, "(\(name) >= %@)", str, count: (canMatch || caseSensitive) ? getCount : 0) { + lhs($0) >= str + } + assertQuery(Root.self, "(\(name) < %@)", str, count: (canMatch || caseSensitive) ? ltCount : 0) { + lhs($0) < str + } + assertQuery(Root.self, "(\(name) <= %@)", str, count: (canMatch || caseSensitive) ? letCount : 0) { + lhs($0) <= str + } + } + } + + func testLexicographicalComparison() { + let likeStrings: [(String, Int, Int, Int, Int, Bool)] = [ + ("Foó", 0, 1, 0, 1, false), + ("f*", 0, 0, 1, 1, true), + ("*ó", 1, 1, 0, 0, false), + ("f?ó", 0, 0, 1, 1, true), + ("f*ó", 0, 0, 1, 1, true), + ("f??ó", 0, 0, 1, 1, true), + ("*o*", 1, 1, 0, 0, false), + ("*O*", 1, 1, 0, 0, false), + ("?o?", 1, 1, 0, 0, false), + ("?O?", 1, 1, 0, 0, false), + ("Foô", 0, 0, 1, 1, false), + ("Fpó", 0, 0, 1, 1, false), + ("Goó", 0, 0, 1, 1, false), + ("Foò", 1, 1, 0, 0, false), + ("Fnó", 1, 1, 0, 0, false), + ("Eoó", 1, 1, 0, 0, false), + ] + % for property in string(properties + optProperties): + validateStringLexicographicalComparison("${property.colName}", \Query<${property.className}>.${property.rawValueName}, + likeStrings.map { (${property.wrap('$0.0')}, $0.1, $0.2, $0.3, $0.4, $0.5) }, canMatch: ${ifEnum(property, 'false', 'true')}) + % end + } + // MARK: - Data func validateData(_ name: String, _ lhs: (Query) -> Query, @@ -2127,6 +2223,38 @@ class QueryTests: TestCase, @unchecked Sendable { $0.stringEnumCol != $0.stringEnumCol } + assertQuery("(stringEnumCol > stringEnumCol)", count: 0) { + $0.stringEnumCol > $0.stringEnumCol + } + + assertQuery("(stringCol > stringCol)", count: 0) { + $0.stringCol > $0.stringCol + } + + assertQuery("(stringEnumCol >= stringEnumCol)", count: 1) { + $0.stringEnumCol >= $0.stringEnumCol + } + + assertQuery("(stringCol >= stringCol)", count: 1) { + $0.stringCol >= $0.stringCol + } + + assertQuery("(stringEnumCol < stringEnumCol)", count: 0) { + $0.stringEnumCol < $0.stringEnumCol + } + + assertQuery("(stringCol < stringCol)", count: 0) { + $0.stringCol < $0.stringCol + } + + assertQuery("(stringEnumCol <= stringEnumCol)", count: 1) { + $0.stringEnumCol <= $0.stringEnumCol + } + + assertQuery("(stringCol <= stringCol)", count: 1) { + $0.stringCol <= $0.stringCol + } + assertThrows(assertQuery("", count: 1) { $0.arrayCol == $0.arrayCol }, reason: "Comparing two collection columns is not permitted.")