Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"],
Expand All @@ -48,6 +56,10 @@ let package = Package(
name: "MIOCoreDataTests",
dependencies: ["MIOCoreData", "TestModel"]
),
.testTarget(
name: "MIOCoreDataSerializationTests",
dependencies: ["MIOCoreDataSerialization"]
),
.testTarget(
name: "AppleCoreDataTests"
)
Expand Down
6 changes: 6 additions & 0 deletions Sources/CoreDataSwift/NSManagedObjectModelParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
94 changes: 94 additions & 0 deletions Sources/MIOCoreDataSerialization/MCSerializationPolicy.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<NSManagedObject> { 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()
}
}
Loading