Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d5a32e8
#15 Add `ArithmeticExpression` predicate expression type
colemancda Aug 16, 2026
ed3b098
#15 Add arithmetic case to `Predicate.Expression`
colemancda Aug 16, 2026
7299873
#15 Evaluate arithmetic expressions in memory
colemancda Aug 16, 2026
907be17
#15 Convert arithmetic, collection, aggregate and range predicate exp…
colemancda Aug 16, 2026
a56cb71
#15 Bridge arithmetic expressions to `NSExpression`
colemancda Aug 16, 2026
bcc73b4
#15 Detect functions nested in arithmetic expressions
colemancda Aug 16, 2026
6943e4c
#15 Add `ArithmeticExpression` tests
colemancda Aug 16, 2026
ea372ce
#15 Add tests for arithmetic, collection and range predicate conversion
colemancda Aug 16, 2026
4329de8
#15 Add CoreData fetch tests for arithmetic and ALL/ANY predicates
colemancda Aug 16, 2026
b73dd2f
#15 Resolve key paths that traverse a relationship when evaluating in…
colemancda Aug 16, 2026
a124ca2
#15 Index objects by identifier for relationship key path traversal
colemancda Aug 16, 2026
937ac20
#15 Resolve related objects from every entity when fetching in memory
colemancda Aug 16, 2026
1e9ca2f
#15 Add relationship key path traversal tests
colemancda Aug 16, 2026
832c33f
#15 Verify empty to-many `ALL` semantics match CoreData
colemancda Aug 16, 2026
5824b72
#15 Convert regex `contains` to a `MATCHES` comparison
colemancda Aug 16, 2026
aad1c77
#15 Add regex predicate conversion tests
colemancda Aug 16, 2026
8d8f0f3
#15 Add CoreData fetch test for regex predicates
colemancda Aug 16, 2026
9e620a3
#15 Build the object index without dynamic casting for Embedded Swift
colemancda Aug 16, 2026
700db6c
#15 Truncate integer division to match Swift and CoreData
colemancda Aug 16, 2026
ac344a8
#15 Document integer division semantics
colemancda Aug 16, 2026
4ea17ac
#15 Test integer division truncation and overflow guards
colemancda Aug 16, 2026
cdf0a80
#15 Test converted integer division agrees with Swift evaluation
colemancda Aug 16, 2026
5b11022
#15 Test integer division matches CoreData
colemancda Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Sources/CoreDataModel/FunctionEvaluation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ internal extension FetchRequest.Predicate.Expression {
return true
case .attribute, .relationship, .keyPath:
return false
case let .arithmetic(arithmetic):
return arithmetic.left.containsFunction || arithmetic.right.containsFunction
}
}
}
Expand Down
1 change: 1 addition & 0 deletions Sources/CoreDataModel/NSPredicate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ internal extension FetchRequest.Predicate.Expression {
case let .attribute(value): return NSExpression(forConstantValue: value.toFoundation())
case let .relationship(value): return NSExpression(forConstantValue: value.toFoundation())
case let .function(value): return NSExpression(forFunction: value.name, arguments: value.arguments.map { $0.toFoundation() })
case let .arithmetic(value): return NSExpression(forFunction: value.function.rawValue, arguments: [value.left.toFoundation(), value.right.toFoundation()])
}
}
}
Expand Down
21 changes: 19 additions & 2 deletions Sources/CoreModel/FetchRequestEvaluation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public extension FetchRequest {
/// Filters by entity and predicate, sorts by the sort descriptors
/// (with a stable identifier tiebreaker), then applies the fetch offset and limit.
///
/// Objects of other entities may be included; they're filtered out of the results but
/// remain available for key paths that traverse a relationship (e.g. `events.name`).
///
/// - Parameters:
/// - objects: The objects to evaluate the fetch request against.
/// - functions: Custom functions (keyed by name) that `.function` expressions can invoke.
Expand All @@ -23,7 +26,8 @@ public extension FetchRequest {
) -> [ModelData] {
var results = objects.filter { $0.entity == entity }
if let predicate {
results = results.filter { predicate.evaluate(with: $0, functions: functions) }
let index = objects.index()
results = results.filter { predicate.evaluate(with: $0, functions: functions, objects: index) }
}
results = results.sorted(by: sortDescriptors, functions: functions)
if fetchOffset > 0 {
Expand All @@ -47,7 +51,20 @@ public extension Array where Element == ModelData {
by predicate: FetchRequest.Predicate,
functions: [String: DatabaseFunction] = [:]
) -> [ModelData] {
filter { predicate.evaluate(with: $0, functions: functions) }
let index = self.index()
return filter { predicate.evaluate(with: $0, functions: functions, objects: index) }
}

/// Index these objects by identifier, for resolving key paths that traverse a relationship.
///
/// - Note: Built by hand rather than with `Dictionary.init(_:uniquingKeysWith:)`,
/// which relies on dynamic casting and is unavailable under Embedded Swift.
internal func index() -> [ObjectID: ModelData] {
var index = [ObjectID: ModelData](minimumCapacity: count)
for object in self where index[object.id] == nil {
index[object.id] = object
}
return index
}

/// Sort in memory by the given descriptors, resolving function terms with the
Expand Down
7 changes: 6 additions & 1 deletion Sources/CoreModel/InMemoryStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,12 @@ internal final class InMemoryStorage {
try validate(fetchRequest.entity)
let values = (state.objects[fetchRequest.entity].map { Array($0.values) } ?? [])
.map { normalized(entity: fetchRequest.entity, $0, objects: state.objects) }
return fetchRequest.evaluate(values, functions: state.functions)
// objects of other entities are filtered out of the results, but let key paths
// that traverse a relationship (e.g. `events.name`) resolve their related objects
let related = state.objects
.filter { $0.key != fetchRequest.entity }
.flatMap { $0.value.values }
return fetchRequest.evaluate(values + related, functions: state.functions)
}
}

Expand Down
90 changes: 90 additions & 0 deletions Sources/CoreModel/Predicate/ArithmeticExpression.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//
// ArithmeticExpression.swift
// CoreModel
//
// Created by Alsey Coleman Miller on 8/16/26.
// Copyright © 2026 PureSwift. All rights reserved.
//

public extension FetchRequest.Predicate {

/// An arithmetic operation on two expressions (e.g. `age + 1`).
struct ArithmeticExpression: Equatable, Hashable, Sendable {

/// The arithmetic function to apply.
public var function: Function

/// The left operand.
public var left: Expression

/// The right operand.
public var right: Expression

public init(function: Function, left: Expression, right: Expression) {
self.function = function
self.left = left
self.right = right
}
}
}

// MARK: - Supporting Types

public extension FetchRequest.Predicate.ArithmeticExpression {

/// Arithmetic function.
///
/// Raw values match the corresponding `NSExpression` function names,
/// with operands passed in `(left, right)` order.
enum Function: String, Sendable, CaseIterable {

/// Addition (`left + right`).
case add = "add:to:"

/// Subtraction (`left - right`).
case subtract = "from:subtract:"

/// Multiplication (`left * right`).
case multiply = "multiply:by:"

/// Division (`left / right`).
///
/// Integer operands divide truncating, the way Swift's `/` and
/// `NSExpression`'s `divide:by:` both do; floating-point operands
/// produce a floating-point value.
case divide = "divide:by:"

/// Remainder (`left % right`), integers only.
case modulus = "modulus:by:"
}
}

public extension FetchRequest.Predicate.ArithmeticExpression.Function {

/// The operator symbol (e.g. `+`).
var symbol: String {
switch self {
case .add: return "+"
case .subtract: return "-"
case .multiply: return "*"
case .divide: return "/"
case .modulus: return "%"
}
}
}

// MARK: - CustomStringConvertible

extension FetchRequest.Predicate.ArithmeticExpression: CustomStringConvertible {

public var description: String {
"(" + left.description + " " + function.symbol + " " + right.description + ")"
}
}

// MARK: - Codable

#if !hasFeature(Embedded)
extension FetchRequest.Predicate.ArithmeticExpression: Codable {}
extension FetchRequest.Predicate.ArithmeticExpression.Function: Codable {}
#endif
Loading
Loading