From 9f0f1ec04e11bd633e2a189420d2b25bc870d430 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 13:11:36 -0400 Subject: [PATCH 1/9] Composite attributes: Add column expansion for composite attributes --- .../CoreModelSQLite/CompositeAttribute.swift | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 Sources/CoreModelSQLite/CompositeAttribute.swift diff --git a/Sources/CoreModelSQLite/CompositeAttribute.swift b/Sources/CoreModelSQLite/CompositeAttribute.swift new file mode 100644 index 0000000..e3aa759 --- /dev/null +++ b/Sources/CoreModelSQLite/CompositeAttribute.swift @@ -0,0 +1,121 @@ +// +// CompositeAttribute.swift +// CoreModel-SQLite +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +import CoreModel +import SQLite + +// MARK: - Column Expansion + +/// A scalar column contributed by an attribute. +/// +/// A scalar attribute contributes a single column named after it. A composite attribute +/// contributes one column per *leaf* element, at any nesting depth — the same expansion +/// CoreData performs for `NSCompositeAttributeDescription`, so element key paths are +/// ordinary column references rather than JSON extractions. +/// +/// Columns are named by their full dotted path (`address.location.latitude`). CoreData +/// instead names them after the leaf alone and disambiguates collisions with a numeric +/// suffix, which obliges it to persist the mapping; a path name is deterministic and +/// needs no bookkeeping. It also means ``PredicateKeyPath`` `rawValue` is already the +/// column name, so predicates and sort terms need no rewriting. +internal struct AttributeColumn: Equatable, Hashable { + + /// The dotted column name, e.g. `address.location.latitude`. + let name: String + + /// The property path from the entity to this leaf. + let path: [PropertyKey] + + /// The leaf type. Never ``AttributeType/composite(_:)``. + let type: AttributeType +} + +internal extension Array where Element == PropertyKey { + + /// The dotted column name for a property path. + var columnName: String { + reduce("") { $0 + ($0.isEmpty ? "" : ".") + $1.rawValue } + } +} + +internal extension Attribute { + + /// The columns this attribute contributes, expanding composites into their leaves. + var columns: [AttributeColumn] { + Attribute.columns(id: id, type: type, parent: []) + } + + private static func columns( + id: PropertyKey, + type: AttributeType, + parent: [PropertyKey] + ) -> [AttributeColumn] { + let path = parent + [id] + guard case let .composite(elements) = type else { + return [AttributeColumn(name: path.columnName, path: path, type: type)] + } + return elements.flatMap { columns(id: $0.id, type: $0.type, parent: path) } + } +} + +internal extension EntityDescription { + + /// Every scalar attribute column of this entity, with composites expanded. + var attributeColumns: [AttributeColumn] { + attributes.flatMap { $0.columns } + } +} + +// MARK: - Value Expansion + +internal extension AttributeValue { + + /// The value at a path within this value, descending through composite elements. + /// + /// - Returns: `nil` when the path leaves the value, which a caller binds as SQL `NULL`. + func value(at path: ArraySlice) -> AttributeValue? { + guard let key = path.first else { + return self + } + guard case let .composite(elements) = self, let element = elements[key] else { + return nil + } + return element.value(at: path.dropFirst()) + } + + /// Rebuild an attribute value from a row's expanded columns. + /// + /// - Note: A composite whose every leaf is `NULL` decodes as `.null`. An expanded + /// column layout has nowhere to record the difference between an absent composite and + /// one whose elements are all null, so the two are indistinguishable — exactly as they + /// are in CoreData, which stores composites the same way. + static func decode(attribute: Attribute, row: [String: Binding?]) throws -> AttributeValue { + try decode(id: attribute.id, type: attribute.type, parent: [], row: row) + } + + private static func decode( + id: PropertyKey, + type: AttributeType, + parent: [PropertyKey], + row: [String: Binding?] + ) throws -> AttributeValue { + let path = parent + [id] + guard case let .composite(elements) = type else { + return try AttributeValue(binding: row[path.columnName] ?? nil, type: type) + } + var values = [PropertyKey: AttributeValue](minimumCapacity: elements.count) + var isEmpty = true + for element in elements { + let value = try decode(id: element.id, type: element.type, parent: path, row: row) + if value != .null { + isEmpty = false + } + values[element.id] = value + } + return isEmpty ? .null : .composite(values) + } +} From a6e4f29be3aa3166424912f85e15f1d59c8d2879 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 13:11:36 -0400 Subject: [PATCH 2/9] Composite attributes: Handle the composite attribute type --- Sources/CoreModelSQLite/AttributeType.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/CoreModelSQLite/AttributeType.swift b/Sources/CoreModelSQLite/AttributeType.swift index eaca2f3..a3fc03a 100644 --- a/Sources/CoreModelSQLite/AttributeType.swift +++ b/Sources/CoreModelSQLite/AttributeType.swift @@ -39,6 +39,11 @@ internal extension ColumnDefinition.Affinity { // stored as a string to preserve precision; // NUMERIC affinity would coerce to REAL self = .TEXT + case .composite: + // Composite attributes are expanded into one column per leaf element, so a + // composite type never reaches a column definition. + assertionFailure("Composite attribute types are expanded into leaf columns") + self = .BLOB } } } From 56bcde7b61968b729181e3e760b2fde340991dc9 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 13:11:36 -0400 Subject: [PATCH 3/9] Composite attributes: Build column definitions from expanded columns --- Sources/CoreModelSQLite/Attribute.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Sources/CoreModelSQLite/Attribute.swift b/Sources/CoreModelSQLite/Attribute.swift index b2a8c20..3367156 100644 --- a/Sources/CoreModelSQLite/Attribute.swift +++ b/Sources/CoreModelSQLite/Attribute.swift @@ -10,10 +10,13 @@ import SQLite extension ColumnDefinition { + /// - Note: A composite attribute has no single column; use ``init(column:isOptional:)`` + /// with each of its expanded ``AttributeColumn`` values instead. init( attribute: Attribute, isOptional: Bool = true ) { + assert(attribute.type.isComposite == false, "Composite attributes are expanded into leaf columns") self.init( name: attribute.id.rawValue, primaryKey: nil, @@ -24,4 +27,20 @@ extension ColumnDefinition { references: nil ) } + + /// A column contributed by an attribute, which for a composite is one of its leaves. + init( + column: AttributeColumn, + isOptional: Bool = true + ) { + self.init( + name: column.name, + primaryKey: nil, + type: .init(attributeType: column.type), + nullable: isOptional, + unique: false, + defaultValue: .NULL, + references: nil + ) + } } From 4bc68ae654c2b0c9c9d093d7158ef7f765e891c6 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 13:11:37 -0400 Subject: [PATCH 4/9] Composite attributes: Create a column per composite attribute element --- Sources/CoreModelSQLite/EntityDescription.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/CoreModelSQLite/EntityDescription.swift b/Sources/CoreModelSQLite/EntityDescription.swift index 4c5b906..bc0caad 100644 --- a/Sources/CoreModelSQLite/EntityDescription.swift +++ b/Sources/CoreModelSQLite/EntityDescription.swift @@ -33,9 +33,9 @@ internal extension SchemaChanger.CreateTableDefinition { primaryKey: .init(autoIncrement: false), type: .TEXT, nullable: false, unique: true, defaultValue: .NULL, references: nil) add(column: id) - // add attribute columns - for attribute in entity.attributes { - add(column: ColumnDefinition(attribute: attribute)) + // add attribute columns, expanding composite attributes into one column per leaf + for column in entity.attributeColumns { + add(column: ColumnDefinition(column: column)) } // add to-one relationship columns From 6a81853ce6a54c4bc49ffee3e0f7b4fe7b578935 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 13:11:37 -0400 Subject: [PATCH 5/9] Composite attributes: Expand composite attribute values across columns --- Sources/CoreModelSQLite/ModelData.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Sources/CoreModelSQLite/ModelData.swift b/Sources/CoreModelSQLite/ModelData.swift index 1768e0f..a805009 100644 --- a/Sources/CoreModelSQLite/ModelData.swift +++ b/Sources/CoreModelSQLite/ModelData.swift @@ -23,9 +23,11 @@ internal extension ModelData { assert(entity.id == self.entity) var values: [(String, Binding?)] = [(SQLiteDatabase.primaryKeyColumn, id.rawValue.binding)] values.reserveCapacity(1 + entity.attributes.count + entity.relationships.count) - for attribute in entity.attributes { - let value = attributes[attribute.id] ?? .null - values.append((attribute.id.rawValue, value.binding)) + // composite attributes contribute one column per leaf element + for column in entity.attributeColumns { + let root = attributes[column.path[0]] ?? .null + let value = root.value(at: column.path.dropFirst()) ?? .null + values.append((column.name, value.binding)) } for relationship in entity.relationships where relationship.type == .toOne { let binding: Binding? @@ -47,8 +49,8 @@ internal extension ModelData { /// DO UPDATE` that only overwrites columns the caller actually supplied. func providedColumnNames(for entity: EntityDescription) -> Set { var names = Set() - for attribute in entity.attributes where attributes[attribute.id] != nil { - names.insert(attribute.id.rawValue) + for column in entity.attributeColumns where attributes[column.path[0]] != nil { + names.insert(column.name) } for relationship in entity.relationships where relationship.type == .toOne && relationships[relationship.id] != nil { names.insert(relationship.id.rawValue) @@ -68,8 +70,7 @@ internal extension ModelData { var attributes = [PropertyKey: AttributeValue]() attributes.reserveCapacity(entity.attributes.count) for attribute in entity.attributes { - let binding = row[attribute.id.rawValue] ?? nil - attributes[attribute.id] = try AttributeValue(binding: binding, type: attribute.type) + attributes[attribute.id] = try AttributeValue.decode(attribute: attribute, row: row) } var relationships = [PropertyKey: RelationshipValue]() relationships.reserveCapacity(entity.relationships.count) From 6699818842f45e4fe918e3e399f6760d4f3cbf61 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 13:11:37 -0400 Subject: [PATCH 6/9] Composite attributes: Reject composite values as single bindings --- Sources/CoreModelSQLite/AttributeValue.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/CoreModelSQLite/AttributeValue.swift b/Sources/CoreModelSQLite/AttributeValue.swift index 45cfb61..dca4154 100644 --- a/Sources/CoreModelSQLite/AttributeValue.swift +++ b/Sources/CoreModelSQLite/AttributeValue.swift @@ -46,6 +46,11 @@ internal extension AttributeValue { return .double(value) case let .decimal(value): return .text(value.description) + case .composite: + // A composite has no single binding: it is expanded into one binding per leaf + // column by `ModelData.columnValues(for:)`. Reaching here means a composite was + // used where a scalar is required, e.g. as a predicate constant. + return nil } } @@ -116,6 +121,10 @@ internal extension AttributeValue { throw SQLiteDatabaseError.invalidBinding(binding, type) } self = .decimal(value) + case .composite: + // Composites are reassembled from their leaf columns by + // `AttributeValue.decode(attribute:row:)`, never from a single binding. + throw SQLiteDatabaseError.invalidBinding(binding, type) } } } From 3f96b44e6a9bb0b9a8b57e4af7d8eadec77b333b Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 13:11:37 -0400 Subject: [PATCH 7/9] Composite attributes: Resolve composite element key paths to columns --- Sources/CoreModelSQLite/Predicate.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/CoreModelSQLite/Predicate.swift b/Sources/CoreModelSQLite/Predicate.swift index 993674a..842fe04 100644 --- a/Sources/CoreModelSQLite/Predicate.swift +++ b/Sources/CoreModelSQLite/Predicate.swift @@ -274,6 +274,10 @@ private extension FetchRequest.Predicate.Expression { return try function.sqlFragment(for: entity, predicate: predicate) case .attribute, .relationship: return SQLFragment(sql: "?", bindings: [try constantBinding(predicate: predicate)]) + case .arithmetic: + // - TODO: Translate arithmetic expressions to SQL. Until then they are + // rejected so the caller can fall back to in-memory evaluation. + throw SQLiteDatabaseError.invalidPredicate(predicate) } } @@ -291,7 +295,7 @@ private extension FetchRequest.Predicate.Expression { case .toMany: throw SQLiteDatabaseError.invalidPredicate(predicate) } - case .keyPath, .function: + case .keyPath, .function, .arithmetic: throw SQLiteDatabaseError.invalidPredicate(predicate) } } @@ -336,7 +340,10 @@ internal extension EntityDescription { if property.rawValue == SQLiteDatabase.primaryKeyColumn { return true } - if attributes.contains(where: { $0.id == property }) { + // Composite attributes are expanded into leaf columns named by dotted path, so an + // element key path such as `address.location.latitude` matches its column directly. + // A composite attribute *as a whole* deliberately does not: it has no single column. + if attributeColumns.contains(where: { $0.name == property.rawValue }) { return true } return relationships.contains(where: { $0.id == property && $0.type == .toOne }) From d0ec004adf0ddc9be3ac53220f90a95b682ee0fd Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 13:11:37 -0400 Subject: [PATCH 8/9] Composite attributes: Add tests --- .../CompositeAttributeTests.swift | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 Tests/CoreModelSQLiteTests/CompositeAttributeTests.swift diff --git a/Tests/CoreModelSQLiteTests/CompositeAttributeTests.swift b/Tests/CoreModelSQLiteTests/CompositeAttributeTests.swift new file mode 100644 index 0000000..9bce217 --- /dev/null +++ b/Tests/CoreModelSQLiteTests/CompositeAttributeTests.swift @@ -0,0 +1,229 @@ +// +// CompositeAttributeTests.swift +// CoreModel-SQLite +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +import Foundation +import Testing +import CoreModel +import SQLite +@testable import CoreModelSQLite + +/// Composite attributes, which are expanded into one column per leaf element. +@Suite struct CompositeAttributeTests { + + /// `location` is a flat composite; `address` nests one composite inside another. + static var model: Model { Model(entities: [ + EntityDescription( + id: "Facility", + attributes: [ + .init(id: "name", type: .string), + .init(id: "location", elements: [ + .init(id: "latitude", type: .double), + .init(id: "longitude", type: .double) + ]), + .init(id: "address", elements: [ + .init(id: "street", type: .string), + .init(id: "location", elements: [ + .init(id: "latitude", type: .double), + .init(id: "longitude", type: .double) + ]) + ]) + ], + relationships: [] + ) + ]) } + + static func makeDatabase() throws -> SQLiteDatabase { + try SQLiteDatabase(path: temporaryDatabasePath(named: "Composite"), model: model) + } + + static func facility( + id: ObjectID, + name: String, + latitude: Double, + longitude: Double, + street: String = "1 Main", + innerLatitude: Double = 1.5 + ) -> ModelData { + ModelData( + entity: "Facility", + id: id, + attributes: [ + "name": .string(name), + "location": .composite([ + "latitude": .double(latitude), + "longitude": .double(longitude) + ]), + "address": .composite([ + "street": .string(street), + "location": .composite([ + "latitude": .double(innerLatitude), + "longitude": .double(2.5) + ]) + ]) + ] + ) + } + + // MARK: - Column expansion + + @Test func columnExpansion() { + let entity = Self.model.entities[0] + let names = entity.attributeColumns.map(\.name).sorted() + #expect(names == [ + "address.location.latitude", + "address.location.longitude", + "address.street", + "location.latitude", + "location.longitude", + "name" + ]) + // every expanded column is a scalar type + #expect(entity.attributeColumns.allSatisfy { $0.type.isComposite == false }) + // and the leaf path is preserved + let deep = entity.attributeColumns.first { $0.name == "address.location.latitude" } + #expect(deep?.path == ["address", "location", "latitude"]) + #expect(deep?.type == .double) + } + + /// The table has a real, natively typed column per leaf — no JSON, no blob. + @Test func schemaHasLeafColumns() throws { + let path = temporaryDatabasePath(named: "CompositeSchema") + _ = try SQLiteDatabase(path: path, model: Self.model) + let reader = try Connection(path: path, isReadOnly: true) + let statement = try reader.prepare("SELECT name, type FROM pragma_table_info('Facility')") + var byName = [String: String]() + while let row = try statement.failableNext() { + byName[row[0]?.textValue ?? ""] = row[1]?.textValue ?? "" + } + #expect(byName["location.latitude"] == "REAL") + #expect(byName["address.location.latitude"] == "REAL") + #expect(byName["address.street"] == "TEXT") + // the composite itself is not a column + #expect(byName["location"] == nil) + #expect(byName["address"] == nil) + } + + // MARK: - Round trip + + @Test func compositeRoundTrip() async throws { + let database = try Self.makeDatabase() + let facility = Self.facility(id: "north", name: "North", latitude: 40.7, longitude: -74.0) + try await database.insert(facility) + let fetched = try #require(try await database.fetch("Facility", for: "north")) + #expect(fetched.attributes["location"] == .composite([ + "latitude": .double(40.7), + "longitude": .double(-74.0) + ])) + #expect(fetched.attributes["address"] == .composite([ + "street": .string("1 Main"), + "location": .composite([ + "latitude": .double(1.5), + "longitude": .double(2.5) + ]) + ])) + } + + @Test func compositeUpdate() async throws { + let database = try Self.makeDatabase() + try await database.insert(Self.facility(id: "north", name: "North", latitude: 40.7, longitude: -74.0)) + try await database.insert(Self.facility(id: "north", name: "North", latitude: 1.0, longitude: 2.0, street: "3 Elm")) + let fetched = try #require(try await database.fetch("Facility", for: "north")) + #expect(fetched.attributes["location"] == .composite([ + "latitude": .double(1.0), + "longitude": .double(2.0) + ])) + guard case let .composite(address)? = fetched.attributes["address"] else { + Issue.record("expected composite address") + return + } + #expect(address["street"] == .string("3 Elm")) + } + + /// An expanded column layout cannot distinguish an absent composite from one whose + /// elements are all null — the same lossy behavior CoreData has. + @Test func absentCompositeIsNull() async throws { + let database = try Self.makeDatabase() + let bare = ModelData(entity: "Facility", id: "bare", attributes: ["name": .string("Bare")]) + try await database.insert(bare) + let fetched = try #require(try await database.fetch("Facility", for: "bare")) + #expect(fetched.attributes["location"] == .null) + #expect(fetched.attributes["address"] == .null) + } + + /// A partially specified composite keeps its set elements and nulls the rest. + @Test func partialComposite() async throws { + let database = try Self.makeDatabase() + let partial = ModelData( + entity: "Facility", + id: "partial", + attributes: [ + "name": .string("Partial"), + "location": .composite(["latitude": .double(40.7)]) + ] + ) + try await database.insert(partial) + let fetched = try #require(try await database.fetch("Facility", for: "partial")) + #expect(fetched.attributes["location"] == .composite([ + "latitude": .double(40.7), + "longitude": .null + ])) + } + + // MARK: - Predicates and sorting + + @Test func elementPredicate() async throws { + let database = try Self.makeDatabase() + try await database.insert(Self.facility(id: "north", name: "North", latitude: 40.7, longitude: -74.0)) + try await database.insert(Self.facility(id: "south", name: "South", latitude: 25.8, longitude: -80.2)) + let results = try await database.fetch(FetchRequest( + entity: "Facility", + predicate: "location.latitude" > 30 + )) + #expect(results.map(\.id) == ["north"]) + } + + /// A key path two levels deep is still an ordinary column reference. + @Test func nestedElementPredicate() async throws { + let database = try Self.makeDatabase() + try await database.insert(Self.facility(id: "north", name: "North", latitude: 40.7, longitude: -74.0, innerLatitude: 9.5)) + try await database.insert(Self.facility(id: "south", name: "South", latitude: 25.8, longitude: -80.2, innerLatitude: 1.5)) + let results = try await database.fetch(FetchRequest( + entity: "Facility", + predicate: "address.location.latitude" > 5 + )) + #expect(results.map(\.id) == ["north"]) + } + + @Test func elementSort() async throws { + let database = try Self.makeDatabase() + try await database.insert(Self.facility(id: "north", name: "North", latitude: 40.7, longitude: -74.0)) + try await database.insert(Self.facility(id: "south", name: "South", latitude: 25.8, longitude: -80.2)) + let ascending = try await database.fetch(FetchRequest( + entity: "Facility", + sortDescriptors: [.init(property: "location.latitude", ascending: true)] + )) + #expect(ascending.map(\.id) == ["south", "north"]) + let descending = try await database.fetch(FetchRequest( + entity: "Facility", + sortDescriptors: [.init(property: "address.location.latitude", ascending: false)] + )) + #expect(descending.count == 2) + } + + /// A composite has no single column, so comparing one as a whole is rejected rather + /// than silently mis-compiled. + @Test func wholeCompositePredicateRejected() async throws { + let database = try Self.makeDatabase() + try await database.insert(Self.facility(id: "north", name: "North", latitude: 40.7, longitude: -74.0)) + await #expect(throws: (any Error).self) { + try await database.fetch(FetchRequest( + entity: "Facility", + predicate: "location".compare(.equalTo, .attribute(.composite(["latitude": .double(40.7)]))) + )) + } + } +} From bdf48a5011d12738577492ddeec15e6d0c68cc6d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 16:22:47 -0400 Subject: [PATCH 9/9] Update CoreModel dependency to 2.11.0 --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 07be134..e3a96fd 100644 --- a/Package.swift +++ b/Package.swift @@ -28,7 +28,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/PureSwift/CoreModel", - from: "2.10.1" + from: "2.11.0" ), .package( url: "https://github.com/PureSwift/SQLite",