1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SwiftUI FetchRequestでGroup byはできない

Last updated at Posted at 2023-01-31

はじめに

  • Xcode 14.2

紹介するサンプルでは以下のModelを使用しています。

スクリーンショット 2023-01-31 17.12.07.png

SwiftUIにおけるFetchRequest

SwiftUIでCore Dataの機能が使えるFetchedResults があります。
これを使うことにより、Core Dataの変化を検知してSwiftUIのViewを更新することが可能になっています。

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    
    @FetchRequest (sortDescriptors: [.init(keyPath: \Item.timestamp, ascending: true)])
    private var items: FetchedResults<Item>

    var body: some View {
        NavigationView {
            List {
                ForEach(items, id: \.self) { item in
                    Text(item.amount, format: .currency(code: item.currencyCode!))
                }
            }
        }
    }
}

このFetchedResultsは@FetchRequestを使って定義ができます。
単純なデータ表示であればsortDescriptorsのプロパティを使ってアイテム表示が可能ですが、自分でNSFetchRequestを定義することもできます。

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

    private static var itemRequest: NSFetchRequest<Item> {
        let fetchRequest: NSFetchRequest<Item> = .init(entityName: "Item")

        fetchRequest.sortDescriptors = [.init(keyPath: \Item.timestamp, ascending: true)]
        
        return fetchRequest
    }
    
    @FetchRequest(fetchRequest: itemRequest)
    private var items: FetchedResults<Item>

    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    Text(item.timestamp!, formatter: itemFormatter)
                }
            }
        }
    }
}

このとき、FetchedResultsで表示するのは、Core DataにEntityとして登録しているItemですが、FetchedResultではNSFetchRequestResultのprotocolに対応したものであればいいので、例えばNSNumberなどもNSFetchRequestResultに対応しているので、こんな感じにすれば件数表示もCore Dataから直接取得して件数表示可能です。

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

    private static var itemRequest: NSFetchRequest<NSNumber> {
        let fetchRequest = NSFetchRequest<NSNumber>(entityName: "Item")
        fetchRequest.resultType = .countResultType

        fetchRequest.sortDescriptors = [.init(keyPath: \Item.timestamp, ascending: true)]
        
        return fetchRequest
    }
    
    @FetchRequest(fetchRequest: itemRequest)
    private var items: FetchedResults<NSNumber>

    var body: some View {
        NavigationView {
            List {
                ForEach(items, id: \.self) { item in
                    Text(item.intValue, format: .number)
                }
            }
        }
    }
}

クラッシュするFetchRequestのGroup by

同じように、NSFetchRequestではNSDictionaryを使ったGroup byを行った結果を取得することも可能です。なので、以下のような、Group Byを使えるNSFetchRequestを用意しました。これは通貨currencyCodeごとに合計を出す想定のコードです。

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

    private static var itemRequest: NSFetchRequest<NSDictionary> {
        let fetchRequest:NSFetchRequest<NSDictionary> = .init(entityName: "Item")
        
        fetchRequest.resultType = .dictionaryResultType
        
        let amountExpression = NSExpression(forKeyPath: "amount")
        let sumExpression = NSExpression(forFunction: "sum:", arguments: [amountExpression])
        let sumDescription = NSExpressionDescription()
        sumDescription.expression = sumExpression
        sumDescription.name = "sumOfAmount"
        sumDescription.expressionResultType = .decimalAttributeType

        let currencyCodeExpression = NSExpression(forKeyPath: "currencyCode")
        let currencyCodeDescription = NSExpressionDescription()
        currencyCodeDescription.expression = currencyCodeExpression
        currencyCodeDescription.name = "currencyCode"
        currencyCodeDescription.expressionResultType = .stringAttributeType

        fetchRequest.propertiesToFetch = [currencyCodeDescription, sumDescription]
        fetchRequest.propertiesToGroupBy = [currencyCodeDescription]

        fetchRequest.sortDescriptors = [NSSortDescriptor.init(key: "timestamp", ascending: true)]
        
        return fetchRequest
    }
    
    @FetchRequest(fetchRequest: itemRequest)
    private var items: FetchedResults<NSDictionary>

    var body: some View {
        NavigationView {
            List {
                ForEach(items, id: \.self) { item in
                    Text(item.description)
                }
            }
        }
    }
}

しかし、このコードを動かしてみると以下のような理由でクラッシュしてしまいます。

2023-01-31 17:14:57.661647+0900 Groups[5801:625811] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'NSFetchedResultsController does not support both change tracking and fetch request's with NSDictionaryResultType'

クラッシュへの対応

このクラッシュは、SwiftUIだけで見られるものではありません。
例えばUITableViewControllerでCore Dataのデータを表示しようとして、NSFetchedResultsControllerとNSFetchedResultsControllerDelegateを用いてデータの変更をした場合、NSDictionaryResultTypeを使用して表示をしようとすると同じ理由でクラッシュします。

SwiftUIでもおそらくデータの変更対応にNSFetchedResultsControllerを使っているため、同じエラーが起きると思われます。

対処法

Core DataのGroup byを使わずに、NSManagedObjectのモデルを取得して擬似的にGroup byを実装するしかないと思います。
ということで、通貨ごとに合計を出すコードを実装してみます。ついでに+ボタンを押すとユーロが追加されUIが更新されます。

import SwiftUI
import CoreData

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    
    @FetchRequest (sortDescriptors: [.init(keyPath: \Item.timestamp, ascending: true)])
    private var items: FetchedResults<Item>
    
    var body: some View {
        NavigationView {
            List {
                ForEach(groupBy(items).sorted(by: >), id: \.key) { item in
                    NavigationLink {
                        Text(item.value, format: .currency(code: item.key))
                    } label: {
                        Text(item.value, format: .currency(code: item.key))
                    }
                }
            }
            .toolbar {
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        }
    }
    
    func groupBy(_ items: FetchedResults<Item>) -> [String : Int] {
        Dictionary.init(grouping: items) {
            $0.currencyCode ?? ""
        }
        .mapValues { values in
            values.reduce(0) { partialResult, item in
                partialResult + Int(item.amount)
            }
        }
    }
    
    private func addItem() {
        withAnimation {
            let newItem = Item(context: viewContext)
            newItem.timestamp = Date()
            newItem.currencyCode = "EUR"
            newItem.amount = 123
            
            do {
                try viewContext.save()
            } catch {
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }
}

スクリーンショット 2023-01-31 18.18.28.png

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?