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
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,28 @@ jobs:
run: swift build -v
- name: Test
run: swift test -v

android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Test Swift Package on Android
uses: skiptools/swift-android-action@v2
with:
swift-version: '6.3'
free-disk-space: true

windows:
runs-on: windows-latest
steps:
- uses: compnerd/gha-setup-swift@main
with:
branch: swift-6.3.2-release
tag: 6.3.2-RELEASE
- uses: actions/checkout@v4
- name: Swift version
run: swift --version
- name: Build
run: swift build -v
- name: Test
run: swift test -v
33 changes: 32 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,48 @@
// swift-tools-version: 6.0
import PackageDescription
import CompilerPluginSupport
import class Foundation.ProcessInfo

// get environment variables
let environment = ProcessInfo.processInfo.environment
let dynamicLibrary = environment["SWIFT_BUILD_DYNAMIC_LIBRARY"] == "1"

// force building as dynamic library
let libraryType: PackageDescription.Product.Library.LibraryType? = dynamicLibrary ? .dynamic : nil

let package = Package(
name: "SQLite",
products: [
.library(
name: "SQLite",
type: libraryType,
targets: ["SQLite"]
)
],
dependencies: [
// Darwin platforms link the system SQLite3.framework; everywhere else
// has no system SQLite, so this package's embedded copy is used instead.
.package(
url: "https://github.com/PureSwift/swift-sqlcipher",
branch: "main"
)
],
targets: [
.target(
name: "SQLite"
name: "SQLite",
dependencies: [
.product(
name: "SQLCipher",
package: "swift-sqlcipher",
condition: .when(platforms: [.linux, .android, .windows, .wasi, .openbsd])
)
],
swiftSettings: [
.define(
"SQLITE_SWIFT_SQLCIPHER",
.when(platforms: [.linux, .android, .windows, .wasi, .openbsd])
)
]
),
.testTarget(
name: "SQLiteTests",
Expand Down
185 changes: 185 additions & 0 deletions Sources/SQLite/AggregateFunction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//
// AggregateFunction.swift
// SQLite
//
// Created by Alsey Coleman Miller on 7/16/26.
//

#if SQLITE_SWIFT_STANDALONE
import sqlite3
#elseif SQLITE_SWIFT_SQLCIPHER
import SQLCipher
#elseif os(Linux)
import SwiftToolchainCSQLite
#else
import SQLite3
#endif

public extension Connection {

/// Registers a custom SQL aggregate function (e.g. `SUM`, `GROUP_CONCAT`).
///
/// - Parameters:
/// - name: Name of the function as it will be invoked in SQL.
/// - argumentCount: Number of arguments the function accepts, or `nil` for a variable number of arguments.
/// - deterministic: Whether the function always returns the same result given the same inputs.
/// - initialState: Produces the starting accumulator value for each group.
/// - step: Invoked once per row in the group, mutating the accumulator.
/// - final: Produces the function's result from the accumulator once the group is exhausted.
func createAggregateFunction<State>(
_ name: String,
argumentCount: Int32? = nil,
deterministic: Bool = false,
initialState: @escaping () -> State,
step: @escaping (inout State, borrowing [Binding]) -> Void,
final: @escaping (State) -> Binding
) throws(SQLiteError) {
try handle.createAggregateFunction(
name,
argumentCount: argumentCount ?? -1,
deterministic: deterministic,
initialState: initialState,
step: step,
final: final
).get()
}
}

// MARK: - Private Implementation

/// Retains a per-group accumulator behind a type-erased box, so it can be stored via
/// `sqlite3_aggregate_context`, which only provides a raw memory slot.
internal final class AggregateStateBox<State> {

var state: State

init(_ state: State) {
self.state = state
}
}

/// Type-erases the generic `State` of `createAggregateFunction` so it can be stored
/// behind a single, non-generic `sqlite3_user_data` pointer.
internal final class AggregateFunctionBox {

let makeState: () -> AnyObject

let step: (AnyObject, borrowing [Binding]) -> Void

let final: (AnyObject) -> Binding

init<State>(
initialState: @escaping () -> State,
step: @escaping (inout State, borrowing [Binding]) -> Void,
final: @escaping (State) -> Binding
) {
self.makeState = { AggregateStateBox(initialState()) }
self.step = { boxed, arguments in
let box = boxed as! AggregateStateBox<State>
step(&box.state, arguments)
}
self.final = { boxed in
let box = boxed as! AggregateStateBox<State>
return final(box.state)
}
}
}

internal extension Connection.Handle {

func createAggregateFunction<State>(
_ name: String,
argumentCount: Int32,
deterministic: Bool,
initialState: @escaping () -> State,
step: @escaping (inout State, borrowing [Binding]) -> Void,
final: @escaping (State) -> Binding
) -> Result<Void, SQLiteError> {
let box = AggregateFunctionBox(initialState: initialState, step: step, final: final)
let context = Unmanaged.passRetained(box).toOpaque()
var flags = SQLITE_UTF8
if deterministic {
flags |= SQLITE_DETERMINISTIC
}
let resultCode = sqlite3_create_function_v2(
pointer,
name,
argumentCount,
flags,
context,
nil,
{ sqliteContext, argc, argv in
guard let sqliteContext, let boxPointer = sqlite3_user_data(sqliteContext) else {
return
}
let functionBox = Unmanaged<AggregateFunctionBox>.fromOpaque(boxPointer).takeUnretainedValue()
guard let stateObject = sqliteContext.aggregateState(functionBox.makeState) else {
return
}
let arguments: [Binding] = (0 ..< Int(argc)).map { Binding(sqliteValue: argv?[$0]) }
functionBox.step(stateObject, arguments)
},
{ sqliteContext in
guard let sqliteContext, let boxPointer = sqlite3_user_data(sqliteContext) else {
return
}
let functionBox = Unmanaged<AggregateFunctionBox>.fromOpaque(boxPointer).takeUnretainedValue()
let stateObject = sqliteContext.finalizeAggregateState() ?? functionBox.makeState()
sqliteContext.setResult(functionBox.final(stateObject))
},
{ boxPointer in
guard let boxPointer else { return }
Unmanaged<AggregateFunctionBox>.fromOpaque(boxPointer).release()
}
)
guard resultCode == SQLITE_OK else {
Unmanaged<AggregateFunctionBox>.fromOpaque(context).release()
return check(resultCode)
}
return .success(())
}
}

internal extension OpaquePointer {

/// Retrieves this invocation's accumulator, allocating and retaining it via
/// `sqlite3_aggregate_context` on first use within the group.
func aggregateState(_ makeState: () -> AnyObject) -> AnyObject? {
guard let raw = sqlite3_aggregate_context(self, Int32(MemoryLayout<UnsafeMutableRawPointer?>.size)) else {
return nil
}
let slot = raw.assumingMemoryBound(to: UnsafeMutableRawPointer?.self)
if let existing = slot.pointee {
return Unmanaged<AnyObject>.fromOpaque(existing).takeUnretainedValue()
}
let state = makeState()
slot.pointee = Unmanaged.passRetained(state).toOpaque()
return state
}

/// Retrieves this invocation's accumulator without allocating a new one, leaving
/// ownership untouched. Returns `nil` if `xStep` was never called.
func peekAggregateState() -> AnyObject? {
guard let raw = sqlite3_aggregate_context(self, 0) else {
return nil
}
let slot = raw.assumingMemoryBound(to: UnsafeMutableRawPointer?.self)
guard let existing = slot.pointee else {
return nil
}
return Unmanaged<AnyObject>.fromOpaque(existing).takeUnretainedValue()
}

/// Retrieves and releases this invocation's accumulator without allocating a new one,
/// for use in `xFinal`. Returns `nil` if `xStep` was never called (an empty group).
func finalizeAggregateState() -> AnyObject? {
guard let raw = sqlite3_aggregate_context(self, 0) else {
return nil
}
let slot = raw.assumingMemoryBound(to: UnsafeMutableRawPointer?.self)
guard let existing = slot.pointee else {
return nil
}
return Unmanaged<AnyObject>.fromOpaque(existing).takeRetainedValue()
}
}
93 changes: 93 additions & 0 deletions Sources/SQLite/Collation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//
// Collation.swift
// SQLite
//
// Created by Alsey Coleman Miller on 7/16/26.
//

#if SQLITE_SWIFT_STANDALONE
import sqlite3
#elseif SQLITE_SWIFT_SQLCIPHER
import SQLCipher
#elseif os(Linux)
import SwiftToolchainCSQLite
#else
import SQLite3
#endif

public extension Connection {

/// Registers a custom SQL collating sequence for use with `COLLATE` clauses.
///
/// - Parameters:
/// - name: Name of the collation as it will be invoked in SQL (e.g. `COLLATE NAME`).
/// - compare: Compares two strings, returning a negative value if `lhs` sorts before `rhs`,
/// zero if they are equivalent, or a positive value if `lhs` sorts after `rhs` — matching
/// the convention of `strcmp`.
func createCollation(_ name: String, _ compare: @escaping (_ lhs: String, _ rhs: String) -> Int) throws(SQLiteError) {
try handle.createCollation(name, compare: compare).get()
}

/// Removes a previously registered custom collating sequence.
func removeCollation(_ name: String) throws(SQLiteError) {
try handle.removeCollation(name).get()
}
}

// MARK: - Private Implementation

fileprivate final class CollationBox {

let compare: (String, String) -> Int

init(_ compare: @escaping (String, String) -> Int) {
self.compare = compare
}
}

internal extension Connection.Handle {

func createCollation(_ name: String, compare: @escaping (String, String) -> Int) -> Result<Void, SQLiteError> {
let box = CollationBox(compare)
let context = Unmanaged.passRetained(box).toOpaque()
let resultCode = sqlite3_create_collation_v2(
pointer,
name,
SQLITE_UTF8,
context,
{ pArg, length1, data1, length2, data2 in
guard let pArg else {
return 0
}
let box = Unmanaged<CollationBox>.fromOpaque(pArg).takeUnretainedValue()
let string1 = String(sqliteCollationBytes: data1, count: length1)
let string2 = String(sqliteCollationBytes: data2, count: length2)
return Int32(box.compare(string1, string2))
},
{ pArg in
guard let pArg else { return }
Unmanaged<CollationBox>.fromOpaque(pArg).release()
}
)
guard resultCode == SQLITE_OK else {
Unmanaged<CollationBox>.fromOpaque(context).release()
return check(resultCode)
}
return .success(())
}

func removeCollation(_ name: String) -> Result<Void, SQLiteError> {
check(sqlite3_create_collation_v2(pointer, name, SQLITE_UTF8, nil, nil, nil))
}
}

fileprivate extension String {

init(sqliteCollationBytes pointer: UnsafeRawPointer?, count: Int32) {
guard count > 0, let pointer else {
self = ""
return
}
self = String(decoding: UnsafeRawBufferPointer(start: pointer, count: Int(count)), as: UTF8.self)
}
}
7 changes: 6 additions & 1 deletion Sources/SQLite/ConnectionLocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@
// Created by Alsey Coleman Miller on 7/4/25.
//

#if canImport(Foundation)
#if canImport(FoundationEssentials)
import FoundationEssentials
#elseif canImport(Foundation)
import Foundation
#endif

#if canImport(FoundationEssentials) || canImport(Foundation)

public extension Connection {

Expand Down
7 changes: 6 additions & 1 deletion Sources/SQLite/DataBinding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@
// Created by Alsey Coleman Miller on 7/4/25.
//

#if canImport(Foundation)
#if canImport(FoundationEssentials)
import FoundationEssentials
#elseif canImport(Foundation)
import Foundation
#endif

#if canImport(FoundationEssentials) || canImport(Foundation)

extension Data: BindingConvertible {

Expand Down
Loading
Loading