Passer à la navigation principale

Comment utiliser Drizzle ORM avec Capacitor et SQLite

Configurer Drizzle ORM avec Capacitor et SQLite : un driver sqlite-proxy fonctionnel, un schéma typé, des requêtes relationnelles, des transactions et des migrations Drizzle Kit en ligne.

Crédits de l'article

Martin Donadieu

Auteur

Valeria

Relecteur

Jordan

Éditeur

Comment utiliser Drizzle ORM avec Capacitor et SQLite

Drizzle ORM fonctionne dans une application Capacitor grâce à son sqlite-proxy Le driver : vous donnez à Drizzle une fonction asynchrone qui exécute des requêtes SQL sur un plugin SQLite natif et renvoie les résultats sous forme d'arrays, et Drizzle gère le constructeur de requête typée, les requêtes relationnelles et les transactions par-dessus. Cette guide construit cette fonction pour @capacitor-community/sqlite, montre comment envoyer les migrations Drizzle Kit dans le bundle de l'application et liste les cas d'edge qui corrompent les résultats en silence si vous les manquez.

Tout ce qui suit a été testé avec drizzle-orm 0,45 et drizzle-kit 0,31 sur Capacitor 8.

Pourquoi Drizzle sur une base de données mobile

  • Une structure de données en TypeScript. Les tables sont des objets TypeScript ordinaires. Les types pour les requêtes SELECT et INSERT sont déduits, pas de code de génération.
  • Un API de forme SQL. db.select().from(users).where(eq(users.id, 1)) Cartographie un à un vers SQL, vous pouvez donc raisonner sur ce qui s'exécute sur le dispositif.
  • Migrations générées à partir du schéma. Le Kit Drizzle compare votre schéma et écrit des fichiers de migration SQL.
  • Pas de décorateurs. Fonctionne avec Vite et esbuild sans drapeaux TypeScript supplémentaires.

Le compromis : Drizzle n'a pas de Capacitor pilote dans son package, vous devez donc écrire le pont vous-même. Il s'agit de 30 lignes.

Choisir le plugin SQLite

Drizzle's sqlite-proxy le pilote cartographie les lignes de résultats par position: pour all et values il s'attend à une tableau d'arrays, pour get un tableau unique, dans le même ordre que les colonnes dans les plugins SQLite générés SELECTles plugins SQLite Capacitor retournent des objets clés par nom de colonne, donc le pilote doit convertir chaque objet en tableau. Cela ne fonctionne que si les clés des objets maintiennent l'ordre des colonnes.

Plugin Format de ligne L'ordre des colonnes est préservé Adapté pour le proxy Drizzle
@capacitor-community/sqlite Objets Oui. Sur iOS, le plugin envoie une ios_columns liste et la couche JS reconstitue chaque ligne dans l'ordre des colonnes Très bien
@capgo/capacitor-fast-sql Objets Non garanti sur iOS, les lignes sont sérialisées à partir d'un dictionnaire Swift Utilisez Kysely à la place

C'est pourquoi ce guide utilise le plugin communautaire. Si vous utilisez déjà Fast SQL, ou si vous souhaitez son chemin de données plus rapide, Kysely lit les lignes par nom de colonne et travaille directement avec elle. Pour un aperçu de ces deux plugins, voir How to use SQLite in a Capacitor application.

Installation

bun add drizzle-orm @capacitor-community/sqlite
bun add -d drizzle-kit
bunx cap sync

@capacitor-community/sqlite les bundles SQLCipher sur iOS et Android, afin que votre application embarque l'encryption code même si vous n'avez pas encrypté quoi que ce soit. Gardez cela à l'esprit pour les questions de conformité à l'exportation vers l'App Store.

Définir le schéma

// src/db/schema.ts
import { relations } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .notNull()
    .$defaultFn(() => new Date()),
});

export const posts = sqliteTable(
  'posts',
  {
    id: integer('id').primaryKey({ autoIncrement: true }),
    authorId: integer('author_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    title: text('title').notNull(),
    published: integer('published', { mode: 'boolean' }).notNull().default(false),
  },
  (t) => [index('idx_posts_author').on(t.authorId)],
);

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));

Quelques détails comptent sur mobile :

  • mode: 'timestamp' stocke les secondes depuis l'époque sous forme d'entier et vous donne Date des objets en TypeScript.
  • $defaultFn(() => new Date()) Définit la valeur par défaut en JavaScript. Un défaut SQL comme unixepoch() nécessite SQLite 3.38 ou une version plus récente, que les anciens systèmes d'Android ne possèdent pas.
  • mode: 'boolean' stocke 1/0. Drizzle convertit dans les deux sens.
  • relations() est ce qui alimente db.query.* avec with: {...}. Les clés étrangères ne suffisent pas pour les requêtes relationnelles.

Ouvrez la connexion

Le plugin de la communauté garde un registre des connexions. Lors de la rechargement en direct ou de la remplacement de module chaud, votre code peut s'exécuter deux fois, il est donc préférable de réutiliser une connexion existante plutôt que de créer une deuxième.

// src/db/connection.ts
import {
  CapacitorSQLite,
  SQLiteConnection,
  type SQLiteDBConnection,
} from '@capacitor-community/sqlite';

const sqlite = new SQLiteConnection(CapacitorSQLite);

export async function openConnection(name: string): Promise<SQLiteDBConnection> {
  const consistent = (await sqlite.checkConnectionsConsistency()).result;
  const exists = (await sqlite.isConnection(name, false)).result;

  const conn =
    consistent && exists
      ? await sqlite.retrieveConnection(name, false)
      : await sqlite.createConnection(name, false, 'no-encryption', 1, false);

  await conn.open();
  await conn.execute('PRAGMA foreign_keys = ON', false);
  return conn;
}

Le plugin ajoute SQLite.db au nom du fichier, donc app devient appSQLite.db sur le disque. Sur le web, le plugin nécessite l' jeep-sqlite élément personnalisé et sqlite.initWebStore() avant la première connexion. Suivez la configuration web du plugin si vous ciblez le navigateur.

Le pilote sqlite-proxy

C'est le cœur de l'intégration. Drizzle appelle la fonction avec la requête SQL, les paramètres et un method:

  • run: pas de lignes nécessaires (INSERT, UPDATE, DELETE, DDL, contrôle de transaction).
  • all: chaque ligne, sous forme d'array.
  • get: la première ligne, sous forme d'array, ou undefined.
  • valueschaque ligne, sous forme d'arrays (utilisés par raw) values() Pourquoi chaque branch existe :
// src/db/client.ts
import type { SQLiteDBConnection } from '@capacitor-community/sqlite';
import { drizzle } from 'drizzle-orm/sqlite-proxy';
import * as schema from './schema';

const READ = /^\s*(select|pragma|with|explain)\b/i;
const TX_CONTROL = /^\s*(begin|commit|rollback|savepoint|release)\b/i;

export function createDb(conn: SQLiteDBConnection) {
  return drizzle(
    async (sql, params, method) => {
      // Transaction control: no params, no rows, no implicit wrapping
      if (TX_CONTROL.test(sql)) {
        await conn.execute(sql, false);
        return { rows: [] };
      }

      let rows: Record<string, unknown>[];
      if (READ.test(sql)) {
        rows = (await conn.query(sql, params)).values ?? [];
      } else {
        // 'all' returns rows for INSERT/UPDATE/DELETE ... RETURNING
        const res = await conn.run(sql, params, false, method === 'run' ? 'no' : 'all');
        rows = res.changes?.values ?? [];
      }

      if (method === 'run') return { rows: [] };

      // Drizzle maps by position. The plugin keeps column order in each object.
      const arrays = rows.map((row) => Object.values(row));
      return { rows: method === 'get' ? (arrays[0] as any) : arrays };
    },
    { schema },
  );
}

export type AppDb = ReturnType<typeof createDb>;

: Drizzle implémente

  • TX_CONTROL: Drizzle implémente db.transaction() , et begin, commit, rollback, and savepoint/release pour les transactions imbriquées. Ils passent par execute(sql, false), the same path TypeORM’s official Capacitor driver uses for BEGIN/COMMIT.
  • false comme troisième argument run(): La plugin par défaut enveloppe chaque instruction dans sa propre transaction, ce qui entre en collision avec l'explicité de Drizzle. begin.
  • returnMode 'all': rend .returning() work for inserts, updates and deletes.
  • Object.values(row): transforme la ligne cléée en tableau positionnel.

J'ai exécuté cette fonction exacte contre une base SQLite en mémoire qui retourne les lignes de la même manière que le plugin. Les insertions avec .returning(): les selects filtrés, .get()Les requêtes relationnelles, les transactions et les annulations en cas d'erreur produisent des résultats corrects.

: Initialisation une fois

// src/db/index.ts
import { openConnection } from './connection';
import { createDb, type AppDb } from './client';
import { runMigrations } from './migrate';
import bundle from '../../drizzle/migrations';

let dbPromise: Promise<AppDb> | null = null;

export function getDb(): Promise<AppDb> {
  if (!dbPromise) {
    dbPromise = (async () => {
      const conn = await openConnection('app');
      await runMigrations(conn, bundle);
      return createDb(conn);
    })();
  }
  return dbPromise;
}

: Migrations avec Drizzle Kit

Drizzle intégré sqlite-proxy le migrateur lit .sql fichiers à partir du disque avec Node. fsLa solution consiste à faire en sorte que le kit Drizzle empaquete la requête SQL en un module JavaScript que vous pouvez appliquer vous-même.

1. Configurez Drizzle Kit

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'sqlite',
  driver: 'expo',
});

driver: 'expo' ne signifie pas que vous avez besoin d'Expo. Cela indique à Drizzle Kit de rédiger également drizzle/migrations.js, qui importe le journal et chaque .sql fichier. C'est le format dont tout runtime non Node a besoin.

2. Générez

bunx drizzle-kit generate

Vous obtenez drizzle/0000_<name>.sql, drizzle/meta/_journal.json et drizzle/migrations.jsCommit tous les trois. Exécutez. generate Ne pas modifier les fichiers SQL qui ont déjà été déployés.

3. Faites importer le bundler les fichiers .sql en texte

migrations.js a des lignes comme import m0000 from './0000_fearless_selene.sql'. Vite ne le sait pas .sql, ajoutez donc un petit plugin au lieu d'une autre dépendance :

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    {
      name: 'sql-as-text',
      transform(code, id) {
        if (id.endsWith('.sql')) {
          return { code: `export default ${JSON.stringify(code)};`, map: null };
        }
      },
    },
  ],
});

Et une déclaration de type pour que TypeScript accepte l'import :

// src/sql.d.ts
declare module '*.sql' {
  const content: string;
  export default content;
}

Utilisez webpack (éditeurs personnalisés Angular, anciens paramètres) un asset/source règle pour .sql fichiers au lieu de.

4. Appliquez les migrations au démarrage

// src/db/migrate.ts
import type { SQLiteDBConnection } from '@capacitor-community/sqlite';

interface MigrationBundle {
  journal: { entries: { idx: number; when: number; tag: string }[] };
  migrations: Record<string, string>;
}

export async function runMigrations(conn: SQLiteDBConnection, bundle: MigrationBundle) {
  await conn.execute(
    `CREATE TABLE IF NOT EXISTS __drizzle_migrations (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       hash TEXT NOT NULL,
       created_at NUMERIC
     )`,
    false,
  );

  const res = await conn.query(
    'SELECT created_at FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1',
  );
  const lastApplied = Number(res.values?.[0]?.created_at ?? 0);

  for (const entry of bundle.journal.entries) {
    if (entry.when <= lastApplied) continue;

    const key = `m${entry.idx.toString().padStart(4, '0')}`;
    const sqlText = bundle.migrations[key];
    if (!sqlText) throw new Error(`Missing migration ${entry.tag}`);

    const statements = sqlText
      .split('--> statement-breakpoint')
      .map((s) => s.trim())
      .filter(Boolean);

    await conn.beginTransaction();
    try {
      for (const statement of statements) {
        await conn.execute(statement, false);
      }
      await conn.run(
        'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)',
        [entry.tag, entry.when],
        false,
      );
      await conn.commitTransaction();
    } catch (error) {
      await conn.rollbackTransaction();
      throw error;
    }
  }
}

Cela suit la même règle que les migrateurs de Drizzle : une migration est en attente lorsque son horodatage journal est plus récent que le plus récent enregistré. Chaque migration s'exécute dans sa propre transaction, donc une erreur laisse l'état précédent intact. L'appeler à chaque lancement est peu coûteux.

Si vous expédiez du JavaScript avec Capgo mises à jour en direct, de nouvelles migrations atteignent les utilisateurs sans une mise à jour de magasin. Gardez-les additives (nouvelles tables, nouvelles colonnes nulles), car un retour en arrière vers une ancienne version de bundle s'exécutera contre le schéma plus récent.

Requêtes

import { and, desc, eq, sql } from 'drizzle-orm';
import { getDb } from './db';
import { posts, users } from './db/schema';

const db = await getDb();

// Insert and get the row back
const [ada] = await db
  .insert(users)
  .values({ name: 'Ada', email: 'ada@example.com' })
  .returning();

// Bulk insert
await db.insert(posts).values([
  { authorId: ada.id, title: 'Draft' },
  { authorId: ada.id, title: 'Live', published: true },
]);

// Select with filters
const live = await db
  .select()
  .from(posts)
  .where(and(eq(posts.authorId, ada.id), eq(posts.published, true)))
  .orderBy(desc(posts.id));

// Single row or undefined
const user = await db.select().from(users).where(eq(users.email, 'ada@example.com')).get();

// Update and delete
await db.update(posts).set({ published: true }).where(eq(posts.id, live[0].id));
await db.delete(posts).where(eq(posts.published, false));

// Aggregate
const [{ count }] = await db
  .select({ count: sql<number>`count(*)` })
  .from(posts);

Requêtes relationnelles

const authors = await db.query.users.findMany({
  with: { posts: { where: (p, { eq }) => eq(p.published, true) } },
});

const one = await db.query.users.findFirst({
  where: (u, { eq }) => eq(u.id, ada.id),
  with: { posts: true },
});

Drizzle construit ces résultats avec les fonctions JSON de SQLite, puis parse le résultat. Ils fonctionnent sur iOS et sur les dernières versions d'Android. Si votre minSdkVersion est ancienne, vérifiez SELECT json('{}') sur votre appareil de test le plus ancien.

Jointures : duplication de noms de colonnes

C'est le seul angle aigüe de n'importe quel pilote de proxy construit sur des lignes d'objets. Drizzle ne l'ajoute pas. AS produit deux colonnes appelées

// Wrong: both columns are named "id" in the result set
await db
  .select({ postId: posts.id, userId: users.id })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id));

produit deux colonnes appelées id. They collapse into one key in the row object, the array is one value short, and Drizzle assigns values to the wrong fields. In my test postId est rentré avec l'identifiant de l'utilisateur et userId was undefinedJointures : duplication de noms de colonnes

Alias les champs explicitement :

await db
  .select({
    postId: sql<number>`${posts.id}`.as('post_id'),
    userId: sql<number>`${users.id}`.as('user_id'),
    title: posts.title,
    author: users.name,
  })
  .from(posts)
  .innerJoin(users, eq(posts.authorId, users.id));

La sélection de colonnes avec des noms différents (posts.title, users.name) est acceptable. Évitez db.select().from(a).innerJoin(b, ...) sans liste de champs lorsque les deux tables partagent des noms de colonnes comme id ou created_atLes requêtes relationnelles ne présentent pas ce problème.

Transactions

await db.transaction(async (tx) => {
  const [user] = await tx
    .insert(users)
    .values({ name: 'Grace', email: 'grace@example.com' })
    .returning();

  await tx.insert(posts).values({ authorId: user.id, title: 'Hello' });

  // Nested transactions become savepoints
  await tx.transaction(async (inner) => {
    await inner.insert(posts).values({ authorId: user.id, title: 'Maybe' });
  });
});

Si l'appel de callback lance une erreur, Drizzle envoie rollback. Calling tx.rollback() tue également la transaction, en lançant une erreur.

annule également la transaction, en lançant une erreur. await Appels réseau à l'intérieur d'eux. D'autres écritures sur la même connexion s'exécuteraient à l'intérieur de votre transaction ouverte.

Troubleshooting

Erreur de connexion déjà existante lors du démarrage. Your initialization ran twice, usually because of hot reload. Use the checkConnectionsConsistency et retrieveConnection un modèle présenté ci-dessus.

cannot start a transaction within a transaction. Un run() appel a été effectué sans false comme troisième argument, donc le plugin a ouvert sa propre transaction à l'intérieur de Drizzle.

Les migrations sont exécutées à chaque lancement et échouent avec table already exists. Le __drizzle_migrations insert est manquant ou annulé. Vérifiez que le journal est correct. when valeur dans votre bundle correspond aux lignes de la table.

Build error Failed to parse source for import analysis sur .sql. Le plugin de transformation Vite manque ou s'exécute après un autre plugin qui tente de parser le fichier. Mettez-l’en premier dans la plugins l'array.

.returning() renvoie un tableau vide. L'écriture a réussi run avec le mode de retour no. Assurez-vous que le pilote passe 'all' lorsque method n'est pas run.

Les dates sont décalées d'un facteur de 1000. mode: 'timestamp' stocke les secondes, mode: 'timestamp_ms' stockez les millisecondes. Choisissez-en un par colonne et ne les mixez pas avec les données brutes. Date.now() insère.

Quand choisir autre chose

Ajouter un plugin natif signifie reconstruire les applications iOS et Android. Si vous n'avez pas un Mac à portée de main, Capgo Build construit et signe à la fois dans le cloud.

Mises à jour instantanées pour les applications Capacitor

Lorsqu'un bug de la couche web est en ligne, expédiez la correction par Capgo au lieu d'attendre des jours pour l'approbation de la boutique d'applications. Les utilisateurs obtiennent la mise à jour en arrière-plan tandis que les changements natifs restent dans la voie de revue normale.

Dernières actualités de notre Blog

Capgo vous donne les meilleures informations dont vous avez besoin pour créer une application mobile vraiment professionnelle.