Aller au contenu principal

Comment utiliser des extensions SQLite personnalisées dans Capacitor

Charger des extensions SQLite personnalisées dans une app Capacitor : les limites iOS et Android, une extension C qui compile pour les deux, et comment l’enregistrer pour chaque connexion.

Logo Capacitor pour un guide sur les extensions SQLite personnalisées dans les applications Capacitor

Vous pouvez utiliser des extensions SQLite personnalisées dans une application Capacitor, mais uniquement sous forme de code natif compilé dans l’application : sur iOS, vous liez l’extension statiquement et appelez sa fonction d’initialisation à chaque connexion, et sur Android, vous incluez un build de SQLite qui prend en charge le chargement d’extensions et vous livrez l’extension sous forme de bibliothèque native pour chaque architecture CPU. Ce guide explique pourquoi les plateformes diffèrent, vous fournit une extension fonctionnelle en C et montre les modifications natives exactes pour @capgo/capacitor-fast-sql sur Capacitor 8.

Avant d'écrire du C, vérifiez que vous en avez réellement besoin. De nombreuses « extensions » sont déjà intégrées.

Ce qu'est une extension SQLite

Une extension est un code natif qui enregistre de nouveaux éléments auprès d'une connexion SQLite : fonctions scalaires (haversine_km(...)), fonctions d'agrégation, collations, tables virtuelles et tokenizers FTS5. Chaque extension expose un point d'entrée :

int sqlite3_<name>_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi);

SQLite l'appelle une fois par connexion. Tout ce que vous enregistrez n'existe que sur cette connexion.

Raisons typiques d'en écrire une dans une application mobile :

  • un tokenizer FTS5 personnalisé pour une langue que le tokenizer unicode61 le tokenizer gère mal
  • a math or geo function you need in WHERE et ORDER BY (tri par distance, scoring),
  • une collation qui trie les noms comme vos utilisateurs l'attendent,
  • une extension de recherche vectorielle pour la récupération sur l’appareil.

Vérifiez ce qui est déjà intégré

Exécutez-les via votre plugin sur les versions les plus anciennes d’iOS et d’Android que vous prenez en charge :

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));

Ce pour quoi vous n’avez souvent pas besoin d’extension :

Besoin Fonctionnalité intégrée Vérifier
JSON dans les colonnes Fonctions JSON (json_extract, ->>) Intégré depuis SQLite 3.38. Le SQLite système des anciennes versions d’Android peut ne pas les inclure
Recherche plein texte FTS4, FTS5 ENABLE_FTS4 / ENABLE_FTS5 dans compile_options
Boîtes englobantes spatiales R*Tree ENABLE_RTREE
sin, cos, sqrt, pow Fonctions mathématiques ENABLE_MATH_FUNCTIONS, SQLite 3.35+

Si la fonctionnalité est présente sur toutes les cibles, arrêtez-vous ici. Le build web de Fast SQL utilise le Wasm SQLite officiel, qui inclut les fonctions JSON, FTS5 et mathématiques.

Pourquoi iOS et Android fonctionnent différemment

iOS. Le système libsqlite3 est construit avec SQLITE_OMIT_LOAD_EXTENSION, donc load_extension() n’existe pas. Les règles de l’App Store interdisent également de télécharger et d’exécuter du nouveau code (directive 2.5.2), donc charger un .dylib à l’exécution est hors de question. L’extension doit être compilée dans le binaire de l’application. De nombreux tutoriels appellent ensuite sqlite3_auto_extension() au démarrage. Ne vous y fiez pas : dans le SDK iOS fourni avec Xcode, sqlite3.h le déclare avec API_DEPRECATED("Process-global auto extensions are not supported on Apple platforms", ...). La méthode fiable consiste à appeler votre fonction d’initialisation sur chaque handle de connexion juste après son ouverture.

Android. Le SQLite intégré au framework Android (android.database.sqlite) ne dispose d’aucun API pour charger des extensions. Il vous faut un build SQLite inclus dans votre application qui en possède. requery/sqlite-android est un remplacement direct des classes du framework, fournit une version récente de SQLite (avec FTS5 et JSON) et prend en charge les extensions chargeables via SQLiteCustomExtension. Votre extension devient un .so fichier par ABI dans l’APK.

Web. Le build Wasm standard de SQLite ne peut pas charger de code natifs. Il faudrait un build Wasm personnalisé de SQLite avec votre extension compilée dedans.

Ce que les plugins prennent en charge aujourd’hui

iOS Android Le Web
@capgo/capacitor-fast-sql 8.2 Aucun hook ni patch requis Utilise le SQLite du framework, patch requis SQLite Wasm standard
@capacitor-community/sqlite 8.1 loadExtension() est déclaré dans le JS API mais non implémenté nativement Idem Non implémenté

Ainsi, pour l'instant, ajouter une extension implique un petit patch natif. C'est une modification limitée, et Bun peut la conserver entre les installations.

Étape 1 : écrire l'extension

Une fonction de distance est un bon exemple : utile, autonome et facile à vérifier. Enregistrez-la sous 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);
}

Le même fichier se compile des deux façons :

  • En tant qu'extension chargeable (Android) : SQLITE_EXTENSION_INIT1/2 routez chaque appel SQLite via le pApi pointeur transmis par SQLite, de sorte que le .so n'est pas lié à SQLite.
  • Statiquement, avec -DSQLITE_CORE (iOS) : les macros deviennent des no-ops et les appels vont directement à la bibliothèque SQLite liée par l'app.

SQLITE_DETERMINISTIC permet à SQLite d'utiliser la fonction dans les index sur expressions. SQLITE_INNOCUOUS le marque comme sûr à utiliser dans les vues et les triggers.

J’ai compilé ce fichier des deux façons sur macOS : en statique avec -DSQLITE_CORE et j’ai appelé sqlite3_geo_init(db, NULL, NULL) sur une connexion SQLite système, et en tant que bibliothèque chargeable via load_extension. Les deux renvoient environ 343,6 km pour Paris-Londres.

Étape 2 : iOS, compiler dans l’app et enregistrer par connexion

Ajouter la source à la cible de l’app

  1. Dans Xcode, faites glisser geo.c dans le App groupe et cochez la App cible.
  2. Xcode propose de créer un bridging header. Acceptez, ou créez App/App-Bridging-Header.h et définissez-le sous Paramètres de build > Swift Compiler - General > Objective-C Bridging Header.
  3. Dans Phases de build > Compile Sources, double-cliquez sur geo.c et ajoutez le flag par fichier -DSQLITE_CORE.

Le SDK iOS embarque les deux sqlite3.h et sqlite3ext.h, so no extra headers are needed.

App-Bridging-Header.h:

#include <sqlite3.h>

int sqlite3_geo_init(sqlite3 *db, char **pzErrMsg, const sqlite3_api_routines *pApi);

Patcher Fast SQL pour exposer un hook ouvert

Le plugin ouvre les connexions dans son propre module, il a donc besoin d'un hook que votre app peut définir. Bun peut patcher une dépendance et réappliquer le patch à chaque installation :

bun patch @capgo/capacitor-fast-sql

Modifier node_modules/@capgo/capacitor-fast-sql/ios/Sources/CapgoCapacitorFastSqlPlugin/SQLDatabase.swift. Ajoutez un hook public en haut du fichier :

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)?
}

Puis dans init(...), après le bloc de chiffrement et avant try applyPerformanceOptions(...), appelez-le :

if let hook = FastSqlExtensions.onOpen {
    let rc = hook(db)
    if rc != SQLITE_OK {
        throw SQLError.openFailed(message: "Extension init failed with code \(rc)")
    }
}

Enregistrez le patch :

bun patch --commit node_modules/@capgo/capacitor-fast-sql

Bun écrit un fichier sous patches/ et l’enregistre dans package.json sous patchedDependencies. Commitez les deux. Notre guide sur comment patcher un plugin Capacitor couvre ce workflow plus en détail.

Enregistrez l’extension au lancement

Dans ios/App/App/AppDelegate.swift, importez le module du plugin et définissez le hook dans 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
}

Comme le hook s’exécute dans le chemin d’ouverture du plugin, chaque connexion reçoit la fonction, y compris celles ouvertes après un rechargement du WebView. Le définir dans didFinishLaunching garantit qu’il est en place avant que JavaScript puisse appeler connect().

les builds SQLCipher. Si vous utilisez le subspec SQLCipher du plugin, la connexion appartient au SQLite de SQLCipher, tandis que votre -DSQLITE_CORE fichier peut se lier aux symboles de la bibliothèque système. Testez explicitement cette combinaison avant de déployer, ou conservez les données dépendant de l’extension dans une base de données non chiffrée.

Étape 3 : Android, embarquez un SQLite capable de charger des extensions

Compiler l’extension pour chaque ABI

Vous avez besoin du NDK Android (installez-le depuis le SDK Manager d’Android Studio) et sqlite3ext.h , ce qui sqlite3.h issu de la amalgamation SQLite à côté de 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 aligne la bibliothèque pour les appareils avec des pages mémoire de 16 Ko. Google Play exige la prise en charge de 16 Ko pour les applications ciblant Android 15 et supérieur, voir Taille de page de 16 Ko et plugins Capacitor. Si votre application ne cible que des appareils 64 bits, vous pouvez supprimer les deux ABIs 32 bits.

La bibliothèque est nommée libgeo.so, le nom du point d’entrée par défaut de SQLite est sqlite3_geo_init, ce qui correspond à la fonction C.

Extraire les bibliothèques natives à l’installation

Les versions récentes d’Android Gradle Plugin conservent .so fichiers non compressés dans l’APK au lieu de les extraire. SQLite charge les extensions via un chemin de fichier, il faut donc demander l’extraction dans android/app/build.gradle:

android {
    packaging {
        jniLibs {
            useLegacyPackaging = true
        }
    }
}

Ajouter requery sqlite-android

Dans android/build.gradle (racine), ajoutez JitPack :

allprojects {
    repositories {
        google()
        mavenCentral()
        maven { url 'https://jitpack.io' }
    }
}

Patcher Fast SQL pour l’utiliser

Ouvrez la même session de patch Bun (bun patch @capgo/capacitor-fast-sql) et modifiez deux fichiers.

node_modules/@capgo/capacitor-fast-sql/android/build.gradle, dans dependencies:

implementation 'com.github.requery:sqlite-android:3.49.0'

Utilisez la dernière version que JitPack indique comme compilée pour le dépôt.

node_modules/@capgo/capacitor-fast-sql/android/src/main/java/app/capgo/capacitor/fastsql/SQLDatabase.java : remplacez les imports du framework par ceux de requery et ouvrez via une configuration qui porte les extensions.

// 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
}

les classes de requery reprennent celles du framework API (rawQuery, compileStatement, execSQL, transactions), donc le reste du fichier se compile sans modification. android.database.Cursor reste tel quel. Commitez le patch avec bun patch --commit node_modules/@capgo/capacitor-fast-sql.

Cela ne modifie que le chemin non chiffré. Les connexions chiffrées utilisent les classes de SQLCipher, que ce patch ne touche pas.

Enregistrez l’extension dans 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);
    }
}

Enregistrez avant super.onCreate pour que la liste soit remplie avant que le WebView ne puisse exécuter du JavaScript.

Android sans C : fonctions Java

Si vous n’avez besoin que d’une fonction scalaire, requery peut en enregistrer une écrite en Java, sans NDK. Dans le constructeur SQLDatabase modifié, après l’ouverture :

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)));
});

C’est plus lent que le C pour les requêtes lourdes car chaque appel passe par JNI, mais c’est bien plus simple à maintenir. Les tokenizers FTS5 et les tables virtuelles nécessitent toujours du C.

Étape 4 : l’utiliser depuis JavaScript

Après bunx cap sync et une recompilation native :

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],
);

Le BETWEEN box permet à SQLite d’utiliser un index sur (lat, lon) pour filtrer les candidats avant que la fonction ne calcule les distances exactes. Sans cela, la fonction s’exécute sur chaque ligne.

Un fallback JavaScript pour le web

Le build web ne peut pas charger votre code en C, alors détectez-l’et utilisez un 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.

Tokenizers FTS5 personnalisés

Le même mécanisme enregistre les tokenizers FTS5. Dans votre fonction d'init, récupérez le fts5_api pointeur avec le SELECT fts5(?1) pattern de la documentation FTS5, puis appelez xCreateTokenizer. Deux exigences supplémentaires :

  • La bibliothèque SQLite doit inclure FTS5. Le build de requery le fait. Sur iOS, vérifiez PRAGMA compile_options pour ENABLE_FTS5.
  • Chaque connexion qui écrit ou lit dans la table FTS doit avoir le tokenizer enregistré, car FTS5 stocke le nom du tokenizer dans la définition de la table. C'est pourquoi le hook par connexion est crucial.
CREATE VIRTUAL TABLE docs USING fts5(title, body, tokenize = 'my_tokenizer');

Le déployer et le maintenir

  • Nouveau build natif requis. Les extensions, les patchs et les changements Gradle sont natifs. Ils nécessitent un nouveau build pour l'App Store et le Play Store. Build Capgo peut compiler et signer les deux dans le cloud. Depuis avril 2026, les uploads iOS doivent être compilés avec Xcode 26 ou une version ultérieure.
  • Le JavaScript peut toujours évoluer rapidement. SQL qui appelle haversine_km réside dans votre bundle web et peut être déployé avec les live updates, tant que le build natif contenant l'extension est déjà installé. Conditionnez le nouveau SQL à une vérification de la version native si des utilisateurs peuvent encore exécuter un ancien binaire.
  • Mises à jour de plugins. Lorsque vous mettez à jour Fast SQL, Bun réapplique le patch. Si les lignes patchées ont bougé, l'installation échoue avec une erreur explicite au lieu de supprimer silencieusement votre hook. Recréez le patch sur la nouvelle version.

Troubleshooting

no such function: haversine_km sur iOS. Le hook n'a pas été défini avant connect(), ou la base de données a été ouverte avant didFinishLaunching l'exécution. Ajoutez un log dans le hook pour confirmer qu'il est appelé.

Crash dans sqlite3_geo_init sur iOS. -DSQLITE_CORE est manquant, donc le fichier a été compilé comme une extension chargeable qui déréférence le pApi pointeur, qui se trouve nil ici. Ajoutez le flag par fichier dans Compile Sources.

no such function sur Android. Le fichier patché SQLDatabase importe toujours android.database.sqlite.SQLiteDatabase, ou l’extension a été ajoutée à la liste après la première connexion. Log SQLDatabase.extensions.size() lors de la connexion.

dlopen failed: library "…/libgeo.so" not found. La bibliothèque est manquante pour l’ABI de l’appareil (la plupart des téléphones sont arm64-v8a), ou les bibliothèques natives n’ont pas été extraites. Vérifiez useLegacyPackaging et inspectez l’APK avec l’APK Analyzer d’Android Studio.

undefined symbol: sqlite3_geo_init. Le nom du point d’entrée transmis à SQLiteCustomExtension ne correspond pas à la fonction C, ou la fonction a été déclarée static.

Play Console émet un avertissement sur l’alignement 16 Ko. Recompilez les .so fichiers avec -Wl,-z,max-page-size=16384 et un NDK récent, et mettez à jour requery vers une version compilée avec un alignement de 16 Ko.

La fonction fonctionne sur Android mais pas dans le simulateur iOS. Assurez-vous que geo.c est membre de la App cible, pas seulement présente dans l'arborescence.

Résumé

Plateforme Comment l'extension est intégrée Où elle est enregistrée
iOS Compilé dans l'app avec -DSQLITE_CORE Hook de plugin qui appelle sqlite3_geo_init à chaque nouvelle connexion
Android .so par ABI dans jniLibs, chargé par requery sqlite-android SQLiteCustomExtension liste lue à l’ouverture de chaque connexion
Le Web Non pris en charge par SQLite Wasm standard Repli JavaScript

Commencez par vérifier les fonctionnalités intégrées. Si vous avez toujours besoin d’une extension, écrivez-l’une fois en C, gardez les patches natifs au minimum et testez sur les appareils les plus anciens que vous prenez en charge. Pour la configuration générale du plugin, voir Comment utiliser SQLite dans une application Capacitor.

Live updates pour les apps Capacitor

Lorsqu'un bug de la couche web est en ligne, déployez le correctif via Capgo au lieu d'attendre des jours l'approbation des stores. Les utilisateurs reçoivent la mise à jour en arrière-plan, tandis que les changements natifs suivent le circuit de validation habituel.

Les derniers articles du blog

Capgo vous offre les meilleures informations pour créer une application mobile vraiment professionnelle.