Skip to content

Commit 69c2b1c

Browse files
committed
Register custom functions via the SQLite C API directly
Call sqlite3_create_function_v2 with @convention(c) function pointers and a retained context pointer, instead of SQLite.swift's createFunction. SQLite.swift registers the callback as a @convention(block) closure cast to a raw pointer, which is unreliable off Apple platforms and segfaults on Linux (upstream stephencelis/SQLite.swift#1071). A plain C function pointer works everywhere. - New CustomFunction.swift: C-API registration, argument/result marshalling between sqlite3_value/context and AttributeValue, with the SQLite C module imported from the system SQLite3 on Apple and the embedded copy elsewhere. - Package.swift depends on stephencelis/CSQLite so the C API is available on Linux/Android where the system SQLite3 module isn't. - Removed the Darwin-only gate on the custom-function tests; they now run on every platform.
1 parent 403a05f commit 69c2b1c

7 files changed

Lines changed: 133 additions & 53 deletions

File tree

Package.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,14 @@ let package = Package(
3636
url: "https://github.com/PureSwift/CoreModel",
3737
from: "2.8.0"
3838
),
39-
sqliteDependency
39+
sqliteDependency,
40+
// Off Apple platforms, SQLite.swift links the embedded SQLite from this package.
41+
// We depend on it directly so we can call the SQLite C API (custom functions)
42+
// where the system `SQLite3` module isn't available.
43+
.package(
44+
url: "https://github.com/stephencelis/CSQLite",
45+
from: "3.50.4"
46+
)
4047
],
4148
targets: [
4249
.target(
@@ -46,6 +53,13 @@ let package = Package(
4653
.product(
4754
name: "SQLite",
4855
package: "SQLite.swift"
56+
),
57+
// On Apple platforms the SQLite C API comes from the system `SQLite3`
58+
// module; elsewhere it comes from SQLite.swift's embedded copy.
59+
.product(
60+
name: "SQLiteSwiftCSQLite",
61+
package: "CSQLite",
62+
condition: .when(platforms: [.linux, .android])
4963
)
5064
]
5165
),

Sources/CoreModelSQLite/AttributeValue.swift

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,26 +43,6 @@ internal extension AttributeValue {
4343
}
4444
}
4545

46-
/// Decode from a SQLite binding value with no declared attribute type (e.g. a
47-
/// raw argument passed into a custom SQL function), inferring the value's shape
48-
/// from the binding's runtime type.
49-
init(binding: Binding?) {
50-
switch binding {
51-
case .none:
52-
self = .null
53-
case let value as Int64:
54-
self = .int64(value)
55-
case let value as Double:
56-
self = .double(value)
57-
case let value as String:
58-
self = .string(value)
59-
case let value as Blob:
60-
self = .data(Data(value.bytes))
61-
default:
62-
self = .null
63-
}
64-
}
65-
6646
/// Decode from a SQLite binding value, interpreting it according to the declared attribute type.
6747
init(binding: Binding?, type: AttributeType) throws {
6848
guard let binding else {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
//
2+
// CustomFunction.swift
3+
// CoreModel-SQLite
4+
//
5+
// Created by Alsey Coleman Miller on 7/16/26.
6+
//
7+
8+
import Foundation
9+
import CoreModel
10+
import SQLite
11+
#if canImport(Darwin)
12+
import SQLite3
13+
#elseif canImport(SQLiteSwiftCSQLite)
14+
import SQLiteSwiftCSQLite
15+
#elseif canImport(CSQLite)
16+
import CSQLite
17+
#else
18+
import SQLite3
19+
#endif
20+
21+
// Registers custom scalar functions by calling `sqlite3_create_function_v2` directly
22+
// with `@convention(c)` function pointers, rather than SQLite.swift's `createFunction`.
23+
// SQLite.swift registers the callback with a `@convention(block)` closure cast to a raw
24+
// pointer, which is unreliable off Apple platforms (upstream
25+
// https://github.com/stephencelis/SQLite.swift/issues/1071). A plain C function pointer
26+
// plus a retained context pointer works identically on every platform.
27+
28+
/// The SQLite `SQLITE_TRANSIENT` sentinel destructor, telling SQLite to copy a result
29+
/// value immediately (it is a macro in C, so it isn't imported).
30+
private let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
31+
32+
/// Retains a ``DatabaseFunction`` so it can be passed through SQLite as an opaque pointer
33+
/// and recovered inside the C callback.
34+
private final class FunctionBox {
35+
let function: DatabaseFunction
36+
init(_ function: DatabaseFunction) { self.function = function }
37+
}
38+
39+
internal extension SQLite.Connection {
40+
41+
/// Registers a `DatabaseFunction` with this connection via the SQLite C API.
42+
func register(function: DatabaseFunction) throws {
43+
let box = Unmanaged.passRetained(FunctionBox(function))
44+
let flags = SQLITE_UTF8 | (function.deterministic ? SQLITE_DETERMINISTIC : 0)
45+
let argumentCount = function.argumentCount.map { Int32($0) } ?? -1
46+
let code = sqlite3_create_function_v2(
47+
handle,
48+
function.name,
49+
argumentCount,
50+
flags,
51+
box.toOpaque(),
52+
{ context, argc, argv in
53+
let function = Unmanaged<FunctionBox>.fromOpaque(sqlite3_user_data(context)).takeUnretainedValue().function
54+
var arguments = [AttributeValue?]()
55+
arguments.reserveCapacity(Int(argc))
56+
for index in 0..<Int(argc) {
57+
arguments.append(argumentValue(argv?[index]))
58+
}
59+
setResult(context, function.evaluate(arguments))
60+
},
61+
nil, // xStep (scalar function, no aggregate)
62+
nil, // xFinal
63+
{ pointer in
64+
// Balance `passRetained` when SQLite drops the function.
65+
guard let pointer else { return }
66+
Unmanaged<FunctionBox>.fromOpaque(pointer).release()
67+
}
68+
)
69+
guard code == SQLITE_OK else {
70+
box.release() // xDestroy isn't called when registration fails
71+
throw SQLiteDatabaseError.unableToCreateFunction(function.name, code)
72+
}
73+
}
74+
}
75+
76+
/// Read a SQLite argument value into an ``AttributeValue``, inferring its shape from the
77+
/// value's runtime storage class.
78+
private func argumentValue(_ value: OpaquePointer?) -> AttributeValue {
79+
switch sqlite3_value_type(value) {
80+
case SQLITE_INTEGER:
81+
return .int64(sqlite3_value_int64(value))
82+
case SQLITE_FLOAT:
83+
return .double(sqlite3_value_double(value))
84+
case SQLITE_TEXT:
85+
guard let text = sqlite3_value_text(value) else { return .null }
86+
return .string(String(cString: text))
87+
case SQLITE_BLOB:
88+
guard let bytes = sqlite3_value_blob(value) else { return .data(Data()) }
89+
return .data(Data(bytes: bytes, count: Int(sqlite3_value_bytes(value))))
90+
default:
91+
return .null
92+
}
93+
}
94+
95+
/// Set a function's result on the SQLite context from an ``AttributeValue``.
96+
private func setResult(_ context: OpaquePointer?, _ value: AttributeValue?) {
97+
guard let value, let binding = value.binding else {
98+
sqlite3_result_null(context)
99+
return
100+
}
101+
switch binding {
102+
case let integer as Int64:
103+
sqlite3_result_int64(context, integer)
104+
case let double as Double:
105+
sqlite3_result_double(context, double)
106+
case let text as String:
107+
sqlite3_result_text(context, text, -1, transientDestructor)
108+
case let blob as Blob:
109+
sqlite3_result_blob(context, blob.bytes, Int32(blob.bytes.count), transientDestructor)
110+
default:
111+
sqlite3_result_null(context)
112+
}
113+
}

Sources/CoreModelSQLite/Database.swift

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,8 @@ extension SQLiteDatabase: ModelStorage {
114114

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

@@ -135,22 +130,6 @@ public extension SQLiteDatabase {
135130
}
136131
}
137132

138-
internal extension SQLite.Connection {
139-
140-
/// Registers a `DatabaseFunction` with this connection, bridging SQLite's untyped
141-
/// `Binding` values to/from `AttributeValue` at the boundary.
142-
func register(function: DatabaseFunction) {
143-
createFunction(
144-
function.name,
145-
argumentCount: function.argumentCount.map { UInt($0) },
146-
deterministic: function.deterministic
147-
) { arguments in
148-
let values: [AttributeValue?] = arguments.map { AttributeValue(binding: $0) }
149-
return function.evaluate(values)?.binding
150-
}
151-
}
152-
}
153-
154133
internal extension SQLiteDatabase {
155134

156135
func asyncYield() async throws {

Sources/CoreModelSQLite/Error.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@ public enum SQLiteDatabaseError: Error {
2020

2121
/// The predicate cannot be represented as SQL.
2222
case invalidPredicate(FetchRequest.Predicate)
23+
24+
/// A custom function could not be registered with SQLite. Carries the SQLite result code.
25+
case unableToCreateFunction(String, Int32)
2326
}

Sources/CoreModelSQLite/ViewContext.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,6 @@ public final class SQLiteViewContext: ViewContext {
6161
/// paired ``SQLiteDatabase`` must also be registered here to be usable from
6262
/// queries run through this view context.
6363
public func register(function: DatabaseFunction) throws {
64-
connection.register(function: function)
64+
try connection.register(function: function)
6565
}
6666
}

Tests/CoreModelSQLiteTests/CustomFunctionTests.swift

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,6 @@ import CoreModel
44
import SQLite
55
@testable import CoreModelSQLite
66

7-
// Custom SQL functions are only exercised on Apple platforms: SQLite.swift's
8-
// `createFunction` registers the callback with `@convention(block)` + an
9-
// `unsafeBitCast` to a raw pointer, which is unreliable on non-Apple platforms
10-
// (the block pointer is invalid and corrupts SQLite's heap — upstream
11-
// https://github.com/stephencelis/SQLite.swift/issues/1071). Gate these tests to
12-
// Darwin so CI stays green until that is resolved.
13-
#if canImport(Darwin)
14-
157
/// A Haversine distance function, in meters, written directly in the test — CoreModelSQLite
168
/// itself has no notion of "distance" or geo data; this exercises the generic
179
/// `DatabaseFunction`/`.function` expression mechanism using a realistic example.
@@ -323,4 +315,3 @@ private func randomSites(count: Int, seed: UInt64) -> [(id: ObjectID, latitude:
323315
#expect(oracle.isEmpty == false)
324316
}
325317

326-
#endif // canImport(Darwin)

0 commit comments

Comments
 (0)