Zum Hauptinhalt springen

Drizzle ORM mit Capacitor und SQLite verwenden

Einrichten von Drizzle ORM mit Capacitor und SQLite: ein funktionierender sqlite-proxy-Treiber, ein typisiertes Schema, relationale Abfragen, Transaktionen und in-app Drizzle Kit-Migrationen.

Artikelcredits

Martin Donadieu

Schreiber

Valeria

Rezensent

Jordan

Redakteur

Wie man Drizzle ORM mit Capacitor und SQLite verwendet

Drizzle ORM läuft in einer Capacitor-Anwendung über ihren sqlite-proxy Treiber: Sie geben Drizzle eine asynchrone Funktion, die SQL auf einem native SQLite-Plugin ausführt und die Ergebnisse als Arrays zurückgibt, und Drizzle handhabt den typisierten Query Builder, die relationalen Abfragen und die Transaktionen darüber. @capacitor-community/sqlite, zeigt, wie Drizzle Kit-Migrationen innerhalb der App-Bundle zu versenden sind, und listet die Edge-Fälle auf, die Ergebnisse verfälschen, wenn man sie übergeht.

zeigt, wie man Drizzle Kit-Migrationen innerhalb der App-Bundle schickt, und listet die Randfälle auf, die Ergebnisse stumm verderben, wenn man sie verpasst. drizzle-orm 0.45 und drizzle-kit 0.31 auf Capacitor 8.

Warum Drizzle auf einem mobilen Datenbank?

  • Schema in TypeScript. Tische sind einfache TypeScript-Objekte. Die Typen für SELECT- und INSERT-Anfragen werden automatisch ermittelt, kein code-Erstellungsstep.
  • SQL-förmige API. db.select().from(users).where(eq(users.id, 1)) macht eine zu eins mit SQL, sodass Sie sich überlegen können, was auf dem Gerät ausgeführt wird.
  • Migrations werden aus dem Schema generiert. Drizzle Kit differt Ihr Schema und schreibt SQL-Migrationsdateien.
  • Keine Dekoratoren. Funktioniert mit Vite und esbuild ohne zusätzliche TypeScript-Flags.

Der Kompromiss: Drizzle hat kein Capacitor-Treiber in seinem Paket, also müssen Sie die Brücke selbst schreiben. Sie besteht aus etwa 30 Zeilen.

Auswahl des SQLite-Plugins

Drizzle’s sqlite-proxy Treiber mappt Ergebniszeilen nach Position: für all und values erwartet eine Liste von Listen, für get eine einzelne Liste, in der gleichen Reihenfolge wie die Spalten in der generierten SELECT. Capacitor SQLite-Plugins liefern Objekte, die durch Spaltennamen gekennzeichnet sind, daher muss der Treiber jeden Objekt wieder in eine Liste umwandeln. Das funktioniert nur, wenn die Objekt-Schlüssel die Spaltenreihenfolge aufrechterhalten.

Plugin Zeilenformat Spaltenreihenfolge aufrechterhalten Für Drizzle-Proxy geeignet
@capacitor-community/sqlite Objekte Ja. Auf iOS sendet das Plugin einen ios_columns Liste und die JS-Schicht wiederholt jede Zeile in Spaltenordnung Sehr gut
@capgo/capacitor-fast-sql Objekte Keine Garantie auf iOS, Zeilen werden aus einem Swift-Dictionary serialisiert Verwende stattdessen Kysely

Deshalb wird in dieser Anleitung die Community-Plugin verwendet. Wenn Sie bereits Fast SQL verwenden oder einen schnelleren Datenpfad wünschen, Kysely liest Zeilen nach Spaltenname und arbeitet direkt mit ihm. Für eine Übersicht über beide Plugins siehe Wie Sie SQLite in einer Capacitor-Anwendung verwenden.

Installieren

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

@capacitor-community/sqlite Bündelt SQLCipher auf iOS und Android, so dass Ihre App Verschlüsselung code mitführt, auch wenn Sie nichts verschlüsseln. Beachten Sie das bei Fragen zur Exportkonzession im App Store.

Definieren Sie das Schema

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

Auf einem Mobilgerät zählen einige Details:

  • mode: 'timestamp' speichert Sekunden seit der Epochen als Ganzzahl und gibt dir Date Objekte in TypeScript zurück.
  • $defaultFn(() => new Date()) setzt die Standardwerte in JavaScript. Ein SQL-Standardwert wie unixepoch() erfordert SQLite 3.38 oder neuer, was ältere Android-System-SQLite nicht hat.
  • mode: 'boolean' speichert 1/0. Drizzle konvertiert beide Richtungen.
  • relations() ist das, was db.query.* mit with: {...}Fremdschlüssel reichen allein nicht für relationale Abfragen aus.

Öffnen Sie die Verbindung

The community plugin keeps a registry of connections. During live reload or hot module replacement your code can run twice, so reuse an existing connection instead of creating a second one.

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

Der Plugin hängt SQLite.db zur Dateiendung an, so app wird appSQLite.db auf dem Disk. Im Web benötigt das Plugin das jeep-sqlite benutzerdefinierte Element und sqlite.initWebStore() vor der ersten Verbindung. Folgen Sie der Web-Einrichtung des Plugins, wenn Sie den Browser anpeilen.

Der sqlite-proxy-Treiber

Dies ist das Herz der Integration. Drizzle ruft die Funktion mit der SQL-Anweisung, den Parametern und einem method:

  • run: keine Zeilen erforderlich (INSERT, UPDATE, DELETE, DDL, Transaktionssteuerung).
  • all: jede Zeile, als Arrays.
  • get: die erste Zeile, als Array, oder undefined.
  • values: jede Zeile, als Arrays (von raw values() Aufrufe und interne Funktionen.
// 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>;

Weshalb jede Zweig existiert:

  • TX_CONTROL: Drizzle implementiert db.transaction() indem es begin, commit, rollback, und savepoint/release für vertikal gestapelte Transaktionen. Sie gehen durch execute(sql, false), den gleichen Weg, den der offizielle Capacitor-Treiber von TypeORM für BEGIN/COMMIT.
  • false als dritte Argument für run(): die Standard-Plugin-Wrapper umschließt jeden Statement in seiner eigenen Transaktion, was mit Drizzle’s expliziter begin.
  • returnMode 'all': macht .returning() funktionieren für Inserts, Updates und Löschungen.
  • Object.values(row)wiederholt die gekennzeichnete Zeile in eine positionale Array.

I habe diese Funktion gegen ein in-Memory-SQLite ausgeliefert, das Zeilen zurückgibt, wie der Plugin das tut. Einfügungen mit .returning(), gefilterte Selektionen .get(), relationale Abfragen, Transaktionen und Rollbacks bei einem geworfenen Fehler erzeugen alle korrekte Ergebnisse.

Einmal initialisieren

// 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 mit Drizzle Kit

Drizzle’s integrierte sqlite-proxy migrator liest .sql Dateien vom Disk mit Node’s fs, was ein WebView nicht hat. Die Lösung besteht darin, Drizzle Kit die SQL in ein JavaScript-Modul zu packen und es selbst anzuwenden.

1. Konfigurieren Sie 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' Es bedeutet nicht, dass Sie Expo benötigen. Es sagt Drizzle Kit, auch zu schreiben drizzle/migrations.js, das die Journal-Datei und jede .sql Datei. Das ist die Formatierung, die jeder nicht-Node- Runtime benötigt.

2. Generieren

bunx drizzle-kit generate

Sie erhalten drizzle/0000_<name>.sql, drizzle/meta/_journal.json und drizzle/migrations.js. Alle drei verpflichten. Führen Sie generate wiederholt nach jedem Schemaänderungen durch. Bearbeiten Sie keine SQL-Dateien, die bereits abgeschickt wurden.

3. Lassen Sie den Bundler .sql als Text importieren

migrations.js mit Zeilen wie import m0000 from './0000_fearless_selene.sql'. Vite weiß nicht .sql, also fügen Sie stattdessen ein kleines Plugin hinzu:

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

Und eine Typdeklaration, damit TypeScript die Importierung akzeptiert:

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

Mit Webpack (Angular-Custom-Buildern, ältere Konfigurationen) verwenden Sie ein asset/source Regel für .sql Dateien anstatt.

4. Migrations bei Start anwenden

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

Dies folgt der gleichen Regel, die Drizzle's eigene Migrator verwenden: Eine Migration ist ausstehend, wenn ihr Journal-Timestamp neuer ist als der neueste, der aufgezeichnet wurde. Jede Migration läuft in einer eigenen Transaktion ab, sodass ein Fehler den vorherigen Zustand unverändert lässt. Die Anwendung auf jeden Start ist günstig.

Wenn Sie JavaScript mit Capgo Live-Updatesversenden, erreichen neue Migrations die Benutzer ohne eine Store-Release. Halten Sie sie additiv (neue Tabellen, neue nullable Spalten), weil eine Rückkehr zu einer älteren Bundle gegen die neueren Schema läuft.

Abfragen

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

Relationen Abfragen

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 baut diese mit SQLite's JSON-Funktionen auf, dann analysiert es das Ergebnis. Sie funktionieren auf iOS und auf den neueren Android-Versionen. Wenn Ihr minSdkVersion ist alt, überprüfen Sie SELECT json('{}') auf Ihrem ältesten Testgerät.

Joins: Spaltenalias duplizierte Spaltennamen

Dies ist der eine scharfe Kantenpunkt von jedem Proxytreiber, der auf Objektzeilen aufbaut. Drizzle fügt nichts hinzu AS erzeugt zwei Spalten mit dem Namen

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

erzeugt zwei Spalten mit dem Namen 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 . Es wird keine Fehlermeldung ausgelöst. userId was undefinedSpalten auswählen, die unterschiedliche Namen haben (

) ist in Ordnung. Vermeiden Sie

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

Wählen Sie Spalten mit unterschiedlichen Namen ("posts.title, users.nameSelecting columns with different names ( db.select().from(a).innerJoin(b, ...) ohne eine Spaltenliste, wenn beide Tabellen Spalten mit denselben Namen haben wie id oder created_atRelationale Abfragen haben dieses Problem nicht.

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

Wenn die Callback-Funktion ausläuft, sendet Drizzle rollback. Calling tx.rollback() auch beendet die Transaktion, indem es werfen lässt.

Es gibt eine Verbindung pro Datenbank, daher sollten Transaktionen kurz gehalten und nicht await Netzanfragen innerhalb von ihnen. Andere Schreibvorgänge auf derselben Verbindung würden innerhalb Ihrer offenen Transaktion ausgeführt.

Troubleshooting

“Verbindung bereits existiert”-Fehler beim Start. Ihre Initialisierung wurde zweimal ausgeführt, normalerweise wegen Hot Reload. Verwenden Sie das checkConnectionsConsistency und retrieveConnection Muster, das oben gezeigt wird.

cannot start a transaction within a transaction. A run() Der Aufruf wurde ohne false als dritter Argument, sodass das Plugin seine eigene Transaktion innerhalb von Drizzle eröffnete.

Migrations werden bei jedem Start durchgeführt und scheitern mit table already exists. Der __drizzle_migrations Einfügen fehlt oder wird zurückgerollt. Überprüfen Sie, dass die Journal when Werte in Ihrem Bundle den Zeilen in der Tabelle entsprechen.

Build-Fehler Failed to parse source for import analysis auf .sql. Der Vite-Transform-Plugin fehlt oder wird nach einem anderen Plugin ausgeführt, das versucht, den Datei zu parsen. Setzen Sie es an erster Stelle in der plugins Array.

.returning() gibt eine leere Liste zurück. Die Schreibvorgang ist erfolgreich. run mit Rückgabemodus noMachen Sie sicher, dass der Treiber funktioniert 'all' wenn method ist nicht run.

Die Daten sind um einen Faktor von 1000 zu hoch. mode: 'timestamp' speichert Sekunden mode: 'timestamp_ms' speichere Millisekunden. Wählen Sie eine pro Spalte aus und vermischen Sie sie nicht mit Rohdaten. Date.now() inserts.

Wann etwas anderes wählen

Ein native Plugin bedeutet, dass die iOS- und Android-Apps neu erstellt werden müssen. Wenn Sie kein Mac zur Hand haben: Capgo Build beide im Cloud erstellen und signieren.

Live Updates für Capacitor-Apps

When a web-layer bug is live, ship the fix through Capgo instead of waiting days for app store approval. Users get the update in the background while native changes stay in the normal review path.

Unterstützung durch Martin

Get Started Now

Neueste aus unserem Blog

Capgo bietet Ihnen die besten Einblicke, um eine echte Profi-App zu erstellen.