Copilot, Claude, Cursor, and others all read from ~/.agents/. The npx skills CLI handles fan-out to tool-specific directories.
259 lines
6.4 KiB
Markdown
259 lines
6.4 KiB
Markdown
---
|
|
name: Swift Model Design
|
|
description: Model design patterns including single source of truth and computed properties
|
|
globs: ["**/*.swift"]
|
|
---
|
|
|
|
# Model Design Patterns
|
|
|
|
**Computed properties should be the single source of truth for derived data.**
|
|
|
|
## Single Source of Truth Principle
|
|
|
|
Never store data that can be computed from other stored data. This prevents sync bugs and simplifies maintenance.
|
|
|
|
### Name Fields Pattern
|
|
|
|
When a model has multiple name components, use a computed property for the display name:
|
|
|
|
```swift
|
|
@Model
|
|
final class Person {
|
|
var prefix: String = "" // "Dr.", "Mr.", etc.
|
|
var firstName: String = ""
|
|
var middleName: String = ""
|
|
var lastName: String = ""
|
|
var suffix: String = "" // "Jr.", "III", etc.
|
|
var nickname: String = ""
|
|
|
|
// GOOD - Computed from individual fields
|
|
var fullName: String {
|
|
var parts: [String] = []
|
|
if !prefix.isEmpty { parts.append(prefix) }
|
|
if !firstName.isEmpty { parts.append(firstName) }
|
|
if !middleName.isEmpty { parts.append(middleName) }
|
|
if !lastName.isEmpty { parts.append(lastName) }
|
|
if !suffix.isEmpty { parts.append(suffix) }
|
|
return parts.joined(separator: " ")
|
|
}
|
|
|
|
// For display with nickname
|
|
var displayName: String {
|
|
if !nickname.isEmpty {
|
|
return nickname
|
|
}
|
|
if !firstName.isEmpty {
|
|
return firstName
|
|
}
|
|
return fullName
|
|
}
|
|
|
|
// Plain format for export (no special formatting)
|
|
var vCardName: String {
|
|
[firstName, middleName, lastName]
|
|
.filter { !$0.isEmpty }
|
|
.joined(separator: " ")
|
|
}
|
|
|
|
// BAD - Stored displayName that can get out of sync
|
|
// var storedDisplayName: String // Never add this
|
|
}
|
|
```
|
|
|
|
### Benefits of Computed Properties
|
|
|
|
- **Always up to date**: Changes to individual fields are immediately reflected
|
|
- **No sync bugs**: No risk of stored value diverging from component fields
|
|
- **Simpler code**: No need to update derived values when editing source fields
|
|
- **Less storage**: No duplicate data in database
|
|
|
|
## Derived State Patterns
|
|
|
|
### Counts and Aggregates
|
|
|
|
```swift
|
|
@Model
|
|
final class Project {
|
|
var name: String = ""
|
|
@Relationship(deleteRule: .cascade)
|
|
var tasks: [Task]? = []
|
|
|
|
// GOOD - Computed counts
|
|
var taskCount: Int {
|
|
tasks?.count ?? 0
|
|
}
|
|
|
|
var completedTaskCount: Int {
|
|
tasks?.filter(\.isCompleted).count ?? 0
|
|
}
|
|
|
|
var progress: Double {
|
|
guard taskCount > 0 else { return 0 }
|
|
return Double(completedTaskCount) / Double(taskCount)
|
|
}
|
|
|
|
var isComplete: Bool {
|
|
taskCount > 0 && completedTaskCount == taskCount
|
|
}
|
|
|
|
// BAD - Stored counts that need manual updates
|
|
// var storedTaskCount: Int = 0
|
|
// var storedCompletedCount: Int = 0
|
|
}
|
|
```
|
|
|
|
### Status and State
|
|
|
|
```swift
|
|
@Model
|
|
final class Order {
|
|
var createdAt: Date = Date()
|
|
var paidAt: Date?
|
|
var shippedAt: Date?
|
|
var deliveredAt: Date?
|
|
var cancelledAt: Date?
|
|
|
|
// GOOD - Computed status
|
|
var status: OrderStatus {
|
|
if cancelledAt != nil { return .cancelled }
|
|
if deliveredAt != nil { return .delivered }
|
|
if shippedAt != nil { return .shipped }
|
|
if paidAt != nil { return .paid }
|
|
return .pending
|
|
}
|
|
|
|
var isActive: Bool {
|
|
cancelledAt == nil && deliveredAt == nil
|
|
}
|
|
|
|
var canCancel: Bool {
|
|
shippedAt == nil && cancelledAt == nil
|
|
}
|
|
}
|
|
|
|
enum OrderStatus: String, Codable {
|
|
case pending, paid, shipped, delivered, cancelled
|
|
}
|
|
```
|
|
|
|
### Validation State
|
|
|
|
```swift
|
|
@Model
|
|
final class UserProfile {
|
|
var email: String = ""
|
|
var phone: String = ""
|
|
var firstName: String = ""
|
|
var lastName: String = ""
|
|
|
|
// GOOD - Computed validation
|
|
var isEmailValid: Bool {
|
|
email.contains("@") && email.contains(".")
|
|
}
|
|
|
|
var isPhoneValid: Bool {
|
|
let digits = phone.filter(\.isNumber)
|
|
return digits.count >= 10
|
|
}
|
|
|
|
var isComplete: Bool {
|
|
!firstName.isEmpty && !lastName.isEmpty && isEmailValid
|
|
}
|
|
|
|
var validationErrors: [String] {
|
|
var errors: [String] = []
|
|
if firstName.isEmpty { errors.append("First name is required") }
|
|
if lastName.isEmpty { errors.append("Last name is required") }
|
|
if !isEmailValid { errors.append("Valid email is required") }
|
|
return errors
|
|
}
|
|
}
|
|
```
|
|
|
|
## When to Store vs Compute
|
|
|
|
### Store When:
|
|
|
|
- Data comes from an external source (API, user input)
|
|
- Computation is expensive and value is accessed frequently
|
|
- Historical accuracy matters (price at time of purchase)
|
|
- You need to query/filter by the value in database
|
|
|
|
### Compute When:
|
|
|
|
- Value is derived from other stored properties
|
|
- Value can change when source properties change
|
|
- Keeping values in sync would be error-prone
|
|
- Computation is fast (string concatenation, simple math)
|
|
|
|
## Caching Expensive Computations
|
|
|
|
For expensive computed values accessed frequently:
|
|
|
|
```swift
|
|
@Observable
|
|
@MainActor
|
|
final class AnalyticsStore {
|
|
private var items: [Item] = []
|
|
|
|
// Cache invalidation tracking
|
|
private var itemsVersion = 0
|
|
private var cachedStatsVersion = -1
|
|
private var cachedStats: Statistics?
|
|
|
|
var statistics: Statistics {
|
|
// Return cached if valid
|
|
if cachedStatsVersion == itemsVersion, let cached = cachedStats {
|
|
return cached
|
|
}
|
|
|
|
// Compute and cache
|
|
let stats = computeStatistics()
|
|
cachedStats = stats
|
|
cachedStatsVersion = itemsVersion
|
|
return stats
|
|
}
|
|
|
|
func updateItems(_ newItems: [Item]) {
|
|
items = newItems
|
|
itemsVersion += 1 // Invalidate cache
|
|
}
|
|
|
|
private func computeStatistics() -> Statistics {
|
|
// Expensive computation
|
|
}
|
|
}
|
|
```
|
|
|
|
## Identifiable and Hashable
|
|
|
|
Implement `Identifiable` for use with SwiftUI lists:
|
|
|
|
```swift
|
|
@Model
|
|
final class Item {
|
|
var id: UUID = UUID() // Or use @Attribute(.unique) if not using CloudKit
|
|
var name: String = ""
|
|
}
|
|
|
|
// SwiftData models are automatically Identifiable if they have an 'id' property
|
|
```
|
|
|
|
For value types used in Sets or as Dictionary keys:
|
|
|
|
```swift
|
|
struct Tag: Hashable, Codable {
|
|
let id: UUID
|
|
let name: String
|
|
|
|
// Hashable based on id only
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(id)
|
|
}
|
|
|
|
static func == (lhs: Tag, rhs: Tag) -> Bool {
|
|
lhs.id == rhs.id
|
|
}
|
|
}
|
|
```
|