diff --git a/Sources/JSONAPI/Resource/Entity.swift b/Sources/JSONAPI/Resource/Entity.swift index 595c0cb..c03ec6b 100644 --- a/Sources/JSONAPI/Resource/Entity.swift +++ b/Sources/JSONAPI/Resource/Entity.swift @@ -420,12 +420,37 @@ public extension EntityProxy { return entity.relationships[keyPath: path].id } + /// Access to an Id of an optional `ToOneRelationship`. + /// This allows you to write `entity ~> \.other` instead + /// of `entity.relationships.other?.id`. + public static func ~>(entity: Self, path: KeyPath?>) -> OtherEntity.WrappedIdentifier where OtherEntity.WrappedIdentifier == OtherEntity.Identifier? { + // Implementation Note: This signature applies to `ToOneRelationship?` + // whereas the one below applies to `ToOneRelationship?` + return entity.relationships[keyPath: path]?.id + } + + /// Access to an Id of an optional `ToOneRelationship`. + /// This allows you to write `entity ~> \.other` instead + /// of `entity.relationships.other?.id`. + public static func ~>(entity: Self, path: KeyPath?>) -> OtherEntity.Identifier? where OtherEntity.WrappedIdentifier == OtherEntity.Identifier { + // Implementation Note: This signature applies to `ToOneRelationship?` + // whereas the one above applies to `ToOneRelationship?` + 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: Self, path: KeyPath>) -> [OtherEntity.Identifier] { return entity.relationships[keyPath: path].ids } + + /// Access to all Ids of an optional `ToManyRelationship`. + /// This allows you to write `entity ~> \.others` instead + /// of `entity.relationships.others?.ids`. + public static func ~>(entity: Self, path: KeyPath?>) -> [OtherEntity.Identifier]? { + return entity.relationships[keyPath: path]?.ids + } } infix operator ~> diff --git a/Sources/JSONAPITestLib/EntityCheck.swift b/Sources/JSONAPITestLib/EntityCheck.swift index 4260bc3..e27890f 100644 --- a/Sources/JSONAPITestLib/EntityCheck.swift +++ b/Sources/JSONAPITestLib/EntityCheck.swift @@ -76,7 +76,8 @@ public extension Entity { } for relationship in relationshipsMirror.children { - if relationship.value as? _RelationshipType == nil { + if relationship.value as? _RelationshipType == nil, + relationship.value as? OptionalRelationshipType == nil { problems.append(.nonRelationship(named: relationship.label ?? "unnamed")) } } diff --git a/Tests/JSONAPITests/Entity/EntityTests.swift b/Tests/JSONAPITests/Entity/EntityTests.swift index 0ae1680..dd9dc5d 100644 --- a/Tests/JSONAPITests/Entity/EntityTests.swift +++ b/Tests/JSONAPITests/Entity/EntityTests.swift @@ -24,6 +24,10 @@ class EntityTests: XCTestCase { XCTAssertEqual(entity2 ~> \.other, entity1.id) } + + func test_optional_relationship_operator_access() { + + } func test_toMany_relationship_operator_access() { let entity1 = TestEntity1() @@ -33,6 +37,10 @@ class EntityTests: XCTestCase { XCTAssertEqual(entity3 ~> \.others, [entity1.id, entity2.id, entity4.id]) } + + func test_optionalToMany_relationship_opeartor_access() { + + } func test_relationshipIds() { let entity1 = TestEntity1() @@ -63,9 +71,12 @@ class EntityTests: XCTestCase { let _ = TestEntity6(id: .init(rawValue: "6"), attributes: .init(here: .init(value: "here"), maybeHere: nil, maybeNull: .init(value: nil))) let _ = TestEntity7(id: .init(rawValue: "7"), attributes: .init(here: .init(value: "hello"), maybeHereMaybeNull: .init(value: "world"))) XCTAssertNoThrow(try TestEntity8(id: .init(rawValue: "8"), attributes: .init(string: .init(value: "hello"), int: .init(value: 10), stringFromInt: .init(rawValue: 20), plus: .init(rawValue: 30), doubleFromInt: .init(rawValue: 32), omitted: nil, nullToString: .init(rawValue: nil)))) - let _ = TestEntity9(id: .init(rawValue: "9"), relationships: .init(one: entity1.pointer, nullableOne: nil)) - let _ = TestEntity9(id: .init(rawValue: "9"), relationships: .init(one: entity1.pointer, nullableOne: .init(entity: nil))) - let _ = TestEntity9(id: .init(rawValue: "9"), relationships: .init(one: entity1.pointer, nullableOne: .init(entity: entity1, meta: .none, links: .none))) + let _ = TestEntity9(id: .init(rawValue: "9"), relationships: .init(one: entity1.pointer, nullableOne: nil, optionalOne: nil, optionalNullableOne: nil, optionalMany: nil)) + let _ = TestEntity9(id: .init(rawValue: "9"), relationships: .init(one: entity1.pointer, nullableOne: .init(entity: nil), optionalOne: nil, optionalNullableOne: nil, optionalMany: nil)) + let _ = TestEntity9(id: .init(rawValue: "9"), relationships: .init(one: entity1.pointer, nullableOne: .init(entity: entity1, meta: .none, links: .none), optionalOne: nil, optionalNullableOne: nil, optionalMany: nil)) + let _ = TestEntity9(id: .init(rawValue: "9"), relationships: .init(one: entity1.pointer, nullableOne: nil, optionalOne: entity1.pointer, optionalNullableOne: nil, optionalMany: nil)) + let _ = TestEntity9(id: .init(rawValue: "9"), relationships: .init(one: entity1.pointer, nullableOne: nil, optionalOne: nil, optionalNullableOne: .init(entity: entity1, meta: .none, links: .none), optionalMany: nil)) + let _ = TestEntity9(id: .init(rawValue: "9"), relationships: .init(one: entity1.pointer, nullableOne: nil, optionalOne: nil, optionalNullableOne: .init(entity: entity1, meta: .none, links: .none), optionalMany: .init(entities: [], meta: .none, links: .none))) let e10id1 = TestEntity10.Identifier(rawValue: "hello") let e10id2 = TestEntity10.Id(rawValue: "world") let e10id3: TestEntity10.Id = "!" @@ -275,18 +286,50 @@ extension EntityTests { // MARK: Relationship omission and nullification extension EntityTests { - func test_nullableRelationshipNotNull() { + func test_nullableRelationshipNotNullOrOmitted() { let entity = decoded(type: TestEntity9.self, - data: entity_omitted_relationship) + data: entity_optional_not_omitted_relationship) XCTAssertEqual((entity ~> \.nullableOne)?.rawValue, "3323") XCTAssertEqual((entity ~> \.one).rawValue, "4459") + XCTAssertNil(entity ~> \.optionalOne) + XCTAssertEqual((entity ~> \.optionalNullableOne)?.rawValue, "1229") + XCTAssertNoThrow(try TestEntity9.check(entity)) + } + + func test_nullableRelationshipNotNullOrOmitted_encode() { + test_DecodeEncodeEquality(type: TestEntity9.self, + data: entity_optional_not_omitted_relationship) + } + + func test_nullableRelationshipNotNull() { + let entity = decoded(type: TestEntity9.self, + data: entity_omitted_relationship) + + XCTAssertEqual((entity ~> \.nullableOne)?.rawValue, "3323") + XCTAssertEqual((entity ~> \.one).rawValue, "4459") + XCTAssertNil(entity ~> \.optionalNullableOne) XCTAssertNoThrow(try TestEntity9.check(entity)) } func test_nullableRelationshipNotNull_encode() { test_DecodeEncodeEquality(type: TestEntity9.self, - data: entity_omitted_relationship) + data: entity_omitted_relationship) + } + + func test_optionalNullableRelationshipNulled() { + let entity = decoded(type: TestEntity9.self, + data: entity_optional_nullable_nulled_relationship) + + XCTAssertEqual((entity ~> \.nullableOne)?.rawValue, "3323") + XCTAssertEqual((entity ~> \.one).rawValue, "4459") + XCTAssertNil(entity ~> \.optionalNullableOne) + XCTAssertNoThrow(try TestEntity9.check(entity)) + } + + func test_optionalNullableRelationshipNulled_encode() { + test_DecodeEncodeEquality(type: TestEntity9.self, + data: entity_optional_nullable_nulled_relationship) } func test_nullableRelationshipIsNull() { @@ -295,6 +338,7 @@ extension EntityTests { XCTAssertNil(entity ~> \.nullableOne) XCTAssertEqual((entity ~> \.one).rawValue, "4452") + XCTAssertNil(entity ~> \.optionalNullableOne) XCTAssertNoThrow(try TestEntity9.check(entity)) } @@ -302,6 +346,22 @@ extension EntityTests { test_DecodeEncodeEquality(type: TestEntity9.self, data: entity_nulled_relationship) } + + func test_optionalToManyIsNotOmitted() { + let entity = decoded(type: TestEntity9.self, + data: entity_optional_to_many_relationship_not_omitted) + + XCTAssertEqual((entity ~> \.nullableOne)?.rawValue, "3323") + XCTAssertEqual((entity ~> \.one).rawValue, "4459") + XCTAssertEqual((entity ~> \.optionalMany)?[0].rawValue, "332223") + XCTAssertNil(entity ~> \.optionalNullableOne) + XCTAssertNoThrow(try TestEntity9.check(entity)) + } + + func test_optionalToManyIsNotOmitted_encode() { + test_DecodeEncodeEquality(type: TestEntity9.self, + data: entity_optional_to_many_relationship_not_omitted) + } } // MARK: Relationships of same type as root entity @@ -581,13 +641,14 @@ extension EntityTests { let nullableOne: ToOneRelationship + let optionalOne: ToOneRelationship? + + let optionalNullableOne: ToOneRelationship? + + let optionalMany: ToManyRelationship? + // 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. } } diff --git a/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift b/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift index f2e14c2..1384c11 100644 --- a/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift +++ b/Tests/JSONAPITests/Entity/stubs/EntityStubs.swift @@ -228,6 +228,57 @@ let entity_int_to_string_attribute = """ } """.data(using: .utf8)! +let entity_optional_not_omitted_relationship = """ +{ + "id": "1", + "type": "ninth_test_entities", + "relationships": { + "nullableOne": { + "data": { + "id": "3323", + "type": "test_entities" + } + }, + "one": { + "data": { + "id": "4459", + "type": "test_entities" + } + }, + "optionalNullableOne": { + "data": { + "id": "1229", + "type": "test_entities" + } + } + } +} +""".data(using: .utf8)! + +let entity_optional_nullable_nulled_relationship = """ +{ + "id": "1", + "type": "ninth_test_entities", + "relationships": { + "nullableOne": { + "data": { + "id": "3323", + "type": "test_entities" + } + }, + "one": { + "data": { + "id": "4459", + "type": "test_entities" + } + }, + "optionalNullableOne": { + "data": null + } + } +} +""".data(using: .utf8)! + let entity_omitted_relationship = """ { "id": "1", @@ -249,6 +300,35 @@ let entity_omitted_relationship = """ } """.data(using: .utf8)! +let entity_optional_to_many_relationship_not_omitted = """ +{ + "id": "1", + "type": "ninth_test_entities", + "relationships": { + "nullableOne": { + "data": { + "id": "3323", + "type": "test_entities" + } + }, + "one": { + "data": { + "id": "4459", + "type": "test_entities" + } + }, + "optionalMany": { + "data": [ + { + "id": "332223", + "type": "test_entities" + } + ] + } + } +} +""".data(using: .utf8)! + let entity_nulled_relationship = """ { "id": "1",