From 0546e7e970eb0e6234cb9f47b6838d5e6b28d5f2 Mon Sep 17 00:00:00 2001 From: Kyle Fuller Date: Sun, 1 Mar 2020 13:34:03 +0000 Subject: [PATCH 1/4] feat: support KeyPath predicates --- Sources/QueryKit/KeyPath.swift | 86 ++++++++++++++++++ Tests/QueryKitTests/KeyPathTests.swift | 121 +++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 Sources/QueryKit/KeyPath.swift create mode 100644 Tests/QueryKitTests/KeyPathTests.swift diff --git a/Sources/QueryKit/KeyPath.swift b/Sources/QueryKit/KeyPath.swift new file mode 100644 index 0000000..f55733d --- /dev/null +++ b/Sources/QueryKit/KeyPath.swift @@ -0,0 +1,86 @@ +import CoreData + +// MARK: Predicate + +public func == (lhs: KeyPath, rhs: V) -> Predicate { + return Predicate(predicate: lhs == rhs) +} + +public func != (lhs: KeyPath, rhs: V) -> Predicate { + return Predicate(predicate: lhs != rhs) +} + +public func > (lhs: KeyPath, rhs: V) -> Predicate { + return Predicate(predicate: lhs > rhs) +} + +public func >= (lhs: KeyPath, rhs: V) -> Predicate { + return Predicate(predicate: lhs >= rhs) +} + +public func < (lhs: KeyPath, rhs: V) -> Predicate { + return Predicate(predicate: lhs < rhs) +} + +public func <= (lhs: KeyPath, rhs: V) -> Predicate { + return Predicate(predicate: lhs <= rhs) +} + +public func ~= (lhs: KeyPath, rhs: V) -> Predicate { + return Predicate(predicate: lhs ~= rhs) +} + +public func << (lhs: KeyPath, rhs: [V]) -> Predicate { + return Predicate(predicate: lhs << rhs) +} + +public func << (lhs: KeyPath, rhs: Range) -> Predicate { + return Predicate(predicate: lhs << rhs) +} + +// MARK: - NSPredicate + +public func == (lhs: KeyPath, rhs: V) -> NSPredicate { + let attribute = Attribute((lhs as AnyKeyPath)._kvcKeyPathString!) + return attribute == rhs +} + +public func != (lhs: KeyPath, rhs: V) -> NSPredicate { + let attribute = Attribute((lhs as AnyKeyPath)._kvcKeyPathString!) + return attribute != rhs +} + +public func > (lhs: KeyPath, rhs: V) -> NSPredicate { + let attribute = Attribute((lhs as AnyKeyPath)._kvcKeyPathString!) + return attribute > rhs +} + +public func >= (lhs: KeyPath, rhs: V) -> NSPredicate { + let attribute = Attribute((lhs as AnyKeyPath)._kvcKeyPathString!) + return attribute >= rhs +} + +public func < (lhs: KeyPath, rhs: V) -> NSPredicate { + let attribute = Attribute((lhs as AnyKeyPath)._kvcKeyPathString!) + return attribute < rhs +} + +public func <= (lhs: KeyPath, rhs: V) -> NSPredicate { + let attribute = Attribute((lhs as AnyKeyPath)._kvcKeyPathString!) + return attribute <= rhs +} + +public func ~= (lhs: KeyPath, rhs: V) -> NSPredicate { + let attribute = Attribute((lhs as AnyKeyPath)._kvcKeyPathString!) + return attribute ~= rhs +} + +public func << (lhs: KeyPath, rhs: [V]) -> NSPredicate { + let attribute = Attribute((lhs as AnyKeyPath)._kvcKeyPathString!) + return attribute << rhs +} + +public func << (lhs: KeyPath, rhs: Range) -> NSPredicate { + let attribute = Attribute((lhs as AnyKeyPath)._kvcKeyPathString!) + return attribute << rhs +} diff --git a/Tests/QueryKitTests/KeyPathTests.swift b/Tests/QueryKitTests/KeyPathTests.swift new file mode 100644 index 0000000..1bef2f5 --- /dev/null +++ b/Tests/QueryKitTests/KeyPathTests.swift @@ -0,0 +1,121 @@ +import XCTest +@testable import QueryKit + +class KeyPathTests: XCTestCase { + func testEqualityOperator() { + let predicate: Predicate = \User.name == "kyle" + XCTAssertEqual(predicate.predicate, NSPredicate(format:"name == 'kyle'")) + } + + func testEqualityOperatorWithOptional() { + let predicate: Predicate = \User.name == nil + XCTAssertEqual(predicate.predicate, NSPredicate(format:"name == %@", NSNull())) + } + + func testInequalityOperator() { + let predicate: Predicate = \User.name != "kyle" + XCTAssertEqual(predicate.predicate, NSPredicate(format:"name != 'kyle'")) + } + + func testInqqualityOperatorWithOptional() { + let predicate: Predicate = \User.name != nil + XCTAssertEqual(predicate.predicate, NSPredicate(format:"name != %@", NSNull())) + } + + func testGreaterThanOperator() { + let predicate: Predicate = \User.age > 17 + XCTAssertEqual(predicate.predicate, NSPredicate(format:"age > 17")) + } + + func testGreaterThanOrEqualOperator() { + let predicate: Predicate = \User.age >= 18 + XCTAssertEqual(predicate.predicate, NSPredicate(format:"age >= 18")) + } + + func testLessThanOperator() { + let predicate: Predicate = \User.age < 18 + XCTAssertEqual(predicate.predicate, NSPredicate(format:"age < 18")) + } + + func testLessThanOrEqualOperator() { + let predicate: Predicate = \User.age <= 17 + XCTAssertEqual(predicate.predicate, NSPredicate(format:"age <= 17")) + } + + func testLikeOperator() { + let predicate: Predicate = \User.name ~= "k*" + XCTAssertEqual(predicate.predicate, NSPredicate(format:"name LIKE 'k*'")) + } + + func testInOperator() { + let predicate: Predicate = \User.age << [5, 10] + XCTAssertEqual(predicate.predicate, NSPredicate(format:"age IN %@", [5, 10])) + } + + func testBetweenRangeOperator() { + let predicate: Predicate = \User.age << (32 ..< 64) + XCTAssertEqual(predicate.predicate, NSPredicate(format:"age BETWEEN %@", [32, 64])) + } +} + +class KeyPathNSPredicateTests: XCTestCase { + func testEqualityOperator() { + let predicate: NSPredicate = \User.name == "kyle" + XCTAssertEqual(predicate, NSPredicate(format:"name == 'kyle'")) + } + + func testEqualityOperatorWithOptional() { + let predicate: NSPredicate = \User.name == nil + XCTAssertEqual(predicate, NSPredicate(format:"name == %@", NSNull())) + } + + func testInequalityOperator() { + let predicate: NSPredicate = \User.name != "kyle" + XCTAssertEqual(predicate, NSPredicate(format:"name != 'kyle'")) + } + + func testInqqualityOperatorWithOptional() { + let predicate: NSPredicate = \User.name != nil + XCTAssertEqual(predicate, NSPredicate(format:"name != %@", NSNull())) + } + + func testGreaterThanOperator() { + let predicate: NSPredicate = \User.age > 17 + XCTAssertEqual(predicate, NSPredicate(format:"age > 17")) + } + + func testGreaterThanOrEqualOperator() { + let predicate: NSPredicate = \User.age >= 18 + XCTAssertEqual(predicate, NSPredicate(format:"age >= 18")) + } + + func testLessThanOperator() { + let predicate: NSPredicate = \User.age < 18 + XCTAssertEqual(predicate, NSPredicate(format:"age < 18")) + } + + func testLessThanOrEqualOperator() { + let predicate: NSPredicate = \User.age <= 17 + XCTAssertEqual(predicate, NSPredicate(format:"age <= 17")) + } + + func testLikeOperator() { + let predicate: NSPredicate = \User.name ~= "k*" + XCTAssertEqual(predicate, NSPredicate(format:"name LIKE 'k*'")) + } + + func testInOperator() { + let predicate: NSPredicate = \User.age << [5, 10] + XCTAssertEqual(predicate, NSPredicate(format:"age IN %@", [5, 10])) + } + + func testBetweenRangeOperator() { + let predicate: NSPredicate = \User.age << (32 ..< 64) + XCTAssertEqual(predicate, NSPredicate(format:"age BETWEEN %@", [32, 64])) + } +} + +class User: NSManagedObject { + @objc var name: String? + @NSManaged var age: Int +} From a3df08fb2b1c6b39abc0c0533e494ca1f5234c15 Mon Sep 17 00:00:00 2001 From: Kyle Fuller Date: Sun, 1 Mar 2020 13:39:44 +0000 Subject: [PATCH 2/4] feat: support KeyPath ordering --- Sources/QueryKit/QuerySet.swift | 5 +++++ Tests/QueryKitTests/QuerySetTests.swift | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Sources/QueryKit/QuerySet.swift b/Sources/QueryKit/QuerySet.swift index 8fa51d6..9d057c1 100644 --- a/Sources/QueryKit/QuerySet.swift +++ b/Sources/QueryKit/QuerySet.swift @@ -63,6 +63,11 @@ extension QuerySet { // MARK: Type-safe Sorting + /// Returns a new QuerySet containing objects ordered by the given key path. + public func orderBy(_ keyPath: KeyPath, ascending: Bool) -> QuerySet { + return orderBy(NSSortDescriptor(key: (keyPath as AnyKeyPath)._kvcKeyPathString!, ascending: ascending)) + } + /// Returns a new QuerySet containing objects ordered by the given sort descriptor. public func orderBy(_ closure:((ModelType.Type) -> (SortDescriptor))) -> QuerySet { return orderBy(closure(ModelType.self).sortDescriptor) diff --git a/Tests/QueryKitTests/QuerySetTests.swift b/Tests/QueryKitTests/QuerySetTests.swift index 0653a5a..6a73b2b 100644 --- a/Tests/QueryKitTests/QuerySetTests.swift +++ b/Tests/QueryKitTests/QuerySetTests.swift @@ -44,6 +44,22 @@ class QuerySetTests: XCTestCase { // MARK: Sorting + func testOrderByKeyPathAscending() { + let qs = queryset.orderBy(\.name, ascending: true) + + XCTAssertEqual(qs.sortDescriptors, [ + NSSortDescriptor(key: "name", ascending: true), + ]) + } + + func testOrderByKeyPathDecending() { + let qs = queryset.orderBy(\.name, ascending: false) + + XCTAssertEqual(qs.sortDescriptors, [ + NSSortDescriptor(key: "name", ascending: false), + ]) + } + func testOrderBySortDescriptor() { let sortDescriptor = NSSortDescriptor(key: "name", ascending: true) let qs = queryset.orderBy(sortDescriptor) From 4530948193c0967f3e1c6aeaeba57e0b1834848b Mon Sep 17 00:00:00 2001 From: Kyle Fuller Date: Sun, 1 Mar 2020 13:44:59 +0000 Subject: [PATCH 3/4] feat: support KeyPath filter/exclude --- Sources/QueryKit/Attribute.swift | 4 ++-- Sources/QueryKit/QuerySet.swift | 10 ++++++++++ Tests/QueryKitTests/QuerySetTests.swift | 10 ++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Sources/QueryKit/Attribute.swift b/Sources/QueryKit/Attribute.swift index 078707b..ecdf487 100644 --- a/Sources/QueryKit/Attribute.swift +++ b/Sources/QueryKit/Attribute.swift @@ -112,11 +112,11 @@ prefix public func ! (left: Attribute) -> NSPredicate { public extension QuerySet { func filter(_ attribute:Attribute) -> QuerySet { - return filter(attribute == true) + return filter((attribute == true) as NSPredicate) } func exclude(_ attribute:Attribute) -> QuerySet { - return filter(attribute == false) + return filter((attribute == false) as NSPredicate) } } diff --git a/Sources/QueryKit/QuerySet.swift b/Sources/QueryKit/QuerySet.swift index 9d057c1..6f85230 100644 --- a/Sources/QueryKit/QuerySet.swift +++ b/Sources/QueryKit/QuerySet.swift @@ -80,6 +80,11 @@ extension QuerySet { // MARK: Filtering + /// Returns a new QuerySet containing objects that match the given predicate. + public func filter(_ predicate: Predicate) -> QuerySet { + return filter(predicate.predicate) + } + /// Returns a new QuerySet containing objects that match the given predicate. public func filter(_ predicate:NSPredicate) -> QuerySet { var futurePredicate = predicate @@ -97,6 +102,11 @@ extension QuerySet { return filter(newPredicate) } + /// Returns a new QuerySet containing objects that exclude the given predicate. + public func exclude(_ predicate: Predicate) -> QuerySet { + return exclude(predicate.predicate) + } + /// Returns a new QuerySet containing objects that exclude the given predicate. public func exclude(_ predicate:NSPredicate) -> QuerySet { let excludePredicate = NSCompoundPredicate(type: NSCompoundPredicate.LogicalType.not, subpredicates: [predicate]) diff --git a/Tests/QueryKitTests/QuerySetTests.swift b/Tests/QueryKitTests/QuerySetTests.swift index 6a73b2b..5ecab25 100644 --- a/Tests/QueryKitTests/QuerySetTests.swift +++ b/Tests/QueryKitTests/QuerySetTests.swift @@ -96,6 +96,11 @@ class QuerySetTests: XCTestCase { // MARK: Filtering + func testFilterKeyPath() { + let qs = queryset.filter(\.name == "Kyle") + XCTAssertEqual(qs.predicate?.description, "name == \"Kyle\"") + } + func testFilterPredicate() { let predicate = NSPredicate(format: "name == Kyle") let qs = queryset.filter(predicate) @@ -136,6 +141,11 @@ class QuerySetTests: XCTestCase { // MARK: Exclusion + func testExcludeKeyPath() { + let qs = queryset.exclude(\.name == "Kyle") + XCTAssertEqual(qs.predicate?.description, "NOT name == \"Kyle\"") + } + func testExcludePredicate() { let predicate = NSPredicate(format: "name == Kyle") let qs = queryset.exclude(predicate) From 7934e49b5c0cfb1382588752f1ea75735a4bd16b Mon Sep 17 00:00:00 2001 From: Kyle Fuller Date: Sun, 1 Mar 2020 13:57:26 +0000 Subject: [PATCH 4/4] docs: update documentation for KeyPath --- CHANGELOG.md | 15 ++++++++++ README.md | 84 ++++++++++++++++------------------------------------ 2 files changed, 41 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4638af..2fb1ba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,3 +5,18 @@ ### Breaking * Drops support for Swift 3 and Swift 4. Swift 5 or newer must be used. + +### Enhancements + +* Added support for ordering by Swift KeyPath, for example: + + ```swift + queryset.orderBy(\.createdAt, ascending: true) + ``` + +* Added support for filtering and excluding by Swift KeyPath, for example: + + ```swift + queryset.exclude(\.name == "Kyle") + queryset.filter(\.createdAt > Date()) + ``` diff --git a/README.md b/README.md index 69f0960..2cc9993 100644 --- a/README.md +++ b/README.md @@ -6,29 +6,10 @@ QueryKit, a simple type-safe Core Data query language. ## Usage -To get the most out of QueryKit, and to get full type-safe queries, you may -add extensions for your Core Data models providing properties which describe -your models. You may use [querykit-cli](https://github.com/QueryKit/querykit-cli) -to generate these automatically. - -An extension for our a `Person` model might look as follows: - -```swift -extension User { - static var name:Attribute { return Attribute("name") } - static var age:Attribute { return Attribute("age") } -} -``` - -This provides static properties on our User model which represent each property -on our Core Data model, these may be used to construct predicates and sort -descriptors with compile time safety, without stringly typing them -into your application. - ```swift -let namePredicate = Person.name == "Kyle" -let agePredicate = Person.age > 25 -let ageSortDescriptor = Person.age.descending() +QuerySet(context, "Person") + .orderedBy(.name, ascending: true) + .filter(\.age >= 18) ``` ### QuerySet @@ -40,20 +21,19 @@ results based on the given parameters. #### Retrieving all objects ```swift -let queryset = Person.queryset(context) +let queryset = QuerySet(context, "Person") ``` #### Retrieving specific objects with filters You may filter a QuerySet using the `filter` and `exclude` methods, which -accept a closure passing the model type allowing you to access the -type-safe attributes. +accept a predicate which can be constructed using KeyPath extensions. -The `filter` and `exclude` methods return brand new QuerySet's including your filter. +The `filter` and `exclude` methods return new QuerySet's including your filter. ```swift -queryset.filter { $0.name == "Kyle" } -queryset.exclude { $0.age > 25 } +queryset.filter(\.name == "Kyle") +queryset.exclude(\.age > 25) ``` You may also use standard `NSPredicate` if you want to construct complicated @@ -70,14 +50,13 @@ The result of refining a QuerySet is itself a QuerySet, so it’s possible to chain refinements together. For example: ```swift -queryset.filter { $0.name == "Kyle" } - .exclude { $0.age < 25 } - .filter { $0.isEmployed } +queryset.filter(\.name == "Kyle") + .exclude(\.age < 25) ``` -Each time you refine a QuerySet, you get a brand-new QuerySet that is in -no way bound to the previous QuerySet. Each refinement creates a separate -and distinct QuerySet that may be stored, used and reused. +Each time you refine a QuerySet, you get a new QuerySet instance that is in no +way bound to the previous QuerySet. Each refinement creates a separate and +distinct QuerySet that may be stored, used and reused. #### QuerySets are lazy @@ -88,17 +67,15 @@ QuerySet is *evaluated*. #### Ordering You may order a QuerySet's results by using the `orderBy` function which -accepts a closure passing the model type, and expects a sort descriptor in -return. +accepts a KeyPath. ```swift -queryset.orderBy { $0.name.ascending() } +queryset.orderBy(\.name, ascending: true) ``` You may also pass in an `NSSortDescriptor` if you would rather. ```swift -queryset.orderBy(Person.name.ascending()) queryset.orderBy(NSSortDescriptor(key: "name", ascending: true)) ``` @@ -108,7 +85,7 @@ Using slicing, a QuerySet's results may be limited to a specified range. For example, to get the first 5 items in our QuerySet: ```swift -queryset[0..5] +queryset[0...5] ``` **NOTE**: *Remember, QuerySets are lazily evaluated. Slicing doesn’t evaluate the query.* @@ -158,35 +135,26 @@ count or an error if the operation failed. let deleted = try? queryset.delete() ``` -#### Attribute - -The `Attribute` is a generic structure for creating predicates in a -type-safe manner as shown at the start of the README. - -```swift -let name = Attribute("name") -let age = Attribute("age") -``` - ##### Operators -QueryKit provides custom operator functions allowing you to create predicates. +QueryKit provides KeyPath extensions providing operator functions allowing you +to create predicates. ```swift // Name is equal to Kyle -name == "Kyle" +\Person.name == "Kyle" // Name is either equal to Kyle or Katie -name << ["Kyle", "Katie"] +\.Person.name << ["Kyle", "Katie"] // Age is equal to 27 -age == 27 +\.Person.age == 27 // Age is more than or equal to 25 -age >= 25 +\Person.age >= 25 // Age is within the range 22 to 30. -age << (22...30) +\Person.age << (22...30) ``` The following types of comparisons are supported using Attribute: @@ -210,13 +178,13 @@ QueryKit provides the `!`, `&&` and `||` operators for joining multiple predicat ```swift // Persons name is Kyle or Katie -Person.name == "Kyle" || Person.name == "Katie" +\Person.name == "Kyle" || \Person.name == "Katie" // Persons age is more than 25 and their name is Kyle -Person.age >= 25 && Person.name == "Kyle" +\Person.age >= 25 && \Person.name == "Kyle" // Persons name is not Kyle -!(Person.name == "Kyle") +!(\Person.name == "Kyle") ``` ## Installation