Skip to content

Commit ea30b8b

Browse files
authored
Merge pull request #34 from PureSwift/feature/predicate
Add support for Foundation.Predicate
2 parents fff8b7b + 818c137 commit ea30b8b

2 files changed

Lines changed: 532 additions & 0 deletions

File tree

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
//
2+
// FoundationPredicate.swift
3+
// CoreModel
4+
//
5+
// Created by Alsey Coleman Miller on 8/16/26.
6+
// Copyright © 2026 PureSwift. All rights reserved.
7+
//
8+
9+
#if canImport(FoundationEssentials)
10+
import FoundationEssentials
11+
#elseif canImport(Foundation)
12+
import Foundation
13+
#endif
14+
15+
#if canImport(FoundationEssentials) || canImport(Foundation)
16+
17+
// MARK: - Foundation.Predicate Conversion
18+
19+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
20+
public extension FetchRequest.Predicate {
21+
22+
#if canImport(FoundationEssentials)
23+
/// Creates a ``FetchRequest.Predicate`` from a `Predicate` built with the `#Predicate` macro.
24+
///
25+
/// Throws ``FetchRequest/Predicate/ConversionError`` for expressions with no
26+
/// CoreModel equivalent (e.g. arithmetic, subscripts, closures).
27+
init<T>(_ predicate: FoundationEssentials.Predicate<T>) throws {
28+
self = try Self.predicate(converting: predicate.expression)
29+
}
30+
#else
31+
/// Creates a ``FetchRequest.Predicate`` from a ``Foundation.Predicate`` built with the `#Predicate` macro.
32+
///
33+
/// Throws ``FetchRequest/Predicate/ConversionError`` for expressions with no
34+
/// CoreModel equivalent (e.g. arithmetic, subscripts, closures).
35+
init<T>(_ predicate: Foundation.Predicate<T>) throws {
36+
self = try Self.predicate(converting: predicate.expression)
37+
}
38+
#endif
39+
}
40+
41+
// MARK: - Conversion Error
42+
43+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
44+
public extension FetchRequest.Predicate {
45+
46+
/// An error converting a ``Foundation.Predicate`` expression tree.
47+
enum ConversionError: Swift.Error, Sendable {
48+
49+
/// The expression type has no CoreModel equivalent.
50+
case unsupportedExpression(String)
51+
52+
/// The key path doesn't reference stored properties by name.
53+
case unsupportedKeyPath(String)
54+
55+
/// The constant value can't be represented as an attribute value.
56+
case unsupportedValue(String)
57+
}
58+
}
59+
60+
// MARK: - Conversion
61+
62+
/// Intermediate result of converting a `PredicateExpressions` tree node.
63+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
64+
internal enum ConvertedPredicateExpression {
65+
66+
/// A value expression (key path or constant).
67+
case expression(FetchRequest.Predicate.Expression)
68+
69+
/// A boolean predicate.
70+
case predicate(FetchRequest.Predicate)
71+
}
72+
73+
/// Conforming `PredicateExpressions` node types convert themselves to CoreModel form.
74+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
75+
internal protocol CoreModelPredicateConvertible {
76+
77+
func toCoreModel() throws -> ConvertedPredicateExpression
78+
}
79+
80+
/// Range nodes convert to inclusive bounds for a compound `>= lower && <= upper` predicate.
81+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
82+
internal protocol CoreModelRangeConvertible {
83+
84+
func coreModelBounds() throws -> (lower: FetchRequest.Predicate.Expression, upper: FetchRequest.Predicate.Expression)
85+
}
86+
87+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
88+
internal extension FetchRequest.Predicate {
89+
90+
static func node(converting expression: Any) throws -> ConvertedPredicateExpression {
91+
guard let convertible = expression as? any CoreModelPredicateConvertible else {
92+
throw ConversionError.unsupportedExpression(String(describing: Swift.type(of: expression)))
93+
}
94+
return try convertible.toCoreModel()
95+
}
96+
97+
static func predicate(converting expression: Any) throws -> FetchRequest.Predicate {
98+
switch try node(converting: expression) {
99+
case let .predicate(predicate):
100+
return predicate
101+
case let .expression(.attribute(.bool(value))):
102+
return .value(value)
103+
case let .expression(.keyPath(keyPath)):
104+
// a boolean property used directly as a predicate
105+
return .comparison(.init(left: .keyPath(keyPath), right: .attribute(.bool(true))))
106+
case let .expression(expression):
107+
throw ConversionError.unsupportedExpression(expression.description)
108+
}
109+
}
110+
111+
static func expression(converting expression: Any) throws -> Expression {
112+
switch try node(converting: expression) {
113+
case let .expression(expression):
114+
return expression
115+
case let .predicate(predicate):
116+
throw ConversionError.unsupportedExpression(predicate.description)
117+
}
118+
}
119+
120+
/// Merge nested compounds of the same logical type (`a && b && c` becomes one `.and`).
121+
static func subpredicates(of predicate: FetchRequest.Predicate, _ type: Compound.Logical​Type) -> [FetchRequest.Predicate] {
122+
guard type != .not,
123+
case let .compound(compound) = predicate,
124+
compound.type == type else {
125+
return [predicate]
126+
}
127+
return compound.subpredicates
128+
}
129+
}
130+
131+
// MARK: - Key Path Resolution
132+
133+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
134+
internal extension PredicateKeyPath {
135+
136+
/// Resolve a Swift key path to its property names.
137+
///
138+
/// Uses Key-Value Coding on Darwin (for `@objc` properties), falling back to
139+
/// parsing the key path's reflection-based `debugDescription` (e.g. `\Person.name`),
140+
/// which requires field metadata for the traversed stored properties.
141+
static func propertyNames(for keyPath: AnyKeyPath) throws -> [String] {
142+
#if canImport(Darwin)
143+
if let kvcString = keyPath._kvcKeyPathString {
144+
return kvcString.split(separator: ".").map(String.init)
145+
}
146+
#endif
147+
let description = keyPath.debugDescription
148+
guard description.hasPrefix("\\") else {
149+
throw FetchRequest.Predicate.ConversionError.unsupportedKeyPath(description)
150+
}
151+
let components = description.dropFirst().split(separator: ".").dropFirst()
152+
let names: [String] = components.map { component in
153+
// optional chaining and force unwrapping traverse the same property
154+
var name = Substring(component)
155+
while name.hasSuffix("?") || name.hasSuffix("!") {
156+
name = name.dropLast()
157+
}
158+
return String(name)
159+
}
160+
guard names.isEmpty == false,
161+
names.allSatisfy({ name in
162+
name.isEmpty == false
163+
&& name.contains("<") == false
164+
&& name.hasPrefix("subscript") == false
165+
}) else {
166+
throw FetchRequest.Predicate.ConversionError.unsupportedKeyPath(description)
167+
}
168+
return names
169+
}
170+
}
171+
172+
// MARK: - PredicateExpressions Conformances
173+
174+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
175+
extension PredicateExpressions.Variable: CoreModelPredicateConvertible {
176+
177+
func toCoreModel() throws -> ConvertedPredicateExpression {
178+
// the fetched object itself; key paths are appended by `KeyPath` nodes
179+
.expression(.keyPath(PredicateKeyPath(keys: [])))
180+
}
181+
}
182+
183+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
184+
extension PredicateExpressions.KeyPath: CoreModelPredicateConvertible {
185+
186+
func toCoreModel() throws -> ConvertedPredicateExpression {
187+
guard case let .expression(.keyPath(base)) = try FetchRequest.Predicate.node(converting: root) else {
188+
throw FetchRequest.Predicate.ConversionError.unsupportedKeyPath(String(describing: keyPath))
189+
}
190+
let names = try PredicateKeyPath.propertyNames(for: keyPath as AnyKeyPath)
191+
let keys = base.keys + names.map { .property($0) }
192+
return .expression(.keyPath(PredicateKeyPath(keys: keys)))
193+
}
194+
}
195+
196+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
197+
extension PredicateExpressions.Value: CoreModelPredicateConvertible {
198+
199+
func toCoreModel() throws -> ConvertedPredicateExpression {
200+
guard let encodable = value as? AttributeEncodable else {
201+
throw FetchRequest.Predicate.ConversionError.unsupportedValue(String(describing: type(of: value)))
202+
}
203+
return .expression(.attribute(encodable.attributeValue))
204+
}
205+
}
206+
207+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
208+
extension PredicateExpressions.NilLiteral: CoreModelPredicateConvertible {
209+
210+
func toCoreModel() throws -> ConvertedPredicateExpression {
211+
.expression(.attribute(.null))
212+
}
213+
}
214+
215+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
216+
extension PredicateExpressions.Equal: CoreModelPredicateConvertible {
217+
218+
func toCoreModel() throws -> ConvertedPredicateExpression {
219+
.predicate(.comparison(.init(
220+
left: try FetchRequest.Predicate.expression(converting: lhs),
221+
right: try FetchRequest.Predicate.expression(converting: rhs),
222+
type: .equalTo
223+
)))
224+
}
225+
}
226+
227+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
228+
extension PredicateExpressions.NotEqual: CoreModelPredicateConvertible {
229+
230+
func toCoreModel() throws -> ConvertedPredicateExpression {
231+
.predicate(.comparison(.init(
232+
left: try FetchRequest.Predicate.expression(converting: lhs),
233+
right: try FetchRequest.Predicate.expression(converting: rhs),
234+
type: .notEqualTo
235+
)))
236+
}
237+
}
238+
239+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
240+
extension PredicateExpressions.Comparison: CoreModelPredicateConvertible {
241+
242+
func toCoreModel() throws -> ConvertedPredicateExpression {
243+
let type: FetchRequest.Predicate.Comparison.Operator
244+
switch op {
245+
case .lessThan:
246+
type = .lessThan
247+
case .lessThanOrEqual:
248+
type = .lessThanOrEqualTo
249+
case .greaterThan:
250+
type = .greaterThan
251+
case .greaterThanOrEqual:
252+
type = .greaterThanOrEqualTo
253+
@unknown default:
254+
throw FetchRequest.Predicate.ConversionError.unsupportedExpression(String(describing: op))
255+
}
256+
return .predicate(.comparison(.init(
257+
left: try FetchRequest.Predicate.expression(converting: lhs),
258+
right: try FetchRequest.Predicate.expression(converting: rhs),
259+
type: type
260+
)))
261+
}
262+
}
263+
264+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
265+
extension PredicateExpressions.Conjunction: CoreModelPredicateConvertible {
266+
267+
func toCoreModel() throws -> ConvertedPredicateExpression {
268+
let lhs = try FetchRequest.Predicate.predicate(converting: self.lhs)
269+
let rhs = try FetchRequest.Predicate.predicate(converting: self.rhs)
270+
return .predicate(.compound(.and(
271+
FetchRequest.Predicate.subpredicates(of: lhs, .and)
272+
+ FetchRequest.Predicate.subpredicates(of: rhs, .and)
273+
)))
274+
}
275+
}
276+
277+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
278+
extension PredicateExpressions.Disjunction: CoreModelPredicateConvertible {
279+
280+
func toCoreModel() throws -> ConvertedPredicateExpression {
281+
let lhs = try FetchRequest.Predicate.predicate(converting: self.lhs)
282+
let rhs = try FetchRequest.Predicate.predicate(converting: self.rhs)
283+
return .predicate(.compound(.or(
284+
FetchRequest.Predicate.subpredicates(of: lhs, .or)
285+
+ FetchRequest.Predicate.subpredicates(of: rhs, .or)
286+
)))
287+
}
288+
}
289+
290+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
291+
extension PredicateExpressions.Negation: CoreModelPredicateConvertible {
292+
293+
func toCoreModel() throws -> ConvertedPredicateExpression {
294+
.predicate(.compound(.not(try FetchRequest.Predicate.predicate(converting: wrapped))))
295+
}
296+
}
297+
298+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
299+
extension PredicateExpressions.SequenceContains: CoreModelPredicateConvertible {
300+
301+
func toCoreModel() throws -> ConvertedPredicateExpression {
302+
.predicate(.comparison(.init(
303+
left: try FetchRequest.Predicate.expression(converting: sequence),
304+
right: try FetchRequest.Predicate.expression(converting: element),
305+
type: .contains
306+
)))
307+
}
308+
}
309+
310+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
311+
extension PredicateExpressions.CollectionContainsCollection: CoreModelPredicateConvertible {
312+
313+
func toCoreModel() throws -> ConvertedPredicateExpression {
314+
.predicate(.comparison(.init(
315+
left: try FetchRequest.Predicate.expression(converting: base),
316+
right: try FetchRequest.Predicate.expression(converting: other),
317+
type: .contains
318+
)))
319+
}
320+
}
321+
322+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
323+
extension PredicateExpressions.SequenceStartsWith: CoreModelPredicateConvertible {
324+
325+
func toCoreModel() throws -> ConvertedPredicateExpression {
326+
.predicate(.comparison(.init(
327+
left: try FetchRequest.Predicate.expression(converting: base),
328+
right: try FetchRequest.Predicate.expression(converting: prefix),
329+
type: .beginsWith
330+
)))
331+
}
332+
}
333+
334+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
335+
extension PredicateExpressions.RangeExpressionContains: CoreModelPredicateConvertible {
336+
337+
func toCoreModel() throws -> ConvertedPredicateExpression {
338+
guard let range = self.range as? any CoreModelRangeConvertible else {
339+
throw FetchRequest.Predicate.ConversionError.unsupportedExpression(String(describing: type(of: self.range)))
340+
}
341+
let bounds = try range.coreModelBounds()
342+
let element = try FetchRequest.Predicate.expression(converting: self.element)
343+
// CoreModel can't evaluate BETWEEN, so lower ranges to a compound comparison
344+
return .predicate(.compound(.and([
345+
.comparison(.init(left: element, right: bounds.lower, type: .greaterThanOrEqualTo)),
346+
.comparison(.init(left: element, right: bounds.upper, type: .lessThanOrEqualTo))
347+
])))
348+
}
349+
}
350+
351+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
352+
extension PredicateExpressions.ClosedRange: CoreModelRangeConvertible {
353+
354+
func coreModelBounds() throws -> (lower: FetchRequest.Predicate.Expression, upper: FetchRequest.Predicate.Expression) {
355+
(
356+
lower: try FetchRequest.Predicate.expression(converting: self.lower),
357+
upper: try FetchRequest.Predicate.expression(converting: self.upper)
358+
)
359+
}
360+
}
361+
362+
#if canImport(Darwin)
363+
@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *)
364+
extension PredicateExpressions.StringLocalizedStandardContains: CoreModelPredicateConvertible {
365+
366+
func toCoreModel() throws -> ConvertedPredicateExpression {
367+
.predicate(.comparison(.init(
368+
left: try FetchRequest.Predicate.expression(converting: root),
369+
right: try FetchRequest.Predicate.expression(converting: other),
370+
type: .contains,
371+
options: [.caseInsensitive, .diacriticInsensitive]
372+
)))
373+
}
374+
}
375+
#endif
376+
377+
#endif

0 commit comments

Comments
 (0)