diff --git a/Package.swift b/Package.swift index 8ea0b81..ad5c01b 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,7 @@ let package = Package( products: [ .library(name: "CoreDataSwift", targets: ["CoreDataSwift"]), .library(name: "MIOCoreData", targets: ["MIOCoreData"]), + .library(name: "MIOCoreDataSerialization", targets: ["MIOCoreDataSerialization"]), ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -38,6 +39,13 @@ let package = Package( ] // swiftSettings: [ .define( "APPLE_CORE_DATA" ) ] ), + .target( + name: "MIOCoreDataSerialization", + dependencies: [ + "MIOCoreData", + .product(name: "MIOCore", package: "MIOCore"), + ] + ), .target( name: "TestModel", dependencies: ["MIOCoreData"], @@ -48,6 +56,10 @@ let package = Package( name: "MIOCoreDataTests", dependencies: ["MIOCoreData", "TestModel"] ), + .testTarget( + name: "MIOCoreDataSerializationTests", + dependencies: ["MIOCoreDataSerialization"] + ), .testTarget( name: "AppleCoreDataTests" ) diff --git a/Sources/CoreDataSwift/NSManagedObjectModelParser.swift b/Sources/CoreDataSwift/NSManagedObjectModelParser.swift index 8ab242b..c73f24f 100644 --- a/Sources/CoreDataSwift/NSManagedObjectModelParser.swift +++ b/Sources/CoreDataSwift/NSManagedObjectModelParser.swift @@ -266,6 +266,12 @@ class MIOManagedObjectModelParser : NSObject, XMLParserDelegate case "Transformable": attrType = NSAttributeType.transformableAttributeType + case "Binary", "Binary Data": + attrType = NSAttributeType.binaryDataAttributeType + + case "URI": + attrType = NSAttributeType.URIAttributeType + default: print("MIOManagedObjectModel: Unknown class type: " + type); } diff --git a/Sources/MIOCoreDataSerialization/MCSerializationPolicy.swift b/Sources/MIOCoreDataSerialization/MCSerializationPolicy.swift new file mode 100644 index 0000000..70ff80c --- /dev/null +++ b/Sources/MIOCoreDataSerialization/MCSerializationPolicy.swift @@ -0,0 +1,94 @@ +// +// MCSerializationPolicy.swift +// +// Created by MIO Research Labs on 2026. +// + +import Foundation +import MIOCoreData + +public struct MCSerializationPolicy : Sendable +{ + /// How a `Decimal` reaches JSON. + public enum DecimalFormat : Sendable + { + /// `NSDecimalNumber`. + case number + /// A quoted string. + case string + } + + /// Attribute holding the row identity. Defaults to `"identifier"`, which is + /// the convention MIOPersistentStore use, but a project on a schema it did + /// not design can say otherwise per entity. + public var identifierKey: @Sendable ( NSEntityDescription ) -> String + + /// UUIDs are emitted uppercased by default, matching what PostgreSQL and + /// the existing MIO stack produce. + public var uppercaseUUIDs: Bool + + public var decimalFormat: DecimalFormat + + /// Dates are ISO 8601 with a timezone by default. + public var dateFormatter: @Sendable ( Date ) -> String + + /// Whether a nil attribute appears as `NSNull` or is left out of the + /// dictionary. Omitting is friendlier to PATCH-style APIs, where an absent + /// key and an explicit null mean different things. + public var includeNulls: Bool + + public init ( identifierKey: @escaping @Sendable ( NSEntityDescription ) -> String = { _ in "identifier" }, + uppercaseUUIDs: Bool = true, + decimalFormat: DecimalFormat = .string, + includeNulls: Bool = false, + dateFormatter: @escaping @Sendable ( Date ) -> String = MCSerializationPolicy.iso8601 ) { + self.identifierKey = identifierKey + self.uppercaseUUIDs = uppercaseUUIDs + self.decimalFormat = decimalFormat + self.includeNulls = includeNulls + self.dateFormatter = dateFormatter + } + + public static let `default` = MCSerializationPolicy() + + /// Fractional seconds included, because dropping them silently reorders + /// records written in the same second. + @Sendable + public static func iso8601 ( _ date: Date ) -> String { + return iso8601Formatter.string( from: date ) + } + + nonisolated(unsafe) private static let iso8601Formatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [ .withInternetDateTime, .withFractionalSeconds ] + return f + }() +} + +public enum MCSerializationError : Error, CustomStringConvertible +{ + /// A value the model says is required is nil and has no default. + case missingRequiredValue( entity: String, property: String ) + + /// An attribute type this layer will not guess at. + case unsupportedAttribute( entity: String, property: String, reason: String ) + + /// A value that does not match its declared attribute type. + case valueTypeMismatch( entity: String, property: String, value: Any? ) + + /// The policy names an identity attribute the entity does not have. + case identityAttributeMissing( entity: String, key: String ) + + public var description: String { + switch self { + case .missingRequiredValue( let e, let p ): + return "[MCSerialization] \(e).\(p) is required but has no value and no default" + case .unsupportedAttribute( let e, let p, let reason ): + return "[MCSerialization] \(e).\(p) cannot be serialized: \(reason)" + case .valueTypeMismatch( let e, let p, let value ): + return "[MCSerialization] \(e).\(p) got \(String( describing: value )), which does not match its declared type" + case .identityAttributeMissing( let e, let key ): + return "[MCSerialization] the policy names \"\(key)\" as identity, but \(e) has no such attribute" + } + } +} diff --git a/Sources/MIOCoreDataSerialization/NSAttributeDescription+MCSerialization.swift b/Sources/MIOCoreDataSerialization/NSAttributeDescription+MCSerialization.swift new file mode 100644 index 0000000..6e79993 --- /dev/null +++ b/Sources/MIOCoreDataSerialization/NSAttributeDescription+MCSerialization.swift @@ -0,0 +1,118 @@ +// +// NSAttributeDescription+MCSerialization.swift +// +// Created by MIO Research Labs on 2026. +// +// One attribute, model value to JSON value. +// +// The other direction already exists: `coreDataValue( from: )` in +// MIOCoreData's NSAttributeDescription+ValueConversion.swift. This module does +// not duplicate it, it only adds the outbound half and the object-level +// assembly on top. +// + +import Foundation +import MIOCoreData + +extension NSAttributeDescription +{ + /// The attribute's value as something `JSONSerialization` will accept. + /// + /// Returns `NSNull` for an absent optional, so a caller that wants the key + /// omitted can filter; `NSManagedObject.mcs_json` does that via the policy. + public func mcs_jsonValue ( from value: Any?, + policy: MCSerializationPolicy = .default ) throws -> Any? { + + let entityName = entity.name ?? "?" + + if value == nil || value is NSNull { + if let fallback = defaultValue, fallback is NSNull == false { + return try mcs_jsonValue( from: fallback, policy: policy ) + } + if isOptional { return NSNull() } + throw MCSerializationError.missingRequiredValue( entity: entityName, property: name ) + } + + switch attributeType { + + case .UUIDAttributeType: + if let uuid = value as? UUID { + return policy.uppercaseUUIDs ? uuid.uuidString.uppercased() : uuid.uuidString.lowercased() + } + // Some drivers hand identity back as text. Normalise rather + // than pass through, so one row does not serialize two ways + // depending on which backend produced it. + if let text = value as? String, let uuid = UUID( uuidString: text ) { + return policy.uppercaseUUIDs ? uuid.uuidString.uppercased() : uuid.uuidString.lowercased() + } + throw MCSerializationError.valueTypeMismatch( entity: entityName, property: name, value: value ) + + case .dateAttributeType: + if let date = value as? Date { return policy.dateFormatter( date ) } + // Already formatted by a driver that returned text; trust it + // rather than reformat something we cannot parse unambiguously. + if let text = value as? String { return text } + throw MCSerializationError.valueTypeMismatch( entity: entityName, property: name, value: value ) + + case .decimalAttributeType: + guard let decimal = mcs_decimal( from: value ) else { + throw MCSerializationError.valueTypeMismatch( entity: entityName, property: name, value: value ) + } + switch policy.decimalFormat { + case .number: return NSDecimalNumber( decimal: decimal ) + case .string: return NSDecimalNumber( decimal: decimal ).stringValue + } + + case .binaryDataAttributeType: + guard let data = value as? Data else { + throw MCSerializationError.valueTypeMismatch( entity: entityName, property: name, value: value ) + } + return data.base64EncodedString() + + case .URIAttributeType: + if let url = value as? URL { return url.absoluteString } + if let text = value as? String { return text } + throw MCSerializationError.valueTypeMismatch( entity: entityName, property: name, value: value ) + + case .transformableAttributeType: + // A transformable is whatever its value transformer says it is, + // and this module has no way to know. Guessing works only for + // values that happen to be JSON already. + throw MCSerializationError.unsupportedAttribute( + entity: entityName, property: name, + reason: "transformable attributes need their own value transformer" ) + + case .integer16AttributeType, .integer32AttributeType, .integer64AttributeType, + .doubleAttributeType, .floatAttributeType, + .booleanAttributeType, .stringAttributeType: + return value + + case .objectIDAttributeType, .undefinedAttributeType: + return NSNull() + + @unknown default: + throw MCSerializationError.unsupportedAttribute( + entity: entityName, property: name, reason: "unknown attribute type \(attributeType)" ) + } + } + + /// The inverse, delegating to MIOCoreData's existing converter so there is + /// exactly one implementation of JSON-to-model-value in the ecosystem. + public func mcs_modelValue ( from json: Any? ) throws -> Any? { + if json == nil || json is NSNull { + if isOptional { return nil } + if let fallback = defaultValue { return fallback } + throw MCSerializationError.missingRequiredValue( entity: entity.name ?? "?", property: name ) + } + return try coreDataValue( from: json ) + } + + private func mcs_decimal ( from value: Any? ) -> Decimal? { + if let d = value as? Decimal { return d } + if let n = value as? NSDecimalNumber { return n.decimalValue } + if let d = value as? Double { return Decimal( d ) } + if let i = value as? Int { return Decimal( i ) } + if let s = value as? String { return Decimal( string: s ) } + return nil + } +} diff --git a/Sources/MIOCoreDataSerialization/NSManagedObject+MCSerialization.swift b/Sources/MIOCoreDataSerialization/NSManagedObject+MCSerialization.swift new file mode 100644 index 0000000..8a051e6 --- /dev/null +++ b/Sources/MIOCoreDataSerialization/NSManagedObject+MCSerialization.swift @@ -0,0 +1,93 @@ +// +// NSManagedObject+MCSerialization.swift +// +// Created by MIO Research Labs on 2026. +// +// A managed object to a JSON dictionary and back, driven entirely by the +// entity description. +// + +import Foundation +import MIOCoreData + +extension NSManagedObject +{ + /// Attributes plus relationship identity references. + public func mcs_json ( policy: MCSerializationPolicy = .default ) throws -> [String:Any] { + + var json: [String:Any] = [:] + let identifierKey = policy.identifierKey( entity ) + + for (name, attribute) in entity.attributesByName { + let value = try attribute.mcs_jsonValue( from: self.value( forKey: name ), policy: policy ) + + if value is NSNull && policy.includeNulls == false && name != identifierKey { continue } + json[ name ] = value ?? NSNull() + } + + for (name, relationship) in entity.relationshipsByName { + guard let value = try mcs_reference( for: relationship, named: name, policy: policy ) else { + if policy.includeNulls { json[ name ] = NSNull() } + continue + } + json[ name ] = value + } + + return json + } + + /// Applies a JSON dictionary onto this object's attributes. + /// + /// Attributes only. Relationships are deliberately not resolved here. + public func mcs_setAttributes ( fromJSON json: [String:Any], + policy: MCSerializationPolicy = .default ) throws { + + for (name, attribute) in entity.attributesByName { + guard json.keys.contains( name ) else { continue } + let value = try attribute.mcs_modelValue( from: json[ name ] ) + setValue( value, forKey: name ) + } + } + + /// The identity of a related object, or an array of them. + private func mcs_reference ( for relationship: NSRelationshipDescription, + named name: String, + policy: MCSerializationPolicy ) throws -> Any? { + + guard let raw = self.value( forKey: name ), raw is NSNull == false else { return nil } + + func identity ( _ object: Any ) throws -> Any? { + guard let managed = object as? NSManagedObject else { return nil } + let key = policy.identifierKey( managed.entity ) + guard let attribute = managed.entity.attributesByName[ key ] else { + throw MCSerializationError.unsupportedAttribute( + entity: managed.entity.name ?? "?", property: key, + reason: "the identity attribute named by the policy does not exist on this entity" ) + } + return try attribute.mcs_jsonValue( from: managed.value( forKey: key ), policy: policy ) + } + + if relationship.isToMany == false { + return try identity( raw ) + } + + // Sets are unordered, so the array is sorted to keep a payload stable + // between runs. An unstable payload makes diffs and caching useless. + let objects: [Any] + if let set = raw as? Set { objects = Array( set ) } + else if let array = raw as? [Any] { objects = array } + else { return nil } + + let ids = try objects.compactMap { try identity( $0 ) } + return ids.map { String( describing: $0 ) }.sorted() + } +} + +extension NSEntityDescription +{ + /// Every property this module would put in a payload, in a stable order. + /// Useful for building a projection or documenting an endpoint. + public func mcs_serializableKeys ( ) -> [String] { + return ( Array( attributesByName.keys ) + Array( relationshipsByName.keys ) ).sorted() + } +} diff --git a/Tests/MIOCoreDataSerializationTests/SerializationTests.swift b/Tests/MIOCoreDataSerializationTests/SerializationTests.swift new file mode 100644 index 0000000..ae82829 --- /dev/null +++ b/Tests/MIOCoreDataSerializationTests/SerializationTests.swift @@ -0,0 +1,315 @@ +// +// SerializationTests.swift +// +// Created by MIO Research Labs on 2026. +// + +import Foundation +import XCTest +import MIOCore +import MIOCoreData +@testable import MIOCoreDataSerialization + +// `import XCTest` drags Apple's real CoreData into scope on Apple platforms. +#if !APPLE_CORE_DATA +typealias NSManagedObjectModel = CoreDataSwift.NSManagedObjectModel +typealias NSManagedObjectContext = CoreDataSwift.NSManagedObjectContext +typealias NSManagedObject = CoreDataSwift.NSManagedObject +typealias NSEntityDescription = CoreDataSwift.NSEntityDescription +typealias NSAttributeDescription = CoreDataSwift.NSAttributeDescription +typealias NSPersistentContainer = CoreDataSwift.NSPersistentContainer +typealias NSPersistentStoreDescription = CoreDataSwift.NSPersistentStoreDescription +#endif + +final class SerializationTests : XCTestCase +{ + // MARK: - Fixture + + static let modelXML = """ + + + + + + + + + + + + + + + + + + + + + + """ + + nonisolated(unsafe) static let model: NSManagedObjectModel = { + let dir = URL( fileURLWithPath: NSTemporaryDirectory() ) + .appendingPathComponent( "mcs-model-\(UUID().uuidString)" ) + try! FileManager.default.createDirectory( at: dir, withIntermediateDirectories: true ) + let file = dir.appendingPathComponent( "contents" ) + try! modelXML.write( to: file, atomically: true, encoding: .utf8 ) + + guard let mom = NSManagedObjectModel( contentsOf: file ), mom.entities.isEmpty == false else { + fatalError( "fixture model failed to parse" ) + } + for entity in mom.entities { + _MIOCoreRegisterClass( type: NSManagedObject.self, forKey: entity.name! ) + } + return mom + }() + + var moc: NSManagedObjectContext! + + override func setUpWithError ( ) throws { + let container = NSPersistentContainer( name: "Fixture", managedObjectModel: Self.model ) + let description = NSPersistentStoreDescription( url: URL( fileURLWithPath: "/dev/null" ) ) + description.type = CoreDataSwift.NSInMemoryStoreType + container.persistentStoreDescriptions = [ description ] + container.loadPersistentStores { _, _ in } + moc = container.viewContext + } + + private func entity ( _ name: String ) -> NSEntityDescription { + return Self.model.entitiesByName[ name ]! + } + + private func attribute ( _ entityName: String, _ name: String ) -> NSAttributeDescription { + return entity( entityName ).attributesByName[ name ]! + } + + @discardableResult + private func makeItem ( id: UUID = UUID(), title: String = "a title" ) -> NSManagedObject { + let item = NSEntityDescription.insertNewObject( forEntityName: "Item", into: moc ) + item.setValue( id, forKey: "identifier" ) + item.setValue( title, forKey: "title" ) + item.setValue( false, forKey: "done" ) + item.setValue( Int64( 3 ), forKey: "count" ) + item.setValue( Date( timeIntervalSince1970: 1_700_000_000.25 ), forKey: "createdAt" ) + return item + } + + // MARK: - Value types + + func testUUIDBecomesAnUppercasedString ( ) throws { + let id = UUID() + let value = try attribute( "Item", "identifier" ).mcs_jsonValue( from: id ) + XCTAssertEqual( value as? String, id.uuidString.uppercased() ) + } + + func testUUIDCasingFollowsThePolicy ( ) throws { + let id = UUID() + var policy = MCSerializationPolicy.default + policy.uppercaseUUIDs = false + let value = try attribute( "Item", "identifier" ).mcs_jsonValue( from: id, policy: policy ) + XCTAssertEqual( value as? String, id.uuidString.lowercased() ) + } + + func testUUIDArrivingAsTextIsNormalisedNotPassedThrough ( ) throws { + // A driver may hand identity back as text. One row must not serialize + // two different ways depending on which backend produced it. + let id = UUID() + let value = try attribute( "Item", "identifier" ).mcs_jsonValue( from: id.uuidString.lowercased() ) + XCTAssertEqual( value as? String, id.uuidString.uppercased() ) + } + + func testDateIsISO8601WithFractionalSeconds ( ) throws { + let date = Date( timeIntervalSince1970: 1_700_000_000.25 ) + let value = try attribute( "Item", "createdAt" ).mcs_jsonValue( from: date ) + let text = try XCTUnwrap( value as? String ) + + XCTAssertTrue( text.contains( "T" ), text ) + // Fractional seconds matter: without them, records written in the same + // second lose their order. + XCTAssertTrue( text.contains( ".25" ) || text.contains( ".250" ), text ) + } + + func testDecimalIsAStringByDefault ( ) throws { + let value = try attribute( "Item", "price" ).mcs_jsonValue( from: Decimal( string: "3.50" )! ) + XCTAssertEqual( value as? String, "3.5" ) + } + + func testDecimalCanBeANumberByPolicy ( ) throws { + var policy = MCSerializationPolicy.default + policy.decimalFormat = .number + let value = try attribute( "Item", "price" ).mcs_jsonValue( from: Decimal( string: "3.50" )!, policy: policy ) + XCTAssertEqual( ( value as? NSDecimalNumber )?.stringValue, "3.5" ) + } + + func testBinaryBecomesBase64 ( ) throws { + let data = Data( [ 0x4D, 0x49, 0x4F ] ) + let value = try attribute( "Item", "blob" ).mcs_jsonValue( from: data ) + XCTAssertEqual( value as? String, data.base64EncodedString() ) + } + + func testURIBecomesItsAbsoluteString ( ) throws { + let url = URL( string: "https://example.com/a?b=c" )! + let value = try attribute( "Item", "link" ).mcs_jsonValue( from: url ) + XCTAssertEqual( value as? String, url.absoluteString ) + } + + func testScalarsPassThrough ( ) throws { + XCTAssertEqual( try attribute( "Item", "title" ).mcs_jsonValue( from: "x" ) as? String, "x" ) + XCTAssertEqual( try attribute( "Item", "done" ).mcs_jsonValue( from: true ) as? Bool, true ) + XCTAssertEqual( try attribute( "Item", "count" ).mcs_jsonValue( from: Int64( 9 ) ) as? Int64, 9 ) + } + + // MARK: - Nil handling + + func testRequiredNilThrowsRatherThanDisappearing ( ) { + // The failure mode this replaces: a required value silently serializing + // as absent, so the other side accepts a payload and misreads it. + XCTAssertThrowsError( try attribute( "Item", "title" ).mcs_jsonValue( from: nil ) ) { error in + guard case MCSerializationError.missingRequiredValue = error else { + return XCTFail( "wrong error: \(error)" ) + } + } + } + + func testRequiredNilFallsBackToTheModelDefault ( ) throws { + // `count` is required but has a default of 3, so nil is recoverable. + let value = try attribute( "Item", "count" ).mcs_jsonValue( from: nil ) + XCTAssertEqual( ( value as? NSNumber )?.intValue, 3 ) + } + + func testOptionalNilIsNull ( ) throws { + XCTAssertTrue( try attribute( "Item", "note" ).mcs_jsonValue( from: nil ) is NSNull ) + } + + func testTransformableIsRefusedNotGuessed ( ) throws { + // Optional, so a nil transformable still serializes as null and does + // not poison every other test. It is only a value that cannot be + // handled, because the transformer is the caller's business. + let attr = attribute( "Item", "payload" ) + XCTAssertEqual( attr.attributeType, .transformableAttributeType ) + + XCTAssertThrowsError( try attr.mcs_jsonValue( from: "anything" ) ) { error in + guard case MCSerializationError.unsupportedAttribute = error else { + return XCTFail( "wrong error: \(error)" ) + } + } + } + + // MARK: - Objects + + func testObjectSerializesAttributesAndOmitsNullsByDefault ( ) throws { + let id = UUID() + let item = makeItem( id: id, title: "write it" ) + + let json = try item.mcs_json() + + XCTAssertEqual( json[ "identifier" ] as? String, id.uuidString.uppercased() ) + XCTAssertEqual( json[ "title" ] as? String, "write it" ) + XCTAssertEqual( json[ "done" ] as? Bool, false ) + XCTAssertNil( json[ "note" ], "optional nils should be omitted by default" ) + } + + func testNullsCanBeIncludedByPolicy ( ) throws { + var policy = MCSerializationPolicy.default + policy.includeNulls = true + let json = try makeItem().mcs_json( policy: policy ) + XCTAssertTrue( json[ "note" ] is NSNull ) + } + + func testToOneSerializesAsAnIdentityReferenceNotANestedObject ( ) throws { + let boxID = UUID() + let box = NSEntityDescription.insertNewObject( forEntityName: "Box", into: moc ) + box.setValue( boxID, forKey: "identifier" ) + box.setValue( "a box", forKey: "name" ) + + let item = makeItem() + item.setValue( box, forKey: "box" ) + + let json = try item.mcs_json() + XCTAssertEqual( json[ "box" ] as? String, boxID.uuidString.uppercased() ) + XCTAssertFalse( json[ "box" ] is [String:Any], "relationships must not nest" ) + } + + func testToManySerializesAsASortedArrayOfIdentities ( ) throws { + let box = NSEntityDescription.insertNewObject( forEntityName: "Box", into: moc ) + box.setValue( UUID(), forKey: "identifier" ) + box.setValue( "a box", forKey: "name" ) + + let a = makeItem( id: UUID( uuidString: "00000000-0000-0000-0000-0000000000AA" )! ) + let b = makeItem( id: UUID( uuidString: "00000000-0000-0000-0000-0000000000BB" )! ) + a.setValue( box, forKey: "box" ) + b.setValue( box, forKey: "box" ) + + let json = try box.mcs_json() + let ids = try XCTUnwrap( json[ "items" ] as? [String] ) + + XCTAssertEqual( ids.count, 2 ) + // Sorted, because a set has no order and an unstable payload makes + // diffing and caching useless. + XCTAssertEqual( ids, ids.sorted() ) + } + + func testIdentityAttributeNameComesFromThePolicy ( ) throws { + var policy = MCSerializationPolicy.default + policy.identifierKey = { _ in "title" } + + let box = NSEntityDescription.insertNewObject( forEntityName: "Box", into: moc ) + box.setValue( UUID(), forKey: "identifier" ) + box.setValue( "a box", forKey: "name" ) + + let item = makeItem( title: "used as identity" ) + item.setValue( box, forKey: "box" ) + + // Box has no `title`, so this must fail loudly rather than emit a + // reference that points at nothing. + XCTAssertThrowsError( try item.mcs_json( policy: policy ) ) + } + + // MARK: - The actual contract + + func testOutputIsAcceptedByJSONSerialization ( ) throws { + // Everything above asserts shape. + var policy = MCSerializationPolicy.default + policy.includeNulls = true + + let item = makeItem() + item.setValue( Decimal( string: "12.34" ), forKey: "price" ) + item.setValue( Data( [ 1, 2, 3 ] ), forKey: "blob" ) + item.setValue( URL( string: "https://example.com" ), forKey: "link" ) + + let json = try item.mcs_json( policy: policy ) + + XCTAssertTrue( JSONSerialization.isValidJSONObject( json ), "\(json)" ) + XCTAssertNoThrow( try JSONSerialization.data( withJSONObject: json ) ) + } + + // MARK: - Round trip + + func testAttributesRoundTripThroughJSONAndBack ( ) throws { + let id = UUID() + let item = makeItem( id: id, title: "original" ) + item.setValue( Int64( 42 ), forKey: "count" ) + + let json = try item.mcs_json() + + let restored = NSEntityDescription.insertNewObject( forEntityName: "Item", into: moc ) + try restored.mcs_setAttributes( fromJSON: json ) + + XCTAssertEqual( restored.value( forKey: "identifier" ) as? UUID, id ) + XCTAssertEqual( restored.value( forKey: "title" ) as? String, "original" ) + XCTAssertEqual( restored.value( forKey: "count" ) as? Int64, 42 ) + } + + func testSetAttributesLeavesAbsentKeysAlone ( ) throws { + let item = makeItem( title: "keep me" ) + try item.mcs_setAttributes( fromJSON: [ "count": 99 ] ) + + XCTAssertEqual( item.value( forKey: "title" ) as? String, "keep me" ) + XCTAssertEqual( item.value( forKey: "count" ) as? Int64, 99 ) + } + + func testSerializableKeysAreStable ( ) { + XCTAssertEqual( entity( "Box" ).mcs_serializableKeys(), [ "identifier", "items", "name" ] ) + } +}