From d5a32e8c3b24ef6a8634d7354a5db7537daf8ac9 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:38:47 -0400 Subject: [PATCH 01/23] #15 Add `ArithmeticExpression` predicate expression type --- .../Predicate/ArithmeticExpression.swift | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 Sources/CoreModel/Predicate/ArithmeticExpression.swift diff --git a/Sources/CoreModel/Predicate/ArithmeticExpression.swift b/Sources/CoreModel/Predicate/ArithmeticExpression.swift new file mode 100644 index 0000000..7c14868 --- /dev/null +++ b/Sources/CoreModel/Predicate/ArithmeticExpression.swift @@ -0,0 +1,86 @@ +// +// 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`), always producing 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 From ed3b098c10bb82e00636f6d33cf82e0ca3387345 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:38:47 -0400 Subject: [PATCH 02/23] #15 Add arithmetic case to `Predicate.Expression` --- Sources/CoreModel/Predicate/Expression.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/CoreModel/Predicate/Expression.swift b/Sources/CoreModel/Predicate/Expression.swift index 2e2e0a9..a99c0f2 100644 --- a/Sources/CoreModel/Predicate/Expression.swift +++ b/Sources/CoreModel/Predicate/Expression.swift @@ -31,6 +31,9 @@ public extension FetchRequest.Predicate { /// Expression that invokes a named function (e.g. a custom function registered /// with the underlying store) with a list of argument expressions. case function(FunctionExpression) + + /// Expression that applies an arithmetic operation to two subexpressions. + indirect case arithmetic(ArithmeticExpression) } /// Type of predicate expression. @@ -40,6 +43,7 @@ public extension FetchRequest.Predicate { case relationship case keyPath case function + case arithmetic } } @@ -55,6 +59,7 @@ public extension FetchRequest.Predicate.Expression { case .relationship: return .relationship case .keyPath: return .keyPath case .function: return .function + case .arithmetic: return .arithmetic } } } @@ -70,6 +75,7 @@ extension FetchRequest.Predicate.Expression: CustomStringConvertible { case let .relationship(value): return value.predicateDescription case let .keyPath(value): return value.description case let .function(value): return value.description + case let .arithmetic(value): return value.description } } } @@ -140,6 +146,9 @@ extension FetchRequest.Predicate.Expression: Codable { case .function: let expression = try container.decode(FetchRequest.Predicate.FunctionExpression.self, forKey: .expression) self = .function(expression) + case .arithmetic: + let expression = try container.decode(FetchRequest.Predicate.ArithmeticExpression.self, forKey: .expression) + self = .arithmetic(expression) } } @@ -157,6 +166,8 @@ extension FetchRequest.Predicate.Expression: Codable { try container.encode(keyPath.rawValue, forKey: .expression) case let .function(value): try container.encode(value, forKey: .expression) + case let .arithmetic(value): + try container.encode(value, forKey: .expression) } } } From 72998730b2b37437f6d93b1bc2c4dee9e317fbd6 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:38:47 -0400 Subject: [PATCH 03/23] #15 Evaluate arithmetic expressions in memory --- Sources/CoreModel/Predicate/Evaluate.swift | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/Sources/CoreModel/Predicate/Evaluate.swift b/Sources/CoreModel/Predicate/Evaluate.swift index 05e8a39..6740dd0 100644 --- a/Sources/CoreModel/Predicate/Evaluate.swift +++ b/Sources/CoreModel/Predicate/Evaluate.swift @@ -127,6 +127,10 @@ internal extension FetchRequest.Predicate.Expression { $0.evaluate(with: data, functions: functions)?.attributeValue } return registered.evaluate(arguments).map { .attribute($0) } + case let .arithmetic(arithmetic): + let lhs = arithmetic.left.evaluate(with: data, functions: functions)?.attributeValue + let rhs = arithmetic.right.evaluate(with: data, functions: functions)?.attributeValue + return AttributeValue.arithmetic(arithmetic.function, lhs, rhs).map { .attribute($0) } } } } @@ -277,6 +281,50 @@ internal extension AttributeValue { return nil } + /// An integer representation for integer value types, for integer arithmetic. + var integerValue: Int64? { + switch self { + case let .int16(value): return Int64(value) + case let .int32(value): return Int64(value) + case let .int64(value): return value + default: return nil + } + } + + /// Apply an arithmetic function to two values. + /// + /// Integer operands stay in integer arithmetic (except division, which always + /// produces a floating-point value, matching `NSExpression`'s `divide:by:`); + /// mixed or floating-point operands are computed as `Double`. + /// Returns `nil` for non-numeric operands, division by zero, or + /// floating-point remainder. + static func arithmetic( + _ function: FetchRequest.Predicate.ArithmeticExpression.Function, + _ lhs: AttributeValue?, + _ rhs: AttributeValue? + ) -> AttributeValue? { + guard let lhs, let rhs else { return nil } + if let leftInteger = lhs.integerValue, let rightInteger = rhs.integerValue { + switch function { + case .add: return .int64(leftInteger &+ rightInteger) + case .subtract: return .int64(leftInteger &- rightInteger) + case .multiply: return .int64(leftInteger &* rightInteger) + case .divide: break // always floating-point + case .modulus: return rightInteger == 0 ? nil : .int64(leftInteger % rightInteger) + } + } + guard let leftNumber = lhs.comparableDouble, let rightNumber = rhs.comparableDouble else { + return nil + } + switch function { + case .add: return .double(leftNumber + rightNumber) + case .subtract: return .double(leftNumber - rightNumber) + case .multiply: return .double(leftNumber * rightNumber) + case .divide: return rightNumber == 0 ? nil : .double(leftNumber / rightNumber) + case .modulus: return nil // integers only + } + } + static func areEqual(_ lhs: AttributeValue?, _ rhs: AttributeValue?, caseInsensitive: Bool) -> Bool { switch (lhs, rhs) { case (.none, .none), (.some(.null), .none), (.none, .some(.null)), (.some(.null), .some(.null)): From 907be17d91a1ed143445efd0e8606946e44b6cd1 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:38:47 -0400 Subject: [PATCH 04/23] #15 Convert arithmetic, collection, aggregate and range predicate expressions --- .../Predicate/FoundationPredicate.swift | 316 ++++++++++++++---- 1 file changed, 255 insertions(+), 61 deletions(-) diff --git a/Sources/CoreModel/Predicate/FoundationPredicate.swift b/Sources/CoreModel/Predicate/FoundationPredicate.swift index 2d83004..abbcd16 100644 --- a/Sources/CoreModel/Predicate/FoundationPredicate.swift +++ b/Sources/CoreModel/Predicate/FoundationPredicate.swift @@ -23,17 +23,21 @@ public extension FetchRequest.Predicate { /// Creates a ``FetchRequest.Predicate`` from a `Predicate` built with the `#Predicate` macro. /// /// Throws ``FetchRequest/Predicate/ConversionError`` for expressions with no - /// CoreModel equivalent (e.g. arithmetic, subscripts, closures). + /// CoreModel equivalent (e.g. subscripts, type casts, nested closures). init(_ predicate: FoundationEssentials.Predicate) throws { - self = try Self.predicate(converting: predicate.expression) + var context = PredicateConversionContext() + context.variables[predicate.variable.key] = PredicateKeyPath(keys: []) + self = try Self.predicate(converting: predicate.expression, in: context) } #else /// Creates a ``FetchRequest.Predicate`` from a ``Foundation.Predicate`` built with the `#Predicate` macro. /// /// Throws ``FetchRequest/Predicate/ConversionError`` for expressions with no - /// CoreModel equivalent (e.g. arithmetic, subscripts, closures). + /// CoreModel equivalent (e.g. subscripts, type casts, nested closures). init(_ predicate: Foundation.Predicate) throws { - self = try Self.predicate(converting: predicate.expression) + var context = PredicateConversionContext() + context.variables[predicate.variable.key] = PredicateKeyPath(keys: []) + self = try Self.predicate(converting: predicate.expression, in: context) } #endif } @@ -63,39 +67,52 @@ public extension FetchRequest.Predicate { @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) internal enum ConvertedPredicateExpression { - /// A value expression (key path or constant). + /// A value expression (key path, constant, or arithmetic). case expression(FetchRequest.Predicate.Expression) /// A boolean predicate. case predicate(FetchRequest.Predicate) } +/// State threaded through a conversion — the key path each predicate variable +/// (the root input, or a collection element bound by `allSatisfy`/`contains(where:)`) +/// resolves to. +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +internal struct PredicateConversionContext { + + var variables = [PredicateExpressions.VariableID: PredicateKeyPath]() +} + /// Conforming `PredicateExpressions` node types convert themselves to CoreModel form. @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) internal protocol CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression } -/// Range nodes convert to inclusive bounds for a compound `>= lower && <= upper` predicate. +/// Range nodes convert to bounds for a compound comparison predicate. @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) internal protocol CoreModelRangeConvertible { - func coreModelBounds() throws -> (lower: FetchRequest.Predicate.Expression, upper: FetchRequest.Predicate.Expression) + func coreModelBounds(in context: PredicateConversionContext) throws -> ( + lower: FetchRequest.Predicate.Expression, + upper: FetchRequest.Predicate.Expression, + upperOperator: FetchRequest.Predicate.Comparison.Operator + ) } @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) internal extension FetchRequest.Predicate { - static func node(converting expression: Any) throws -> ConvertedPredicateExpression { + static func node(converting expression: Any, in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { guard let convertible = expression as? any CoreModelPredicateConvertible else { throw ConversionError.unsupportedExpression(String(describing: Swift.type(of: expression))) } - return try convertible.toCoreModel() + return try convertible.toCoreModel(in: context) } - static func predicate(converting expression: Any) throws -> FetchRequest.Predicate { - switch try node(converting: expression) { + static func predicate(converting expression: Any, in context: PredicateConversionContext) throws -> FetchRequest.Predicate { + switch try node(converting: expression, in: context) { case let .predicate(predicate): return predicate case let .expression(.attribute(.bool(value))): @@ -108,8 +125,8 @@ internal extension FetchRequest.Predicate { } } - static func expression(converting expression: Any) throws -> Expression { - switch try node(converting: expression) { + static func expression(converting expression: Any, in context: PredicateConversionContext) throws -> Expression { + switch try node(converting: expression, in: context) { case let .expression(expression): return expression case let .predicate(predicate): @@ -117,6 +134,14 @@ internal extension FetchRequest.Predicate { } } + /// Convert the sequence operand of a collection expression to a key path. + static func keyPath(converting expression: Any, in context: PredicateConversionContext) throws -> PredicateKeyPath { + guard case let .expression(.keyPath(keyPath)) = try node(converting: expression, in: context) else { + throw ConversionError.unsupportedExpression(String(describing: Swift.type(of: expression))) + } + return keyPath + } + /// Merge nested compounds of the same logical type (`a && b && c` becomes one `.and`). static func subpredicates(of predicate: FetchRequest.Predicate, _ type: Compound.Logical​Type) -> [FetchRequest.Predicate] { guard type != .not, @@ -126,6 +151,27 @@ internal extension FetchRequest.Predicate { } return compound.subpredicates } + + /// Convert a bound-variable test into a comparison with the given modifier + /// (`ALL` for `allSatisfy`, `ANY` for `contains(where:)`). + static func modifiedComparison( + sequence: Any, + test: Any, + variable: PredicateExpressions.VariableID, + modifier: Comparison.Modifier, + in context: PredicateConversionContext + ) throws -> ConvertedPredicateExpression { + let base = try keyPath(converting: sequence, in: context) + var innerContext = context + innerContext.variables[variable] = base + let test = try predicate(converting: test, in: innerContext) + // only a single direct comparison can carry an ALL/ANY modifier + guard case var .comparison(comparison) = test, comparison.modifier == nil else { + throw ConversionError.unsupportedExpression(test.description) + } + comparison.modifier = modifier + return .predicate(.comparison(comparison)) + } } // MARK: - Key Path Resolution @@ -174,19 +220,20 @@ internal extension PredicateKeyPath { @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.Variable: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { - // the fetched object itself; key paths are appended by `KeyPath` nodes - .expression(.keyPath(PredicateKeyPath(keys: []))) + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + // the fetched object or a bound collection element; key paths are appended by `KeyPath` nodes + guard let keyPath = context.variables[key] else { + throw FetchRequest.Predicate.ConversionError.unsupportedExpression(String(describing: Swift.type(of: self))) + } + return .expression(.keyPath(keyPath)) } } @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.KeyPath: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { - guard case let .expression(.keyPath(base)) = try FetchRequest.Predicate.node(converting: root) else { - throw FetchRequest.Predicate.ConversionError.unsupportedKeyPath(String(describing: keyPath)) - } + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + let base = try FetchRequest.Predicate.keyPath(converting: root, in: context) let names = try PredicateKeyPath.propertyNames(for: keyPath as AnyKeyPath) let keys = base.keys + names.map { .property($0) } return .expression(.keyPath(PredicateKeyPath(keys: keys))) @@ -196,9 +243,9 @@ extension PredicateExpressions.KeyPath: CoreModelPredicateConvertible { @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.Value: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { guard let encodable = value as? AttributeEncodable else { - throw FetchRequest.Predicate.ConversionError.unsupportedValue(String(describing: type(of: value))) + throw FetchRequest.Predicate.ConversionError.unsupportedValue(String(describing: Swift.type(of: value))) } return .expression(.attribute(encodable.attributeValue)) } @@ -207,7 +254,7 @@ extension PredicateExpressions.Value: CoreModelPredicateConvertible { @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.NilLiteral: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { .expression(.attribute(.null)) } } @@ -215,10 +262,10 @@ extension PredicateExpressions.NilLiteral: CoreModelPredicateConvertible { @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.Equal: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { .predicate(.comparison(.init( - left: try FetchRequest.Predicate.expression(converting: lhs), - right: try FetchRequest.Predicate.expression(converting: rhs), + left: try FetchRequest.Predicate.expression(converting: lhs, in: context), + right: try FetchRequest.Predicate.expression(converting: rhs, in: context), type: .equalTo ))) } @@ -227,10 +274,10 @@ extension PredicateExpressions.Equal: CoreModelPredicateConvertible { @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.NotEqual: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { .predicate(.comparison(.init( - left: try FetchRequest.Predicate.expression(converting: lhs), - right: try FetchRequest.Predicate.expression(converting: rhs), + left: try FetchRequest.Predicate.expression(converting: lhs, in: context), + right: try FetchRequest.Predicate.expression(converting: rhs, in: context), type: .notEqualTo ))) } @@ -239,7 +286,7 @@ extension PredicateExpressions.NotEqual: CoreModelPredicateConvertible { @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.Comparison: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { let type: FetchRequest.Predicate.Comparison.Operator switch op { case .lessThan: @@ -254,8 +301,8 @@ extension PredicateExpressions.Comparison: CoreModelPredicateConvertible { throw FetchRequest.Predicate.ConversionError.unsupportedExpression(String(describing: op)) } return .predicate(.comparison(.init( - left: try FetchRequest.Predicate.expression(converting: lhs), - right: try FetchRequest.Predicate.expression(converting: rhs), + left: try FetchRequest.Predicate.expression(converting: lhs, in: context), + right: try FetchRequest.Predicate.expression(converting: rhs, in: context), type: type ))) } @@ -264,9 +311,9 @@ extension PredicateExpressions.Comparison: CoreModelPredicateConvertible { @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.Conjunction: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { - let lhs = try FetchRequest.Predicate.predicate(converting: self.lhs) - let rhs = try FetchRequest.Predicate.predicate(converting: self.rhs) + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + let lhs = try FetchRequest.Predicate.predicate(converting: self.lhs, in: context) + let rhs = try FetchRequest.Predicate.predicate(converting: self.rhs, in: context) return .predicate(.compound(.and( FetchRequest.Predicate.subpredicates(of: lhs, .and) + FetchRequest.Predicate.subpredicates(of: rhs, .and) @@ -277,9 +324,9 @@ extension PredicateExpressions.Conjunction: CoreModelPredicateConvertible { @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.Disjunction: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { - let lhs = try FetchRequest.Predicate.predicate(converting: self.lhs) - let rhs = try FetchRequest.Predicate.predicate(converting: self.rhs) + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + let lhs = try FetchRequest.Predicate.predicate(converting: self.lhs, in: context) + let rhs = try FetchRequest.Predicate.predicate(converting: self.rhs, in: context) return .predicate(.compound(.or( FetchRequest.Predicate.subpredicates(of: lhs, .or) + FetchRequest.Predicate.subpredicates(of: rhs, .or) @@ -290,18 +337,94 @@ extension PredicateExpressions.Disjunction: CoreModelPredicateConvertible { @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.Negation: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { - .predicate(.compound(.not(try FetchRequest.Predicate.predicate(converting: wrapped)))) + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + .predicate(.compound(.not(try FetchRequest.Predicate.predicate(converting: wrapped, in: context)))) } } +// MARK: - Arithmetic Conformances + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension PredicateExpressions.Arithmetic: CoreModelPredicateConvertible { + + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + let function: FetchRequest.Predicate.ArithmeticExpression.Function + switch op { + case .add: + function = .add + case .subtract: + function = .subtract + case .multiply: + function = .multiply + @unknown default: + throw FetchRequest.Predicate.ConversionError.unsupportedExpression(String(describing: op)) + } + return .expression(.arithmetic(.init( + function: function, + left: try FetchRequest.Predicate.expression(converting: lhs, in: context), + right: try FetchRequest.Predicate.expression(converting: rhs, in: context) + ))) + } +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension PredicateExpressions.FloatDivision: CoreModelPredicateConvertible { + + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + .expression(.arithmetic(.init( + function: .divide, + left: try FetchRequest.Predicate.expression(converting: lhs, in: context), + right: try FetchRequest.Predicate.expression(converting: rhs, in: context) + ))) + } +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension PredicateExpressions.IntDivision: CoreModelPredicateConvertible { + + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + .expression(.arithmetic(.init( + function: .divide, + left: try FetchRequest.Predicate.expression(converting: lhs, in: context), + right: try FetchRequest.Predicate.expression(converting: rhs, in: context) + ))) + } +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension PredicateExpressions.IntRemainder: CoreModelPredicateConvertible { + + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + .expression(.arithmetic(.init( + function: .modulus, + left: try FetchRequest.Predicate.expression(converting: lhs, in: context), + right: try FetchRequest.Predicate.expression(converting: rhs, in: context) + ))) + } +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension PredicateExpressions.UnaryMinus: CoreModelPredicateConvertible { + + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + // NSExpression has no negation function, so multiply by -1 + .expression(.arithmetic(.init( + function: .multiply, + left: try FetchRequest.Predicate.expression(converting: wrapped, in: context), + right: .attribute(.int64(-1)) + ))) + } +} + +// MARK: - Collection Conformances + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.SequenceContains: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { .predicate(.comparison(.init( - left: try FetchRequest.Predicate.expression(converting: sequence), - right: try FetchRequest.Predicate.expression(converting: element), + left: try FetchRequest.Predicate.expression(converting: sequence, in: context), + right: try FetchRequest.Predicate.expression(converting: element, in: context), type: .contains ))) } @@ -310,10 +433,10 @@ extension PredicateExpressions.SequenceContains: CoreModelPredicateConvertible { @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.CollectionContainsCollection: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { .predicate(.comparison(.init( - left: try FetchRequest.Predicate.expression(converting: base), - right: try FetchRequest.Predicate.expression(converting: other), + left: try FetchRequest.Predicate.expression(converting: base, in: context), + right: try FetchRequest.Predicate.expression(converting: other, in: context), type: .contains ))) } @@ -322,28 +445,76 @@ extension PredicateExpressions.CollectionContainsCollection: CoreModelPredicateC @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.SequenceStartsWith: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { .predicate(.comparison(.init( - left: try FetchRequest.Predicate.expression(converting: base), - right: try FetchRequest.Predicate.expression(converting: prefix), + left: try FetchRequest.Predicate.expression(converting: base, in: context), + right: try FetchRequest.Predicate.expression(converting: prefix, in: context), type: .beginsWith ))) } } +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension PredicateExpressions.SequenceAllSatisfy: CoreModelPredicateConvertible { + + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + try FetchRequest.Predicate.modifiedComparison( + sequence: sequence, + test: test, + variable: variable.key, + modifier: .all, + in: context + ) + } +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension PredicateExpressions.SequenceContainsWhere: CoreModelPredicateConvertible { + + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + try FetchRequest.Predicate.modifiedComparison( + sequence: sequence, + test: test, + variable: variable.key, + modifier: .any, + in: context + ) + } +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension PredicateExpressions.SequenceMinimum: CoreModelPredicateConvertible { + + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + let base = try FetchRequest.Predicate.keyPath(converting: elements, in: context) + return .expression(.keyPath(PredicateKeyPath(keys: base.keys + [.operator(.min)]))) + } +} + +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension PredicateExpressions.SequenceMaximum: CoreModelPredicateConvertible { + + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + let base = try FetchRequest.Predicate.keyPath(converting: elements, in: context) + return .expression(.keyPath(PredicateKeyPath(keys: base.keys + [.operator(.max)]))) + } +} + +// MARK: - Range Conformances + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.RangeExpressionContains: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { guard let range = self.range as? any CoreModelRangeConvertible else { - throw FetchRequest.Predicate.ConversionError.unsupportedExpression(String(describing: type(of: self.range))) + throw FetchRequest.Predicate.ConversionError.unsupportedExpression(String(describing: Swift.type(of: self.range))) } - let bounds = try range.coreModelBounds() - let element = try FetchRequest.Predicate.expression(converting: self.element) + let bounds = try range.coreModelBounds(in: context) + let element = try FetchRequest.Predicate.expression(converting: self.element, in: context) // CoreModel can't evaluate BETWEEN, so lower ranges to a compound comparison return .predicate(.compound(.and([ .comparison(.init(left: element, right: bounds.lower, type: .greaterThanOrEqualTo)), - .comparison(.init(left: element, right: bounds.upper, type: .lessThanOrEqualTo)) + .comparison(.init(left: element, right: bounds.upper, type: bounds.upperOperator)) ]))) } } @@ -351,22 +522,45 @@ extension PredicateExpressions.RangeExpressionContains: CoreModelPredicateConver @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.ClosedRange: CoreModelRangeConvertible { - func coreModelBounds() throws -> (lower: FetchRequest.Predicate.Expression, upper: FetchRequest.Predicate.Expression) { + func coreModelBounds(in context: PredicateConversionContext) throws -> ( + lower: FetchRequest.Predicate.Expression, + upper: FetchRequest.Predicate.Expression, + upperOperator: FetchRequest.Predicate.Comparison.Operator + ) { ( - lower: try FetchRequest.Predicate.expression(converting: self.lower), - upper: try FetchRequest.Predicate.expression(converting: self.upper) + lower: try FetchRequest.Predicate.expression(converting: self.lower, in: context), + upper: try FetchRequest.Predicate.expression(converting: self.upper, in: context), + upperOperator: .lessThanOrEqualTo ) } } +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +extension PredicateExpressions.Range: CoreModelRangeConvertible { + + func coreModelBounds(in context: PredicateConversionContext) throws -> ( + lower: FetchRequest.Predicate.Expression, + upper: FetchRequest.Predicate.Expression, + upperOperator: FetchRequest.Predicate.Comparison.Operator + ) { + ( + lower: try FetchRequest.Predicate.expression(converting: self.lower, in: context), + upper: try FetchRequest.Predicate.expression(converting: self.upper, in: context), + upperOperator: .lessThan + ) + } +} + +// MARK: - String Conformances + #if canImport(Darwin) @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.StringLocalizedStandardContains: CoreModelPredicateConvertible { - func toCoreModel() throws -> ConvertedPredicateExpression { + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { .predicate(.comparison(.init( - left: try FetchRequest.Predicate.expression(converting: root), - right: try FetchRequest.Predicate.expression(converting: other), + left: try FetchRequest.Predicate.expression(converting: root, in: context), + right: try FetchRequest.Predicate.expression(converting: other, in: context), type: .contains, options: [.caseInsensitive, .diacriticInsensitive] ))) From a56cb71966158c1935a5920e33e9e2c924b14a33 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:38:47 -0400 Subject: [PATCH 05/23] #15 Bridge arithmetic expressions to `NSExpression` --- Sources/CoreDataModel/NSPredicate.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/CoreDataModel/NSPredicate.swift b/Sources/CoreDataModel/NSPredicate.swift index 21ecfa5..ab46565 100644 --- a/Sources/CoreDataModel/NSPredicate.swift +++ b/Sources/CoreDataModel/NSPredicate.swift @@ -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()]) } } } From bcc73b42ee2358970cac04e812dd910bbc60bc4d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:38:47 -0400 Subject: [PATCH 06/23] #15 Detect functions nested in arithmetic expressions --- Sources/CoreDataModel/FunctionEvaluation.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/CoreDataModel/FunctionEvaluation.swift b/Sources/CoreDataModel/FunctionEvaluation.swift index efc7fee..88a189d 100644 --- a/Sources/CoreDataModel/FunctionEvaluation.swift +++ b/Sources/CoreDataModel/FunctionEvaluation.swift @@ -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 } } } From 6943e4c00816cca2460edd85c6bcb02f0ef5edd5 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:38:47 -0400 Subject: [PATCH 07/23] #15 Add `ArithmeticExpression` tests --- .../ArithmeticExpressionTests.swift | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 Tests/CoreModelTests/ArithmeticExpressionTests.swift diff --git a/Tests/CoreModelTests/ArithmeticExpressionTests.swift b/Tests/CoreModelTests/ArithmeticExpressionTests.swift new file mode 100644 index 0000000..d5c0fa9 --- /dev/null +++ b/Tests/CoreModelTests/ArithmeticExpressionTests.swift @@ -0,0 +1,143 @@ +// +// ArithmeticExpressionTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +#if canImport(FoundationEssentials) +import FoundationEssentials +#elseif canImport(Foundation) +import Foundation +#endif +import Testing +@testable import CoreModel + +@Suite struct ArithmeticExpressionTests { + + static var person: ModelData { + ModelData( + entity: "Person", + id: ObjectID(rawValue: "1"), + attributes: [ + "name": .string("Alice"), + "age": .int64(30), + "height": .double(1.7) + ] + ) + } + + @Test func description() { + + let expression = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .add, left: .keyPath("age"), right: .attribute(.int64(1))) + ) + #expect(expression.description == "(age + 1)") + #expect(FetchRequest.Predicate.ArithmeticExpression.Function.allCases.map(\.symbol) == ["+", "-", "*", "/", "%"]) + } + + @Test func evaluation() { + + let person = Self.person + + // integer arithmetic stays integral + let add = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .add, left: .keyPath("age"), right: .attribute(.int64(5))) + ) + #expect(add.compare(.equalTo, .attribute(.int64(35))).evaluate(with: person)) + + let subtract = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .subtract, left: .keyPath("age"), right: .attribute(.int64(12))) + ) + #expect(subtract.compare(.lessThan, .attribute(.int64(19))).evaluate(with: person)) + + let multiply = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .multiply, left: .keyPath("age"), right: .attribute(.int64(2))) + ) + #expect(multiply.compare(.equalTo, .attribute(.int64(60))).evaluate(with: person)) + + let modulus = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .modulus, left: .keyPath("age"), right: .attribute(.int64(7))) + ) + #expect(modulus.compare(.equalTo, .attribute(.int64(2))).evaluate(with: person)) + + // division is always floating-point + let divide = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .divide, left: .keyPath("age"), right: .attribute(.int64(4))) + ) + #expect(divide.compare(.equalTo, .attribute(.double(7.5))).evaluate(with: person)) + + // mixed integer and floating-point operands promote to double + let mixed = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .multiply, left: .keyPath("height"), right: .attribute(.int64(100))) + ) + #expect(mixed.compare(.greaterThan, .attribute(.double(169))).evaluate(with: person)) + + // nested arithmetic: (age + 2) * 2 == 64 + let nested = FetchRequest.Predicate.Expression.arithmetic( + .init( + function: .multiply, + left: .arithmetic(.init(function: .add, left: .keyPath("age"), right: .attribute(.int64(2)))), + right: .attribute(.int64(2)) + ) + ) + #expect(nested.compare(.equalTo, .attribute(.int64(64))).evaluate(with: person)) + } + + @Test func invalidOperands() { + + let person = Self.person + + // division by zero resolves to nil and never satisfies a comparison + let divideByZero = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .divide, left: .keyPath("age"), right: .attribute(.int64(0))) + ) + #expect(divideByZero.compare(.equalTo, .attribute(.int64(0))).evaluate(with: person) == false) + #expect(divideByZero.compare(.greaterThanOrEqualTo, .attribute(.int64(0))).evaluate(with: person) == false) + + // non-numeric operands resolve to nil + let string = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .add, left: .keyPath("name"), right: .attribute(.int64(1))) + ) + #expect(string.compare(.equalTo, .attribute(.int64(1))).evaluate(with: person) == false) + + // floating-point remainder is unsupported + let floatModulus = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .modulus, left: .keyPath("height"), right: .attribute(.int64(2))) + ) + #expect(floatModulus.compare(.lessThan, .attribute(.int64(2))).evaluate(with: person) == false) + } + + @Test func fetchRequestEvaluation() { + + let people = [ + ("Alice", 30), + ("Bob", 17), + ("Alina", 20) + ].map { name, age in + ModelData( + entity: "Person", + id: ObjectID(rawValue: name), + attributes: ["age": .int64(numericCast(age))] + ) + } + // age * 2 >= 40 + let predicate = FetchRequest.Predicate.Expression + .arithmetic(.init(function: .multiply, left: .keyPath("age"), right: .attribute(.int64(2)))) + .compare(.greaterThanOrEqualTo, .attribute(.int64(40))) + let request = FetchRequest(entity: "Person", predicate: predicate) + #expect(request.evaluate(people).map(\.id.rawValue) == ["Alice", "Alina"]) + } + + #if !hasFeature(Embedded) + @Test func codable() throws { + + let predicate = FetchRequest.Predicate.Expression + .arithmetic(.init(function: .add, left: .keyPath("age"), right: .attribute(.int64(1)))) + .compare(.greaterThan, .attribute(.int64(18))) + let encoded = try JSONEncoder().encode(predicate) + let decoded = try JSONDecoder().decode(FetchRequest.Predicate.self, from: encoded) + #expect(decoded == predicate) + } + #endif +} From ea372cec9f4c862d5e020c7b71b68aa59dd74fc9 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:38:47 -0400 Subject: [PATCH 08/23] #15 Add tests for arithmetic, collection and range predicate conversion --- .../FoundationPredicateTests.swift | 125 +++++++++++++++++- 1 file changed, 123 insertions(+), 2 deletions(-) diff --git a/Tests/CoreModelTests/FoundationPredicateTests.swift b/Tests/CoreModelTests/FoundationPredicateTests.swift index 1b67838..2e08d23 100644 --- a/Tests/CoreModelTests/FoundationPredicateTests.swift +++ b/Tests/CoreModelTests/FoundationPredicateTests.swift @@ -21,6 +21,15 @@ import Testing var age: Int var isActive: Bool var nickname: String? + var height: Double + var friends: [FriendModel] + var scores: [Int] + } + + struct FriendModel { + + var name: String + var age: Int } static var people: [ModelData] { @@ -132,12 +141,124 @@ import Testing #expect(predicate == .comparison(.init(left: .keyPath("nickname"), right: .attribute(.null), type: .equalTo))) } + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + @Test func arithmetic() throws { + + let add = try FetchRequest.Predicate(#Predicate { $0.age + 1 > 18 }) + #expect(add == .comparison(.init( + left: .arithmetic(.init(function: .add, left: .keyPath("age"), right: .attribute(.int64(1)))), + right: .attribute(.int64(18)), + type: .greaterThan + ))) + #expect(Self.people.filtered(by: add).map(\.id.rawValue) == ["Alice", "Alina"]) + + let subtract = try FetchRequest.Predicate(#Predicate { $0.age - 10 >= 18 }) + #expect(subtract == .comparison(.init( + left: .arithmetic(.init(function: .subtract, left: .keyPath("age"), right: .attribute(.int64(10)))), + right: .attribute(.int64(18)), + type: .greaterThanOrEqualTo + ))) + #expect(Self.people.filtered(by: subtract).map(\.id.rawValue) == ["Alice"]) + + let multiply = try FetchRequest.Predicate(#Predicate { $0.age * 2 == 40 }) + #expect(multiply == .comparison(.init( + left: .arithmetic(.init(function: .multiply, left: .keyPath("age"), right: .attribute(.int64(2)))), + right: .attribute(.int64(40)), + type: .equalTo + ))) + #expect(Self.people.filtered(by: multiply).map(\.id.rawValue) == ["Alina"]) + + let divide = try FetchRequest.Predicate(#Predicate { $0.height / 2 < 1.0 }) + #expect(divide == .comparison(.init( + left: .arithmetic(.init(function: .divide, left: .keyPath("height"), right: .attribute(.double(2)))), + right: .attribute(.double(1.0)), + type: .lessThan + ))) + + let modulus = try FetchRequest.Predicate(#Predicate { $0.age % 2 == 0 }) + #expect(modulus == .comparison(.init( + left: .arithmetic(.init(function: .modulus, left: .keyPath("age"), right: .attribute(.int64(2)))), + right: .attribute(.int64(0)), + type: .equalTo + ))) + #expect(Self.people.filtered(by: modulus).map(\.id.rawValue) == ["Alice", "Alina"]) + + // unary minus lowers to multiplication by -1 + let negated = try FetchRequest.Predicate(#Predicate { -$0.age < 0 }) + #expect(negated == .comparison(.init( + left: .arithmetic(.init(function: .multiply, left: .keyPath("age"), right: .attribute(.int64(-1)))), + right: .attribute(.int64(0)), + type: .lessThan + ))) + #expect(Self.people.filtered(by: negated).count == 3) + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + @Test func halfOpenRange() throws { + + let range = try FetchRequest.Predicate(#Predicate { (18..<30).contains($0.age) }) + #expect(range == .compound(.and([ + .comparison(.init(left: .keyPath("age"), right: .attribute(.int64(18)), type: .greaterThanOrEqualTo)), + .comparison(.init(left: .keyPath("age"), right: .attribute(.int64(30)), type: .lessThan)) + ]))) + #expect(Self.people.filtered(by: range).map(\.id.rawValue) == ["Alina"]) + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + @Test func collectionModifiers() throws { + + // allSatisfy over a nested collection becomes an ALL comparison + let all = try FetchRequest.Predicate(#Predicate { $0.friends.allSatisfy { $0.age >= 18 } }) + #expect(all == .comparison(.init( + left: .keyPath("friends.age"), + right: .attribute(.int64(18)), + type: .greaterThanOrEqualTo, + modifier: .all + ))) + + // contains(where:) becomes an ANY comparison + let any = try FetchRequest.Predicate(#Predicate { $0.friends.contains { $0.name == "Bob" } }) + #expect(any == .comparison(.init( + left: .keyPath("friends.name"), + right: .attribute(.string("Bob")), + type: .equalTo, + modifier: .any + ))) + + // a compound test can't carry a modifier + #expect(throws: FetchRequest.Predicate.ConversionError.self) { + try FetchRequest.Predicate(#Predicate { $0.friends.allSatisfy { $0.age >= 18 && $0.name != "Bob" } }) + } + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + @Test func aggregates() throws { + + let min = try FetchRequest.Predicate(#Predicate { $0.scores.min() == 10 }) + #expect(min == .comparison(.init( + left: .keyPath(PredicateKeyPath(keys: [.property("scores"), .operator(.min)])), + right: .attribute(.int64(10)), + type: .equalTo + ))) + + let max = try FetchRequest.Predicate(#Predicate { $0.scores.max() == 100 }) + #expect(max == .comparison(.init( + left: .keyPath(PredicateKeyPath(keys: [.property("scores"), .operator(.max)])), + right: .attribute(.int64(100)), + type: .equalTo + ))) + } + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) @Test func unsupported() { - // arithmetic has no CoreModel equivalent + // subscripts have no CoreModel equivalent + #expect(throws: FetchRequest.Predicate.ConversionError.self) { + try FetchRequest.Predicate(#Predicate { $0.scores[0] > 10 }) + } + // nil-coalescing has no CoreModel equivalent #expect(throws: FetchRequest.Predicate.ConversionError.self) { - try FetchRequest.Predicate(#Predicate { $0.age + 1 > 18 }) + try FetchRequest.Predicate(#Predicate { ($0.nickname ?? "") == "Al" }) } } From 4329de882866c2c10c9912889c3a085bd5d0b1bf Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:38:47 -0400 Subject: [PATCH 09/23] #15 Add CoreData fetch tests for arithmetic and ALL/ANY predicates --- Tests/CoreModelTests/CoreDataModelTests.swift | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/Tests/CoreModelTests/CoreDataModelTests.swift b/Tests/CoreModelTests/CoreDataModelTests.swift index 19543c9..1702407 100644 --- a/Tests/CoreModelTests/CoreDataModelTests.swift +++ b/Tests/CoreModelTests/CoreDataModelTests.swift @@ -29,6 +29,20 @@ import Testing return context } + /// Mirrors the `Person` entity for `#Predicate` conversion. + struct PersonRecord { + + var name: String + var age: Int + var events: [EventRecord] + } + + /// Mirrors the `Event` entity for `#Predicate` conversion. + struct EventRecord { + + var name: String + } + @Test func attributeTypeConversion() throws { guard #available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) else { return @@ -137,6 +151,61 @@ import Testing #expect(results[0].attributes["name"] == .string("alina")) } + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + @Test func arithmeticPredicateFetch() throws { + + let context = try Self.makeContext() + try context.insert([ + Person(name: "Alice", age: 30).encode(), + Person(name: "Bob", age: 17).encode() + ]) + + // CoreModel API: age * 2 >= 40, bridged to NSExpression's multiply:by: + let direct = FetchRequest.Predicate.Expression + .arithmetic(.init(function: .multiply, left: .keyPath("age"), right: .attribute(.int64(2)))) + .compare(.greaterThanOrEqualTo, .attribute(.int64(40))) + let directResults = try context.fetch(FetchRequest(entity: Person.entityName, predicate: direct)) + #expect(directResults.map { $0.attributes["name"] } == [.string("Alice")]) + + // Foundation.Predicate: age + 1 >= 21 + let converted = try FetchRequest.Predicate(#Predicate { $0.age + 1 >= 21 }) + let convertedResults = try context.fetch(FetchRequest(entity: Person.entityName, predicate: converted)) + #expect(convertedResults.map { $0.attributes["name"] } == [.string("Alice")]) + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + @Test func modifierPredicateFetch() throws { + + let context = try Self.makeContext() + let wwdc = Event(name: "WWDC", date: Date()) + let other = Event(name: "Other", date: Date()) + try context.insert([wwdc.encode(), other.encode()]) + try context.insert([ + Person(name: "Alice", age: 30, events: [wwdc.id, other.id]).encode(), + Person(name: "Bob", age: 17, events: [other.id]).encode() + ]) + + // CoreModel API: ANY events.name == "WWDC" + let anyDirect = "events.name".compare(.any, .equalTo, [], .attribute(.string("WWDC"))) + let anyResults = try context.fetch(FetchRequest(entity: Person.entityName, predicate: anyDirect)) + #expect(anyResults.map { $0.attributes["name"] } == [.string("Alice")]) + + // Foundation.Predicate: contains(where:) converts to the same ANY comparison + let anyConverted = try FetchRequest.Predicate(#Predicate { $0.events.contains { $0.name == "WWDC" } }) + #expect(anyConverted == anyDirect) + + // CoreModel API: ALL events.name == "Other" + let allDirect = "events.name".compare(.all, .equalTo, [], .attribute(.string("Other"))) + let allResults = try context.fetch(FetchRequest(entity: Person.entityName, predicate: allDirect)) + #expect(allResults.map { $0.attributes["name"] } == [.string("Bob")]) + + // Foundation.Predicate: allSatisfy converts to the same ALL comparison + let allConverted = try FetchRequest.Predicate(#Predicate { $0.events.allSatisfy { $0.name == "Other" } }) + #expect(allConverted == allDirect) + let allConvertedResults = try context.fetch(FetchRequest(entity: Person.entityName, predicate: allConverted)) + #expect(allConvertedResults.map { $0.attributes["name"] } == [.string("Bob")]) + } + @Test func nullRelationshipInsert() throws { guard #available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) else { return From b73dd2f1168e957a339665a5162468261992b9d5 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:46:11 -0400 Subject: [PATCH 10/23] #15 Resolve key paths that traverse a relationship when evaluating in memory --- Sources/CoreModel/Predicate/Evaluate.swift | 113 ++++++++++++++++----- 1 file changed, 89 insertions(+), 24 deletions(-) diff --git a/Sources/CoreModel/Predicate/Evaluate.swift b/Sources/CoreModel/Predicate/Evaluate.swift index 6740dd0..942f14d 100644 --- a/Sources/CoreModel/Predicate/Evaluate.swift +++ b/Sources/CoreModel/Predicate/Evaluate.swift @@ -26,6 +26,9 @@ public extension FetchRequest.Predicate { /// - Parameters: /// - data: The object instance to evaluate the predicate against. /// - functions: Custom functions (keyed by name) that `.function` expressions can invoke. + /// - objects: Related objects (keyed by identifier) that key paths traversing a + /// relationship are resolved against, e.g. `events.name` with an `ALL`/`ANY` modifier. + /// Key paths that traverse a relationship absent from this index evaluate to `false`. /// - Returns: Whether the object satisfies the predicate. /// /// - Note: The `.matches` operator requires regular expression support and always @@ -34,7 +37,8 @@ public extension FetchRequest.Predicate { /// and always evaluates to `false`. func evaluate( with data: ModelData, - functions: [String: DatabaseFunction] = [:] + functions: [String: DatabaseFunction] = [:], + objects: [ObjectID: ModelData] = [:] ) -> Bool { switch self { case let .value(value): @@ -42,25 +46,28 @@ public extension FetchRequest.Predicate { case let .compound(compound): switch compound { case let .and(subpredicates): - return subpredicates.allSatisfy { $0.evaluate(with: data, functions: functions) } + return subpredicates.allSatisfy { $0.evaluate(with: data, functions: functions, objects: objects) } case let .or(subpredicates): - return subpredicates.contains { $0.evaluate(with: data, functions: functions) } + return subpredicates.contains { $0.evaluate(with: data, functions: functions, objects: objects) } case let .not(subpredicate): - return subpredicate.evaluate(with: data, functions: functions) == false + return subpredicate.evaluate(with: data, functions: functions, objects: objects) == false } case let .comparison(comparison): - return comparison.evaluate(with: data, functions: functions) + return comparison.evaluate(with: data, functions: functions, objects: objects) } } } // MARK: - Supporting Types -/// A resolved predicate expression value — either an attribute or a relationship. -internal enum PredicateValue: Equatable, Hashable, Sendable { +/// A resolved predicate expression value — an attribute, a relationship, or the +/// values gathered by traversing a to-many relationship (e.g. `events.name`), +/// which only an `ALL`/`ANY` comparison can be applied to. +internal indirect enum PredicateValue: Equatable, Hashable, Sendable { case attribute(AttributeValue) case relationship(RelationshipValue) + case aggregate([PredicateValue]) } internal extension PredicateValue { @@ -81,6 +88,12 @@ internal extension PredicateValue { return value } + /// The values traversed through a to-many relationship, if any. + var aggregateValues: [PredicateValue]? { + guard case let .aggregate(values) = self else { return nil } + return values + } + /// An object identifier this value can represent, for relationship comparisons. var objectIDValue: ObjectID? { switch self { @@ -103,7 +116,8 @@ internal extension FetchRequest.Predicate.Expression { /// Resolve this expression to a value for the given object. func evaluate( with data: ModelData, - functions: [String: DatabaseFunction] + functions: [String: DatabaseFunction], + objects: [ObjectID: ModelData] = [:] ) -> PredicateValue? { switch self { case let .attribute(value): @@ -118,43 +132,94 @@ internal extension FetchRequest.Predicate.Expression { if let relationship = data.relationships[key] { return .relationship(relationship) } - return nil + // a key path like `events.name` traverses a relationship to related objects + return keyPath.traverse(data, functions: functions, objects: objects) case let .function(function): guard let registered = functions[function.name] else { return nil } let arguments = function.arguments.map { - $0.evaluate(with: data, functions: functions)?.attributeValue + $0.evaluate(with: data, functions: functions, objects: objects)?.attributeValue } return registered.evaluate(arguments).map { .attribute($0) } case let .arithmetic(arithmetic): - let lhs = arithmetic.left.evaluate(with: data, functions: functions)?.attributeValue - let rhs = arithmetic.right.evaluate(with: data, functions: functions)?.attributeValue + let lhs = arithmetic.left.evaluate(with: data, functions: functions, objects: objects)?.attributeValue + let rhs = arithmetic.right.evaluate(with: data, functions: functions, objects: objects)?.attributeValue return AttributeValue.arithmetic(arithmetic.function, lhs, rhs).map { .attribute($0) } } } } +// MARK: - Key Path Traversal + +internal extension PredicateKeyPath { + + /// Resolve a multi-component key path by following its leading relationship + /// into the related objects (e.g. `events.name`). + /// + /// A to-one relationship resolves to the single related value, a to-many to an + /// aggregate of every related value. Returns `nil` when the leading key isn't a + /// relationship on this object, and skips related objects missing from the index. + func traverse( + _ data: ModelData, + functions: [String: DatabaseFunction], + objects: [ObjectID: ModelData] + ) -> PredicateValue? { + guard keys.count > 1, case let .property(name) = keys[0] else { + return nil + } + guard let relationship = data.relationships[PropertyKey(rawValue: name)] else { + return nil + } + let remaining = FetchRequest.Predicate.Expression.keyPath( + PredicateKeyPath(keys: Array(keys.dropFirst())) + ) + switch relationship { + case .null: + return nil + case let .toOne(objectID): + guard let related = objects[objectID] else { return nil } + return remaining.evaluate(with: related, functions: functions, objects: objects) + case let .toMany(objectIDs): + let values = objectIDs.compactMap { objectID in + objects[objectID].flatMap { + remaining.evaluate(with: $0, functions: functions, objects: objects) + } + } + return .aggregate(values) + } + } +} + // MARK: - Comparison Evaluation internal extension FetchRequest.Predicate.Comparison { func evaluate( with data: ModelData, - functions: [String: DatabaseFunction] + functions: [String: DatabaseFunction], + objects: [ObjectID: ModelData] = [:] ) -> Bool { - let lhs = left.evaluate(with: data, functions: functions) - let rhs = right.evaluate(with: data, functions: functions) + let lhs = left.evaluate(with: data, functions: functions, objects: objects) + let rhs = right.evaluate(with: data, functions: functions, objects: objects) // aggregate modifiers apply the comparison to each element of a to-many relationship - if let modifier, case let .relationship(.toMany(objectIDs)) = lhs { - switch modifier { - case .any: - return objectIDs.contains { - type.evaluate(.relationship(.toOne($0)), rhs, options: options) - } - case .all: - return objectIDs.allSatisfy { - type.evaluate(.relationship(.toOne($0)), rhs, options: options) + if let modifier { + let elements: [PredicateValue]? + switch lhs { + case let .relationship(.toMany(objectIDs)): + elements = objectIDs.map { .relationship(.toOne($0)) } + case let .aggregate(values): + // values traversed through a to-many relationship, e.g. `events.name` + elements = values + default: + elements = nil + } + if let elements { + switch modifier { + case .any: + return elements.contains { type.evaluate($0, rhs, options: options) } + case .all: + return elements.allSatisfy { type.evaluate($0, rhs, options: options) } } } } From a124ca22368c231c88921f01fd4804f93161546b Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:46:11 -0400 Subject: [PATCH 11/23] #15 Index objects by identifier for relationship key path traversal --- Sources/CoreModel/FetchRequestEvaluation.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Sources/CoreModel/FetchRequestEvaluation.swift b/Sources/CoreModel/FetchRequestEvaluation.swift index 514f15d..0bca378 100644 --- a/Sources/CoreModel/FetchRequestEvaluation.swift +++ b/Sources/CoreModel/FetchRequestEvaluation.swift @@ -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. @@ -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 { @@ -47,7 +51,13 @@ 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. + internal func index() -> [ObjectID: ModelData] { + Dictionary(map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) } /// Sort in memory by the given descriptors, resolving function terms with the From 937ac201b81b31605c5f37e5688a05091bf25a5d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:46:11 -0400 Subject: [PATCH 12/23] #15 Resolve related objects from every entity when fetching in memory --- Sources/CoreModel/InMemoryStorage.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/CoreModel/InMemoryStorage.swift b/Sources/CoreModel/InMemoryStorage.swift index c61fe7c..8a9830b 100644 --- a/Sources/CoreModel/InMemoryStorage.swift +++ b/Sources/CoreModel/InMemoryStorage.swift @@ -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) } } From 1e9ca2ff14653c4f2a850a273513b840ee25774e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:46:11 -0400 Subject: [PATCH 13/23] #15 Add relationship key path traversal tests --- .../KeyPathTraversalTests.swift | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 Tests/CoreModelTests/KeyPathTraversalTests.swift diff --git a/Tests/CoreModelTests/KeyPathTraversalTests.swift b/Tests/CoreModelTests/KeyPathTraversalTests.swift new file mode 100644 index 0000000..6c774c0 --- /dev/null +++ b/Tests/CoreModelTests/KeyPathTraversalTests.swift @@ -0,0 +1,165 @@ +// +// KeyPathTraversalTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 8/16/26. +// + +#if canImport(FoundationEssentials) +import FoundationEssentials +#elseif canImport(Foundation) +import Foundation +#endif +import Testing +@testable import CoreModel + +/// Key paths that traverse a relationship (e.g. `events.name`), which `ALL`/`ANY` +/// comparisons rely on and CoreData resolves natively. +@Suite struct KeyPathTraversalTests { + + struct Fixture { + + static let wwdc = ModelData( + entity: "Event", + id: ObjectID(rawValue: "wwdc"), + attributes: ["name": .string("WWDC"), "attendance": .int64(5000)] + ) + + static let other = ModelData( + entity: "Event", + id: ObjectID(rawValue: "other"), + attributes: ["name": .string("Other"), "attendance": .int64(10)] + ) + + /// Attends both events. + static let alice = ModelData( + entity: "Person", + id: ObjectID(rawValue: "alice"), + attributes: ["name": .string("Alice")], + relationships: [ + "events": .toMany([wwdc.id, other.id]), + "favorite": .toOne(wwdc.id) + ] + ) + + /// Attends only the non-WWDC event. + static let bob = ModelData( + entity: "Person", + id: ObjectID(rawValue: "bob"), + attributes: ["name": .string("Bob")], + relationships: [ + "events": .toMany([other.id]), + "favorite": .toOne(other.id) + ] + ) + + /// Attends nothing. + static let carol = ModelData( + entity: "Person", + id: ObjectID(rawValue: "carol"), + attributes: ["name": .string("Carol")], + relationships: ["events": .toMany([])] + ) + + static let all = [alice, bob, carol, wwdc, other] + } + + @Test func anyModifier() { + + // ANY events.name == "WWDC" + let predicate = "events.name".compare(.any, .equalTo, [], .attribute(.string("WWDC"))) + let request = FetchRequest(entity: "Person", predicate: predicate) + #expect(request.evaluate(Fixture.all).map(\.id.rawValue) == ["alice"]) + } + + @Test func allModifier() { + + // ALL events.name == "Other" + let predicate = "events.name".compare(.all, .equalTo, [], .attribute(.string("Other"))) + let request = FetchRequest(entity: "Person", predicate: predicate) + // Carol has no events, so the comparison holds vacuously, as it does in CoreData + #expect(request.evaluate(Fixture.all).map(\.id.rawValue) == ["bob", "carol"]) + } + + @Test func toOneTraversal() { + + // a to-one relationship resolves to a single value, no modifier needed + let predicate = FetchRequest.Predicate.comparison(.init( + left: .keyPath("favorite.name"), + right: .attribute(.string("WWDC")), + type: .equalTo + )) + let request = FetchRequest(entity: "Person", predicate: predicate) + #expect(request.evaluate(Fixture.all).map(\.id.rawValue) == ["alice"]) + } + + @Test func numericTraversal() { + + // ANY events.attendance > 1000 + let predicate = "events.attendance".compare(.any, .greaterThan, [], .attribute(.int64(1000))) + let request = FetchRequest(entity: "Person", predicate: predicate) + #expect(request.evaluate(Fixture.all).map(\.id.rawValue) == ["alice"]) + } + + @Test func unresolvedRelatedObjects() { + + // without the related objects, a traversing key path can't resolve + let predicate = "events.name".compare(.any, .equalTo, [], .attribute(.string("WWDC"))) + let request = FetchRequest(entity: "Person", predicate: predicate) + #expect(request.evaluate([Fixture.alice, Fixture.bob]).isEmpty) + } + + @Test func inMemoryStorage() throws { + + // the same traversal through the in-memory store, which holds every entity + let model = Model(entities: Person.self, Event.self) + let storage = InMemoryStorage(model: model) + let wwdc = Event(name: "WWDC", date: Date()) + let other = Event(name: "Other", date: Date()) + try storage.insert([wwdc.encode(), other.encode()]) + let alice = Person(name: "Alice", age: 30, events: [wwdc.id, other.id]) + let bob = Person(name: "Bob", age: 17, events: [other.id]) + try storage.insert([alice.encode(), bob.encode()]) + + let anyRequest = FetchRequest( + entity: Person.entityName, + predicate: "events.name".compare(.any, .equalTo, [], .attribute(.string("WWDC"))) + ) + #expect(try storage.fetch(anyRequest).map { $0.attributes["name"] } == [.string("Alice")]) + + let allRequest = FetchRequest( + entity: Person.entityName, + predicate: "events.name".compare(.all, .equalTo, [], .attribute(.string("Other"))) + ) + #expect(try storage.fetch(allRequest).map { $0.attributes["name"] } == [.string("Bob")]) + } + + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + @Test func foundationPredicate() throws { + + // the same traversals, built with the #Predicate macro + let any = try FetchRequest.Predicate(#Predicate { $0.events.contains { $0.name == "WWDC" } }) + #expect(FetchRequest(entity: "Person", predicate: any).evaluate(Fixture.all).map(\.id.rawValue) == ["alice"]) + + let all = try FetchRequest.Predicate(#Predicate { $0.events.allSatisfy { $0.name == "Other" } }) + #expect(FetchRequest(entity: "Person", predicate: all).evaluate(Fixture.all).map(\.id.rawValue) == ["bob", "carol"]) + + let toOne = try FetchRequest.Predicate(#Predicate { $0.favorite.name == "WWDC" }) + #expect(FetchRequest(entity: "Person", predicate: toOne).evaluate(Fixture.all).map(\.id.rawValue) == ["alice"]) + } + + /// Mirrors the `Person` fixture for `#Predicate` conversion. + struct PersonRecord { + + var name: String + var events: [EventRecord] + var favorite: EventRecord + } + + /// Mirrors the `Event` fixture for `#Predicate` conversion. + struct EventRecord { + + var name: String + var attendance: Int + } +} From 832c33ffaa1fda8953cb685d9bcb079896b498b2 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 10:46:11 -0400 Subject: [PATCH 14/23] #15 Verify empty to-many `ALL` semantics match CoreData --- Tests/CoreModelTests/CoreDataModelTests.swift | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Tests/CoreModelTests/CoreDataModelTests.swift b/Tests/CoreModelTests/CoreDataModelTests.swift index 1702407..4871c9f 100644 --- a/Tests/CoreModelTests/CoreDataModelTests.swift +++ b/Tests/CoreModelTests/CoreDataModelTests.swift @@ -182,7 +182,8 @@ import Testing try context.insert([wwdc.encode(), other.encode()]) try context.insert([ Person(name: "Alice", age: 30, events: [wwdc.id, other.id]).encode(), - Person(name: "Bob", age: 17, events: [other.id]).encode() + Person(name: "Bob", age: 17, events: [other.id]).encode(), + Person(name: "Carol", age: 25, events: []).encode() ]) // CoreModel API: ANY events.name == "WWDC" @@ -194,16 +195,27 @@ import Testing let anyConverted = try FetchRequest.Predicate(#Predicate { $0.events.contains { $0.name == "WWDC" } }) #expect(anyConverted == anyDirect) - // CoreModel API: ALL events.name == "Other" + // CoreModel API: ALL events.name == "Other". + // Carol has no events, so the comparison holds vacuously — the same semantics + // CoreModel's in-memory evaluator implements, verified here against CoreData itself. let allDirect = "events.name".compare(.all, .equalTo, [], .attribute(.string("Other"))) - let allResults = try context.fetch(FetchRequest(entity: Person.entityName, predicate: allDirect)) - #expect(allResults.map { $0.attributes["name"] } == [.string("Bob")]) + let allRequest = FetchRequest(entity: Person.entityName, sortDescriptors: [.init(property: "name")], predicate: allDirect) + let allResults = try context.fetch(allRequest) + #expect(allResults.map { $0.attributes["name"] } == [.string("Bob"), .string("Carol")]) // Foundation.Predicate: allSatisfy converts to the same ALL comparison let allConverted = try FetchRequest.Predicate(#Predicate { $0.events.allSatisfy { $0.name == "Other" } }) #expect(allConverted == allDirect) - let allConvertedResults = try context.fetch(FetchRequest(entity: Person.entityName, predicate: allConverted)) - #expect(allConvertedResults.map { $0.attributes["name"] } == [.string("Bob")]) + let allConvertedResults = try context.fetch( + FetchRequest(entity: Person.entityName, sortDescriptors: [.init(property: "name")], predicate: allConverted) + ) + #expect(allConvertedResults.map { $0.attributes["name"] } == [.string("Bob"), .string("Carol")]) + + // the in-memory evaluator agrees with CoreData on the same objects + let objects = try context.fetch(FetchRequest(entity: Person.entityName)) + + context.fetch(FetchRequest(entity: Event.entityName)) + let inMemory = allRequest.evaluate(objects) + #expect(inMemory.map { $0.attributes["name"] } == [.string("Bob"), .string("Carol")]) } @Test func nullRelationshipInsert() throws { From 5824b729bf128b9de4eb6caccaa719ee91cf4cee Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 11:11:38 -0400 Subject: [PATCH 15/23] #15 Convert regex `contains` to a `MATCHES` comparison --- .../Predicate/FoundationPredicate.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Sources/CoreModel/Predicate/FoundationPredicate.swift b/Sources/CoreModel/Predicate/FoundationPredicate.swift index abbcd16..b4cb322 100644 --- a/Sources/CoreModel/Predicate/FoundationPredicate.swift +++ b/Sources/CoreModel/Predicate/FoundationPredicate.swift @@ -244,6 +244,11 @@ extension PredicateExpressions.KeyPath: CoreModelPredicateConvertible { extension PredicateExpressions.Value: CoreModelPredicateConvertible { func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + // the macro wraps regexes in a type that retains the source pattern + if #available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *), + let regex = value as? PredicateExpressions.PredicateRegex { + return .expression(.attribute(.string(regex.stringRepresentation))) + } guard let encodable = value as? AttributeEncodable else { throw FetchRequest.Predicate.ConversionError.unsupportedValue(String(describing: Swift.type(of: value))) } @@ -553,6 +558,24 @@ extension PredicateExpressions.Range: CoreModelRangeConvertible { // MARK: - String Conformances +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) +extension PredicateExpressions.StringContainsRegex: CoreModelPredicateConvertible { + + func toCoreModel(in context: PredicateConversionContext) throws -> ConvertedPredicateExpression { + let subject = try FetchRequest.Predicate.expression(converting: self.subject, in: context) + let pattern = try FetchRequest.Predicate.expression(converting: self.regex, in: context) + guard case let .attribute(.string(pattern)) = pattern else { + throw FetchRequest.Predicate.ConversionError.unsupportedValue(pattern.description) + } + // `MATCHES` matches the whole value, so pad the pattern to express `contains` + return .predicate(.comparison(.init( + left: subject, + right: .attribute(.string(".*" + pattern + ".*")), + type: .matches + ))) + } +} + #if canImport(Darwin) @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) extension PredicateExpressions.StringLocalizedStandardContains: CoreModelPredicateConvertible { From aad1c77c61272a8e91d868eafe19dd89791c8d56 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 11:11:38 -0400 Subject: [PATCH 16/23] #15 Add regex predicate conversion tests --- .../FoundationPredicateTests.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Tests/CoreModelTests/FoundationPredicateTests.swift b/Tests/CoreModelTests/FoundationPredicateTests.swift index 2e08d23..a1703db 100644 --- a/Tests/CoreModelTests/FoundationPredicateTests.swift +++ b/Tests/CoreModelTests/FoundationPredicateTests.swift @@ -249,6 +249,34 @@ import Testing ))) } + @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) + @Test func regex() throws { + + // the pattern is recovered from the regex the macro captures, and padded + // because MATCHES matches the whole value rather than a substring + let dynamic = try Regex("Al[a-z]+") + let predicate = try FetchRequest.Predicate(#Predicate { $0.name.contains(dynamic) }) + #expect(predicate == .comparison(.init( + left: .keyPath("name"), + right: .attribute(.string(".*Al[a-z]+.*")), + type: .matches + ))) + #expect(Self.people.filtered(by: predicate).map(\.id.rawValue) == ["Alice", "Alina"]) + + // regex literals and RegexBuilder regexes carry their pattern too + let literal = try FetchRequest.Predicate(#Predicate { $0.name.contains(#/^Al/#) }) + #expect(literal == .comparison(.init( + left: .keyPath("name"), + right: .attribute(.string(".*^Al.*")), + type: .matches + ))) + #expect(Self.people.filtered(by: literal).map(\.id.rawValue) == ["Alice", "Alina"]) + + // the CoreModel API expresses the same comparison directly + let direct = "name".compare(.matches, .attribute(.string(".*Al[a-z]+.*"))) + #expect(Self.people.filtered(by: direct).map(\.id.rawValue) == ["Alice", "Alina"]) + } + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) @Test func unsupported() { From 8d8f0f32400bf85a7bdbb063a7c034fac6f4807f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 11:11:38 -0400 Subject: [PATCH 17/23] #15 Add CoreData fetch test for regex predicates --- Tests/CoreModelTests/CoreDataModelTests.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Tests/CoreModelTests/CoreDataModelTests.swift b/Tests/CoreModelTests/CoreDataModelTests.swift index 4871c9f..f299540 100644 --- a/Tests/CoreModelTests/CoreDataModelTests.swift +++ b/Tests/CoreModelTests/CoreDataModelTests.swift @@ -218,6 +218,27 @@ import Testing #expect(inMemory.map { $0.attributes["name"] } == [.string("Bob"), .string("Carol")]) } + @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) + @Test func regexPredicateFetch() throws { + + let context = try Self.makeContext() + try context.insert([ + Person(name: "Alice", age: 30).encode(), + Person(name: "Bob", age: 17).encode() + ]) + + // Foundation.Predicate: the captured pattern becomes a MATCHES comparison + let regex = try Regex("Al[a-z]+") + let converted = try FetchRequest.Predicate(#Predicate { $0.name.contains(regex) }) + #expect(converted == "name".compare(.matches, .attribute(.string(".*Al[a-z]+.*")))) + let results = try context.fetch(FetchRequest(entity: Person.entityName, predicate: converted)) + #expect(results.map { $0.attributes["name"] } == [.string("Alice")]) + + // the in-memory evaluator agrees with CoreData on the same objects + let objects = try context.fetch(FetchRequest(entity: Person.entityName)) + #expect(objects.filtered(by: converted).map { $0.attributes["name"] } == [.string("Alice")]) + } + @Test func nullRelationshipInsert() throws { guard #available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) else { return From 9e620a3ef16005834ad20207415c903eacb22025 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 11:17:22 -0400 Subject: [PATCH 18/23] #15 Build the object index without dynamic casting for Embedded Swift --- Sources/CoreModel/FetchRequestEvaluation.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/CoreModel/FetchRequestEvaluation.swift b/Sources/CoreModel/FetchRequestEvaluation.swift index 0bca378..0c17003 100644 --- a/Sources/CoreModel/FetchRequestEvaluation.swift +++ b/Sources/CoreModel/FetchRequestEvaluation.swift @@ -56,8 +56,15 @@ public extension Array where Element == ModelData { } /// 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] { - Dictionary(map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + 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 From 700db6cea8b86ab3fbf7369c44966b577217f574 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 11:45:59 -0400 Subject: [PATCH 19/23] #15 Truncate integer division to match Swift and CoreData --- Sources/CoreModel/Predicate/Evaluate.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/CoreModel/Predicate/Evaluate.swift b/Sources/CoreModel/Predicate/Evaluate.swift index 942f14d..6c8f3e2 100644 --- a/Sources/CoreModel/Predicate/Evaluate.swift +++ b/Sources/CoreModel/Predicate/Evaluate.swift @@ -358,11 +358,11 @@ internal extension AttributeValue { /// Apply an arithmetic function to two values. /// - /// Integer operands stay in integer arithmetic (except division, which always - /// produces a floating-point value, matching `NSExpression`'s `divide:by:`); + /// Integer operands stay in integer arithmetic, so division truncates the way + /// Swift's `/` and `NSExpression`'s `divide:by:` both do (`7 / 2` is `3`, not `3.5`); /// mixed or floating-point operands are computed as `Double`. - /// Returns `nil` for non-numeric operands, division by zero, or - /// floating-point remainder. + /// Returns `nil` for non-numeric operands, division or remainder by zero, + /// an overflowing division, or a floating-point remainder. static func arithmetic( _ function: FetchRequest.Predicate.ArithmeticExpression.Function, _ lhs: AttributeValue?, @@ -374,8 +374,14 @@ internal extension AttributeValue { case .add: return .int64(leftInteger &+ rightInteger) case .subtract: return .int64(leftInteger &- rightInteger) case .multiply: return .int64(leftInteger &* rightInteger) - case .divide: break // always floating-point - case .modulus: return rightInteger == 0 ? nil : .int64(leftInteger % rightInteger) + case .divide: + guard rightInteger != 0 else { return nil } + let (quotient, overflow) = leftInteger.dividedReportingOverflow(by: rightInteger) + return overflow ? nil : .int64(quotient) + case .modulus: + guard rightInteger != 0 else { return nil } + let (remainder, overflow) = leftInteger.remainderReportingOverflow(dividingBy: rightInteger) + return overflow ? nil : .int64(remainder) } } guard let leftNumber = lhs.comparableDouble, let rightNumber = rhs.comparableDouble else { From ac344a838e2e4e3bcf0b58f314a6ded29193d781 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 11:45:59 -0400 Subject: [PATCH 20/23] #15 Document integer division semantics --- Sources/CoreModel/Predicate/ArithmeticExpression.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/CoreModel/Predicate/ArithmeticExpression.swift b/Sources/CoreModel/Predicate/ArithmeticExpression.swift index 7c14868..3a6fc54 100644 --- a/Sources/CoreModel/Predicate/ArithmeticExpression.swift +++ b/Sources/CoreModel/Predicate/ArithmeticExpression.swift @@ -47,7 +47,11 @@ public extension FetchRequest.Predicate.ArithmeticExpression { /// Multiplication (`left * right`). case multiply = "multiply:by:" - /// Division (`left / right`), always producing a floating-point value. + /// 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. From 4ea17ac4d19845087238d263564cb2bf7880c477 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 11:45:59 -0400 Subject: [PATCH 21/23] #15 Test integer division truncation and overflow guards --- .../ArithmeticExpressionTests.swift | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/Tests/CoreModelTests/ArithmeticExpressionTests.swift b/Tests/CoreModelTests/ArithmeticExpressionTests.swift index d5c0fa9..562e3e6 100644 --- a/Tests/CoreModelTests/ArithmeticExpressionTests.swift +++ b/Tests/CoreModelTests/ArithmeticExpressionTests.swift @@ -61,11 +61,18 @@ import Testing ) #expect(modulus.compare(.equalTo, .attribute(.int64(2))).evaluate(with: person)) - // division is always floating-point + // integer division truncates, the way Swift's `/` and NSExpression's `divide:by:` do let divide = FetchRequest.Predicate.Expression.arithmetic( .init(function: .divide, left: .keyPath("age"), right: .attribute(.int64(4))) ) - #expect(divide.compare(.equalTo, .attribute(.double(7.5))).evaluate(with: person)) + #expect(divide.compare(.equalTo, .attribute(.int64(7))).evaluate(with: person)) // 30 / 4 == 7 + #expect(divide.compare(.equalTo, .attribute(.double(7.5))).evaluate(with: person) == false) + + // a floating-point operand divides normally + let floatDivide = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .divide, left: .keyPath("height"), right: .attribute(.int64(2))) + ) + #expect(floatDivide.compare(.equalTo, .attribute(.double(0.85))).evaluate(with: person)) // mixed integer and floating-point operands promote to double let mixed = FetchRequest.Predicate.Expression.arithmetic( @@ -106,6 +113,47 @@ import Testing .init(function: .modulus, left: .keyPath("height"), right: .attribute(.int64(2))) ) #expect(floatModulus.compare(.lessThan, .attribute(.int64(2))).evaluate(with: person) == false) + + // remainder by zero resolves to nil rather than trapping + let modulusByZero = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .modulus, left: .keyPath("age"), right: .attribute(.int64(0))) + ) + #expect(modulusByZero.compare(.equalTo, .attribute(.int64(0))).evaluate(with: person) == false) + + // an overflowing division resolves to nil rather than trapping + let overflow = ModelData( + entity: "Person", + id: ObjectID(rawValue: "overflow"), + attributes: ["age": .int64(.min)] + ) + let overflowingDivide = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .divide, left: .keyPath("age"), right: .attribute(.int64(-1))) + ) + #expect(overflowingDivide.compare(.equalTo, .attribute(.int64(.min))).evaluate(with: overflow) == false) + let overflowingModulus = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .modulus, left: .keyPath("age"), right: .attribute(.int64(-1))) + ) + #expect(overflowingModulus.compare(.equalTo, .attribute(.int64(0))).evaluate(with: overflow) == false) + } + + @Test func integerDivisionTruncates() { + + // every quotient truncates toward zero, matching Swift's `/` + for (dividend, divisor) in [(7, 2), (-7, 2), (7, -2), (-7, -2), (30, 4), (1, 2)] { + let data = ModelData( + entity: "Person", + id: ObjectID(rawValue: "\(dividend)"), + attributes: ["value": .int64(numericCast(dividend))] + ) + let expression = FetchRequest.Predicate.Expression.arithmetic( + .init(function: .divide, left: .keyPath("value"), right: .attribute(.int64(numericCast(divisor)))) + ) + let expected = Int64(dividend / divisor) + #expect( + expression.compare(.equalTo, .attribute(.int64(expected))).evaluate(with: data), + "\(dividend) / \(divisor) should be \(expected)" + ) + } } @Test func fetchRequestEvaluation() { From cdf0a8053a41b7090b678b383a62ae24be651b7a Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 11:45:59 -0400 Subject: [PATCH 22/23] #15 Test converted integer division agrees with Swift evaluation --- .../FoundationPredicateTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Tests/CoreModelTests/FoundationPredicateTests.swift b/Tests/CoreModelTests/FoundationPredicateTests.swift index a1703db..deeacba 100644 --- a/Tests/CoreModelTests/FoundationPredicateTests.swift +++ b/Tests/CoreModelTests/FoundationPredicateTests.swift @@ -183,6 +183,28 @@ import Testing ))) #expect(Self.people.filtered(by: modulus).map(\.id.rawValue) == ["Alice", "Alina"]) + // integer division truncates, so the converted predicate agrees with + // the semantics Swift itself gives the same expression + let intDivide = #Predicate { $0.age / 2 == 8 } + let converted = try FetchRequest.Predicate(intDivide) + #expect(converted == .comparison(.init( + left: .arithmetic(.init(function: .divide, left: .keyPath("age"), right: .attribute(.int64(2)))), + right: .attribute(.int64(8)), + type: .equalTo + ))) + #expect(Self.people.filtered(by: converted).map(\.id.rawValue) == ["Bob"]) // 17 / 2 == 8 + for person in [ + PersonModel(name: "Alice", age: 30, isActive: true, nickname: nil, height: 1.7, friends: [], scores: []), + PersonModel(name: "Bob", age: 17, isActive: false, nickname: nil, height: 1.8, friends: [], scores: []) + ] { + let data = ModelData( + entity: "Person", + id: ObjectID(rawValue: person.name), + attributes: ["age": .int64(numericCast(person.age))] + ) + #expect(try intDivide.evaluate(person) == converted.evaluate(with: data), "\(person.name)") + } + // unary minus lowers to multiplication by -1 let negated = try FetchRequest.Predicate(#Predicate { -$0.age < 0 }) #expect(negated == .comparison(.init( From 5b11022663c6fc95af549149cc1bc88b98b42b47 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sun, 16 Aug 2026 11:45:59 -0400 Subject: [PATCH 23/23] #15 Test integer division matches CoreData --- Tests/CoreModelTests/CoreDataModelTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Tests/CoreModelTests/CoreDataModelTests.swift b/Tests/CoreModelTests/CoreDataModelTests.swift index f299540..338bd5a 100644 --- a/Tests/CoreModelTests/CoreDataModelTests.swift +++ b/Tests/CoreModelTests/CoreDataModelTests.swift @@ -173,6 +173,33 @@ import Testing #expect(convertedResults.map { $0.attributes["name"] } == [.string("Alice")]) } + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) + @Test func integerDivisionMatchesCoreData() throws { + + // `divide:by:` truncates for integer operands, so CoreModel's in-memory + // evaluator has to agree with CoreData rather than divide as floating-point + let context = try Self.makeContext() + try context.insert(Person(name: "Seven", age: 7).encode()) + + // age / 2 == 3 holds under integer division, not under floating-point + let predicate = FetchRequest.Predicate.Expression + .arithmetic(.init(function: .divide, left: .keyPath("age"), right: .attribute(.int64(2)))) + .compare(.equalTo, .attribute(.int64(3))) + let request = FetchRequest(entity: Person.entityName, predicate: predicate) + let coreData = try context.fetch(request) + #expect(coreData.map { $0.attributes["name"] } == [.string("Seven")]) + + let objects = try context.fetch(FetchRequest(entity: Person.entityName)) + #expect(objects.filtered(by: predicate).map { $0.attributes["name"] } == [.string("Seven")]) + + // and the floating-point quotient matches neither engine + let floating = FetchRequest.Predicate.Expression + .arithmetic(.init(function: .divide, left: .keyPath("age"), right: .attribute(.int64(2)))) + .compare(.equalTo, .attribute(.double(3.5))) + #expect(try context.fetch(FetchRequest(entity: Person.entityName, predicate: floating)).isEmpty) + #expect(objects.filtered(by: floating).isEmpty) + } + @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) @Test func modifierPredicateFetch() throws {