はじめに
メモ魔向けのメモアプリを作りました(最下部にリンクあり)。その時にタイトルの内容を苦労して調べたので紹介します。
開発環境
Xcode Version 14.0
機能
前提としてアプリのイメージを最低限紹介したく、画像を2枚貼ります。
左図)メモ一覧画面です。横線でメモが区切られています。薄い紫色の四角がタグです
右図)画面の下半分に、タグの組み合わせごとのメモ数が表示されています。この計算にタイトルの実装を利用しました。
データ構造
データ構造は下図の通りです。1行が1つのメモで、「ZLABELS」列にカンマ区切りでラベルidを入れてます。
※Xcode上では「labels」ですが実際には「ZLABELS」で入ってるようです
(CoreDataの中身の確認方法はこちらの記事で知りました)
Select文でこのZLABELSをGroup Byのキーにしてレコード数を集計します。
実装:CoreDataをSQLのSelect文でGroupBy集計
このやり方を調べるのが一番苦労しました。自分はデータ分析屋なので「データベースはselect文で集計するためのもの」みたいな認識を持っちゃってるんですが、CoreDataは集計する用途では全くないみたいですね。
でも中身がSQLiteというのは知っていたので絶対できるはずだ(別に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など好きなタイミングで実行できます。
シミュレーターとコンソールのキャプチャはこちら:
少し誤算だったのは、カンマを展開して集計することはSQLiteでは出来なさそうで、結局Swift側でも地道な集計が必要だったことです。とはいってもGroupByで前処理できて楽だったので良かったです。
以上「CoreDataをSQLのSelectのGroupByで集計する」方法のご紹介でした。
(参考)そもそも何故この機能が必要だったのか
「どの組み合わせでラベル使ってたっけ?」と全部覚えてないので、何個あるか眺めてからポチポチ選びたかったからです。詳しくはnoteに書いてます。ご興味あれば。
(参考)タグ管理に全振りしたメモアプリ「ラベルメモ」
https://apps.apple.com/jp/app/label-99-note/id1644589126
※iOS版のみ
※タグ管理なのになぜ「ラベルメモ」なのかですが、「タグ」だとSNSのハッシュタグのイメージが強すぎて違和感あったからです。ちなみにMacのFinderはtagと呼んでいて、GoogleのGmailはlabelと呼んでますね。labelが圧倒的少数派な感じです。labelだと音楽レーベルと同じ単語で紛らわしいからかも。ぶっちゃけ今はTagの方が良かったと思ってますが、アプリアイコンを「L」で作っちゃったからラベルのままにしました。。。