Support both attributes whose keys can be omitted and attributes whose values can be null.

This commit is contained in:
Mathew Polzin
2018-11-13 21:34:23 -08:00
parent fa44b5be95
commit 805ef4debe
6 changed files with 216 additions and 10 deletions
+14 -4
View File
@@ -60,7 +60,7 @@ The primary goals of this framework are:
### Misc
- [ ] Support transforms on `Attributes` values (e.g. to support different representations of `Date`)
- [ ] 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`).
- [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`).
- [ ] `EntityType` validator (using reflection)
- [ ] Property-based testing (using `SwiftCheck`)
- [ ] Roll my own `Result` or find an alternative that doesn't use `Foundation`.
@@ -82,8 +82,8 @@ enum PersonDescription: IdentifiedEntityDescription {
typealias Identifier = Id<String, PersonDescription>
struct Attributes: JSONAPI.Attributes {
let name: [String]
let favoriteColor: String
let name: Attribute<[String]>
let favoriteColor: Attribute<String>
}
struct Relationships: JSONAPI.Relationships {
@@ -152,7 +152,17 @@ let friendIds: [Person.Identifier] = person ~> \.friends
### `Attributes`
The `Attributes` of an `EntityDescription` can contain any JSON encodable/decodable types. This is the place to store all attributes of an entity.
The `Attributes` of an `EntityDescription` can contain any JSON encodable/decodable types as long as they are wrapped in an `Attribute` `struct`. This is the place to store all attributes of an entity.
To describe an attribute that may be omitted (i.e. the key might not even be in the JSON object), you make the entire `Attribute` optional:
```
let optionalAttribute: Attribute<String>?
```
To describe an attribute that is expected to exist but might have a `null` value, you make the value within the `Attribute` optional:
```
let nullableAttribute: Attribute<String?>
```
An entity that does not have attributes can be described by adding the following to an `EntityDescription`:
```
+47
View File
@@ -0,0 +1,47 @@
//
// Attribute.swift
// JSONAPI
//
// Created by Mathew Polzin on 11/13/18.
//
public struct Attribute<Value: Codable>: Codable {
public let value: Value
}
extension Attribute: Equatable where Value: Equatable {}
// MARK: - Codable
extension Attribute {
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
// A little trickery follows. If the value is nil, the
// container.decode(Value.self) will fail even if Value
// is Optional. However, we can check if decoding nil
// succeeds and then attempt to coerce nil to a Value
// type at which point we can store nil in `value`.
let anyNil: Any? = nil
if container.decodeNil(),
let val = anyNil as? Value {
value = val
return
}
value = try container.decode(Value.self)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
// See note in decode above about the weirdness
// going on here.
let anyNil: Any? = nil
if let _ = anyNil as? Value,
(value as Any?) == nil {
try container.encodeNil()
}
try container.encode(value)
}
}
+9 -2
View File
@@ -139,8 +139,15 @@ public extension Entity {
/// Access the attribute at the given keypath. This just
/// allows you to write `entity[\.propertyName]` instead
/// of `entity.relationships.propertyName`.
subscript<T>(_ path: KeyPath<EntityType.Attributes, T>) -> T {
return attributes[keyPath: path]
subscript<T>(_ path: KeyPath<EntityType.Attributes, Attribute<T>>) -> T {
return attributes[keyPath: path].value
}
/// Access the attribute at the given keypath. This just
/// allows you to write `entity[\.propertyName]` instead
/// of `entity.relationships.propertyName`.
subscript<T>(_ path: KeyPath<EntityType.Attributes, Attribute<T>?>) -> T? {
return attributes[keyPath: path]?.value
}
}
+82 -3
View File
@@ -81,6 +81,69 @@ class EntityTests: XCTestCase {
XCTAssertEqual(e[\.number], 992299)
XCTAssertEqual((e ~> \.other).rawValue, "2DF03B69-4B0A-467F-B52E-B0C9E44FCECF")
}
}
// MARK: Attribute omission and nullification
extension EntityTests {
func test_entityOneOmittedAttribute() {
let entity = try? JSONDecoder().decode(TestEntity6.self, from: entity_one_omitted_attribute)
XCTAssertNotNil(entity)
guard let e = entity else { return }
XCTAssertEqual(e[\.here], "Hello")
XCTAssertNil(e[\.maybeHere])
XCTAssertEqual(e[\.maybeNull], "World")
}
func test_entityOneNullAttribute() {
let entity = try? JSONDecoder().decode(TestEntity6.self, from: entity_one_null_attribute)
XCTAssertNotNil(entity)
guard let e = entity else { return }
XCTAssertEqual(e[\.here], "Hello")
XCTAssertEqual(e[\.maybeHere], "World")
XCTAssertNil(e[\.maybeNull])
}
func test_entityAllAttribute() {
let entity = try? JSONDecoder().decode(TestEntity6.self, from: entity_all_attributes)
XCTAssertNotNil(entity)
guard let e = entity else { return }
XCTAssertEqual(e[\.here], "Hello")
XCTAssertEqual(e[\.maybeHere], "World")
XCTAssertEqual(e[\.maybeNull], "!")
}
func test_entityOneNullAndOneOmittedAttribute() {
let entity = try? JSONDecoder().decode(TestEntity6.self, from: entity_one_null_and_one_missing_attribute)
XCTAssertNotNil(entity)
guard let e = entity else { return }
XCTAssertEqual(e[\.here], "Hello")
XCTAssertNil(e[\.maybeHere])
XCTAssertNil(e[\.maybeNull])
}
func test_entityBrokenNullableOmittedAttribute() {
let entity = try? JSONDecoder().decode(TestEntity6.self, from: entity_broken_missing_nullable_attribute)
XCTAssertNil(entity)
}
}
// MARK: Test Types
extension EntityTests {
enum TestEntityType1: EntityDescription {
static var type: String { return "test_entities"}
@@ -128,8 +191,9 @@ class EntityTests: XCTestCase {
}
struct Attributes: JSONAPI.Attributes {
let word: String
let number: Int
let word: Attribute<String>
let number: Attribute<Int>
let array: Attribute<[Double]>
}
}
@@ -142,11 +206,26 @@ class EntityTests: XCTestCase {
typealias Relationships = NoRelatives
struct Attributes: JSONAPI.Attributes {
let floater: Double
let floater: Attribute<Double>
}
}
typealias TestEntity5 = Entity<TestEntityType5>
enum TestEntityType6: EntityDescription {
static var type: String { return "sixth_test_entities" }
typealias Identifier = Id<String, TestEntityType6>
typealias Relationships = NoRelatives
struct Attributes: JSONAPI.Attributes {
let here: Attribute<String>
let maybeHere: Attribute<String>?
let maybeNull: Attribute<String?>
}
}
typealias TestEntity6 = Entity<TestEntityType6>
}
extension Entity where EntityType == EntityTests.TestEntityType2 {
@@ -43,7 +43,8 @@ let entity_some_relationships_some_attributes = """
"type": "fourth_test_entities",
"attributes": {
"word": "coolio",
"number": 992299
"number": 992299,
"array": [12.3, 4, 0.1]
},
"relationships": {
"other": {
@@ -55,3 +56,60 @@ let entity_some_relationships_some_attributes = """
}
}
""".data(using: .utf8)!
let entity_one_omitted_attribute = """
{
"id": "1",
"type": "sixth_test_entities",
"attributes": {
"here": "Hello",
"maybeNull": "World"
}
}
""".data(using: .utf8)!
let entity_one_null_attribute = """
{
"id": "1",
"type": "sixth_test_entities",
"attributes": {
"here": "Hello",
"maybeHere": "World",
"maybeNull": null
}
}
""".data(using: .utf8)!
let entity_all_attributes = """
{
"id": "1",
"type": "sixth_test_entities",
"attributes": {
"here": "Hello",
"maybeHere": "World",
"maybeNull": "!"
}
}
""".data(using: .utf8)!
let entity_one_null_and_one_missing_attribute = """
{
"id": "1",
"type": "sixth_test_entities",
"attributes": {
"here": "Hello",
"maybeNull": null
}
}
""".data(using: .utf8)!
let entity_broken_missing_nullable_attribute = """
{
"id": "1",
"type": "sixth_test_entities",
"attributes": {
"here": "Hello",
"maybeHere": "World"
}
}
""".data(using: .utf8)!
+5
View File
@@ -11,8 +11,13 @@ extension DocumentTests {
extension EntityTests {
static let __allTests = [
("test_entityAllAttribute", test_entityAllAttribute),
("test_entityBrokenNullableOmittedAttribute", test_entityBrokenNullableOmittedAttribute),
("test_EntityNoRelationshipsNoAttributes", test_EntityNoRelationshipsNoAttributes),
("test_EntityNoRelationshipsSomeAttributes", test_EntityNoRelationshipsSomeAttributes),
("test_entityOneNullAndOneOmittedAttribute", test_entityOneNullAndOneOmittedAttribute),
("test_entityOneNullAttribute", test_entityOneNullAttribute),
("test_entityOneOmittedAttribute", test_entityOneOmittedAttribute),
("test_EntitySomeRelationshipsNoAttributes", test_EntitySomeRelationshipsNoAttributes),
("test_EntitySomeRelationshipsSomeAttributes", test_EntitySomeRelationshipsSomeAttributes),
("test_relationship_access", test_relationship_access),