Contents

markbattistella/swiftdatacounter

SwiftDataCounter tracks SwiftData model counts, refreshes them after saves from a supplied ModelContext, and reports remaining capacity against optional limits.

Features

  • Live counts: Refresh after saves from the context you supply.
  • Optional limits: Set a shared default or a separate limit for each model type.
  • Scoped counting: Filter by the selected project and change that scope in place.
  • Aggregate queries: Read combined counts, limits, and remaining capacity.
  • Observation and caching: Observe count changes and display cached counts during the initial load.

Installation

Add SwiftDataCounter using Swift Package Manager:

dependencies: [
    .package(
        url: "https://github.com/markbattistella/SwiftDataCounter",
        from: "26.3.8"
    )
]

Alternatively, in Xcode choose File > Add Packages, enter https://github.com/markbattistella/SwiftDataCounter, and add the package to your app target.

Requirements

  • Swift 6.0+
  • iOS 17.0+
  • macOS 14.0+
  • Mac Catalyst 17.0+
  • tvOS 17.0+
  • watchOS 10.0+
  • visionOS 1.0+

Usage

Define the Models

Tracked models must conform to FetchablePersistentModel. The protocol supplies an unfiltered fetchDescriptor, so no custom implementation is required:

import Foundation
import SwiftData
import SwiftDataCounter

@Model
final class Project: FetchablePersistentModel {
    var id: UUID
    var name: String

    init(id: UUID = UUID(), name: String) {
        self.id = id
        self.name = name
    }
}

@Model
final class ProjectTask: FetchablePersistentModel {
    var title: String
    var projectID: UUID
    var isCompleted: Bool

    init(title: String, projectID: UUID, isCompleted: Bool = false) {
        self.title = title
        self.projectID = projectID
        self.isCompleted = isCompleted
    }
}

For existing models, add conformance in an extension instead: extension ProjectTask: FetchablePersistentModel {}. Register both model types in your app's ModelContainer.

Choose Limits

Create and use EntityCounter on the main actor. In the following examples, modelContext is your app's context and selectedProjectID is the selected project's UUID.

To apply the same limit to each model type, use defaultLimit. This counts all projects and all tasks, with a separate limit of 100 for each:

let defaultCounter = EntityCounter(
    context: modelContext,
    for: Project.self, ProjectTask.self,
    defaultLimit: 100
)

For separate limits, supply a PersistentModelLimit for each type. This alternative allows unlimited projects and 10 tasks across all projects:

let allTasksCounter = EntityCounter(
    context: modelContext,
    for: .init(type: Project.self, limit: nil),
         .init(type: ProjectTask.self, limit: 10)
)

Count Tasks in the Selected Project

For the guide's per-project limit, supply a runtime predicate with .scoped(_:limit:scopeID:where:):

let projectID = selectedProjectID

let counter = EntityCounter(
    context: modelContext,
    for: .init(type: Project.self, limit: nil),
         .scoped(
             ProjectTask.self,
             limit: 10,
             scopeID: "\(projectID.uuidString):incomplete",
             where: #Predicate { $0.projectID == projectID && !$0.isCompleted }
         )
)

This counter tracks all projects and only the selected project's incomplete tasks. The remaining examples use this counter.

The scoped predicate replaces the model's static descriptor, including any filters or other settings. Passing where: nil counts all instances of that model.

Use a stable scopeID containing the values that distinguish the predicate. Here it includes the project ID and the incomplete-task filter. Without distinct IDs, different scopes can reuse the wrong cached count before a live fetch. scopeID identifies the cache; the predicate controls what is counted.

Each model type can appear once per counter. To show counts for several projects simultaneously, create a separate counter for each project.

Change the Selected Project

When the selection changes, call rescope with the new project ID:

let projectID = selectedProjectID

counter.rescope(
    ProjectTask.self,
    scopeID: "\(projectID.uuidString):incomplete",
    where: #Predicate { $0.projectID == projectID && !$0.isCompleted }
)

rescope keeps the current task limit and the other models' scopes, clears the previous task count, and immediately refreshes all tracked counts. If the new fetch fails or the context is nil, the task count stays at zero. Untracked types are ignored.

Read Counts and Capacity

Suppose the selected project has six incomplete tasks:

let tasks = counter.count(for: ProjectTask.self)          // 6
let remaining = counter.remaining(for: ProjectTask.self) // 4
let taskLimit = counter.limit(for: ProjectTask.self)      // Optional(10)
let projectLimit = counter.limit(for: Project.self)       // nil: unlimited

if counter.isOverLimit(for: ProjectTask.self) {
    print("This project exceeds its task limit.")
}

remaining(for:) stops at zero. isOverLimit(for:) becomes true only above the limit, so it is still false at exactly 10 tasks. Use remaining capacity to decide whether another task can be added:

if let remaining = counter.remaining(for: ProjectTask.self), remaining == 0 {
    print("Complete a task before adding another.")
}

For unlimited models, limit(for:) and remaining(for:) return nil, and isOverLimit(for:) returns false.

Read Aggregate Values

With two projects in the store and six incomplete tasks in the selected project:

let total = counter.grandCount // 8: two projects + six matching tasks

let combined = counter.combinedLimit(scope: .all)              // nil
let finite = counter.combinedLimit(scope: .excludingUnlimited) // Optional(10)

let remaining = counter.combinedRemaining(scope: .all)              // nil
let finiteRemaining = counter.combinedRemaining(scope: .excludingUnlimited) // Optional(4)

if counter.isOverAnyLimit {
    print("At least one tracked model exceeds its limit.")
}

.all returns nil for combined capacity if any tracked model is unlimited. .excludingUnlimited excludes both the limits and counts of unlimited models. Combined remaining capacity subtracts combined counts from combined limits; use per-model queries when deciding whether that particular model has room for another item.

Update a Limit

To raise the selected project's task limit or remove it:

counter.updateLimit(20, for: ProjectTask.self)
counter.updateLimit(nil, for: ProjectTask.self) // Unlimited

Updated limits survive saves and scope changes on the same counter. Limits are not cached, so supply the desired limits when creating a new counter.

Add Convenience Accessors

Optional typed accessors can keep call sites concise:

extension EntityCounter {
    var taskCount: Int { count(for: ProjectTask.self) }
    var taskRemaining: Int? { remaining(for: ProjectTask.self) }
    var taskLimit: Int? { limit(for: ProjectTask.self) }
    var isTaskOverLimit: Bool { isOverLimit(for: ProjectTask.self) }
}

Use the accessors on the counter instance:

if let remaining = counter.taskRemaining {
    print("This project can add \(remaining) more tasks.")
}

Customise the Default Descriptor

If unscoped task counters should always count incomplete tasks, implement the protocol's descriptor:

extension ProjectTask {
    static var fetchDescriptor: FetchDescriptor<ProjectTask> {
        FetchDescriptor(predicate: #Predicate { !$0.isCompleted })
    }
}

A static descriptor supplies shared counting rules for the model type. Use .scoped when each counter needs its own filter, such as the currently selected project.

Loading, Saves, and Caching

Keep the counter alive for as long as counts are needed. It is @Observable, so SwiftUI can observe counts read from it.

  • Counts initially come from UserDefaults, or zero when no cached value exists.
  • isLoaded becomes true after the initial load attempt, including when the context is nil or a fetch fails. It does not guarantee every count is current.
  • Automatic refreshes follow saves from the supplied context. Unsaved edits do not trigger a refresh, and saves from other contexts or remote sync do not directly trigger one.
  • Every observed save rechecks all tracked predicates. A save outside the selected project normally leaves its task count unchanged.
  • Fetch failures are logged and retain the previous count, except when rescope has cleared it.

Caches are separated by the tracked model set and scope IDs, not by ModelContext or store. Pass a separate userDefaults suite when independent stores use the same models and scopes.

Call counter.stopTracking() to stop observation early. Counts remain readable, but automatic tracking cannot restart on that instance. Observation is also cancelled when the counter is deallocated.

Logging

The package uses OSLog through SimpleLogger to log initialisation, count changes, count-driven limit crossings, and fetch failures. You can inspect these messages in Console.app.

Licence

SwiftDataCounter is released under the MIT licence. See LICENCE for details.

[Shield1]: https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmarkbattistella%2FSwiftDataCounter%2Fbadge%3Ftype%3Dswift-versions

[Shield2]: https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmarkbattistella%2FSwiftDataCounter%2Fbadge%3Ftype%3Dplatforms

[Shield3]: https://img.shields.io/badge/Licence-MIT-white?labelColor=blue&style=flat

Package Metadata

Repository: markbattistella/swiftdatacounter

Default branch: main

README: README.md