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) // UnlimitedUpdated 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. isLoadedbecomestrueafter the initial load attempt, including when the context isnilor 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
rescopehas 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