Skip to content

Commit 182b567

Browse files
committed
Initial Commit
0 parents  commit 182b567

File tree

11 files changed

+553
-0
lines changed

11 files changed

+553
-0
lines changed

.gitignore

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>IDEDidComputeMac32BitWarning</key>
6+
<true/>
7+
</dict>
8+
</plist>

Package.resolved

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"pins" : [
3+
{
4+
"identity" : "swift-syntax",
5+
"kind" : "remoteSourceControl",
6+
"location" : "https://github.com/apple/swift-syntax.git",
7+
"state" : {
8+
"revision" : "6ad4ea24b01559dde0773e3d091f1b9e36175036",
9+
"version" : "509.0.2"
10+
}
11+
}
12+
],
13+
"version" : 2
14+
}

Package.swift

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// swift-tools-version: 5.9
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
import CompilerPluginSupport
6+
7+
let package = Package(
8+
name: "OrderedRelationship",
9+
platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17), .watchOS(.v10), .macCatalyst(.v17)],
10+
products: [
11+
// Products define the executables and libraries a package produces, making them visible to other packages.
12+
.library(
13+
name: "OrderedRelationship",
14+
targets: ["OrderedRelationship"]
15+
)
16+
],
17+
dependencies: [
18+
// Depend on the Swift 5.9 release of SwiftSyntax
19+
.package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"),
20+
],
21+
targets: [
22+
// Targets are the basic building blocks of a package, defining a module or a test suite.
23+
// Targets can depend on other targets in this package and products from dependencies.
24+
// Macro implementation that performs the source transformation of a macro.
25+
.macro(
26+
name: "OrderedRelationshipMacros",
27+
dependencies: [
28+
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
29+
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
30+
]
31+
),
32+
33+
// Library that exposes a macro as part of its API, which is used in client programs.
34+
.target(name: "OrderedRelationship", dependencies: ["OrderedRelationshipMacros"]),
35+
36+
// A test target used to develop the macro implementation.
37+
.testTarget(
38+
name: "OrderedRelationshipTests",
39+
dependencies: [
40+
"OrderedRelationshipMacros",
41+
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
42+
]
43+
),
44+
]
45+
)

README.md

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# SwiftData-OrderedRelationship
2+
A Swift macro taking away the pain in adding ordered relationships to SwiftData models.
3+
4+
## Description
5+
Many SwiftData projects require explicitly ordered relationships. `SwiftData-OrderedRelationship` is a Swift macro that takes away the pain in implementing this and has implicit [CloudKit Sync Conflict Resolution](#conflict-resolution). No special handling on your part is required, when an array with a new order is supplied, `OrderedRelationship` does all the work for you.
6+
7+
## CloudKit Sync
8+
OrderedRelationship is designed to support CloudKit synchronization, providing [fast syncing](#fast-syncing) and implicit [conflict resolution](#conflict-resolution).
9+
10+
### Fast Syncing
11+
OrderedRelationship doesn't store the order of its elements as the index of each element. It rather stores each elements position as a random number between `Int.min` and `Int.max`. When `B` gets inserted between `A` and `C` with the positions 100 and 104[^1], the position of `B` will be randomly chosen between 101, 102 and 103. This means only one object needs to be synced per change.
12+
13+
### Conflict Resolution
14+
As detailed in [fast syncing](#fast-syncing), elements do not have consecutive position numbers, but are distributed randomly between `Int.min` and `Int.max`. By randomly choosing a new number between the numbers inbetween which a new element is inserted or an existing one is moved, having the same operation take place on two different devices with detached state does not require any explicit conflict resolution or re-assigning of indices after both changes have synced to either device.
15+
16+
## Example
17+
18+
Say you have a `SubItem` model:
19+
```Swift
20+
@Model
21+
class SubItem {
22+
init() {}
23+
}
24+
```
25+
26+
Then you can add an ordered relationship to its container `Item`:
27+
```Swift
28+
@Model
29+
final class Item {
30+
@OrderedRelationship
31+
var orderedSubItems: [OrderedSubItem]? = []
32+
33+
init() {}
34+
}
35+
```
36+
37+
The type of the variable is an optional array of an undefined type, preferably the type you want to be ordered with a single word prefix. This type (`OrderedSubItem` in this example) will be defined by the `@OrderedRelationship` macro.
38+
39+
Now you add the inverse relationship to the `SubItem` model:
40+
```Swift
41+
var superitem: Item.OrderedSubItem? = nil
42+
```
43+
44+
That's it. There is nothing more you need to do.
45+
46+
### Resulting Code
47+
The resulting code will contain not only the `OrderedSubItem` model, but also a new variable:
48+
```Swift
49+
var subItems: [SubItem]
50+
```
51+
The variable name is inferred from your custom variable name, removing the first word. You can also specify it explicitly using the [`arrayVariableName` argument](#arrayvariablename). You can both get and set this array. All the work of storing the new order will be performed for you.
52+
53+
## Arguments
54+
The `@OrderedRelationship` macro supports arguments to customize its behavior. All of them are optional, as seen in the example above.
55+
56+
### containingClassName
57+
The name of the class containing the declaration (`Item` in the example above). If `nil`, this will be inferred by the name of the file in which the macro resides.
58+
59+
### itemClassName
60+
The name of the class that the items are. If `nil`, will be inferred by name of the declared array contents without the prefix. In the example above with the array contents being of type `OrderedSubItem`, the type should be `SubItem`.
61+
62+
### arrayVariableName
63+
The variable name of the resulting array. If `nil`, will be inferred by the name of the declared variable without the prefix. In the example above with the variable name being called `orderedSubItems`, the resulting array will be called `subItems`.
64+
65+
### deleteRule
66+
The delete rule to apply to the items. The default value is `.cascade`.
67+
68+
## Why is the macro applied to the ordered items and not the resulting array?
69+
You might wonder why the `@OrderedRelationship` macro is applied to the stored array and not the resulting array, like this:
70+
```Swift
71+
@Model
72+
final class Item {
73+
@OrderedRelationship
74+
var subItems: [SubItem]
75+
76+
init() {}
77+
}
78+
```
79+
While this code is easier to understand, it is simply an impossibility to create such a macro. Macros are expanded alongside each other on a code level basis. This means that the first macro to be expanded in the above code would be the `@Model` macro. The `@Model` macro cannot see the results of the `@OrderedRelationship` macro, since it is expanded after the `@Model` macro. That is why the stored array has to be the one that is declared by the macro user.
80+
81+
82+
83+
[^1]: The positions being this close to each other is a statistically irrelevant event, this is just an example to showcase the method.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import SwiftData
2+
3+
/// A macro that makes a relationship an ordered relationship. It has to be appliced to a variable declaration that is an optional array containing a type name that is not yet defined. For example:
4+
///
5+
/// @OrderedRelationship var rawSubItems: [OrderedSubItem]? = nil
6+
///
7+
/// The model `OrderedSubItem` will be created by the macro.
8+
///
9+
/// - Parameters:
10+
/// - containingClassName: The name of the class containing the declaration. If `nil`, will be inferred by filename.
11+
/// - itemClassName: The name of the class that the items are. If `nil`, will be inferred by name of the declared array contents without the prefix. In the example above with the array contents being of type `OrderedSubItem`, the type should be `SubItem`.
12+
/// - arrayVariableName: The variable name of the resulting array. If `nil`, will be inferred by the name of the declared variable without the prefix. In the example above with the variable name being called `rawSubItems`, the resulting array will be called `subItems`.
13+
/// - deleteRule: The delete rule to apply to the items. The default value is `.cascade`.
14+
@attached(peer, names: overloaded, arbitrary)
15+
public macro OrderedRelationship(
16+
containingClassName: String? = nil,
17+
itemClassName: String? = nil,
18+
arrayVariableName: String? = nil,
19+
deleteRule: Schema.Relationship.DeleteRule = .cascade
20+
) = #externalMacro(module: "OrderedRelationshipMacros", type: "OrderedRelationshipMacro")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Foundation
2+
import SwiftData
3+
import SwiftCompilerPlugin
4+
import SwiftSyntax
5+
import SwiftSyntaxBuilder
6+
import SwiftSyntaxMacros
7+
8+
extension LabeledExprListSyntax {
9+
/// Retrieve the first element with the given label.
10+
func first(labeled name: String) -> Element? {
11+
return first { element in
12+
if let label = element.label, label.text == name {
13+
return true
14+
}
15+
16+
return false
17+
}
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import Foundation
2+
3+
enum OrderedRelationshipError: Error, CustomStringConvertible {
4+
case message(String)
5+
6+
var description: String {
7+
switch self {
8+
case .message(let text):
9+
return text
10+
}
11+
}
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import Foundation
2+
import SwiftData
3+
import SwiftCompilerPlugin
4+
import SwiftSyntax
5+
import SwiftSyntaxBuilder
6+
import SwiftSyntaxMacros
7+
8+
public struct OrderedRelationshipMacro {}
9+
10+
extension OrderedRelationshipMacro: PeerMacro {
11+
12+
public static func expansion(of node: SwiftSyntax.AttributeSyntax, providingPeersOf declaration: some SwiftSyntax.DeclSyntaxProtocol, in context: some SwiftSyntaxMacros.MacroExpansionContext) throws -> [SwiftSyntax.DeclSyntax] {
13+
14+
let argumentList = node.arguments?.as(LabeledExprListSyntax.self) ?? []
15+
let containingModelName: String? = argumentList.first?.expression.as(StringLiteralExprSyntax.self)?.representedLiteralValue
16+
17+
// Find variable and its name
18+
guard
19+
let varDecl = declaration.as(VariableDeclSyntax.self),
20+
let binding = varDecl.bindings.first, varDecl.bindings.count == 1,
21+
let orderedVariableName = binding.pattern.as(IdentifierPatternSyntax.self)?.description
22+
else {
23+
throw OrderedRelationshipError.message("@OrderedRelationship only works on single variables")
24+
}
25+
26+
// Find the item variable name
27+
let itemsVariableName: String
28+
if let name = argumentList.first(labeled: "arrayVariableName")?.expression.as(StringLiteralExprSyntax.self)?.representedLiteralValue {
29+
itemsVariableName = name
30+
} else if let name = try! Regex("[a-z]+([A-Z].*)").wholeMatch(in: orderedVariableName)?[1].substring {
31+
var name = String(name)
32+
let firstLetter = name.removeFirst()
33+
name.insert(contentsOf: firstLetter.lowercased(), at: name.startIndex)
34+
itemsVariableName = name
35+
} else {
36+
throw OrderedRelationshipError.message("Could not infer the items class name, please provide one using the `itemClassName` argument.")
37+
}
38+
39+
// Extract optional array type
40+
guard
41+
let optional = binding.typeAnnotation?.type.as(OptionalTypeSyntax.self),
42+
let array = optional.wrappedType.as(ArrayTypeSyntax.self)
43+
else {
44+
throw OrderedRelationshipError.message("@OrderedRelationship requires an optional array type annotation")
45+
}
46+
let orderedClass = array.element
47+
let orderedClassName = orderedClass.description
48+
49+
// Find the item class name
50+
let itemClassName: String
51+
if let itemModelName = argumentList.first(labeled: "itemClassName")?.expression.as(StringLiteralExprSyntax.self)?.representedLiteralValue {
52+
itemClassName = itemModelName
53+
} else if let itemModelName = try! Regex("[A-Z][a-z]*(.+)").wholeMatch(in: orderedClassName)?[1].substring {
54+
itemClassName = String(itemModelName)
55+
} else {
56+
throw OrderedRelationshipError.message("Could not infer the items class name, please provide one using the `itemClassName` argument.")
57+
}
58+
59+
// Make sure there is no accessorBlock
60+
guard binding.accessorBlock == nil else {
61+
throw OrderedRelationshipError.message("@OrderedRelationship does not support get and set blocks")
62+
}
63+
64+
// Get the container class name
65+
guard
66+
let className = containingModelName ?? context.location(of: declaration, at: .afterLeadingTrivia, filePathMode: .fileID)?.file.description.trimmingCharacters(in: ["\""]).components(separatedBy: "/").last?.replacingOccurrences(of: ".swift", with: "")
67+
else {
68+
throw OrderedRelationshipError.message("No containing class name was found. Please supply one using the `containingClassName` argument.")
69+
}
70+
71+
let deleteRule = argumentList.first(labeled: "deleteRule")?.expression.description ?? ".cascade"
72+
73+
return [
74+
"""
75+
@Model
76+
class \(orderedClass) {
77+
var order: Int = 0
78+
@Relationship(deleteRule: \(raw: deleteRule), inverse: \\\(raw: itemClassName).superitem) var item: \(raw: itemClassName)? = nil
79+
@Relationship(deleteRule: .nullify, inverse: \\\(raw: className).\(raw: orderedVariableName)) var container: \(raw: className)? = nil
80+
81+
init(order: Int, item: \(raw: itemClassName), container: \(raw: className)) {
82+
self.order = order
83+
84+
guard let context = container.modelContext else {
85+
fatalError("Given container for \(orderedClass) has no modelContext.")
86+
}
87+
context.insert(self)
88+
89+
if item.modelContext == nil {
90+
context.insert(item)
91+
} else if item.modelContext != context {
92+
fatalError("New item has different modelContext than its container.")
93+
}
94+
95+
self.item = item
96+
self.container = container
97+
}
98+
}
99+
""",
100+
"""
101+
var \(raw: itemsVariableName): [\(raw: itemClassName)] {
102+
get {
103+
(\(raw: orderedVariableName) ?? []).sorted(using: SortDescriptor(\\.order)).compactMap(\\.item)
104+
}
105+
set {
106+
guard let modelContext else { fatalError("\\(self) is not inserted into a ModelContext yet.") }
107+
108+
var oldOrder = (\(raw: orderedVariableName) ?? []).sorted(using: SortDescriptor(\\.order))
109+
let newOrder = newValue.map({ newValueItem in
110+
oldOrder.first {
111+
$0.item == newValueItem
112+
} ?? .init(order: 0, item: newValueItem, container: self)
113+
})
114+
let differences = newOrder.difference(from: oldOrder)
115+
116+
func completelyRearrangeArray() {
117+
let count = newOrder.count
118+
switch count {
119+
case 0:
120+
return
121+
case 1:
122+
newOrder[0].order = 0
123+
return
124+
default:
125+
break
126+
}
127+
128+
let offset = Int.min / 2
129+
let portion = Int.max / (count - 1)
130+
131+
for index in 0..<count {
132+
newOrder[index].order = offset + portion * index
133+
}
134+
}
135+
136+
for difference in differences {
137+
switch difference {
138+
case .remove(let offset, let element, _):
139+
if !newOrder.contains(element) {
140+
modelContext.delete(element)
141+
}
142+
oldOrder.remove(at: offset)
143+
case .insert(let offset, let element, _):
144+
if oldOrder.isEmpty {
145+
element.order = 0
146+
oldOrder.insert(element, at: offset)
147+
continue
148+
}
149+
150+
var from = Int.min / 2
151+
var to = Int.max / 2
152+
153+
if offset > 0 {
154+
from = oldOrder[offset-1].order + 1
155+
}
156+
if offset < oldOrder.count {
157+
to = oldOrder[offset].order
158+
}
159+
160+
guard from < to else {
161+
completelyRearrangeArray()
162+
return
163+
}
164+
165+
let range: Range<Int> = from..<to
166+
element.order = range.randomElement()!
167+
168+
oldOrder.insert(element, at: offset)
169+
}
170+
}
171+
}
172+
}
173+
"""
174+
]
175+
}
176+
}

0 commit comments

Comments
 (0)