Skip to content

Commit a10fece

Browse files
authored
Merge pull request #22 from PureSwift/feature/functions
Add custom function support to predicates and sort descriptors
2 parents a277351 + c3675e4 commit a10fece

12 files changed

Lines changed: 619 additions & 16 deletions
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
//
2+
// FunctionEvaluation.swift
3+
// CoreDataModel
4+
//
5+
// Created by Alsey Coleman Miller on 7/16/26.
6+
//
7+
8+
#if canImport(CoreData)
9+
import Foundation
10+
import CoreModel
11+
12+
// MARK: - Function detection
13+
14+
internal extension FetchRequest {
15+
16+
/// Whether this fetch request references any custom `.function` expression and
17+
/// therefore requires in-memory evaluation — CoreData cannot execute a custom
18+
/// function as part of a native fetch.
19+
var requiresInMemoryEvaluation: Bool {
20+
if predicate?.containsFunction == true {
21+
return true
22+
}
23+
return sortDescriptors.contains { descriptor in
24+
if case .function = descriptor.term { return true } else { return false }
25+
}
26+
}
27+
}
28+
29+
internal extension FetchRequest.Predicate {
30+
31+
/// Whether this predicate references any `.function` expression.
32+
var containsFunction: Bool {
33+
switch self {
34+
case .value:
35+
return false
36+
case let .comparison(comparison):
37+
return comparison.left.containsFunction || comparison.right.containsFunction
38+
case let .compound(compound):
39+
return compound.subpredicates.contains { $0.containsFunction }
40+
}
41+
}
42+
43+
/// Replace every comparison that references a function with `.value(true)`, so the
44+
/// remaining predicate can be evaluated natively by CoreData as a superset filter.
45+
/// The full predicate is then re-applied in memory.
46+
func strippingFunctionComparisons() -> FetchRequest.Predicate {
47+
switch self {
48+
case .value:
49+
return self
50+
case let .comparison(comparison):
51+
let usesFunction = comparison.left.containsFunction || comparison.right.containsFunction
52+
return usesFunction ? .value(true) : self
53+
case let .compound(compound):
54+
switch compound {
55+
case let .and(subpredicates):
56+
return .compound(.and(subpredicates.map { $0.strippingFunctionComparisons() }))
57+
case let .or(subpredicates):
58+
return .compound(.or(subpredicates.map { $0.strippingFunctionComparisons() }))
59+
case let .not(subpredicate):
60+
return .compound(.not(subpredicate.strippingFunctionComparisons()))
61+
}
62+
}
63+
}
64+
}
65+
66+
internal extension FetchRequest.Predicate.Expression {
67+
68+
var containsFunction: Bool {
69+
switch self {
70+
case .function:
71+
return true
72+
case .attribute, .relationship, .keyPath:
73+
return false
74+
}
75+
}
76+
}
77+
78+
// MARK: - In-memory evaluation
79+
80+
internal extension FetchRequest.Predicate {
81+
82+
/// Evaluate this predicate against a fetched object in memory, calling registered
83+
/// functions for any `.function` expression.
84+
func evaluate(
85+
with data: ModelData,
86+
functions: [String: DatabaseFunction]
87+
) -> Bool {
88+
switch self {
89+
case let .value(value):
90+
return value
91+
case let .compound(compound):
92+
switch compound {
93+
case let .and(subpredicates):
94+
return subpredicates.allSatisfy { $0.evaluate(with: data, functions: functions) }
95+
case let .or(subpredicates):
96+
return subpredicates.contains { $0.evaluate(with: data, functions: functions) }
97+
case let .not(subpredicate):
98+
return subpredicate.evaluate(with: data, functions: functions) == false
99+
}
100+
case let .comparison(comparison):
101+
return comparison.evaluate(with: data, functions: functions)
102+
}
103+
}
104+
}
105+
106+
internal extension FetchRequest.Predicate.Comparison {
107+
108+
func evaluate(
109+
with data: ModelData,
110+
functions: [String: DatabaseFunction]
111+
) -> Bool {
112+
let lhs = left.evaluate(with: data, functions: functions)
113+
let rhs = right.evaluate(with: data, functions: functions)
114+
return type.evaluate(lhs, rhs, options: options)
115+
}
116+
}
117+
118+
internal extension FetchRequest.Predicate.Expression {
119+
120+
/// Resolve this expression to a value for a fetched object.
121+
func evaluate(
122+
with data: ModelData,
123+
functions: [String: DatabaseFunction]
124+
) -> AttributeValue? {
125+
switch self {
126+
case let .attribute(value):
127+
return value
128+
case let .keyPath(keyPath):
129+
return data.attributes[PropertyKey(rawValue: keyPath.rawValue)]
130+
case let .function(function):
131+
guard let registered = functions[function.name] else {
132+
return nil
133+
}
134+
let arguments = function.arguments.map { $0.evaluate(with: data, functions: functions) }
135+
return registered.evaluate(arguments)
136+
case .relationship:
137+
// relationships aren't compared by the in-memory function path
138+
return nil
139+
}
140+
}
141+
}
142+
143+
// MARK: - Operator evaluation
144+
145+
private extension FetchRequest.Predicate.Comparison.Operator {
146+
147+
func evaluate(
148+
_ lhs: AttributeValue?,
149+
_ rhs: AttributeValue?,
150+
options: Set<FetchRequest.Predicate.Comparison.Option>
151+
) -> Bool {
152+
let caseInsensitive = options.contains(.caseInsensitive)
153+
switch self {
154+
case .equalTo:
155+
return AttributeValue.areEqual(lhs, rhs, caseInsensitive: caseInsensitive)
156+
case .notEqualTo:
157+
return AttributeValue.areEqual(lhs, rhs, caseInsensitive: caseInsensitive) == false
158+
case .lessThan:
159+
return (AttributeValue.order(lhs, rhs)).map { $0 < 0 } ?? false
160+
case .lessThanOrEqualTo:
161+
return (AttributeValue.order(lhs, rhs)).map { $0 <= 0 } ?? false
162+
case .greaterThan:
163+
return (AttributeValue.order(lhs, rhs)).map { $0 > 0 } ?? false
164+
case .greaterThanOrEqualTo:
165+
return (AttributeValue.order(lhs, rhs)).map { $0 >= 0 } ?? false
166+
case .beginsWith:
167+
return AttributeValue.stringCompare(lhs, rhs, caseInsensitive: caseInsensitive) { $0.hasPrefix($1) }
168+
case .endsWith:
169+
return AttributeValue.stringCompare(lhs, rhs, caseInsensitive: caseInsensitive) { $0.hasSuffix($1) }
170+
case .contains:
171+
return AttributeValue.stringCompare(lhs, rhs, caseInsensitive: caseInsensitive) { $0.contains($1) }
172+
case .like, .matches:
173+
return AttributeValue.stringCompare(lhs, rhs, caseInsensitive: caseInsensitive) { subject, pattern in
174+
subject.range(of: like(pattern: pattern, matches: self == .matches), options: .regularExpression) != nil
175+
}
176+
case .in, .between:
177+
// right-hand collections aren't represented as a single AttributeValue
178+
return false
179+
}
180+
}
181+
182+
/// Convert a Cocoa-style `LIKE` pattern (`*`, `?`) or a full regular expression into
183+
/// an anchored regular expression string.
184+
private func like(pattern: String, matches: Bool) -> String {
185+
guard matches == false else {
186+
return pattern // already a regular expression
187+
}
188+
let escaped = NSRegularExpression.escapedPattern(for: pattern)
189+
.replacingOccurrences(of: "\\*", with: ".*")
190+
.replacingOccurrences(of: "\\?", with: ".")
191+
return "^" + escaped + "$"
192+
}
193+
}
194+
195+
private extension AttributeValue {
196+
197+
/// A numeric representation for comparable value types, for ordering comparisons.
198+
var comparableDouble: Double? {
199+
switch self {
200+
case let .bool(value): return value ? 1 : 0
201+
case let .int16(value): return Double(value)
202+
case let .int32(value): return Double(value)
203+
case let .int64(value): return Double(value)
204+
case let .float(value): return Double(value)
205+
case let .double(value): return value
206+
case let .decimal(value): return NSDecimalNumber(decimal: value).doubleValue
207+
case let .date(value): return value.timeIntervalSinceReferenceDate
208+
default: return nil
209+
}
210+
}
211+
212+
var stringValue: String? {
213+
if case let .string(value) = self { return value }
214+
return nil
215+
}
216+
217+
static func areEqual(_ lhs: AttributeValue?, _ rhs: AttributeValue?, caseInsensitive: Bool) -> Bool {
218+
switch (lhs, rhs) {
219+
case (.none, .none), (.some(.null), .none), (.none, .some(.null)), (.some(.null), .some(.null)):
220+
return true
221+
case let (.some(left), .some(right)):
222+
if caseInsensitive, let l = left.stringValue, let r = right.stringValue {
223+
return l.caseInsensitiveCompare(r) == .orderedSame
224+
}
225+
return left == right
226+
default:
227+
return false
228+
}
229+
}
230+
231+
/// Ordering of two values: negative if `lhs < rhs`, zero if equal, positive if greater;
232+
/// `nil` if the values aren't order-comparable.
233+
static func order(_ lhs: AttributeValue?, _ rhs: AttributeValue?) -> Int? {
234+
guard let lhs, let rhs else { return nil }
235+
if let l = lhs.comparableDouble, let r = rhs.comparableDouble {
236+
if l < r { return -1 }
237+
if l > r { return 1 }
238+
return 0
239+
}
240+
if let l = lhs.stringValue, let r = rhs.stringValue {
241+
switch l.compare(r) {
242+
case .orderedAscending: return -1
243+
case .orderedSame: return 0
244+
case .orderedDescending: return 1
245+
}
246+
}
247+
return nil
248+
}
249+
250+
static func stringCompare(
251+
_ lhs: AttributeValue?,
252+
_ rhs: AttributeValue?,
253+
caseInsensitive: Bool,
254+
_ compare: (String, String) -> Bool
255+
) -> Bool {
256+
guard var subject = lhs?.stringValue, var pattern = rhs?.stringValue else {
257+
return false
258+
}
259+
if caseInsensitive {
260+
subject = subject.lowercased()
261+
pattern = pattern.lowercased()
262+
}
263+
return compare(subject, pattern)
264+
}
265+
}
266+
267+
// MARK: - In-memory sorting
268+
269+
internal extension Array where Element == ModelData {
270+
271+
/// Sort in memory by the given descriptors, resolving function terms with the
272+
/// registered functions. Property terms fall back to attribute ordering.
273+
func sortedInMemory(
274+
by sortDescriptors: [FetchRequest.SortDescriptor],
275+
functions: [String: DatabaseFunction]
276+
) -> [ModelData] {
277+
guard sortDescriptors.isEmpty == false else { return self }
278+
return sorted { first, second in
279+
for descriptor in sortDescriptors {
280+
let lhs: AttributeValue?
281+
let rhs: AttributeValue?
282+
switch descriptor.term {
283+
case let .property(property):
284+
lhs = first.attributes[property]
285+
rhs = second.attributes[property]
286+
case let .function(function):
287+
let expression = FetchRequest.Predicate.Expression.function(function)
288+
lhs = expression.evaluate(with: first, functions: functions)
289+
rhs = expression.evaluate(with: second, functions: functions)
290+
}
291+
guard let comparison = AttributeValue.order(lhs, rhs), comparison != 0 else {
292+
continue
293+
}
294+
return descriptor.ascending ? comparison < 0 : comparison > 0
295+
}
296+
// stable tiebreaker on id, matching the native fetch's trailing id sort
297+
return first.id.rawValue < second.id.rawValue
298+
}
299+
}
300+
}
301+
302+
#endif

Sources/CoreDataModel/NSFetchRequest.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@ public extension FetchRequest {
2222
let fetchRequest = NSFetchRequest<ResultType>(entityName: entity.rawValue)
2323
fetchRequest.predicate = predicate?.toFoundation()
2424
fetchRequest.fetchLimit = fetchLimit
25-
var sortDescriptors = sortDescriptors.map {
26-
NSSortDescriptor(key: $0.property.rawValue, ascending: $0.ascending)
25+
var sortDescriptors = sortDescriptors.compactMap { sort -> NSSortDescriptor? in
26+
guard let property = sort.property else {
27+
// Function-based sort terms are not supported by NSFetchRequest;
28+
// they require in-memory evaluation, not yet implemented.
29+
return nil
30+
}
31+
return NSSortDescriptor(key: property.rawValue, ascending: sort.ascending)
2732
}
2833
sortDescriptors.append(NSSortDescriptor(key: NSManagedObject.BuiltInProperty.id.rawValue, ascending: true))
2934
fetchRequest.sortDescriptors = sortDescriptors

0 commit comments

Comments
 (0)