VOOZH about

URL: https://dev.to/capawesome/how-to-use-custom-sqlite-extensions-in-capacitor-l5k

⇱ How to Use Custom SQLite Extensions in Capacitor - DEV Community


SQLite ships with a lot out of the box, but sometimes you need behavior it doesn't provide: a custom FTS5 tokenizer for a language it doesn't handle well, a domain-specific SQL function, or a custom collation. Loadable extensions let you add exactly that. As of version 0.3.9, the Capacitor SQLite plugin supports custom SQLite extensions on both Android and iOS — though each platform uses a different mechanism. This guide walks through both, using a custom FTS5 tokenizer as the running example.

What are custom SQLite extensions?

A SQLite extension is native code that registers new functionality with SQLite: scalar and aggregate functions, collating sequences, virtual tables, and FTS5 tokenizers. Instead of patching SQLite or waiting for a feature upstream, you compile your code and hook it into SQLite. The official SQLite docs on run-time loadable extensions cover the C API in detail.

Two common reasons to reach for one in a mobile app:

  • Custom FTS5 tokenizers — the built-in tokenizers don't fit every language or matching strategy. A custom tokenizer controls exactly how text is split and normalized for full-text search.
  • Custom SQL functions — push logic that's awkward in SQL (specialized string processing, scoring, geospatial math) down into the database, where it runs close to the data.

Why does each platform work differently?

On Android, the system SQLite is compiled without loadable-extension support, so the plugin loads extensions into the bundled requery backend at runtime. On iOS, App Store apps can't load dynamic libraries at runtime and the system SQLite is also built without that support, so the extension has to be statically linked into the binary and registered at startup. Web isn't supported.

The upshot: the extension's C source is the same on both platforms. What changes is how you build and wire it up.

Writing a loadable extension

Every extension follows the same skeleton. The source includes sqlite3ext.h, declares the extension API with SQLITE_EXTENSION_INIT1, and exposes a single init function:

#include 
SQLITE_EXTENSION_INIT1

int sqlite3_sqlitetokenizerar_init(
 sqlite3 *db,
 char **pzErrMsg,
 const sqlite3_api_routines *pApi
) {
 SQLITE_EXTENSION_INIT2(pApi);
 /* Register your custom FTS5 tokenizer with the fts5_api here. */
 return SQLITE_OK;
}

Inside the init function you fetch the fts5_api pointer and call xCreateTokenizer with your tokenizer's callbacks. Implementing the tokenizer itself is beyond this post, but the FTS5 custom tokenizer docs describe the interface.

The init function name follows the SQLite convention sqlite3__init. The INIT1/INIT2 macros let the same source compile two ways: as a runtime-loadable extension on Android, or — with -DSQLITE_CORE — as a statically linked extension on iOS.

Loading the extension on Android

On Android, you compile the extension into a native library per CPU architecture, bundle it, and load it through the androidExtensions option.

Enable the bundled SQLite backend

Extension loading requires the requery backend. Set capawesomeCapacitorSqliteIncludeRequery to true in your app's variables.gradle:

ext {
+ capawesomeCapacitorSqliteIncludeRequery = true // Default: false
}

The requery library is published on JitPack, so add the repository to your app's build.gradle:

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

This option can't be combined with capawesomeCapacitorSqliteIncludeSqlcipher — SQLCipher bundles its own SQLite version.

Compile for each Android ABI

A native library is compiled separately for each ABI: arm64-v8a, armeabi-v7a, x86, and x86_64. The Android NDK includes a Clang toolchain for each. Point variables at your NDK and host toolchain:

export NDK=$HOME/Library/Android/sdk/ndk/
export TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/darwin-x86_64
export API=24

Set API to your minSdkVersion, then compile once per ABI:

for TARGET in \
 aarch64-linux-android:arm64-v8a \
 armv7a-linux-androideabi:armeabi-v7a \
 i686-linux-android:x86 \
 x86_64-linux-android:x86_64
do
 TRIPLE=${TARGET%%:*}
 ABI=${TARGET##*:}
 mkdir -p jniLibs/$ABI
 $TOOLCHAIN/bin/clang \
 --target=$TRIPLE$API \
 -shared -fPIC \
 -o jniLibs/$ABI/libsqlite_tokenizer_ar.so \
 sqlite_tokenizer_ar.c
done

A runtime-loadable extension doesn't link against SQLite; the INIT1/INIT2 macros route calls through the API pointer at load time. You only need sqlite3ext.h (and sqlite3.h) on the include path — grab them from the SQLite amalgamation.

Bundle the native libraries

Place the compiled .so files under android/app/src/main/jniLibs, one subfolder per ABI:

android/app/src/main/jniLibs/
├── arm64-v8a/libsqlite_tokenizer_ar.so
├── armeabi-v7a/libsqlite_tokenizer_ar.so
├── x86/libsqlite_tokenizer_ar.so
└── x86_64/libsqlite_tokenizer_ar.so

Gradle packages these automatically. The plugin resolves each extension whether the .so was extracted to the native library directory or stored uncompressed inside the APK.

Load the extension

Pass the androidExtensions option to open(), referencing each library by name (no lib prefix, no .so suffix):

import { Sqlite } from '@capawesome-team/capacitor-sqlite';

const { databaseId } = await Sqlite.open({
 path: 'my.db',
 androidExtensions: [{ name: 'sqlite_tokenizer_ar' }],
});

SQLite derives the entry point from the file name (strip lib, drop everything after the first dot, lowercase, remove non-alphanumeric characters), so libsqlite_tokenizer_ar.so resolves to sqlite3_sqlitetokenizerar_init. If your init function uses a different name, set entryPoint explicitly.

Loading the extension on iOS

iOS has no plugin option. You compile the C source into your app, declare its entry point, and register it once at startup.

First, add the source file to your app target in Xcode and set the per-file compiler flag -DSQLITE_CORE under Build Phases › Compile Sources. Then declare the init function in your bridging header:

#include 

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

Finally, register the extension before any database is opened — for example in your AppDelegate:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
 sqlite3_auto_extension(unsafeBitCast(sqlite3_sqlitetokenizerar_init, to: (@convention(c) () -> Void).self))
 return true
}

Because registration is global, there's no per-database option on iOS — the tokenizer is available everywhere.

Using a custom tokenizer in full-text search

Once loaded, your tokenizer behaves like any built-in one. Reference it in the tokenize option when creating the virtual table:

await Sqlite.execute({
 databaseId,
 statement: `
 CREATE VIRTUAL TABLE IF NOT EXISTS documents
 USING fts5(title, body, tokenize = 'sqlite_tokenizer_ar');
 `,
});

Full-text queries then run through your tokenizer automatically, with the same code on both platforms:

const { rows } = await Sqlite.query({
 databaseId,
 statement: `SELECT title FROM documents WHERE documents MATCH ?;`,
 values: ['مرحبا'],
});

Wrapping up

Custom SQLite extensions give you an escape hatch when the built-in feature set isn't enough. With the Capacitor SQLite plugin, you write the extension once and integrate it per platform: bundle a native library and pass androidExtensions on Android, or statically link and register with sqlite3_auto_extension on iOS.

The full guide is on the Capawesome blog. Have you used custom SQLite extensions in a Capacitor app? Let me know in the comments.