|
| 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