|
| 1 | +// |
| 2 | +// CompositeAttribute.swift |
| 3 | +// CoreModel-SQLite |
| 4 | +// |
| 5 | +// Created by Alsey Coleman Miller on 8/16/26. |
| 6 | +// |
| 7 | + |
| 8 | +import CoreModel |
| 9 | +import SQLite |
| 10 | + |
| 11 | +// MARK: - Column Expansion |
| 12 | + |
| 13 | +/// A scalar column contributed by an attribute. |
| 14 | +/// |
| 15 | +/// A scalar attribute contributes a single column named after it. A composite attribute |
| 16 | +/// contributes one column per *leaf* element, at any nesting depth — the same expansion |
| 17 | +/// CoreData performs for `NSCompositeAttributeDescription`, so element key paths are |
| 18 | +/// ordinary column references rather than JSON extractions. |
| 19 | +/// |
| 20 | +/// Columns are named by their full dotted path (`address.location.latitude`). CoreData |
| 21 | +/// instead names them after the leaf alone and disambiguates collisions with a numeric |
| 22 | +/// suffix, which obliges it to persist the mapping; a path name is deterministic and |
| 23 | +/// needs no bookkeeping. It also means ``PredicateKeyPath`` `rawValue` is already the |
| 24 | +/// column name, so predicates and sort terms need no rewriting. |
| 25 | +internal struct AttributeColumn: Equatable, Hashable { |
| 26 | + |
| 27 | + /// The dotted column name, e.g. `address.location.latitude`. |
| 28 | + let name: String |
| 29 | + |
| 30 | + /// The property path from the entity to this leaf. |
| 31 | + let path: [PropertyKey] |
| 32 | + |
| 33 | + /// The leaf type. Never ``AttributeType/composite(_:)``. |
| 34 | + let type: AttributeType |
| 35 | +} |
| 36 | + |
| 37 | +internal extension Array where Element == PropertyKey { |
| 38 | + |
| 39 | + /// The dotted column name for a property path. |
| 40 | + var columnName: String { |
| 41 | + reduce("") { $0 + ($0.isEmpty ? "" : ".") + $1.rawValue } |
| 42 | + } |
| 43 | +} |
| 44 | + |
| 45 | +internal extension Attribute { |
| 46 | + |
| 47 | + /// The columns this attribute contributes, expanding composites into their leaves. |
| 48 | + var columns: [AttributeColumn] { |
| 49 | + Attribute.columns(id: id, type: type, parent: []) |
| 50 | + } |
| 51 | + |
| 52 | + private static func columns( |
| 53 | + id: PropertyKey, |
| 54 | + type: AttributeType, |
| 55 | + parent: [PropertyKey] |
| 56 | + ) -> [AttributeColumn] { |
| 57 | + let path = parent + [id] |
| 58 | + guard case let .composite(elements) = type else { |
| 59 | + return [AttributeColumn(name: path.columnName, path: path, type: type)] |
| 60 | + } |
| 61 | + return elements.flatMap { columns(id: $0.id, type: $0.type, parent: path) } |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +internal extension EntityDescription { |
| 66 | + |
| 67 | + /// Every scalar attribute column of this entity, with composites expanded. |
| 68 | + var attributeColumns: [AttributeColumn] { |
| 69 | + attributes.flatMap { $0.columns } |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +// MARK: - Value Expansion |
| 74 | + |
| 75 | +internal extension AttributeValue { |
| 76 | + |
| 77 | + /// The value at a path within this value, descending through composite elements. |
| 78 | + /// |
| 79 | + /// - Returns: `nil` when the path leaves the value, which a caller binds as SQL `NULL`. |
| 80 | + func value(at path: ArraySlice<PropertyKey>) -> AttributeValue? { |
| 81 | + guard let key = path.first else { |
| 82 | + return self |
| 83 | + } |
| 84 | + guard case let .composite(elements) = self, let element = elements[key] else { |
| 85 | + return nil |
| 86 | + } |
| 87 | + return element.value(at: path.dropFirst()) |
| 88 | + } |
| 89 | + |
| 90 | + /// Rebuild an attribute value from a row's expanded columns. |
| 91 | + /// |
| 92 | + /// - Note: A composite whose every leaf is `NULL` decodes as `.null`. An expanded |
| 93 | + /// column layout has nowhere to record the difference between an absent composite and |
| 94 | + /// one whose elements are all null, so the two are indistinguishable — exactly as they |
| 95 | + /// are in CoreData, which stores composites the same way. |
| 96 | + static func decode(attribute: Attribute, row: [String: Binding?]) throws -> AttributeValue { |
| 97 | + try decode(id: attribute.id, type: attribute.type, parent: [], row: row) |
| 98 | + } |
| 99 | + |
| 100 | + private static func decode( |
| 101 | + id: PropertyKey, |
| 102 | + type: AttributeType, |
| 103 | + parent: [PropertyKey], |
| 104 | + row: [String: Binding?] |
| 105 | + ) throws -> AttributeValue { |
| 106 | + let path = parent + [id] |
| 107 | + guard case let .composite(elements) = type else { |
| 108 | + return try AttributeValue(binding: row[path.columnName] ?? nil, type: type) |
| 109 | + } |
| 110 | + var values = [PropertyKey: AttributeValue](minimumCapacity: elements.count) |
| 111 | + var isEmpty = true |
| 112 | + for element in elements { |
| 113 | + let value = try decode(id: element.id, type: element.type, parent: path, row: row) |
| 114 | + if value != .null { |
| 115 | + isEmpty = false |
| 116 | + } |
| 117 | + values[element.id] = value |
| 118 | + } |
| 119 | + return isEmpty ? .null : .composite(values) |
| 120 | + } |
| 121 | +} |
0 commit comments