Skip to content
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions Sources/CoreModelSQLite/Attribute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
)
}
}
5 changes: 5 additions & 0 deletions Sources/CoreModelSQLite/AttributeType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
9 changes: 9 additions & 0 deletions Sources/CoreModelSQLite/AttributeValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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)
}
}
}
Expand Down
121 changes: 121 additions & 0 deletions Sources/CoreModelSQLite/CompositeAttribute.swift
Original file line number Diff line number Diff line change
@@ -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<PropertyKey>) -> 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)
}
}
6 changes: 3 additions & 3 deletions Sources/CoreModelSQLite/EntityDescription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions Sources/CoreModelSQLite/ModelData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -47,8 +49,8 @@ internal extension ModelData {
/// DO UPDATE` that only overwrites columns the caller actually supplied.
func providedColumnNames(for entity: EntityDescription) -> Set<String> {
var names = Set<String>()
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)
Expand All @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions Sources/CoreModelSQLite/Predicate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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 })
Expand Down
Loading
Loading