CoreData に登録したデータを JSON で出力したい！

# はじめに

こんにちは。HIGの清水です。
本記事は、[フューチャー Advent Calendar 2023](https://qiita.com/advent-calendar/2023/future)の5日目の記事です。
昨日は、 @shibukawa さんの「[HTTPでも双方向ソケット通信にチャレンジしてみた(けどやめた)](https://qiita.com/shibukawa/items/1d098e3ba9e065215b40)」でした。
今年は社内でも珍しく、iOSアプリ開発のためにSwiftを読んだり書いたりする機会が多かった一年でした。
そんな一年の締めも、Swiftに関する記事を書いていきたいと思います。

# 内容

今回は、CoreData に登録したデータの変更記録を JSON 形式で出力するする際に詰まった点についてお話します。
以下の順でお話していきます。

* オブジェクトをJSON形式にエンコードして出力する
* CoreDataのEntityモデルをJSON形式で出力する

## 先に結論

`NSManagedObject` は `Codable`ではないため、`NSManagedObject`を継承したクラスをJSONなどにエンコードする際には、カスタムエンコードロジックの実装が必要になる。

## 環境

* macOS Ventura 13.6.1（22G313）
* Xcode 15.0.1

## オブジェクトをJSON形式にエンコードして出力する

Swift で自前で用意したクラスや構造体をエンコード可能にするためには、`Encodable` に準拠する必要があります。
`Encodable` に準拠する書き方に関しては、[Apple の公式ドキュメント](https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types) に詳しく記載してあります。
要約すると、`Encodable`を継承すると良いため、以下のように書けます。

```swift:Sample.playground
import Foundation

// Encodable を継承している。
class Item: Encodable {
    var count: Int
    var name: String

    init(count: Int, name: String) {
        self.count = count
        self.name = name
    }
}

let item = Item(count: 5, name: "hoge")

let encoder = JSONEncoder()
let data: Data = try encoder.encode(item)
if let jsonString = String(data: data, encoding: .utf8) {
    print(jsonString)
}
```

Playground での実行結果は以下です。JSON形式で出力出来ていることが確認できます。

```:出力結果
{"count":5,"name":"hoge"}
```

## CoreDataのEntityモデルをJSON形式で出力する

先ほどの`Item`クラスにCoreDataの`NSManagedObject`を継承したいと思います。

```swift:Item.swift
import Foundation
import CoreData

@objc(Item)
public class Item: NSManagedObject, Identifiable {
    @NSManaged public var count: Int16
    @NSManaged public var name: String
}
```

以下のように、`Encodable`を継承しても、JSON形式で出力できません。

```diff_swift:Item.swift
import Foundation
import CoreData

@objc(Item)
- public class Item: NSManagedObject, Identifiable {
+ public class Item: NSManagedObject, Identifiable, Encodable {
    @NSManaged public var count: Int16
    @NSManaged public var name: String
}
```

```swift:①オブジェクトをJSONにエンコードして出力する
let encoder = JSONEncoder()
let data: Data = try encoder.encode(item)
if let jsonString = String(data: data, encoding: .utf8) {
    print(jsonString)
}
```

```:①の出力結果
{}
```

`NSManagedObject` に `Encodable` を継承する場合、一手間必要でした。
独自にエンコードロジックを用意する必要があります。

https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types#2904058

`CodingKey` が、エンコード（又はデコード）に必要なキーを列挙しており、
`encode(to encoder: Encoder)` が今回自前で用意したエンコード用の関数になります。

```diff_swift:Item.swift
import Foundation
import CoreData

@objc(Item)
public class Item: NSManagedObject, Identifiable, Encodable {
    @NSManaged public var count: Int16
    @NSManaged public var name: String
+
+    enum CodingKeys: CodingKey {
+        case count, name
+    }
+
+    public func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+        try container.encode(count, forKey: .count)
+        try container.encode(name, forKey: .name)
+    }
}
```

これで `NSManagedObject` クラスのオブジェクトをJSON形式で出力できるようになりました。

```:①の出力結果
{"name":"Hoge","count":6}
```

## 追記: なぜカスタムロジックが必要だったのか

結論、`Item` クラスに継承した`NSManagedObject`クラスが、`Codable`な型ではなかったからだと考えています。

[公式リファレンス](https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types#2904055)に以下の記載があります。

> The simplest way to make a type codable is to declare its properties using types that are already Codable. These types include standard library types like String, Int, and Double; and Foundation types like Date, Data, and URL. Any type whose properties are codable automatically conforms to Codable just by declaring that conformance.

「`Codable`（`Encodable` かつ `Decodable`）にする最も簡単な方法は、既に`Codable`になっている型を使ってプロパティを宣言することです。」とあります。
`NSManagedObject`クラスを継承する前の`Item`クラスは、`Int`型と`String`型のプロパティが存在します。
ここで、公式リファレンスでそれぞれの型の説明について見てみます。

公式リファレンスの [Int > Relationships > Conforms To](https://developer.apple.com/documentation/swift/int#conforms-to) の一覧を見ると確かに、`Int` が「`Encodable` かつ `Decodable`」であることが分かります。

![スクリーンショット 2023-12-05 21.05.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2959166/cfbc0f26-4f78-3c43-3e43-3e6cbafb2a61.png)

[`String`](https://developer.apple.com/documentation/swift/string#conforms-to) についても同様に、「`Encodable` かつ `Decodable`」であることが確認できます。

<details><summary>String > Relationships > Conforms To のスクリーンショット</summary>

![スクリーンショット 2023-12-05 21.11.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2959166/7b1e222c-147b-56d0-7301-5d8cef3dfa64.png)
</details>

ここで、[`NSManagedObject`](https://developer.apple.com/documentation/coredata/nsmanagedobject#conforms-to) クラスについて同様に確認すると、「`Encodable` かつ `Decodable`」**ではない**ことが分かりました。

![スクリーンショット 2023-12-05 21.16.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2959166/fad659f8-a89f-a0e3-adfe-328db7d7a135.png)

元々の`Item`クラスは全てのプロパティが「`Encodable` かつ `Decodable`」であったため、`Encodable`を継承するだけでエンコードできた。
`NSManagedObject`クラスを継承した`Item`クラスは、`NSManagedObject`自体が「`Encodable` かつ `Decodable`」ではないため、カスタムエンコードロジックの実装が必要だった。

上記が私が調べた結論です。

## 検証してみた

CoreData に登録したデータをアプリ上でJSONで出力させてみました。
変更した`count`と`name`がJSON形式で表示されていることが分かると思います。
コードは、動画の下に添付しています。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2959166/2f37affa-2cad-67af-2ff2-6948bd6d0149.gif)

<details><summary>Item.swift</summary>

```swift
import Foundation
import CoreData

@objc(Item)
public class Item: NSManagedObject, Identifiable, Encodable {
    @NSManaged public var count: Int16
    @NSManaged public var name: String

    enum CodingKeys: CodingKey {
        case count, name
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(count, forKey: .count)
        try container.encode(name, forKey: .name)
    }
}

```
</details>

<details><summary>ContentView.swift</summary>

```swift
import SwiftUI
import CoreData

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.name, ascending: true)],
        animation: .default)
    private var items: FetchedResults<Item>

    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    NavigationLink {
                        DetailView(item: item)
                    } label: {
                        HStack {
                            Text(item.count.description)
                            Text(item.name.description)
                        }
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
            Text("Select an item")
        }
    }

    private func addItem() {
        withAnimation {
            let newItem = Item(context: viewContext)
            newItem.count = 0
            newItem.name = "Sample"

            do {
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            offsets.map { items[$0] }.forEach(viewContext.delete)

            do {
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }
}
```
</details>


<details><summary>DetailView.swift</summary>

```swift
import SwiftUI
import CoreData
import Foundation

struct DetailView: View {
    @Environment(\.managedObjectContext) private var viewContext
    @State private var count: Int16 = 0
    @State private var name: String = ""
    @State private var jsonString: String = ""
    var item: Item
    var body: some View {
        VStack {
            Button(count.description) {
                count = count + 1
            }
            TextField(item.name, text: $name)
                .border(.black)
                .padding()
                .onAppear {
                    name = item.name
                }
            Button("Print JSON") {
                let encoder = JSONEncoder()
                do {
                    let data: Data = try encoder.encode(item)
                    if let jsonStr = String(data: data, encoding: .utf8) {
                        print(jsonStr)
                        jsonString = jsonStr
                    }
                } catch {}
            }
            Text("JSON = " + jsonString)
        }
        .toolbar {
            ToolbarItem {
                Button(action: saveItem) {
                    Text("Save")
                }
            }
        }
    }

    private func saveItem() {
        withAnimation {
            item.count = count
            item.name = name

            do {
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }
}
```
</details>

# さいごに

明日は、 @starswirl_k さんの「[【React/TypeScript】今年のクリスマスはreact-konvaで雪を降らす](https://qiita.com/starswirl_k/items/78f150133af1e330bb09)」です。
お楽しみに。