diff --git a/Sources/CoreDataModel/FunctionEvaluation.swift b/Sources/CoreDataModel/FunctionEvaluation.swift new file mode 100644 index 0000000..c23c059 --- /dev/null +++ b/Sources/CoreDataModel/FunctionEvaluation.swift @@ -0,0 +1,302 @@ +// +// FunctionEvaluation.swift +// CoreDataModel +// +// Created by Alsey Coleman Miller on 7/16/26. +// + +#if canImport(CoreData) +import Foundation +import CoreModel + +// MARK: - Function detection + +internal extension FetchRequest { + + /// Whether this fetch request references any custom `.function` expression and + /// therefore requires in-memory evaluation — CoreData cannot execute a custom + /// function as part of a native fetch. + var requiresInMemoryEvaluation: Bool { + if predicate?.containsFunction == true { + return true + } + return sortDescriptors.contains { descriptor in + if case .function = descriptor.term { return true } else { return false } + } + } +} + +internal extension FetchRequest.Predicate { + + /// Whether this predicate references any `.function` expression. + var containsFunction: Bool { + switch self { + case .value: + return false + case let .comparison(comparison): + return comparison.left.containsFunction || comparison.right.containsFunction + case let .compound(compound): + return compound.subpredicates.contains { $0.containsFunction } + } + } + + /// Replace every comparison that references a function with `.value(true)`, so the + /// remaining predicate can be evaluated natively by CoreData as a superset filter. + /// The full predicate is then re-applied in memory. + func strippingFunctionComparisons() -> FetchRequest.Predicate { + switch self { + case .value: + return self + case let .comparison(comparison): + let usesFunction = comparison.left.containsFunction || comparison.right.containsFunction + return usesFunction ? .value(true) : self + case let .compound(compound): + switch compound { + case let .and(subpredicates): + return .compound(.and(subpredicates.map { $0.strippingFunctionComparisons() })) + case let .or(subpredicates): + return .compound(.or(subpredicates.map { $0.strippingFunctionComparisons() })) + case let .not(subpredicate): + return .compound(.not(subpredicate.strippingFunctionComparisons())) + } + } + } +} + +internal extension FetchRequest.Predicate.Expression { + + var containsFunction: Bool { + switch self { + case .function: + return true + case .attribute, .relationship, .keyPath: + return false + } + } +} + +// MARK: - In-memory evaluation + +internal extension FetchRequest.Predicate { + + /// Evaluate this predicate against a fetched object in memory, calling registered + /// functions for any `.function` expression. + func evaluate( + with data: ModelData, + functions: [String: DatabaseFunction] + ) -> Bool { + switch self { + case let .value(value): + return value + case let .compound(compound): + switch compound { + case let .and(subpredicates): + return subpredicates.allSatisfy { $0.evaluate(with: data, functions: functions) } + case let .or(subpredicates): + return subpredicates.contains { $0.evaluate(with: data, functions: functions) } + case let .not(subpredicate): + return subpredicate.evaluate(with: data, functions: functions) == false + } + case let .comparison(comparison): + return comparison.evaluate(with: data, functions: functions) + } + } +} + +internal extension FetchRequest.Predicate.Comparison { + + func evaluate( + with data: ModelData, + functions: [String: DatabaseFunction] + ) -> Bool { + let lhs = left.evaluate(with: data, functions: functions) + let rhs = right.evaluate(with: data, functions: functions) + return type.evaluate(lhs, rhs, options: options) + } +} + +internal extension FetchRequest.Predicate.Expression { + + /// Resolve this expression to a value for a fetched object. + func evaluate( + with data: ModelData, + functions: [String: DatabaseFunction] + ) -> AttributeValue? { + switch self { + case let .attribute(value): + return value + case let .keyPath(keyPath): + return data.attributes[PropertyKey(rawValue: keyPath.rawValue)] + case let .function(function): + guard let registered = functions[function.name] else { + return nil + } + let arguments = function.arguments.map { $0.evaluate(with: data, functions: functions) } + return registered.evaluate(arguments) + case .relationship: + // relationships aren't compared by the in-memory function path + return nil + } + } +} + +// MARK: - Operator evaluation + +private extension FetchRequest.Predicate.Comparison.Operator { + + func evaluate( + _ lhs: AttributeValue?, + _ rhs: AttributeValue?, + options: Set + ) -> Bool { + let caseInsensitive = options.contains(.caseInsensitive) + switch self { + case .equalTo: + return AttributeValue.areEqual(lhs, rhs, caseInsensitive: caseInsensitive) + case .notEqualTo: + return AttributeValue.areEqual(lhs, rhs, caseInsensitive: caseInsensitive) == false + case .lessThan: + return (AttributeValue.order(lhs, rhs)).map { $0 < 0 } ?? false + case .lessThanOrEqualTo: + return (AttributeValue.order(lhs, rhs)).map { $0 <= 0 } ?? false + case .greaterThan: + return (AttributeValue.order(lhs, rhs)).map { $0 > 0 } ?? false + case .greaterThanOrEqualTo: + return (AttributeValue.order(lhs, rhs)).map { $0 >= 0 } ?? false + case .beginsWith: + return AttributeValue.stringCompare(lhs, rhs, caseInsensitive: caseInsensitive) { $0.hasPrefix($1) } + case .endsWith: + return AttributeValue.stringCompare(lhs, rhs, caseInsensitive: caseInsensitive) { $0.hasSuffix($1) } + case .contains: + return AttributeValue.stringCompare(lhs, rhs, caseInsensitive: caseInsensitive) { $0.contains($1) } + case .like, .matches: + return AttributeValue.stringCompare(lhs, rhs, caseInsensitive: caseInsensitive) { subject, pattern in + subject.range(of: like(pattern: pattern, matches: self == .matches), options: .regularExpression) != nil + } + case .in, .between: + // right-hand collections aren't represented as a single AttributeValue + return false + } + } + + /// Convert a Cocoa-style `LIKE` pattern (`*`, `?`) or a full regular expression into + /// an anchored regular expression string. + private func like(pattern: String, matches: Bool) -> String { + guard matches == false else { + return pattern // already a regular expression + } + let escaped = NSRegularExpression.escapedPattern(for: pattern) + .replacingOccurrences(of: "\\*", with: ".*") + .replacingOccurrences(of: "\\?", with: ".") + return "^" + escaped + "$" + } +} + +private extension AttributeValue { + + /// A numeric representation for comparable value types, for ordering comparisons. + var comparableDouble: Double? { + switch self { + case let .bool(value): return value ? 1 : 0 + case let .int16(value): return Double(value) + case let .int32(value): return Double(value) + case let .int64(value): return Double(value) + case let .float(value): return Double(value) + case let .double(value): return value + case let .decimal(value): return NSDecimalNumber(decimal: value).doubleValue + case let .date(value): return value.timeIntervalSinceReferenceDate + default: return nil + } + } + + var stringValue: String? { + if case let .string(value) = self { return value } + return nil + } + + 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)): + return true + case let (.some(left), .some(right)): + if caseInsensitive, let l = left.stringValue, let r = right.stringValue { + return l.caseInsensitiveCompare(r) == .orderedSame + } + return left == right + default: + return false + } + } + + /// Ordering of two values: negative if `lhs < rhs`, zero if equal, positive if greater; + /// `nil` if the values aren't order-comparable. + static func order(_ lhs: AttributeValue?, _ rhs: AttributeValue?) -> Int? { + guard let lhs, let rhs else { return nil } + if let l = lhs.comparableDouble, let r = rhs.comparableDouble { + if l < r { return -1 } + if l > r { return 1 } + return 0 + } + if let l = lhs.stringValue, let r = rhs.stringValue { + switch l.compare(r) { + case .orderedAscending: return -1 + case .orderedSame: return 0 + case .orderedDescending: return 1 + } + } + return nil + } + + static func stringCompare( + _ lhs: AttributeValue?, + _ rhs: AttributeValue?, + caseInsensitive: Bool, + _ compare: (String, String) -> Bool + ) -> Bool { + guard var subject = lhs?.stringValue, var pattern = rhs?.stringValue else { + return false + } + if caseInsensitive { + subject = subject.lowercased() + pattern = pattern.lowercased() + } + return compare(subject, pattern) + } +} + +// MARK: - In-memory sorting + +internal extension Array where Element == ModelData { + + /// Sort in memory by the given descriptors, resolving function terms with the + /// registered functions. Property terms fall back to attribute ordering. + func sortedInMemory( + by sortDescriptors: [FetchRequest.SortDescriptor], + functions: [String: DatabaseFunction] + ) -> [ModelData] { + guard sortDescriptors.isEmpty == false else { return self } + return sorted { first, second in + for descriptor in sortDescriptors { + let lhs: AttributeValue? + let rhs: AttributeValue? + switch descriptor.term { + case let .property(property): + lhs = first.attributes[property] + rhs = second.attributes[property] + case let .function(function): + let expression = FetchRequest.Predicate.Expression.function(function) + lhs = expression.evaluate(with: first, functions: functions) + rhs = expression.evaluate(with: second, functions: functions) + } + guard let comparison = AttributeValue.order(lhs, rhs), comparison != 0 else { + continue + } + return descriptor.ascending ? comparison < 0 : comparison > 0 + } + // stable tiebreaker on id, matching the native fetch's trailing id sort + return first.id.rawValue < second.id.rawValue + } + } +} + +#endif diff --git a/Sources/CoreDataModel/NSFetchRequest.swift b/Sources/CoreDataModel/NSFetchRequest.swift index 931e0b5..d80b3c7 100644 --- a/Sources/CoreDataModel/NSFetchRequest.swift +++ b/Sources/CoreDataModel/NSFetchRequest.swift @@ -22,8 +22,13 @@ public extension FetchRequest { let fetchRequest = NSFetchRequest(entityName: entity.rawValue) fetchRequest.predicate = predicate?.toFoundation() fetchRequest.fetchLimit = fetchLimit - var sortDescriptors = sortDescriptors.map { - NSSortDescriptor(key: $0.property.rawValue, ascending: $0.ascending) + var sortDescriptors = sortDescriptors.compactMap { sort -> NSSortDescriptor? in + guard let property = sort.property else { + // Function-based sort terms are not supported by NSFetchRequest; + // they require in-memory evaluation, not yet implemented. + return nil + } + return NSSortDescriptor(key: property.rawValue, ascending: sort.ascending) } sortDescriptors.append(NSSortDescriptor(key: NSManagedObject.BuiltInProperty.id.rawValue, ascending: true)) fetchRequest.sortDescriptors = sortDescriptors diff --git a/Sources/CoreDataModel/NSManagedObjectContext.swift b/Sources/CoreDataModel/NSManagedObjectContext.swift index 6d1d5cb..510174b 100644 --- a/Sources/CoreDataModel/NSManagedObjectContext.swift +++ b/Sources/CoreDataModel/NSManagedObjectContext.swift @@ -19,12 +19,18 @@ extension NSManagedObjectContext: ModelStorage { } public func fetch(_ fetchRequest: FetchRequest) throws -> [ModelData] { - try self.fetchObjects(fetchRequest) - .map { try ModelData(managedObject: $0) } + guard fetchRequest.requiresInMemoryEvaluation else { + return try self.fetchObjects(fetchRequest) + .map { try ModelData(managedObject: $0) } + } + return try fetchInMemory(fetchRequest) } - + public func count(_ fetchRequest: FetchRequest) throws -> UInt { - return UInt(try self.count(for: fetchRequest.toFoundation())) + guard fetchRequest.requiresInMemoryEvaluation else { + return UInt(try self.count(for: fetchRequest.toFoundation())) + } + return UInt(try fetchInMemory(fetchRequest).count) } public func insert(_ value: ModelData) throws { @@ -58,10 +64,63 @@ extension NSManagedObjectContext: ModelStorage { } public func fetchID(_ fetchRequest: FetchRequest) throws -> [ObjectID] { - let fetch = fetchRequest.toFoundation() - fetch.propertiesToFetch = [NSManagedObject.BuiltInProperty.id.rawValue] - fetch.returnsObjectsAsFaults = false - return try self.fetch(fetchRequest.toFoundation()).map { try $0.modelObjectID } + guard fetchRequest.requiresInMemoryEvaluation else { + let fetch = fetchRequest.toFoundation() + fetch.propertiesToFetch = [NSManagedObject.BuiltInProperty.id.rawValue] + fetch.returnsObjectsAsFaults = false + return try self.fetch(fetchRequest.toFoundation()).map { try $0.modelObjectID } + } + return try fetchInMemory(fetchRequest).map { $0.id } + } + + /// Execute a fetch whose predicate or sort references a custom function. + /// + /// CoreData can't run custom functions, so the request is split: it fetches a + /// superset natively (function comparisons replaced with `TRUE`, function sorts and + /// limit/offset dropped), then filters, sorts, and paginates the results in memory + /// using the registered functions. + private func fetchInMemory(_ fetchRequest: FetchRequest) throws -> [ModelData] { + let functions = registeredFunctions + var native = fetchRequest + native.predicate = fetchRequest.predicate?.strippingFunctionComparisons() + native.sortDescriptors = [] + native.fetchLimit = 0 + native.fetchOffset = 0 + var results = try self.fetchObjects(native).map { try ModelData(managedObject: $0) } + if let predicate = fetchRequest.predicate { + results = results.filter { predicate.evaluate(with: $0, functions: functions) } + } + results = results.sortedInMemory(by: fetchRequest.sortDescriptors, functions: functions) + if fetchRequest.fetchOffset > 0 { + results = Array(results.dropFirst(fetchRequest.fetchOffset)) + } + if fetchRequest.fetchLimit > 0 { + results = Array(results.prefix(fetchRequest.fetchLimit)) + } + return results + } + + /// Registers a custom function. CoreData cannot execute custom functions as part of + /// a native fetch, so the function is only recorded for later in-memory evaluation. + public func register(function: DatabaseFunction) throws { + registeredFunctions[function.name] = function + } +} + +// MARK: - Function Registry + +internal extension NSManagedObjectContext { + + /// Key under which the function registry is stored in the context's `userInfo`. + private static let functionRegistryKey = "org.pureswift.CoreDataModel.registeredFunctions" + + /// Functions registered via ``ModelStorage/register(function:)``, keyed by name. + /// + /// Stored in the context's `userInfo`, the dictionary CoreData provides for + /// associating custom data with a managed object context. + var registeredFunctions: [String: DatabaseFunction] { + get { (userInfo[Self.functionRegistryKey] as? [String: DatabaseFunction]) ?? [:] } + set { userInfo[Self.functionRegistryKey] = newValue } } } diff --git a/Sources/CoreDataModel/NSPersistentContainer.swift b/Sources/CoreDataModel/NSPersistentContainer.swift index ca50405..e8613c0 100644 --- a/Sources/CoreDataModel/NSPersistentContainer.swift +++ b/Sources/CoreDataModel/NSPersistentContainer.swift @@ -62,6 +62,12 @@ extension NSPersistentContainer: ModelStorage { try context.fetchID(fetchRequest) } } + + public func register(function: DatabaseFunction) async throws { + try await performBackgroundTask { context in + try context.register(function: function) + } + } } // MARK: - PersistentContainerStorage @@ -170,7 +176,13 @@ public actor PersistentContainerStorage: ModelStorage, ObservableObject { try context.delete(entity, for: ids) } } - + + public func register(function: DatabaseFunction) async throws { + try await performBackgroundTask { (context, model) in + try context.register(function: function) + } + } + private func performBackgroundTask( schedule: NSManagedObjectContext.ScheduledTaskType = .immediate, _ task: @escaping (NSManagedObjectContext, NSManagedObjectModel) throws -> T diff --git a/Sources/CoreDataModel/NSPredicate.swift b/Sources/CoreDataModel/NSPredicate.swift index a901e9a..21ecfa5 100644 --- a/Sources/CoreDataModel/NSPredicate.swift +++ b/Sources/CoreDataModel/NSPredicate.swift @@ -126,6 +126,7 @@ internal extension FetchRequest.Predicate.Expression { case let .keyPath(keyPath): return NSExpression(forKeyPath: keyPath.rawValue) 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() }) } } } diff --git a/Sources/CoreModel/DatabaseFunction.swift b/Sources/CoreModel/DatabaseFunction.swift new file mode 100644 index 0000000..f1b1174 --- /dev/null +++ b/Sources/CoreModel/DatabaseFunction.swift @@ -0,0 +1,42 @@ +// +// DatabaseFunction.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 7/16/26. +// Copyright © 2026 PureSwift. All rights reserved. +// + +/// A custom function that can be registered with a ``ModelStorage`` so it can be +/// invoked from a ``FetchRequest/Predicate`` or ``FetchRequest/SortDescriptor`` via +/// ``FetchRequest/Predicate/Expression/function(_:)``. +/// +/// Backends decide how to execute a registered function: a SQL-backed store may +/// register it directly with the underlying database engine so it can run as part +/// of a query, while other backends may call `evaluate` in memory against already +/// fetched values. +public struct DatabaseFunction: Sendable { + + /// The name used to refer to this function from a predicate or sort descriptor. + public var name: String + + /// The number of arguments this function accepts, or `nil` if variadic. + public var argumentCount: Int? + + /// Whether this function always returns the same result for the same arguments. + public var deterministic: Bool + + /// Evaluates the function given its argument values, returning the result. + public var evaluate: @Sendable ([AttributeValue?]) -> AttributeValue? + + public init( + name: String, + argumentCount: Int? = nil, + deterministic: Bool = true, + evaluate: @escaping @Sendable ([AttributeValue?]) -> AttributeValue? + ) { + self.name = name + self.argumentCount = argumentCount + self.deterministic = deterministic + self.evaluate = evaluate + } +} diff --git a/Sources/CoreModel/Predicate/Expression.swift b/Sources/CoreModel/Predicate/Expression.swift index 88ec330..f9a7575 100644 --- a/Sources/CoreModel/Predicate/Expression.swift +++ b/Sources/CoreModel/Predicate/Expression.swift @@ -19,14 +19,19 @@ public extension FetchRequest.Predicate { /// Expression that invokes `value​For​Key​Path:​` with a given key path. case keyPath(PredicateKeyPath) + + /// 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) } - + /// Type of predicate expression. enum ExpressionType: String, Sendable { case attribute case relationship case keyPath + case function } } @@ -41,6 +46,7 @@ public extension FetchRequest.Predicate.Expression { case .attribute: return .attribute case .relationship: return .relationship case .keyPath: return .keyPath + case .function: return .function } } } @@ -55,6 +61,7 @@ extension FetchRequest.Predicate.Expression: CustomStringConvertible { case let .attribute(value): return value.predicateDescription case let .relationship(value): return value.predicateDescription case let .keyPath(value): return value.description + case let .function(value): return value.description } } } @@ -122,6 +129,9 @@ extension FetchRequest.Predicate.Expression: Codable { case .keyPath: let keyPath = try container.decode(String.self, forKey: .expression) self = .keyPath(PredicateKeyPath(rawValue: keyPath)) + case .function: + let expression = try container.decode(FetchRequest.Predicate.FunctionExpression.self, forKey: .expression) + self = .function(expression) } } @@ -137,6 +147,8 @@ extension FetchRequest.Predicate.Expression: Codable { try container.encode(value, forKey: .expression) case let .keyPath(keyPath): try container.encode(keyPath.rawValue, forKey: .expression) + case let .function(value): + try container.encode(value, forKey: .expression) } } } diff --git a/Sources/CoreModel/Predicate/FunctionExpression.swift b/Sources/CoreModel/Predicate/FunctionExpression.swift new file mode 100644 index 0000000..1a31a0f --- /dev/null +++ b/Sources/CoreModel/Predicate/FunctionExpression.swift @@ -0,0 +1,39 @@ +// +// FunctionExpression.swift +// Predicate +// +// Created by Alsey Coleman Miller on 7/16/26. +// Copyright © 2026 PureSwift. All rights reserved. +// + +public extension FetchRequest.Predicate { + + /// Represents a call to a named function (e.g. a custom function registered + /// with the underlying store via ``DatabaseFunction``) with a list of argument expressions. + struct FunctionExpression: Equatable, Hashable, Sendable { + + /// The name of the function to invoke. + public var name: String + + /// The arguments passed to the function. + public var arguments: [FetchRequest.Predicate.Expression] + + public init(name: String, arguments: [FetchRequest.Predicate.Expression]) { + self.name = name + self.arguments = arguments + } + } +} + +#if !hasFeature(Embedded) +extension FetchRequest.Predicate.FunctionExpression: Codable {} +#endif + +// MARK: - CustomStringConvertible + +extension FetchRequest.Predicate.FunctionExpression: CustomStringConvertible { + + public var description: String { + name + "(" + arguments.map { $0.description }.joined(separator: ", ") + ")" + } +} diff --git a/Sources/CoreModel/SortDescriptor.swift b/Sources/CoreModel/SortDescriptor.swift index 0c0ae05..4fdd9af 100644 --- a/Sources/CoreModel/SortDescriptor.swift +++ b/Sources/CoreModel/SortDescriptor.swift @@ -10,19 +10,42 @@ public extension FetchRequest { struct SortDescriptor: Equatable, Hashable, Sendable { - public var property: PropertyKey + public var term: SortTerm public var ascending: Bool - public init(property: PropertyKey, ascending: Bool = true) { - self.property = property + public init(term: SortTerm, ascending: Bool = true) { + self.term = term self.ascending = ascending } + + public init(property: PropertyKey, ascending: Bool = true) { + self.init(term: .property(property), ascending: ascending) + } + } + + /// What a ``FetchRequest.SortDescriptor`` sorts by — either a plain property, + /// or the result of a function call expression (e.g. a custom function registered + /// with the underlying store via ``DatabaseFunction``). + enum SortTerm: Equatable, Hashable, Sendable { + + case property(PropertyKey) + case function(Predicate.FunctionExpression) + } +} + +public extension FetchRequest.SortDescriptor { + + /// The property being sorted by, if this descriptor's term is `.property`. + var property: PropertyKey? { + guard case let .property(property) = term else { return nil } + return property } } #if !hasFeature(Embedded) extension FetchRequest.SortDescriptor: Codable {} +extension FetchRequest.SortTerm: Codable {} #endif // MARK: - Foundation @@ -36,7 +59,7 @@ public extension FetchRequest.SortDescriptor { /// Creates a ``FetchRequest.SortDescriptor`` from a ``Foundation.SortDescriptor`` init(_ sortDescriptor: Foundation.SortDescriptor) { let sortDescriptor = NSSortDescriptor(sortDescriptor) - self.property = PropertyKey(rawValue: sortDescriptor.key ?? "") + self.term = .property(PropertyKey(rawValue: sortDescriptor.key ?? "")) self.ascending = sortDescriptor.ascending } } diff --git a/Sources/CoreModel/Store.swift b/Sources/CoreModel/Store.swift index 19bdf4a..11d54ff 100644 --- a/Sources/CoreModel/Store.swift +++ b/Sources/CoreModel/Store.swift @@ -32,6 +32,14 @@ public protocol ModelStorage: AnyObject, Sendable { /// Delete the specified managed objects. func delete(_ entity: EntityName, for ids: [ObjectID]) async throws + + /// Register a custom function so it can be invoked from a predicate or sort descriptor + /// via ``FetchRequest/Predicate/Expression/function(_:)``. + /// + /// Backends that can execute the function as part of a native query (e.g. a SQL-backed + /// store) should do so; backends that cannot should fall back to calling `function.evaluate` + /// in memory against fetched values. + func register(function: DatabaseFunction) async throws } #if !hasFeature(Embedded) diff --git a/Tests/CoreModelTests/CoreDataTests.swift b/Tests/CoreModelTests/CoreDataTests.swift index 947ee58..0c25690 100644 --- a/Tests/CoreModelTests/CoreDataTests.swift +++ b/Tests/CoreModelTests/CoreDataTests.swift @@ -99,6 +99,57 @@ struct CoreDataTests { let rentalUnitIDs = try store.viewContext.fetchID(rentalUnitFetchRequest) #expect(rentalUnitIDs == campground.units.map { ObjectID($0) }) } + + /// Register a custom function on the CoreData backend and use it to filter and + /// sort a collection of people by name. CoreData can't execute custom functions in + /// a native fetch, so this exercises the in-memory evaluation fallback. + @available(macOS 12, iOS 15, watchOS 8, tvOS 15, *) + @Test + func customFunctionInMemory() throws { + + let model = Model(entities: Person.self, Event.self) + let coordinator = NSPersistentStoreCoordinator(managedObjectModel: NSManagedObjectModel(model: model)) + try coordinator.addPersistentStore(ofType: NSInMemoryStoreType, configurationName: nil, at: nil, options: nil) + let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) + context.persistentStoreCoordinator = coordinator + + // a function that returns the lowercased name — clearly not a built-in SQL function + try context.register(function: DatabaseFunction(name: "lowercaseName", argumentCount: 1) { arguments in + guard arguments.count == 1, case let .string(name) = arguments[0] else { return nil } + return .string(name.lowercased()) + }) + + let people = [ + Person(name: "Alice", age: 30), + Person(name: "alan", age: 40), + Person(name: "Bob", age: 25), + Person(name: "Charlie", age: 35) + ] + for person in people { + try context.insert(person.encode()) + } + + // filter: names whose lowercased form begins with "a" — matches "Alice" and "alan" + let lowercaseName = FetchRequest.Predicate.Expression.function( + .init(name: "lowercaseName", arguments: [.keyPath(PredicateKeyPath(rawValue: "name"))]) + ) + let filterRequest = FetchRequest( + entity: Person.entityName, + predicate: .comparison(.init(left: lowercaseName, right: .attribute(.string("a")), type: .beginsWith)) + ) + let matches = try context.fetch(filterRequest) + #expect(Set(matches.compactMap { $0.attributes[PropertyKey(rawValue: "name")] }) == [.string("Alice"), .string("alan")]) + #expect(try context.count(filterRequest) == 2) + + // sort by the function result (case-insensitive name order) + let sortRequest = FetchRequest( + entity: Person.entityName, + sortDescriptors: [.init(term: .function(.init(name: "lowercaseName", arguments: [.keyPath(PredicateKeyPath(rawValue: "name"))])), ascending: true)] + ) + let sorted = try context.fetch(sortRequest).map { $0.attributes[PropertyKey(rawValue: "name")] } + // lowercased ordering: "alan" < "alice" < "bob" < "charlie" + #expect(sorted == [.string("alan"), .string("Alice"), .string("Bob"), .string("Charlie")]) + } } #endif diff --git a/Tests/CoreModelTests/DatabaseFunctionTests.swift b/Tests/CoreModelTests/DatabaseFunctionTests.swift new file mode 100644 index 0000000..1e00eae --- /dev/null +++ b/Tests/CoreModelTests/DatabaseFunctionTests.swift @@ -0,0 +1,49 @@ +// +// DatabaseFunctionTests.swift +// CoreModel +// +// Created by Alsey Coleman Miller on 7/16/26. +// + +import Foundation +import Testing +@testable import CoreModel + +@Suite struct DatabaseFunctionTests { + + @Test func evaluate() { + let function = DatabaseFunction(name: "add", argumentCount: 2) { arguments in + guard case let .int64(a) = arguments[0], case let .int64(b) = arguments[1] else { + return nil + } + return .int64(a + b) + } + #expect(function.evaluate([.int64(2), .int64(3)]) == .int64(5)) + } + + @Test func functionExpressionCodable() throws { + let expression = FetchRequest.Predicate.Expression.function( + .init(name: "myFunction", arguments: [.keyPath("foo"), .attribute(.int64(42))]) + ) + let data = try JSONEncoder().encode(expression) + let decoded = try JSONDecoder().decode(FetchRequest.Predicate.Expression.self, from: data) + #expect(decoded == expression) + } + + @Test func sortDescriptorFunctionTerm() throws { + let sort = FetchRequest.SortDescriptor( + term: .function(.init(name: "myFunction", arguments: [.keyPath("foo")])), + ascending: false + ) + #expect(sort.property == nil) + let data = try JSONEncoder().encode(sort) + let decoded = try JSONDecoder().decode(FetchRequest.SortDescriptor.self, from: data) + #expect(decoded == sort) + } + + @Test func sortDescriptorPropertyCompatibility() { + let sort = FetchRequest.SortDescriptor(property: "name", ascending: true) + #expect(sort.property == "name") + #expect(sort.term == .property("name")) + } +}