diff --git a/PredicateKit.podspec b/PredicateKit.podspec index 19d40bf..e9293c7 100644 --- a/PredicateKit.podspec +++ b/PredicateKit.podspec @@ -15,7 +15,7 @@ Pod::Spec.new do |spec| spec.name = "PredicateKit" - spec.version = "1.3.0" + spec.version = "1.4.0" spec.summary = "Write expressive and type-safe predicates for CoreData using key-paths, comparisons and logical operators, literal values, and functions." spec.description = <<-DESC PredicateKit allows Swift developers to write expressive and type-safe predicates for CoreData using key-paths, comparisons and logical operators, literal values, and functions. diff --git a/PredicateKit/CoreData/NSFetchRequestBuilder.swift b/PredicateKit/CoreData/NSFetchRequestBuilder.swift index a191136..f31542a 100644 --- a/PredicateKit/CoreData/NSFetchRequestBuilder.swift +++ b/PredicateKit/CoreData/NSFetchRequestBuilder.swift @@ -73,7 +73,7 @@ struct NSFetchRequestBuilder { case .direct, .any, .all: return NSComparisonPredicate( leftExpression: makeExpression(from: comparison.expression), - rightExpression: NSExpression(forConstantValue: comparison.value), + rightExpression: makeExpression(from: comparison.value), modifier: makeComparisonModifier(from: comparison.modifier), type: makeOperator(from: comparison.operator), options: makeComparisonOptions(from: comparison.options) @@ -110,6 +110,10 @@ struct NSFetchRequestBuilder { expression.toNSExpression(conversionOptions) } + private func makeExpression(from primitive: Primitive) -> NSExpression { + return NSExpression(forConstantValue: primitive.value) + } + private func makeOperator(from operator: ComparisonOperator) -> NSComparisonPredicate.Operator { switch `operator` { case .beginsWith: @@ -298,6 +302,19 @@ extension Query: NSExpressionConvertible { } } +// MARK: - Primitive + +private extension Primitive { + var value: Any? { + switch Self.type { + case .nil: + return NSNull() + default: + return self + } + } +} + // MARK: - KeyPath extension AnyKeyPath { diff --git a/PredicateKit/Predicate.swift b/PredicateKit/Predicate.swift index 2774578..803d5a4 100644 --- a/PredicateKit/Predicate.swift +++ b/PredicateKit/Predicate.swift @@ -290,7 +290,7 @@ public enum Function: Expression where Input.Value: A public enum Index: Expression where Array.Value: AnyArray { public typealias Root = Array.Root - public typealias Value = Array.Value.Element + public typealias Value = Array.Value.ArrayElement case index(Array, Int) case first(Array) @@ -371,6 +371,11 @@ public func == (lhs: E, rhs: T) -> Pre .comparison(.init(lhs, .equal, rhs)) } +@_disfavoredOverload +public func == (lhs: E, rhs: Nil) -> Predicate where E.Value: OptionalType { + .comparison(.init(lhs, .equal, rhs)) +} + public func != (lhs: E, rhs: T) -> Predicate where E.Value == T { .comparison(.init(lhs, .notEqual, rhs)) } @@ -495,15 +500,15 @@ extension Expression where Value: AnyArray { .last(self) } - public func at(index: Int, _ keyPath: KeyPath) -> ArrayElementKeyPath { + public func at(index: Int, _ keyPath: KeyPath) -> ArrayElementKeyPath { .init(.index(index), self, keyPath) } - public func first(_ keyPath: KeyPath) -> ArrayElementKeyPath { + public func first(_ keyPath: KeyPath) -> ArrayElementKeyPath { .init(.first, self, keyPath) } - public func last(_ keyPath: KeyPath) -> ArrayElementKeyPath { + public func last(_ keyPath: KeyPath) -> ArrayElementKeyPath { .init(.last, self, keyPath) } } @@ -600,6 +605,8 @@ extension Expression { // MARK: - Supporting Protocols +// MARK: - StringValue + public protocol StringValue { } @@ -609,10 +616,15 @@ extension String: StringValue { extension Optional: StringValue where Wrapped == String { } +// MARK: - AnyArrayOrSet + public protocol AnyArrayOrSet { associatedtype Element } +extension Array: AnyArrayOrSet { +} + extension Set: AnyArrayOrSet { } @@ -623,16 +635,22 @@ extension Optional: AnyArrayOrSet where Wrapped: AnyArrayOrSet { public typealias Element = Wrapped.Element } +// MARK: - AnyArray + public protocol AnyArray { - associatedtype Element + associatedtype ArrayElement } -extension Array: AnyArrayOrSet { +extension Array: AnyArray { + public typealias ArrayElement = Element } -extension Array: AnyArray { +extension Optional: AnyArray where Wrapped: AnyArray { + public typealias ArrayElement = Wrapped.ArrayElement } +// MARK: - PrimitiveCollection + public protocol PrimitiveCollection { associatedtype PrimitiveElement: Primitive } @@ -649,6 +667,8 @@ extension Optional: PrimitiveCollection where Wrapped: PrimitiveCollection { public typealias PrimitiveElement = Wrapped.PrimitiveElement } +// MARK: - AdditiveCollection + public protocol AdditiveCollection { associatedtype AdditiveElement: AdditiveArithmetic & Primitive } @@ -661,6 +681,8 @@ extension Optional: AdditiveCollection where Wrapped: PrimitiveCollection & Addi public typealias AdditiveElement = Wrapped.AdditiveElement } +// MARK: - ComparableCollection + public protocol ComparableCollection { associatedtype ComparableElement: Comparable & Primitive } @@ -673,7 +695,7 @@ extension Optional: ComparableCollection where Wrapped: ComparableCollection { public typealias ComparableElement = Wrapped.ComparableElement } -// MARK: - +// MARK: - Private Initializers extension Comparison { fileprivate init( diff --git a/PredicateKit/Primitive.swift b/PredicateKit/Primitive.swift index 8f6cb4c..41d4f34 100644 --- a/PredicateKit/Primitive.swift +++ b/PredicateKit/Primitive.swift @@ -20,6 +20,8 @@ import Foundation +// MARK: - Primitive + public protocol Primitive { static var type: Type { get } } @@ -45,6 +47,7 @@ public indirect enum Type: Equatable { case data case wrapped(Type) case array(Type) + case `nil` } extension Bool: Primitive { @@ -131,6 +134,22 @@ extension Optional: Primitive where Wrapped: Primitive { public static var type: Type { Wrapped.type } } +public struct Nil: Primitive, ExpressibleByNilLiteral { + public static var type: Type { .nil } + + public init(nilLiteral: ()) { + } +} + +// MARK: - Optional + +public protocol OptionalType { + associatedtype Wrapped +} + +extension Optional: OptionalType { +} + extension Optional: Comparable where Wrapped: Comparable { public static func < (lhs: Self, rhs: Self) -> Bool { switch (lhs, rhs) { diff --git a/PredicateKit/SwiftUI/SwiftUISupport.swift b/PredicateKit/SwiftUI/SwiftUISupport.swift index 442967c..e18766f 100644 --- a/PredicateKit/SwiftUI/SwiftUISupport.swift +++ b/PredicateKit/SwiftUI/SwiftUISupport.swift @@ -135,3 +135,32 @@ extension FetchRequest { self.init(context: context, predicate: predicate) } } + +@available(iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension FetchRequest { + /// Creates a fetch request that returns all objects in the underlying store. + /// + /// - Important: Use this initializer **only** in conjunction with the SwiftUI property wrapper` @FetchRequest`. Fetch + /// requests created with this initializer cannot be executed outside of SwiftUI as they rely on the CoreData + /// managed object context injected in the environment of a SwiftUI view. + /// + /// ## Example + /// + /// struct ContentView: View { + /// @SwiftUI.FetchRequest() + /// .sorted(by: \Note.creationDate, .ascending) + /// .limit(100) + /// ) + /// var notes: FetchedResults + /// + /// var body: some View { + /// List(notes, id: \.self) { + /// Text($0.text) + /// } + /// } + /// } + /// + public init() { + self.init(predicate: true) + } +} diff --git a/PredicateKitTests/CoreDataTests/NSFetchRequestBuilderTests.swift b/PredicateKitTests/CoreDataTests/NSFetchRequestBuilderTests.swift index 711e9de..85fec5f 100644 --- a/PredicateKitTests/CoreDataTests/NSFetchRequestBuilderTests.swift +++ b/PredicateKitTests/CoreDataTests/NSFetchRequestBuilderTests.swift @@ -1039,6 +1039,45 @@ final class NSFetchRequestBuilderTests: XCTestCase { XCTAssertTrue(fatalError.contains("does not conform to NSExpressionConvertible")) } + + func testObjectNilEqualityPredicate() throws { + let request = makeRequest(\Data.optionalRelationship == nil) + let builder = makeRequestBuilder() + + let result: NSFetchRequest = builder.makeRequest(from: request) + + let comparison = try XCTUnwrap(result.predicate as? NSComparisonPredicate) + XCTAssertEqual(comparison.leftExpression, NSExpression(forKeyPath: "optionalRelationship")) + XCTAssertEqual(comparison.rightExpression, NSExpression(forConstantValue: NSNull())) + XCTAssertEqual(comparison.predicateOperatorType, .equalTo) + XCTAssertEqual(comparison.comparisonPredicateModifier, .direct) + } + + func testArrayNilEqualityPredicate() throws { + let request = makeRequest(\Data.optionalRelationships == nil) + let builder = makeRequestBuilder() + + let result: NSFetchRequest = builder.makeRequest(from: request) + + let comparison = try XCTUnwrap(result.predicate as? NSComparisonPredicate) + XCTAssertEqual(comparison.leftExpression, NSExpression(forKeyPath: "optionalRelationships")) + XCTAssertEqual(comparison.rightExpression, NSExpression(forConstantValue: NSNull())) + XCTAssertEqual(comparison.predicateOperatorType, .equalTo) + XCTAssertEqual(comparison.comparisonPredicateModifier, .direct) + } + + func testNestedPrimitiveNilEqualityPredicate() throws { + let request = makeRequest(\Data.optionalRelationship?.text == nil) + let builder = makeRequestBuilder() + + let result: NSFetchRequest = builder.makeRequest(from: request) + + let comparison = try XCTUnwrap(result.predicate as? NSComparisonPredicate) + XCTAssertEqual(comparison.leftExpression, NSExpression(forKeyPath: "optionalRelationship.text")) + XCTAssertEqual(comparison.rightExpression, NSExpression(forConstantValue: NSNull())) + XCTAssertEqual(comparison.predicateOperatorType, .equalTo) + XCTAssertEqual(comparison.comparisonPredicateModifier, .direct) + } } // MARK: - @@ -1051,6 +1090,8 @@ private class Data: NSManagedObject { @NSManaged var creationDate: Date @NSManaged var relationship: Relationship @NSManaged var relationships: [Relationship] + @NSManaged var optionalRelationship: Relationship? + @NSManaged var optionalRelationships: [Relationship]? } private class Relationship: NSManagedObject { @@ -1079,3 +1120,12 @@ private func makeRequestBuilder( ) -> NSFetchRequestBuilder { .init(entityName: "") } + +class NoteGroup: NSManagedObject { + @NSManaged var notes: [NewNote]? +} + +class NewNote: NSManagedObject { + @NSManaged var group: NoteGroup? + @NSManaged var id: String? +} diff --git a/PredicateKitTests/CoreDataTests/NSManagedObjectContextExtensionsTests.swift b/PredicateKitTests/CoreDataTests/NSManagedObjectContextExtensionsTests.swift index 8c2b1fd..959d17c 100644 --- a/PredicateKitTests/CoreDataTests/NSManagedObjectContextExtensionsTests.swift +++ b/PredicateKitTests/CoreDataTests/NSManagedObjectContextExtensionsTests.swift @@ -629,6 +629,40 @@ final class NSManagedObjectContextExtensionsTests: XCTestCase { XCTAssertTrue(inspector.inspectCalled) } + func testFetchWithNilEquality() throws { + let now = Date() + + try container.viewContext.insertNotes( + (text: "Hello, World!", creationDate: .distantFuture, updateDate: now, numberOfViews: 42, tags: ["greeting"]), + (text: "Goodbye!", creationDate: .distantPast, updateDate: nil, numberOfViews: 3, tags: ["greeting"]) + ) + + let notes: [Note] = try container.viewContext + .fetch(where: \Note.updateDate == nil) + .result() + + XCTAssertEqual(notes.count, 1) + XCTAssertEqual(notes.first?.text, "Goodbye!") + XCTAssertEqual(notes.first?.tags, ["greeting"]) + XCTAssertEqual(notes.first?.numberOfViews, 3) + } + + func testFetchWithArrayNilEqualityNilEquality() throws { + try container.viewContext.insertUsers( + (name: "John Doe", billingAccountType: "Pro", purchases: [35.0, 120.0]), + (name: "Jane Doe", billingAccountType: "Default", purchases: nil) + ) + + let users: [User] = try container.viewContext + .fetch(where: \User.billingInfo.purchases == nil) + .inspect(on: MockNSFetchRequestInspector()) + .result() + + XCTAssertEqual(users.count, 1) + XCTAssertEqual(users.first?.name, "Jane Doe") + XCTAssertEqual(users.first?.billingInfo.accountType, "Default") + } + private func makePersistentContainer() -> NSPersistentContainer { return self.makePersistentContainer(with: model) } @@ -639,6 +673,7 @@ final class NSManagedObjectContextExtensionsTests: XCTestCase { class Note: NSManagedObject { @NSManaged var text: String @NSManaged var creationDate: Date + @NSManaged var updateDate: Date? @NSManaged var numberOfViews: Int @NSManaged var tags: [String] } @@ -654,7 +689,7 @@ class User: NSManagedObject { class BillingInfo: NSManagedObject { @NSManaged var accountType: String - @NSManaged var purchases: [Double] + @NSManaged var purchases: [Double]? } class UserAccount: NSManagedObject { @@ -703,6 +738,21 @@ private extension NSManagedObjectContext { try save() } + func insertNotes( + _ notes: (text: String, creationDate: Date, updateDate: Date?, numberOfViews: Int, tags: [String])... + ) throws { + for description in notes { + let note = NSEntityDescription.insertNewObject(forEntityName: "Note", into: self) as! Note + note.text = description.text + note.tags = description.tags + note.numberOfViews = description.numberOfViews + note.creationDate = description.creationDate + note.updateDate = description.updateDate + } + + try save() + } + func insertAccounts(purchases: [[Double]]) throws { for description in purchases { let account = NSEntityDescription.insertNewObject(forEntityName: "Account", into: self) as! Account @@ -712,7 +762,7 @@ private extension NSManagedObjectContext { try save() } - func insertUsers(_ users: (name: String, billingAccountType: String, purchases: [Double])...) throws { + func insertUsers(_ users: (name: String, billingAccountType: String, purchases: [Double]?)...) throws { for description in users { let user = NSEntityDescription.insertNewObject(forEntityName: "User", into: self) as! User user.name = description.name diff --git a/PredicateKitTests/OperatorTests.swift b/PredicateKitTests/OperatorTests.swift index bac005c..c359bf7 100644 --- a/PredicateKitTests/OperatorTests.swift +++ b/PredicateKitTests/OperatorTests.swift @@ -236,6 +236,42 @@ final class OperatorTests: XCTestCase { XCTAssertEqual(value, 42) } + func testOptionalKeyPathEqualToNil() throws { + let predicate: Predicate = \Data.optionalRelationship == nil + + guard case let .comparison(comparison) = predicate else { + XCTFail("optionalRelationship == nil should result in a comparison") + return + } + + guard let keyPath = comparison.expression.as(KeyPath.self) else { + XCTFail("the left side of the comparison should be a key path expression") + return + } + + XCTAssertEqual(keyPath, \Data.optionalRelationship) + XCTAssertEqual(comparison.operator, .equal) + XCTAssertNotNil(comparison.value as? Nil) + } + + func testOptionalArrayKeyPathEqualToNil() throws { + let predicate: Predicate = \Data.optionalRelationships == nil + + guard case let .comparison(comparison) = predicate else { + XCTFail("optionalRelationships == nil should result in a comparison") + return + } + + guard let keyPath = comparison.expression.as(KeyPath.self) else { + XCTFail("the left side of the comparison should be a key path expression") + return + } + + XCTAssertEqual(keyPath, \Data.optionalRelationships) + XCTAssertEqual(comparison.operator, .equal) + XCTAssertNotNil(comparison.value as? Nil) + } + func testFunctionEqualPrimitive() throws { let predicate = (\Data.tags).count == 20 @@ -2116,6 +2152,8 @@ private struct Data { let stocks: [Double] let relationships: [Relationship] let creationDate: Date + let optionalRelationship: Relationship? + let optionalRelationships: [Relationship]? } private struct Relationship { diff --git a/PredicateKitTests/Resources/DataModel.xcdatamodeld/DataModel.xcdatamodel/contents b/PredicateKitTests/Resources/DataModel.xcdatamodeld/DataModel.xcdatamodel/contents index 7cc3f12..c50369d 100644 --- a/PredicateKitTests/Resources/DataModel.xcdatamodeld/DataModel.xcdatamodel/contents +++ b/PredicateKitTests/Resources/DataModel.xcdatamodeld/DataModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -12,6 +12,7 @@ + @@ -26,11 +27,11 @@ - - - - - - + + + + + + \ No newline at end of file diff --git a/PredicateKitTests/SwiftUITests/SwiftUISupportTests.swift b/PredicateKitTests/SwiftUITests/SwiftUISupportTests.swift index 2db03a8..6df7413 100644 --- a/PredicateKitTests/SwiftUITests/SwiftUISupportTests.swift +++ b/PredicateKitTests/SwiftUITests/SwiftUISupportTests.swift @@ -179,6 +179,29 @@ class SwiftUISupportTests: XCTestCase { XCTAssertEqual(comparison.predicateOperatorType, .equalTo) XCTAssertEqual(comparison.comparisonPredicateModifier, .direct) } + + func testFetchRequestPropertyWrapperWithNoPredicate() throws { + struct ContentView: View { + @SwiftUI.FetchRequest( + fetchRequest: FetchRequest() + .sorted(by: \.text, .ascending) + ) + var notes: FetchedResults + + var body: some View { + List(notes, id: \.self) { + Text($0.text) + } + } + } + + let view = ContentView().environment(\.managedObjectContext, .default) + let request = try XCTUnwrap( + Mirror(reflecting: view).descendant("content", "_notes", "fetchRequest") as? NSFetchRequest + ) + + XCTAssertEqual(request.predicate, NSPredicate(value: true)) + } } // MARK: -