【個人開発】実装紹介 -CoreDataをSQLで集計する方法- (Swift)

# はじめに
メモ魔向けのメモアプリを作りました（最下部にリンクあり）。その時にタイトルの内容を苦労して調べたので紹介します。

# 開発環境
Xcode Version 14.0

# 機能
前提としてアプリのイメージを最低限紹介したく、画像を2枚貼ります。
![Group 182.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734189/cd07cf47-008c-8a13-9876-40139a8c3df1.png)

左図）メモ一覧画面です。横線でメモが区切られています。薄い紫色の四角がタグです
右図）画面の下半分に、タグの組み合わせごとのメモ数が表示されています。この計算にタイトルの実装を利用しました。


# データ構造
データ構造は下図の通りです。1行が1つのメモで、「ZLABELS」列にカンマ区切りでラベルidを入れてます。
※Xcode上では「labels」ですが実際には「ZLABELS」で入ってるようです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734189/31b87239-110a-5ced-b7a5-725656f92067.png)
（CoreDataの中身の確認方法は[こちらの記事](https://ticklecode.com/coredatafilesearch/)で知りました）

Select文でこのZLABELSをGroup Byのキーにしてレコード数を集計します。

# 実装：CoreDataをSQLのSelect文でGroupBy集計
　このやり方を調べるのが一番苦労しました。自分はデータ分析屋なので「**データベースはselect文で集計するためのもの**」みたいな認識を持っちゃってるんですが、CoreDataは集計する用途では全くないみたいですね。
　でも**中身がSQLiteというのは知っていたので絶対できるはずだ**（別にswiftでも計算できるけどめんどくさい）と、調べてみました。そのままヒットする記事は見つけられなかったのですが、いくつか繋ぎ合わせると実現できました。ミニマムにしたサンプルコードがこちらです。

```swift
import SwiftUI
import CoreData
import SQLite3

var db: OpaquePointer?
private var labelsCount:[String:Int] = [:]

struct ContentView: View {
    init(){
        // dbに接続
        openDB()
        // group byで集計
        selectCntGroupByLabels()
        // 集計結果をコンソール出力
        print(labelsCount)
    }
    @Environment(\.managedObjectContext) private var viewContext
    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        animation: .default)
    private var items: FetchedResults<Item>
    @State private var textInput:String = ""
    @State private var labelsInput:String = ""

    var body: some View {
        let frameWidth:CGFloat = 100
        let spaceWidth:CGFloat = 20
        VStack{
            Text("データ登録").font(.title2)
            HStack{
                TextField("text", text: $textInput)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .frame(width: frameWidth)
                Spacer().frame(width: spaceWidth)
                TextField("labels", text: $labelsInput)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .frame(width: frameWidth)
            }
            Button("登録"){ addNewMemo() }

            Divider()
            Text("データ表示").font(.title2)
            ForEach(items){ item in
                if let text = item.text{
                    if let labels = item.labels {
                        HStack{
                            Text(text).frame(width:frameWidth)
                            Spacer().frame(width: spaceWidth)
                            Text(labels).frame(width:frameWidth)
                        }
                    }
                }
            }
        }
    }
    
    func addNewMemo() {
        withAnimation {
            // 新規データ登録
            let newItem = Item(context: viewContext)
            newItem.text = textInput
            newItem.labels = labelsInput
            newItem.timestamp = Date()
            do {
                try viewContext.save()
            } catch {
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
            // 表示を初期化
            textInput = ""
            labelsInput = ""
        }
    }
}

func openDB() {
    let paths = NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.libraryDirectory, FileManager.SearchPathDomainMask.userDomainMask, true)
    let filePath:String = "\(paths[0])/Application Support/qiita_coredata.sqlite" // 要確認。アプリによって異なる。環境でも変わるかも。
    print(filePath)
    if sqlite3_open(filePath, &db) != SQLITE_OK {
        print("DBファイルが見つからず、生成もできません。")
    } else {
        print("DBファイルが生成できました。（対象のパスにDBファイルが存在しました。）")
    }
}

func selectCntGroupByLabels(){
    // ラベルセットごとの個数を取得
    let queryString = """
        SELECT
            ZLABELS, count(*)
        FROM
            ZITEM
        where
            ZLABELS LIKE '%,%'
        group by
            ZLABELS
    """
    var stmt:OpaquePointer?
    // クエリを準備する
    if sqlite3_prepare(db, queryString, -1, &stmt, nil) != SQLITE_OK{
        let errmsg = String(cString: sqlite3_errmsg(db)!)
        print("error preparing insert: \(errmsg)")
        return
    }
    // クエリを実行し、取得したレコードをループして結果を格納
    labelsCount = [:] // 初期化
    while(sqlite3_step(stmt) == SQLITE_ROW){
        labelsCount[String(cString: sqlite3_column_text(stmt, 0))] = Int(sqlite3_column_int(stmt, 1))
    }
}
// コピペ部分がありますが元記事のリンクを見失ってしまいました、すみません。。。
```

このサンプルではinit()内で集計してますが、onAppearなど好きなタイミングで実行できます。
シミュレーターとコンソールのキャプチャはこちら：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2734189/3759d469-9bf9-28f7-2a51-5d1f72200874.png)

少し誤算だったのは、カンマを展開して集計することはSQLiteでは出来なさそうで、結局Swift側でも地道な集計が必要だったことです。とはいってもGroupByで前処理できて楽だったので良かったです。

以上「CoreDataをSQLのSelectのGroupByで集計する」方法のご紹介でした。

# (参考)そもそも何故この機能が必要だったのか
「どの組み合わせでラベル使ってたっけ？」と全部覚えてないので、何個あるか眺めてからポチポチ選びたかったからです。詳しくは[note](https://note.com/yocomaron/n/n0da686299fd7#56cf7ca2-48ae-4810-9219-0a14afbf6a66)に書いてます。ご興味あれば。

# (参考)タグ管理に全振りしたメモアプリ「ラベルメモ」
https://apps.apple.com/jp/app/label-99-note/id1644589126
※iOS版のみ
※タグ管理なのになぜ「ラベルメモ」なのかですが、「タグ」だとSNSのハッシュタグのイメージが強すぎて違和感あったからです。ちなみにMacのFinderはtagと呼んでいて、GoogleのGmailはlabelと呼んでますね。labelが圧倒的少数派な感じです。labelだと音楽レーベルと同じ単語で紛らわしいからかも。ぶっちゃけ今はTagの方が良かったと思ってますが、アプリアイコンを「L」で作っちゃったからラベルのままにしました。。。