Puoi usare estensioni SQLite personalizzate in un'app Capacitor, ma solo come code native compilate nell'app: su iOS colleghi l'estensione staticamente e chiami la sua funzione init a ogni connessione, mentre su Android includi una build di SQLite che supporti il caricamento di estensioni e distribuisci l'estensione come libreria nativa per architettura CPU. Questa guida spiega le differenze tra le piattaforme, ti fornisce un'estensione funzionante in C e mostra le modifiche native esatte per @capgo/capacitor-fast-sql su Capacitor 8.
Prima di scrivere in C, verifica di averne davvero bisogno. Molte "estensioni" sono già integrate.
Cos'è un'estensione SQLite
Un'estensione è code nativa che registra nuove funzionalità in una connessione SQLite: funzioni scalari (haversine_km(...)), funzioni aggregate, collation, tabelle virtuali e tokenizer FTS5. Ogni estensione espone un unico entry point:
int sqlite3_<name>_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi);
SQLite lo chiama una volta per connessione. Tutto ciò che registri esiste solo su quella connessione.
Motivi tipici per scriverne una in un'app mobile:
- un tokenizer FTS5 personalizzato per una lingua che il tokenizer
unicode61predefinito gestisce male, - una funzione matematica o geo necessaria in
WHEREeORDER BY(ordinamento per distanza, scoring), - una collation che ordini i nomi come si aspettano i tuoi utenti,
- un'estensione per la vector search per il retrieval on-device.
Controlla cosa è già integrato
Eseguile tramite il tuo plugin sulle versioni di iOS e Android più vecchie che supporti:
import { FastSQL } from '@capgo/capacitor-fast-sql';
const db = await FastSQL.connect({ database: 'probe' });
const [{ v }] = await db.query('SELECT sqlite_version() AS v');
const options = await db.query('PRAGMA compile_options');
const functions = await db.query('SELECT name FROM pragma_function_list ORDER BY name');
console.log(v, options.map((r) => r.compile_options));
Cose per cui spesso non serve un'estensione:
| Ti servono | Funzionalità integrata | Verifica |
|---|---|---|
| JSON nelle colonne | Funzioni JSON (json_extract, ->>) |
Integrate da SQLite 3.38. Le versioni più vecchie di SQLite su Android potrebbero non averle |
| Ricerca full-text | FTS4, FTS5 | ENABLE_FTS4 / ENABLE_FTS5 nella compile_options |
| Bounding box spaziali | R*Tree | ENABLE_RTREE |
sin, cos, sqrt, pow |
Funzioni matematiche | ENABLE_MATH_FUNCTIONS, SQLite 3.35+ |
Se la funzione è presente su ogni target, fermati qui. La web build di Fast SQL usa l'ufficiale SQLite Wasm, che include JSON, FTS5 e funzioni math.
Perché iOS e Android funzionano diversamente
iOS. Il sistema libsqlite3 è costruito con SQLITE_OMIT_LOAD_EXTENSION, quindi load_extension() non esiste. Le regole dell'App Store vietano anche il download ed esecuzione di nuovi code (guideline 2.5.2), quindi caricare un .dylib comunque l'esecuzione a runtime è esclusa. L'estensione deve essere compilata nel binario dell'app. Molti tutorial quindi suggeriscono di sqlite3_auto_extension() all'avvio. Non fare affidamento su di esso: nell'SDK di iOS incluso in Xcode, sqlite3.h la dichiara con API_DEPRECATED("Process-global auto extensions are not supported on Apple platforms", ...). L'approccio affidabile è chiamare la funzione init su ogni handle di connessione subito dopo l'apertura.
Android. SQLite incluso nel framework Android (android.database.sqlite) non ha API per caricare estensioni. Ti serve una build di SQLite inclusa nella tua app che lo supporti. requery/sqlite-android è un sostituto diretto per le classi del framework, include una versione recente di SQLite (con FTS5 e JSON) e supporta estensioni caricabili tramite SQLiteCustomExtension. La tua estensione diventa un .so file per ABI dentro l'APK.
Web. La build standard di SQLite Wasm non può caricare code nativi. È necessaria una build Wasm personalizzata di SQLite con l'estensione già compilata.
Cosa supportano oggi i plugin
| iOS | Android | Web | |
|---|---|---|---|
@capgo/capacitor-fast-sql 8.2 |
Nessun hook, serve una patch | Usa il framework SQLite, serve una patch | SQLite Wasm standard |
@capacitor-community/sqlite 8.1 |
loadExtension() è dichiarato nel API JS ma non implementato nativamente |
Uguale | Non implementato |
Quindi, per ora, aggiungere un'estensione richiede una piccola patch nativa. È una modifica contenuta e Bun può mantenerla applicata tra le installazioni.
Passaggio 1: scrivi l'estensione
Una funzione di distanza è un ottimo esempio: utile, autonoma e facile da verificare. Salvala come geo.c:
#include <math.h>
#include <stddef.h>
#include "sqlite3ext.h"
SQLITE_EXTENSION_INIT1
static void haversine_km(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
(void)argc;
for (int i = 0; i < 4; i++) {
if (sqlite3_value_type(argv[i]) == SQLITE_NULL) {
sqlite3_result_null(ctx);
return;
}
}
const double earth_radius_km = 6371.0088;
const double to_rad = M_PI / 180.0;
double lat1 = sqlite3_value_double(argv[0]) * to_rad;
double lon1 = sqlite3_value_double(argv[1]) * to_rad;
double lat2 = sqlite3_value_double(argv[2]) * to_rad;
double lon2 = sqlite3_value_double(argv[3]) * to_rad;
double dlat = lat2 - lat1;
double dlon = lon2 - lon1;
double a = sin(dlat / 2) * sin(dlat / 2) +
cos(lat1) * cos(lat2) * sin(dlon / 2) * sin(dlon / 2);
sqlite3_result_double(ctx, 2 * earth_radius_km * asin(sqrt(a)));
}
int sqlite3_geo_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi) {
SQLITE_EXTENSION_INIT2(pApi);
(void)pzErrMsg;
return sqlite3_create_function_v2(
db, "haversine_km", 4,
SQLITE_UTF8 | SQLITE_DETERMINISTIC | SQLITE_INNOCUOUS,
NULL, haversine_km, NULL, NULL, NULL);
}
Lo stesso file permette entrambi i build:
- Come estensione caricabile (Android):
SQLITE_EXTENSION_INIT1/2instrada ogni chiamata SQLite attraverso ilpApipuntatore passato da SQLite, così il.sonon si linka a SQLite. - Staticamente, con
-DSQLITE_CORE(iOS): le macro diventano no-op e le chiamate vanno direttamente alla libreria SQLite linkata dall'app.
SQLITE_DETERMINISTIC permette a SQLite di usare la funzione negli indici sulle espressioni. SQLITE_INNOCUOUS la rende sicura per l'uso in view e trigger.
Ho compilato questo file in entrambi i modi su macOS: staticamente con -DSQLITE_CORE e ho chiamato sqlite3_geo_init(db, NULL, NULL) su una connessione SQLite di sistema, e come libreria caricabile tramite load_extension. Entrambi restituiscono circa 343,6 km per la tratta Parigi-Londra.
Passaggio 2: iOS, compila nell'app e registra per connessione
Aggiungi la sorgente al target dell'app
- In Xcode, trascina
geo.cdentro ilAppgroup e seleziona ilApptarget. - Xcode ti proporrà di creare un bridging header. Accetta, oppure crea
App/App-Bridging-Header.he impostalo sotto Impostazioni di Build > Swift Compiler - General > Objective-C Bridging Header. - in Fasi di Build > Compila Sorgenti, fai doppio clic
geo.ce aggiungi il flag per singolo file-DSQLITE_CORE.
Il SDK di iOS include entrambi sqlite3.h e sqlite3ext.h, quindi non servono header extra.
App-Bridging-Header.h:
#include <sqlite3.h>
int sqlite3_geo_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi);
Patch di Fast SQL per esporre un hook
Il plugin apre le connessioni all'interno del proprio modulo, quindi necessita di un hook configurabile dalla tua app. Bun può patchare una dipendenza e riapplicare la patch a ogni installazione:
bun patch @capgo/capacitor-fast-sql
Modifica node_modules/@capgo/capacitor-fast-sql/ios/Sources/CapgoCapacitorFastSqlPlugin/SQLDatabase.swift. Aggiungi un hook public al top level del file:
public enum FastSqlExtensions {
/// Called with every new connection handle right after it opens.
/// Return SQLITE_OK (0) on success.
public static var onOpen: ((OpaquePointer) -> Int32)?
}
Poi in init(...), dopo il blocco di cifratura e prima di try applyPerformanceOptions(...), chiamalo:
if let hook = FastSqlExtensions.onOpen {
let rc = hook(db)
if rc != SQLITE_OK {
throw SQLError.openFailed(message: "Extension init failed with code \(rc)")
}
}
Salva la patch:
bun patch --commit node_modules/@capgo/capacitor-fast-sql
Bun scrive un file in patches/ e lo registra in package.json sotto patchedDependencies. Commit entrambi. La nostra guida su come patchare un plugin Capacitor approfondisce questo workflow.
Registra l'estensione all'avvio
In ios/App/App/AppDelegate.swift, importa il modulo del plugin e imposta l'hook in application(_:didFinishLaunchingWithOptions:):
import UIKit
import Capacitor
import CapgoCapacitorFastSql // CocoaPods module name. With SPM: import CapgoCapacitorFastSqlPlugin
// Inside the existing AppDelegate class:
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FastSqlExtensions.onOpen = { db in
sqlite3_geo_init(db, nil, nil)
}
return true
}
Poiché l'hook viene eseguito all'interno del percorso aperto del plugin, ogni connessione riceve la funzione, incluse quelle aperte dopo un reload della WebView. Impostarlo in didFinishLaunching garantisce che sia attivo prima che JavaScript possa chiamare connect().
le build di SQLCipher. Se utilizzi la subspec SQLCipher del plugin, la connessione appartiene alla SQLite di SQLCipher, mentre il tuo -DSQLITE_CORE file potrebbe fare il link ai simboli della libreria di sistema. Testa esplicitamente questa combinazione prima del rilascio, oppure mantieni i dati che dipendono dall'estensione in un database non criptato.
Passaggio 3: Android, bundle di una SQLite che può caricare estensioni
Compila l'estensione per ogni ABI
Ti serve l'Android NDK (installalo tramite il SDK Manager di Android Studio) e sqlite3ext.h più sqlite3.h presente nella l'amalgamation di SQLite accanto a geo.c.
export NDK=$HOME/Library/Android/sdk/ndk/<version>
export TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/darwin-x86_64 # linux-x86_64 on Linux
export API=24 # your minSdkVersion
for pair in \
aarch64-linux-android:arm64-v8a \
armv7a-linux-androideabi:armeabi-v7a \
x86_64-linux-android:x86_64 \
i686-linux-android:x86
do
triple=${pair%%:*}
abi=${pair##*:}
mkdir -p android/app/src/main/jniLibs/$abi
$TOOLCHAIN/bin/clang --target=$triple$API \
-shared -fPIC -O2 \
-Wl,-z,max-page-size=16384 \
-o android/app/src/main/jniLibs/$abi/libgeo.so \
geo.c -lm
done
-Wl,-z,max-page-size=16384 allinea la libreria per i dispositivi con pagine di memoria da 16 KB. Google Play richiede il supporto a 16 KB per le app che puntano ad Android 15 e versioni successive, vedi dimensione pagina 16 KB e plugin Capacitor. Se la tua app è destinata solo a dispositivi a 64 bit, puoi rimuovere i due ABI a 32 bit.
La libreria si chiama libgeo.so, so SQLite’s default entry point name is sqlite3_geo_init, che corrisponde alla funzione C.
Estrai le librerie native al momento dell'installazione
Le versioni recenti di Android Gradle Plugin mantengono .so i file non compressi all'interno dell'APK invece di estrarli. SQLite carica le estensioni tramite percorso file, quindi richiedi l'estrazione in android/app/build.gradle:
android {
packaging {
jniLibs {
useLegacyPackaging = true
}
}
}
Aggiungi requery sqlite-android
In android/build.gradle (root), aggiungi JitPack:
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
Applica la patch a Fast SQL per usarlo
Apri la stessa sessione di patch Bun (bun patch @capgo/capacitor-fast-sql) e modifica due file.
node_modules/@capgo/capacitor-fast-sql/android/build.gradle, in dependencies:
implementation 'com.github.requery:sqlite-android:3.49.0'
Usa l'ultima versione che JitPack mostra come buildata per il repository.
node_modules/@capgo/capacitor-fast-sql/android/src/main/java/app/capgo/capacitor/fastsql/SQLDatabase.java: sostituisci gli import del framework con quelli di requery e apri tramite una configurazione che includa le estensioni.
// Replace:
// import android.database.sqlite.SQLiteDatabase;
// import android.database.sqlite.SQLiteStatement;
import io.requery.android.database.sqlite.SQLiteCustomExtension;
import io.requery.android.database.sqlite.SQLiteDatabase;
import io.requery.android.database.sqlite.SQLiteDatabaseConfiguration;
import io.requery.android.database.sqlite.SQLiteStatement;
public class SQLDatabase implements DatabaseConnection {
/** Set from MainActivity before the first connection opens. */
public static final java.util.List<SQLiteCustomExtension> extensions =
new java.util.concurrent.CopyOnWriteArrayList<>();
private SQLiteDatabase db;
private volatile boolean inTransaction = false;
public SQLDatabase(String path, boolean walMode, boolean performancePresets) throws Exception {
SQLiteDatabaseConfiguration config = new SQLiteDatabaseConfiguration(
path, SQLiteDatabase.CREATE_IF_NECESSARY);
config.customExtensions.addAll(extensions);
this.db = SQLiteDatabase.openDatabase(config, null, null);
SQLPerformanceConfig.apply(db::execSQL, walMode, performancePresets);
}
// ... the rest of the class stays as it is
}
Le classi di requery rispecchiano il framework API (rawQuery, compileStatement, execSQL, transazioni), quindi il resto del file compila senza modifiche. android.database.Cursor rimane invariato. Committa la patch con bun patch --commit node_modules/@capgo/capacitor-fast-sql.
Questo modifica solo il percorso non criptato. Le connessioni criptate usano le classi di SQLCipher, che questa patch non tocca.
Registra l'estensione in MainActivity
package com.example.app;
import android.os.Bundle;
import app.capgo.capacitor.fastsql.SQLDatabase;
import com.getcapacitor.BridgeActivity;
import io.requery.android.database.sqlite.SQLiteCustomExtension;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
String path = getApplicationInfo().nativeLibraryDir + "/libgeo.so";
SQLDatabase.extensions.add(new SQLiteCustomExtension(path, "sqlite3_geo_init"));
super.onCreate(savedInstanceState);
}
}
Registra prima super.onCreate così la lista è popolata prima che la WebView possa eseguire JavaScript.
Android senza C: funzioni Java
Se ti serve solo una funzione scalare, requery può registrarne una scritta in Java, senza NDK. All'interno del SQLDatabase costruttore, dopo l'apertura:
db.addFunction("haversine_km", 4, (args, result) -> {
double r = 6371.0088;
double lat1 = Math.toRadians(args.getDouble(0));
double lat2 = Math.toRadians(args.getDouble(2));
double dlat = lat2 - lat1;
double dlon = Math.toRadians(args.getDouble(3) - args.getDouble(1));
double a = Math.pow(Math.sin(dlat / 2), 2)
+ Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(dlon / 2), 2);
result.set(2 * r * Math.asin(Math.sqrt(a)));
});
È più lento del C per query pesanti perché ogni chiamata attraversa JNI, ma è molto più semplice da mantenere. Per i tokenizer FTS5 e le tabelle virtuali serve ancora il C.
Passaggio 4: usalo da JavaScript
Dopo aver bunx cap sync e un rebuild nativo:
import { FastSQL } from '@capgo/capacitor-fast-sql';
const db = await FastSQL.connect({ database: 'places' });
await db.execute(`CREATE TABLE IF NOT EXISTS places (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL
)`);
const nearby = await db.query(
`SELECT id, name, haversine_km(?, ?, lat, lon) AS km
FROM places
WHERE lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?
ORDER BY km
LIMIT 20`,
[userLat, userLon, userLat - 0.5, userLat + 0.5, userLon - 0.7, userLon + 0.7],
);
Il BETWEEN box permette a SQLite di usare un indice su (lat, lon) per restringere i candidati prima che la funzione calcoli le distanze esatte. Senza di esso, la funzione viene eseguita su ogni riga.
Un fallback JavaScript per il web
La build web non può caricare il tuo code in C, quindi rilevalo e usa il fallback:
import { Capacitor } from '@capacitor/core';
export const hasGeoExtension = Capacitor.isNativePlatform();
// On the web: query the bounding box, then sort in JS with the same formula.
Tokenizer FTS5 personalizzati
Lo stesso meccanismo registra i tokenizer FTS5. Nella tua funzione di init, ottieni il fts5_api pointer con il SELECT fts5(?1) pattern dalla documentazione FTS5, quindi chiama xCreateTokenizer. Due requisiti extra:
- La libreria SQLite deve includere FTS5. La build di requery la include. Su iOS, controlla
PRAGMA compile_optionsperENABLE_FTS5. - Ogni connessione che scrive o legge dalla tabella FTS deve avere il tokenizer registrato, poiché FTS5 salva il nome del tokenizer nella definizione della tabella. Ecco perché l'hook per connessione è fondamentale.
CREATE VIRTUAL TABLE docs USING fts5(title, body, tokenize = 'my_tokenizer');
Distribuzione e manutenzione
- Richiede un Native Build. Estensioni, patch e modifiche Gradle sono native. Richiedono un nuovo build per App Store e Play Store. Build Capgo può buildare e firmare entrambi in cloud. Da aprile 2026, gli upload iOS devono usare Xcode 26 o successivi.
- JavaScript può continuare a muoversi velocemente. SQL che chiama
haversine_kmrisiede nel tuo web bundle e può essere distribuito con live updates, purché il Native Build con l'estensione sia già installato. Proteggi il nuovo SQL con un controllo della versione nativa se gli utenti potrebbero avere un binario vecchio. - Upgrade dei plugin. Quando aggiorni Fast SQL, Bun riapplica la patch. Se le righe patchate sono cambiate, l'installazione fallisce esplicitamente invece di ignorare silenziosamente il tuo hook. Ricrea la patch sulla nuova versione.
Risoluzione problemi
no such function: haversine_km su iOS. L'hook non è stato impostato prima connect(), o il database è stato aperto prima che didFinishLaunching venisse eseguito. Aggiungi un log nell'hook per confermare la chiamata.
Crash all'interno di sqlite3_geo_init su iOS. -DSQLITE_CORE manca, quindi il file è stato compilato come estensione caricabile che dereferenzia il pApi pointer, che si trova nil qui. Aggiungi il flag per file in Compile Sources.
no such function su Android. Il patch SQLDatabase importa ancora android.database.sqlite.SQLiteDatabase, oppure l'estensione è stata aggiunta dopo la prima connessione. Controlla i log SQLDatabase.extensions.size() durante la connessione.
dlopen failed: library "…/libgeo.so" not found. La libreria manca per l'ABI del dispositivo (la maggior parte dei telefoni è arm64-v8a), oppure le librerie native non sono state estratte. Controlla useLegacyPackaging e ispeziona l'APK con l'APK Analyzer di Android Studio.
undefined symbol: sqlite3_geo_init. Il nome dell'entry point passato a SQLiteCustomExtension non corrisponde alla funzione C, o la funzione è stata dichiarata static.
Play Console segnala problemi di allineamento a 16 KB. Ricompila i .so file con -Wl,-z,max-page-size=16384 e un NDK recente, e aggiorna requery a una versione con allineamento a 16 KB.
La funzione funziona su Android ma non nel simulatore iOS. Assicurati che geo.c sia un membro del App target, non solo presente nell'albero dei file.
Riassunto
| Piattaforma | Come l'estensione viene caricata | Dove viene registrata |
|---|---|---|
| iOS | Compilata nell'app con -DSQLITE_CORE |
Hook del plugin che chiama sqlite3_geo_init su ogni nuova connessione |
| Android | .so per ABI in jniLibs, caricato da requery sqlite-android |
SQLiteCustomExtension elenco letto ad ogni apertura connessione |
| Web | Non supportato da SQLite Wasm standard | Fallback in JavaScript |
Inizia controllando le funzionalità integrate. Se ti serve ancora un'estensione, scrivila una volta in C, mantieni piccoli i patch nativi e testa sui dispositivi più vecchi che supporti. Per la configurazione generale del plugin, vedi Come usare SQLite in un'app Capacitor.