Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,14 @@ let package = Package(
url: "https://github.com/PureSwift/CoreModel",
from: "2.8.0"
),
sqliteDependency
sqliteDependency,
// Off Apple platforms, SQLite.swift links the embedded SQLite from this package.
// We depend on it directly so we can call the SQLite C API (custom functions)
// where the system `SQLite3` module isn't available.
.package(
url: "https://github.com/stephencelis/CSQLite",
from: "3.50.4"
)
],
targets: [
.target(
Expand All @@ -46,6 +53,13 @@ let package = Package(
.product(
name: "SQLite",
package: "SQLite.swift"
),
// On Apple platforms the SQLite C API comes from the system `SQLite3`
// module; elsewhere it comes from SQLite.swift's embedded copy.
.product(
name: "SQLiteSwiftCSQLite",
package: "CSQLite",
condition: .when(platforms: [.linux, .android])
)
]
),
Expand Down
20 changes: 0 additions & 20 deletions Sources/CoreModelSQLite/AttributeValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,26 +43,6 @@ internal extension AttributeValue {
}
}

/// Decode from a SQLite binding value with no declared attribute type (e.g. a
/// raw argument passed into a custom SQL function), inferring the value's shape
/// from the binding's runtime type.
init(binding: Binding?) {
switch binding {
case .none:
self = .null
case let value as Int64:
self = .int64(value)
case let value as Double:
self = .double(value)
case let value as String:
self = .string(value)
case let value as Blob:
self = .data(Data(value.bytes))
default:
self = .null
}
}

/// Decode from a SQLite binding value, interpreting it according to the declared attribute type.
init(binding: Binding?, type: AttributeType) throws {
guard let binding else {
Expand Down
113 changes: 113 additions & 0 deletions Sources/CoreModelSQLite/CustomFunction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//
// CustomFunction.swift
// CoreModel-SQLite
//
// Created by Alsey Coleman Miller on 7/16/26.
//

import Foundation
import CoreModel
import SQLite
#if canImport(Darwin)
import SQLite3
#elseif canImport(SQLiteSwiftCSQLite)
import SQLiteSwiftCSQLite
#elseif canImport(CSQLite)
import CSQLite
#else
import SQLite3
#endif

// Registers custom scalar functions by calling `sqlite3_create_function_v2` directly
// with `@convention(c)` function pointers, rather than SQLite.swift's `createFunction`.
// SQLite.swift registers the callback with a `@convention(block)` closure cast to a raw
// pointer, which is unreliable off Apple platforms (upstream
// https://github.com/stephencelis/SQLite.swift/issues/1071). A plain C function pointer
// plus a retained context pointer works identically on every platform.

/// The SQLite `SQLITE_TRANSIENT` sentinel destructor, telling SQLite to copy a result
/// value immediately (it is a macro in C, so it isn't imported).
private let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self)

/// Retains a ``DatabaseFunction`` so it can be passed through SQLite as an opaque pointer
/// and recovered inside the C callback.
private final class FunctionBox {
let function: DatabaseFunction
init(_ function: DatabaseFunction) { self.function = function }
}

internal extension SQLite.Connection {

/// Registers a `DatabaseFunction` with this connection via the SQLite C API.
func register(function: DatabaseFunction) throws {
let box = Unmanaged.passRetained(FunctionBox(function))
let flags = SQLITE_UTF8 | (function.deterministic ? SQLITE_DETERMINISTIC : 0)
let argumentCount = function.argumentCount.map { Int32($0) } ?? -1
let code = sqlite3_create_function_v2(
handle,
function.name,
argumentCount,
flags,
box.toOpaque(),
{ context, argc, argv in
let function = Unmanaged<FunctionBox>.fromOpaque(sqlite3_user_data(context)).takeUnretainedValue().function
var arguments = [AttributeValue?]()
arguments.reserveCapacity(Int(argc))
for index in 0..<Int(argc) {
arguments.append(argumentValue(argv?[index]))
}
setResult(context, function.evaluate(arguments))
},
nil, // xStep (scalar function, no aggregate)
nil, // xFinal
{ pointer in
// Balance `passRetained` when SQLite drops the function.
guard let pointer else { return }
Unmanaged<FunctionBox>.fromOpaque(pointer).release()
}
)
guard code == SQLITE_OK else {
box.release() // xDestroy isn't called when registration fails
throw SQLiteDatabaseError.unableToCreateFunction(function.name, code)
}
}
}

/// Read a SQLite argument value into an ``AttributeValue``, inferring its shape from the
/// value's runtime storage class.
private func argumentValue(_ value: OpaquePointer?) -> AttributeValue {
switch sqlite3_value_type(value) {
case SQLITE_INTEGER:
return .int64(sqlite3_value_int64(value))
case SQLITE_FLOAT:
return .double(sqlite3_value_double(value))
case SQLITE_TEXT:
guard let text = sqlite3_value_text(value) else { return .null }
return .string(String(cString: text))
case SQLITE_BLOB:
guard let bytes = sqlite3_value_blob(value) else { return .data(Data()) }
return .data(Data(bytes: bytes, count: Int(sqlite3_value_bytes(value))))
default:
return .null
}
}

/// Set a function's result on the SQLite context from an ``AttributeValue``.
private func setResult(_ context: OpaquePointer?, _ value: AttributeValue?) {
guard let value, let binding = value.binding else {
sqlite3_result_null(context)
return
}
switch binding {
case let integer as Int64:
sqlite3_result_int64(context, integer)
case let double as Double:
sqlite3_result_double(context, double)
case let text as String:
sqlite3_result_text(context, text, -1, transientDestructor)
case let blob as Blob:
sqlite3_result_blob(context, blob.bytes, Int32(blob.bytes.count), transientDestructor)
default:
sqlite3_result_null(context)
}
}
23 changes: 1 addition & 22 deletions Sources/CoreModelSQLite/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,8 @@ extension SQLiteDatabase: ModelStorage {

/// Registers a custom scalar function so it can be invoked from a predicate or sort
/// descriptor via ``FetchRequest/Predicate/Expression/function(_:)``.
///
/// - Important: Only supported on Apple platforms. The underlying SQLite.swift
/// `createFunction` registers the callback through `@convention(block)` +
/// `unsafeBitCast`, which is unreliable on non-Apple platforms and can corrupt
/// SQLite's heap (upstream https://github.com/stephencelis/SQLite.swift/issues/1071).
public func register(function: DatabaseFunction) async throws {
connection.register(function: function)
try connection.register(function: function)
}
}

Expand All @@ -135,22 +130,6 @@ public extension SQLiteDatabase {
}
}

internal extension SQLite.Connection {

/// Registers a `DatabaseFunction` with this connection, bridging SQLite's untyped
/// `Binding` values to/from `AttributeValue` at the boundary.
func register(function: DatabaseFunction) {
createFunction(
function.name,
argumentCount: function.argumentCount.map { UInt($0) },
deterministic: function.deterministic
) { arguments in
let values: [AttributeValue?] = arguments.map { AttributeValue(binding: $0) }
return function.evaluate(values)?.binding
}
}
}

internal extension SQLiteDatabase {

func asyncYield() async throws {
Expand Down
3 changes: 3 additions & 0 deletions Sources/CoreModelSQLite/Error.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ public enum SQLiteDatabaseError: Error {

/// The predicate cannot be represented as SQL.
case invalidPredicate(FetchRequest.Predicate)

/// A custom function could not be registered with SQLite. Carries the SQLite result code.
case unableToCreateFunction(String, Int32)
}
2 changes: 1 addition & 1 deletion Sources/CoreModelSQLite/ViewContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@ public final class SQLiteViewContext: ViewContext {
/// paired ``SQLiteDatabase`` must also be registered here to be usable from
/// queries run through this view context.
public func register(function: DatabaseFunction) throws {
connection.register(function: function)
try connection.register(function: function)
}
}
28 changes: 18 additions & 10 deletions Tests/CoreModelSQLiteTests/CustomFunctionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,6 @@ import CoreModel
import SQLite
@testable import CoreModelSQLite

// Custom SQL functions are only exercised on Apple platforms: SQLite.swift's
// `createFunction` registers the callback with `@convention(block)` + an
// `unsafeBitCast` to a raw pointer, which is unreliable on non-Apple platforms
// (the block pointer is invalid and corrupts SQLite's heap — upstream
// https://github.com/stephencelis/SQLite.swift/issues/1071). Gate these tests to
// Darwin so CI stays green until that is resolved.
#if canImport(Darwin)

/// A Haversine distance function, in meters, written directly in the test — CoreModelSQLite
/// itself has no notion of "distance" or geo data; this exercises the generic
/// `DatabaseFunction`/`.function` expression mechanism using a realistic example.
Expand Down Expand Up @@ -68,6 +60,18 @@ private struct SeededGenerator: RandomNumberGenerator {
}
}

/// Whether this SQLite build includes the R*Tree module (an optional compile-time
/// feature), determined by attempting to create an R*Tree virtual table in memory.
private let isRTreeAvailable: Bool = {
guard let connection = try? Connection(.inMemory) else { return false }
do {
try connection.execute("CREATE VIRTUAL TABLE rtree_probe USING rtree(id, minX, maxX)")
return true
} catch {
return false
}
}()

private func distanceSort(ascending: Bool = true) -> FetchRequest.SortDescriptor {
.init(term: .function(.init(name: "distance", arguments: [
.keyPath("latitude"), .keyPath("longitude"),
Expand Down Expand Up @@ -251,7 +255,12 @@ private func randomSites(count: Int, seed: UInt64) -> [(id: ObjectID, latitude:
/// applies the exact `distance` function for correctness. The final result must match
/// the brute-force in-memory oracle, and the prefilter must be sound (a superset of the
/// exact matches) and actually prune.
@Test func appManagedRTreePrefilter() async throws {
///
/// R*Tree is an optional SQLite compile-time feature (`SQLITE_ENABLE_RTREE`). It's present
/// in the system SQLite on Apple platforms but not in the embedded SQLite used elsewhere,
/// so this test is skipped where the `rtree` module is unavailable.
@Test(.enabled(if: isRTreeAvailable, "R*Tree module not compiled into this SQLite build"))
func appManagedRTreePrefilter() async throws {
let path = temporaryDatabasePath(named: "GeoRTree")
let database = try SQLiteDatabase(path: path, model: geoModel)
try await database.register(function: distanceFunction)
Expand Down Expand Up @@ -323,4 +332,3 @@ private func randomSites(count: Int, seed: UInt64) -> [(id: ObjectID, latitude:
#expect(oracle.isEmpty == false)
}

#endif // canImport(Darwin)
Loading
Loading