allow EntityDescription to decode nullable relationships

This commit is contained in:
Mathew Polzin
2018-11-15 17:16:16 -08:00
parent bf44c2fcdd
commit ee364216bb
6 changed files with 157 additions and 63 deletions
+8 -3
View File
@@ -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<PersonDescription>
let friends: ToManyRelationship<Person>
}
}
```
@@ -148,7 +148,12 @@ typealias Person = Entity<PersonDescription>
### `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<Person?>
```
An entity that does not have relationships can be described by adding the following to an `EntityDescription`:
```
+2 -2
View File
@@ -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 ~><OtherEntity: Relatable>(entity: Entity<EntityType>, path: KeyPath<EntityType.Relationships, ToOneRelationship<OtherEntity>>) -> OtherEntity.Description.Identifier {
public static func ~><OtherEntity: OptionalRelatable>(entity: Entity, path: KeyPath<EntityType.Relationships, ToOneRelationship<OtherEntity>>) -> 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 ~><OtherEntity: Relatable>(entity: Entity<EntityType>, path: KeyPath<EntityType.Relationships, ToManyRelationship<OtherEntity>>) -> [OtherEntity.Description.Identifier] {
public static func ~><OtherEntity: Relatable>(entity: Entity, path: KeyPath<EntityType.Relationships, ToManyRelationship<OtherEntity>>) -> [OtherEntity.Identifier] {
return entity.relationships[keyPath: path].ids
}
}
+57 -31
View File
@@ -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<EntityDescription>`
public struct ToOneRelationship<Relatable: JSONAPI.OptionalRelatable>: Equatable, Relationship, Decodable {
public typealias EntityType = Relatable.Description
public struct ToOneRelationship<Relatable: JSONAPI.OptionalRelatable>: Equatable, Codable {
public let id: EntityType.Identifier
public init(entity: Entity<EntityType>) {
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<Relatable.Description>) {
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<EntityDescription>`
public struct ToManyRelationship<Relatable: JSONAPI.Relatable>: Equatable, Relationship, Decodable {
public typealias EntityType = Relatable.Description
public struct ToManyRelationship<Relatable: JSONAPI.Relatable>: Equatable, Codable {
public let ids: [EntityType.Identifier]
public init(entities: [Entity<EntityType>]) {
ids = entities.map { $0.id }
public let ids: [Relatable.Identifier]
public init<T: JSONAPI.Relatable>(relationships: [ToOneRelationship<T>]) where T.Identifier == Relatable.Identifier {
ids = relationships.map { $0.id }
}
public init<T: Relationship>(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<Relatable.Description>]) {
ids = entities.map { $0.id }
}
}
@@ -59,6 +65,7 @@ public struct ToManyRelationship<Relatable: JSONAPI.Relatable>: Equatable, Relat
/// Optional<T: Relatable> 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)
}
}
}
+51 -2
View File
@@ -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<TestEntityType8>
enum TestEntityType9: EntityDescription {
public static var type: String { return "ninth_test_entities" }
typealias Identifier = Id<String, TestEntityType9>
typealias Attributes = NoAttributes
public struct Relationships: JSONAPI.Relationships {
let one: ToOneRelationship<TestEntity1>
let nullableOne: ToOneRelationship<TestEntity1?>
// a nullable many is not allowed. it should
// just be an empty array.
// omitted relationships are not allowed either,
// so ToOneRelationship<TestEntity1>? (with the
// question on the relationship, not the entity)
// is not a thing.
}
}
typealias TestEntity9 = Entity<TestEntityType9>
enum IntToString: Transformer {
public static func transform(_ from: Int) -> String {
return String(from)
@@ -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)!
@@ -61,28 +61,3 @@ class RelationshipTests: XCTestCase {
typealias TestEntity1 = Entity<TestEntityType1>
}
// 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<String, TestEntityType2>
typealias Attributes = NoAttributes
public struct Relationships: JSONAPI.Relationships {
let maybeOne: ToOneRelationship<TestEntity1>?
let maybeMore: ToManyRelationship<TestEntity1>?
let nullableOne: ToOneRelationship<TestEntity1?>
// a nullable many is not allowed. it should
// just be an empty array.
}
}
}