From ee364216bba03e4825383dce89c4a98b12cbd624 Mon Sep 17 00:00:00 2001 From: Mathew Polzin Date: Thu, 15 Nov 2018 17:16:16 -0800 Subject: [PATCH] allow EntityDescription to decode nullable relationships --- README.md | 11 ++- Sources/JSONAPI/Resource/Entity.swift | 4 +- Sources/JSONAPI/Resource/Relationship.swift | 88 ++++++++++++------- Tests/JSONAPITests/Entity/EntityTests.swift | 53 ++++++++++- .../Entity/stubs/EntityStubs.swift | 39 ++++++++ .../Relationships/RelationshipTests.swift | 25 ------ 6 files changed, 157 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 697d863..857f087 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ The primary goals of this framework are: ### Misc - [x] Support transforms on `Attributes` values (e.g. to support different representations of `Date`) - [x] Support ability to distinguish between `Attributes` fields that are optional (i.e. the key might not be there) and `Attributes` values that are optional (i.e. the key is guaranteed to be there but it might be `null`). -- [ ] Fix `ToOneRelationship` so that it is possible to specify an optional relationship where the value is `null` rather than the key being omitted. +- [x] Fix `ToOneRelationship` so that it is possible to specify an optional relationship where the value is `null` rather than the key being omitted. - [ ] Conform to `CustomStringConvertible` - [x] For `NoIncludes`, do not even loop over the "included" JSON API section if it exists. - [ ] `EntityDescription` validator (using reflection) @@ -98,7 +98,7 @@ enum PersonDescription: IdentifiedEntityDescription { } struct Relationships: JSONAPI.Relationships { - let friends: ToManyRelationship + let friends: ToManyRelationship } } ``` @@ -148,7 +148,12 @@ typealias Person = Entity ### `Relationships` -There are two types of `Relationship`s: `ToOneRelationship` and `ToManyRelationship`. An `EntityDescription`'s `Relationships` type can contain any number of `Relationship`s of either of these types. Do not store anything other than `Relationship`s in the `Relationships` type of an `EntityDescription`. +There are two types of `Relationship`s: `ToOneRelationship` and `ToManyRelationship`. An `EntityDescription`'s `Relationships` type can contain any number of `Relationship`s of either of these types. Do not store anything other than `Relationship`s in the `Relationships` struct of an `EntityDescription`. + +To describe a relationship that may be omitted (i.e. the key is not even present in the JSON object), you make the entire `ToOneRelationship` or `ToManyRelationship` optional. However, this is not recommended because you can also represent optional relationships as nullable which means the key is always present. A `ToManyRelationship` can naturally represent no related objects exist with an empty array, so `ToManyRelationship` does not support nullability at all. A `ToOneRelationship` can be marked as nullable (i.e. the value might be `null` or it might be a resource identifier) like this: +``` +let nullableRelative: ToOneRelationship +``` An entity that does not have relationships can be described by adding the following to an `EntityDescription`: ``` diff --git a/Sources/JSONAPI/Resource/Entity.swift b/Sources/JSONAPI/Resource/Entity.swift index fc27bde..cc69b4c 100644 --- a/Sources/JSONAPI/Resource/Entity.swift +++ b/Sources/JSONAPI/Resource/Entity.swift @@ -163,14 +163,14 @@ public extension Entity { /// Access to an Id of a `ToOneRelationship`. /// This allows you to write `entity ~> \.other` instead /// of `entity.relationships.other.id`. - public static func ~>(entity: Entity, path: KeyPath>) -> OtherEntity.Description.Identifier { + public static func ~>(entity: Entity, path: KeyPath>) -> OtherEntity.Identifier { return entity.relationships[keyPath: path].id } /// Access to all Ids of a `ToManyRelationship`. /// This allows you to write `entity ~> \.others` instead /// of `entity.relationships.others.ids`. - public static func ~>(entity: Entity, path: KeyPath>) -> [OtherEntity.Description.Identifier] { + public static func ~>(entity: Entity, path: KeyPath>) -> [OtherEntity.Identifier] { return entity.relationships[keyPath: path].ids } } diff --git a/Sources/JSONAPI/Resource/Relationship.swift b/Sources/JSONAPI/Resource/Relationship.swift index 6b74de3..292f751 100644 --- a/Sources/JSONAPI/Resource/Relationship.swift +++ b/Sources/JSONAPI/Resource/Relationship.swift @@ -10,48 +10,54 @@ /// You should use the `ToOneRelationship` and `ToManyRelationship` /// concrete types. /// See https://jsonapi.org/format/#document-resource-object-linkage -public protocol Relationship: Equatable, Encodable, CustomStringConvertible { - associatedtype EntityType: JSONAPI.EntityDescription where EntityType.Identifier: IdType - var ids: [EntityType.Identifier] { get } -} +//public protocol Relationship: Equatable, Encodable, CustomStringConvertible { +// associatedtype EntityType: JSONAPI.EntityDescription where EntityType.Identifier: IdType +// var ids: [EntityType.Identifier] { get } +//} /// An Entity relationship that can be encoded to or decoded from /// a JSON API "Resource Linkage." /// See https://jsonapi.org/format/#document-resource-object-linkage /// A convenient typealias might make your code much more legible: `One` -public struct ToOneRelationship: Equatable, Relationship, Decodable { - public typealias EntityType = Relatable.Description +public struct ToOneRelationship: Equatable, Codable { - public let id: EntityType.Identifier - - public init(entity: Entity) { - id = entity.id - } + public let id: Relatable.Identifier - public var ids: [EntityType.Identifier] { + public var ids: [Relatable.Identifier] { return [id] } } +extension ToOneRelationship where Relatable.Description.Identifier == Relatable.Identifier { + public init(entity: Entity) { + id = entity.id + } +} + /// An Entity relationship that can be encoded to or decoded from /// a JSON API "Resource Linkage." /// See https://jsonapi.org/format/#document-resource-object-linkage /// A convenient typealias might make your code much more legible: `Many` -public struct ToManyRelationship: Equatable, Relationship, Decodable { - public typealias EntityType = Relatable.Description +public struct ToManyRelationship: Equatable, Codable { - public let ids: [EntityType.Identifier] - - public init(entities: [Entity]) { - ids = entities.map { $0.id } + public let ids: [Relatable.Identifier] + + public init(relationships: [ToOneRelationship]) where T.Identifier == Relatable.Identifier { + ids = relationships.map { $0.id } } - - public init(relationships: [T]) where T.EntityType == EntityType { - ids = relationships.flatMap { $0.ids } + + private init() { + ids = [] } public static var none: ToManyRelationship { - return .init(entities: []) + return .init() + } +} + +extension ToManyRelationship where Relatable.Description.Identifier == Relatable.Identifier { + public init(entities: [Entity]) { + ids = entities.map { $0.id } } } @@ -59,6 +65,7 @@ public struct ToManyRelationship: Equatable, Relat /// Optional types. public protocol OptionalRelatable { associatedtype Description: EntityDescription where Description.Identifier: IdType + associatedtype Identifier: Equatable & Codable } /// The Relatable protocol describes anything that @@ -71,6 +78,7 @@ extension Entity: Relatable, OptionalRelatable where EntityType.Identifier: IdTy extension Optional: OptionalRelatable where Wrapped: Relatable { public typealias Description = Wrapped.Description + public typealias Identifier = Wrapped.Description.Identifier? } // MARK: Codable @@ -89,23 +97,41 @@ public enum JSONAPIEncodingError: Swift.Error { extension ToOneRelationship { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: ResourceLinkageCodingKeys.self) + + // A little trickery follows. If the id is nil, the + // container.decode(Identifier.self) will fail even if Identifier + // is Optional. However, we can check if decoding nil + // succeeds and then attempt to coerce nil to a Identifier + // type at which point we can store nil in `id`. + let anyNil: Any? = nil + if try container.decodeNil(forKey: .data), + let val = anyNil as? Relatable.Identifier { + id = val + return + } + let identifier = try container.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self, forKey: .data) let type = try identifier.decode(String.self, forKey: .entityType) - guard type == EntityType.type else { - throw JSONAPIEncodingError.typeMismatch(expected: EntityType.type, found: type) + guard type == Relatable.Description.type else { + throw JSONAPIEncodingError.typeMismatch(expected: Relatable.Description.type, found: type) } - id = try identifier.decode(EntityType.Identifier.self, forKey: .id) + id = try identifier.decode(Relatable.Identifier.self, forKey: .id) } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: ResourceLinkageCodingKeys.self) + + if (id as Any?) == nil { + try container.encodeNil(forKey: .data) + } + var identifier = container.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self, forKey: .data) try identifier.encode(id, forKey: .id) - try identifier.encode(EntityType.type, forKey: .entityType) + try identifier.encode(Relatable.Description.type, forKey: .entityType) } } @@ -115,17 +141,17 @@ extension ToManyRelationship { var identifiers = try container.nestedUnkeyedContainer(forKey: .data) - var newIds = [EntityType.Identifier]() + var newIds = [Relatable.Identifier]() while !identifiers.isAtEnd { let identifier = try identifiers.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self) let type = try identifier.decode(String.self, forKey: .entityType) - guard type == EntityType.type else { - throw JSONAPIEncodingError.typeMismatch(expected: EntityType.type, found: type) + guard type == Relatable.Description.type else { + throw JSONAPIEncodingError.typeMismatch(expected: Relatable.Description.type, found: type) } - newIds.append(try identifier.decode(EntityType.Identifier.self, forKey: .id)) + newIds.append(try identifier.decode(Relatable.Identifier.self, forKey: .id)) } ids = newIds } @@ -138,7 +164,7 @@ extension ToManyRelationship { var identifier = identifiers.nestedContainer(keyedBy: ResourceIdentifierCodingKeys.self) try identifier.encode(id, forKey: .id) - try identifier.encode(EntityType.type, forKey: .entityType) + try identifier.encode(Relatable.Description.type, forKey: .entityType) } } } diff --git a/Tests/JSONAPITests/Entity/EntityTests.swift b/Tests/JSONAPITests/Entity/EntityTests.swift index 9f765dd..05463ca 100644 --- a/Tests/JSONAPITests/Entity/EntityTests.swift +++ b/Tests/JSONAPITests/Entity/EntityTests.swift @@ -29,7 +29,7 @@ class EntityTests: XCTestCase { let entity2 = TestEntity1() let entity4 = TestEntity1() let entity3 = TestEntity3(others: .init(relationships: [entity1.pointer, entity2.pointer, entity4.pointer])) - + XCTAssertEqual(entity3 ~> \.others, [entity1.id, entity2.id, entity4.id]) } @@ -182,6 +182,31 @@ extension EntityTests { } } +// MARK: Relationship omission and nullification +extension EntityTests { + func test_nullableRelationshipNotNull() { + let entity = try? JSONDecoder().decode(TestEntity9.self, from: entity_omitted_relationship) + + XCTAssertNotNil(entity) + + guard let e = entity else { return } + + XCTAssertEqual((e ~> \.nullableOne)?.rawValue, "3323") + XCTAssertEqual((e ~> \.one).rawValue, "4459") + } + + func test_nullableRelationshipIsNull() { + let entity = try? JSONDecoder().decode(TestEntity9.self, from: entity_nulled_relationship) + + XCTAssertNotNil(entity) + + guard let e = entity else { return } + + XCTAssertNil(e ~> \.nullableOne) + XCTAssertEqual((e ~> \.one).rawValue, "4452") + } +} + // MARK: Test Types extension EntityTests { @@ -299,7 +324,31 @@ extension EntityTests { } typealias TestEntity8 = Entity - + + enum TestEntityType9: EntityDescription { + public static var type: String { return "ninth_test_entities" } + + typealias Identifier = Id + + typealias Attributes = NoAttributes + + public struct Relationships: JSONAPI.Relationships { + let one: ToOneRelationship + + let nullableOne: ToOneRelationship + + // a nullable many is not allowed. it should + // just be an empty array. + + // omitted relationships are not allowed either, + // so ToOneRelationship? (with the + // question on the relationship, not the entity) + // is not a thing. + } + } + + typealias TestEntity9 = Entity + enum IntToString: Transformer { public static func transform(_ from: Int) -> String { return String(from) diff --git a/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift b/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift index 337fd9f..65c57e8 100644 --- a/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift +++ b/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift @@ -150,3 +150,42 @@ let entity_int_to_string_attribute = """ } } """.data(using: .utf8)! + +let entity_omitted_relationship = """ +{ + "id": "1", + "type": "ninth_test_entities", + "relationships": { + "nullableOne": { + "data": { + "id": "3323", + "type": "test_entities" + } + }, + "one": { + "data": { + "id": "4459", + "type": "test_entities" + } + } + } +} +""".data(using: .utf8)! + +let entity_nulled_relationship = """ +{ + "id": "1", + "type": "ninth_test_entities", + "relationships": { + "nullableOne": { + "data": null + }, + "one": { + "data": { + "id": "4452", + "type": "test_entities" + } + } + } +} +""".data(using: .utf8)! diff --git a/Tests/JSONAPITests/Relationships/RelationshipTests.swift b/Tests/JSONAPITests/Relationships/RelationshipTests.swift index 642d39d..e72d830 100644 --- a/Tests/JSONAPITests/Relationships/RelationshipTests.swift +++ b/Tests/JSONAPITests/Relationships/RelationshipTests.swift @@ -61,28 +61,3 @@ class RelationshipTests: XCTestCase { typealias TestEntity1 = Entity } - -// MARK: omission and nullification -extension RelationshipTests { - func test_omittedRelationship() { - // TODO: fill out test - } - - enum TestEntityType2: EntityDescription { - public static var type: String { return "test_entity2" } - - typealias Identifier = Id - - typealias Attributes = NoAttributes - - public struct Relationships: JSONAPI.Relationships { - let maybeOne: ToOneRelationship? - let maybeMore: ToManyRelationship? - - let nullableOne: ToOneRelationship - - // a nullable many is not allowed. it should - // just be an empty array. - } - } -}