Skip to content

Commit

Permalink
✨ Add support for testing objects for equality (#20)
Browse files Browse the repository at this point in the history
  • Loading branch information
ftchirou authored May 13, 2023
1 parent 8dcaf4d commit 6729efa
Show file tree
Hide file tree
Showing 7 changed files with 162 additions and 10 deletions.
7 changes: 7 additions & 0 deletions PredicateKit/CoreData/NSFetchRequestBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,13 @@ extension Query: NSExpressionConvertible {
}
}

extension ObjectIdentifier: NSExpressionConvertible where Object: NSExpressionConvertible {
func toNSExpression(options: NSExpressionConversionOptions) -> NSExpression {
let root = self.root.toNSExpression(options: options)
return NSExpression(format: "\(root).id")
}
}

// MARK: - Primitive

private extension Primitive {
Expand Down
12 changes: 12 additions & 0 deletions PredicateKit/Predicate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,13 @@ public struct ArrayElementKeyPath<Array, Value>: Expression where Array: Express
let elementKeyPath: AnyKeyPath
}

public struct ObjectIdentifier<Object: Expression, Identifier: Primitive>: Expression {
public typealias Root = Object
public typealias Value = Identifier

let root: Object
}

enum ComparisonOperator {
case lessThan
case lessThanOrEqual
Expand Down Expand Up @@ -371,6 +378,11 @@ public func == <E: Expression, T: Equatable & Primitive> (lhs: E, rhs: T) -> Pre
.comparison(.init(lhs, .equal, rhs))
}

@available(iOS 13.0, *)
public func == <E: Expression, T: Identifiable> (lhs: E, rhs: T) -> Predicate<E.Root> where E.Value == T, T.ID: Primitive {
.comparison(.init(ObjectIdentifier<E, T.ID>(root: lhs), .equal, rhs.id))
}

@_disfavoredOverload
public func == <E: Expression> (lhs: E, rhs: Nil) -> Predicate<E.Root> where E.Value: OptionalType {
.comparison(.init(lhs, .equal, rhs))
Expand Down
45 changes: 45 additions & 0 deletions PredicateKitTests/CoreDataTests/NSFetchRequestBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,27 @@ final class NSFetchRequestBuilderTests: XCTestCase {
XCTAssertEqual(comparison.comparisonPredicateModifier, .direct)
}

@available(iOS 13.0, *)
func testEqualityWithIdentifiable() throws {
guard let identifiable = makeIdentifiable() else {
XCTFail("could not initialize IdentifiableData")
return
}

identifiable.id = "42"

let request = makeRequest(\Data.identifiable == identifiable)
let builder = makeRequestBuilder()

let result: NSFetchRequest<Data> = builder.makeRequest(from: request)

let comparison = try XCTUnwrap(result.predicate as? NSComparisonPredicate)
XCTAssertEqual(comparison.leftExpression, NSExpression(forKeyPath: "identifiable.id"))
XCTAssertEqual(comparison.rightExpression, NSExpression(forConstantValue: "42"))
XCTAssertEqual(comparison.predicateOperatorType, .equalTo)
XCTAssertEqual(comparison.comparisonPredicateModifier, .direct)
}

func testArrayElementEqualPredicate() throws {
let request = makeRequest((\Data.relationships).last(\.count) == 42)
let builder = makeRequestBuilder()
Expand Down Expand Up @@ -1078,6 +1099,25 @@ final class NSFetchRequestBuilderTests: XCTestCase {
XCTAssertEqual(comparison.predicateOperatorType, .equalTo)
XCTAssertEqual(comparison.comparisonPredicateModifier, .direct)
}

private func makeIdentifiable() -> IdentifiableData? {
guard
let model = NSManagedObjectModel.mergedModel(from: [Bundle(for: NSFetchRequestBuilderTests.self)])
else {
return nil
}

let container = makePersistentContainer(with: model)

guard let identifiable = NSEntityDescription.insertNewObject(
forEntityName: "IdentifiableData",
into: container.viewContext
) as? IdentifiableData else {
return nil
}

return identifiable
}
}

// MARK: -
Expand All @@ -1092,6 +1132,7 @@ private class Data: NSManagedObject {
@NSManaged var relationships: [Relationship]
@NSManaged var optionalRelationship: Relationship?
@NSManaged var optionalRelationships: [Relationship]?
@NSManaged var identifiable: IdentifiableData
}

private class Relationship: NSManagedObject {
Expand All @@ -1111,6 +1152,10 @@ private class DataStore: NSAtomicStore {
}
}

class IdentifiableData: NSManagedObject, Identifiable {
@NSManaged var id: String
}

private func makeRequest<T: NSManagedObject>(_ predicate: Predicate<T>) -> FetchRequest<T> {
.init(context: NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType), predicate: predicate)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,27 @@ final class NSManagedObjectContextExtensionsTests: XCTestCase {
XCTAssertNil(texts.first?["creationDate"])
}

@available(iOS 13.0, *)
func testFetchWithObjectComparison() throws {
let attachment1 = try container.viewContext.insertAttachment("1")
let attachment2 = try container.viewContext.insertAttachment("2")

try container.viewContext.insertNotes(
(text: "Hello, World!", creationDate: Date(), numberOfViews: 42, tags: ["greeting"], attachment: attachment1 ),
(text: "Goodbye!", creationDate: Date(), numberOfViews: 3, tags: ["greeting"], attachment: attachment2 ),
(text: "See ya!", creationDate: Date(), numberOfViews: 3, tags: ["greeting"], attachment: attachment2 )
)

let notes: [Note] = try container.viewContext
.fetch(where: \Note.attachment == attachment1)
.result()

XCTAssertEqual(notes.count, 1)
XCTAssertEqual(notes.first?.text, "Hello, World!")
XCTAssertEqual(notes.first?.tags, ["greeting"])
XCTAssertEqual(notes.first?.numberOfViews, 42)
}

func testFetchAll() throws {
try container.viewContext.insertNotes(
(text: "Hello, World!", creationDate: Date(), numberOfViews: 42, tags: ["greeting"]),
Expand Down Expand Up @@ -675,6 +696,7 @@ class Note: NSManagedObject {
@NSManaged var updateDate: Date?
@NSManaged var numberOfViews: Int
@NSManaged var tags: [String]
@NSManaged var attachment: Attachment
}

class Account: NSManagedObject {
Expand All @@ -701,9 +723,13 @@ class Profile: NSManagedObject {
@NSManaged var creationDate: Date
}

class Attachment: NSManagedObject, Identifiable {
@NSManaged var id: String
}

// MARK: -

private extension XCTestCase {
extension XCTestCase {
func makePersistentContainer(with model: NSManagedObjectModel) -> NSPersistentContainer {
let expectation = self.expectation(description: "container")
let description = NSPersistentStoreDescription()
Expand Down Expand Up @@ -752,6 +778,24 @@ private extension NSManagedObjectContext {
try save()
}

func insertNotes(
_ notes: (text: String, creationDate: Date, numberOfViews: Int, tags: [String], attachment: Attachment?)...
) 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

if let attachment = description.attachment {
note.attachment = attachment
}
}

try save()
}

func insertAccounts(purchases: [[Double]]) throws {
for description in purchases {
let account = NSEntityDescription.insertNewObject(forEntityName: "Account", into: self) as! Account
Expand Down Expand Up @@ -793,6 +837,15 @@ private extension NSManagedObjectContext {
try save()
}

func insertAttachment(_ id: String) throws -> Attachment {
let attachment = NSEntityDescription.insertNewObject(forEntityName: "Attachment", into: self) as! Attachment
attachment.id = id

try save()

return attachment
}

func deleteAll<T: NSManagedObject>(_ type: T.Type) {
let fetchRequest = NSFetchRequest<T>(entityName: String(describing: T.self))
fetchRequest.includesPropertyValues = false
Expand Down
32 changes: 32 additions & 0 deletions PredicateKitTests/OperatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,37 @@ final class OperatorTests: XCTestCase {
XCTAssertEqual(value, 42)
}

@available(iOS 13.0, *)
func testKeyPathEqualIdentifiable() throws {
struct Data {
let identifiable: IdentifiableData
}

struct IdentifiableData: Identifiable, Equatable {
let id: String
}

let predicate = \Data.identifiable == IdentifiableData(id: "1")

guard case let .comparison(comparison) = predicate else {
XCTFail("identifiable.id == 1 should result in a comparison")
return
}

guard
let expression = comparison.expression.as(ObjectIdentifier<KeyPath<Data, IdentifiableData>, String>.self)
else {
XCTFail("the left side of the comparison should be a key path expression")
return
}

let value = try XCTUnwrap(comparison.value as? IdentifiableData.ID)

XCTAssertEqual(expression.root, \Data.identifiable)
XCTAssertEqual(comparison.operator, .equal)
XCTAssertEqual(value, "1")
}

func testOptionalKeyPathEqualToNil() throws {
let predicate: Predicate<Data> = \Data.optionalRelationship == nil

Expand Down Expand Up @@ -2197,6 +2228,7 @@ private struct Data {
let creationDate: Date
let optionalRelationship: Relationship?
let optionalRelationships: [Relationship]?
let identifiable: IdentifiableData?
}

private struct Relationship {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20C69" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22C65" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName=".Account" syncable="YES">
<attribute name="purchases" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformerName"/>
</entity>
<entity name="Attachment" representedClassName=".Attachment" syncable="YES">
<attribute name="id" optional="YES" attributeType="String"/>
</entity>
<entity name="BillingInfo" representedClassName=".BillingInfo" syncable="YES">
<attribute name="accountType" optional="YES" attributeType="String"/>
<attribute name="purchases" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformerName"/>
</entity>
<entity name="IdentifiableData" representedClassName=".IdentifiableData" syncable="YES">
<attribute name="id" optional="YES" attributeType="String"/>
</entity>
<entity name="Note" representedClassName=".Note" syncable="YES">
<attribute name="creationDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="numberOfViews" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="tags" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformerName"/>
<attribute name="text" optional="YES" attributeType="String"/>
<attribute name="updateDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="attachment" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Attachment"/>
</entity>
<entity name="Profile" representedClassName=".Profile" syncable="YES">
<attribute name="creationDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
Expand All @@ -26,12 +33,4 @@
<attribute name="name" optional="YES" attributeType="String"/>
<relationship name="profiles" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Profile"/>
</entity>
<elements>
<element name="Account" positionX="0" positionY="0" width="128" height="44"/>
<element name="BillingInfo" positionX="0" positionY="0" width="128" height="59"/>
<element name="Note" positionX="0" positionY="0" width="128" height="104"/>
<element name="Profile" positionX="0" positionY="0" width="128" height="59"/>
<element name="User" positionX="0" positionY="0" width="128" height="59"/>
<element name="UserAccount" positionX="0" positionY="0" width="128" height="59"/>
</elements>
</model>
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ class Note: NSManagedObject {
@NSManaged var creationDate: Date
@NSManaged var numberOfViews: Int
@NSManaged var tags: [String]
@NSManaged var attachment: Attachment
}

// Matches all notes where the text is equal to "Hello, World!".
Expand All @@ -287,6 +288,9 @@ let predicate = \Note.creationDate < Date()

// Matches all notes where the number of views is at least 120.
let predicate = \Note.numberOfViews >= 120

// Matches all notes having the specified attachment. `Attachment` must conform to `Identifiable`.
let predicate = \Note.attachment == attachment
```

#### String comparisons
Expand Down

0 comments on commit 6729efa

Please sign in to comment.