__CAPGO_KEEP_2__

Kysely와 Capacitor 및 SQLite를 사용하는 방법

Kysely를 사용하여 Capacitor와 SQLite와 함께 작동하는 방법: Fast SQL에 대한 작은 Kysely 구문 작성, 테이블을 유형화, 쿼리, 트랜잭션 및 앱 내 마이그레이션을 실행합니다.

기사 크레딧

마틴 도나디유

작가

발레리아

리뷰어

조던

편집자

Kysely를 사용하여 Capacitor와 SQLite와 함께 작동하는 방법

Kysely는 Capacitor 생성과 무거운 런타임 없이 컴파일 타임에 체크된 SQL을 제공하기 때문에 Capacitor와 SQLite와 함께 잘 작동합니다. 이 안내서는 @capgo/capacitor-fast-sql, 유형화된 테이블, CRUD, 조인, 트랜잭션 및 앱 내에서 iOS, Android 및 웹에서 실행되는 마이그레이션을 다룹니다.

SQL 컴파일 타임 체크가 가능하며 code 생성 및 무거운 런타임 없이 사용할 수 있습니다.

Kysely는 모바일 SQLite에 적합한 이유

Kysely는 쿼리 빌더이며 ORM이 아닙니다. SQL과 유사한 문법으로 쿼리를 작성하고, TypeScript은 모든 테이블과 열 이름을 확인하고 결과 타입을 추론합니다.

  • 데코레이터나 리플렉션을 사용하지 않으므로, Vite와 esbuild를 기본적으로 지원합니다.
  • code 생성 단계가 필요하지 않습니다. 인터페이스는 스키마 계약입니다. Database interface는 스키마 계약 계약입니다.
  • __CAPGO_KEEP_0__ live update SQLite를 앱에 설정하지 않은 경우, 시작하세요.
  • __CAPGO_KEEP_0__ 앱에서 SQLite를 사용하는 방법

이 항목은 Fast SQL이 필요로하는 네이티브 구성에 대해 다룹니다. SQLite을 사용하는 Capacitor 앱의 방법이 항목은 Fast SQL이 필요로하는 네이티브 구성에 대해 다룹니다.NSAllowsLocalNetworking iOS와 localhost의 clear text 규칙을 Android에서 사용합니다.

설치

bun add kysely @capgo/capacitor-fast-sql
bunx cap sync

이 안내서에서는 Kysely 0.29과 Fast SQL 8.2을 사용합니다. Kysely 0.29에서는 마이그레이션 클래스가 진입점으로 이동했습니다. kysely/migration 빠른 SQL 구문

A Kysely

A Kysely Dialect 각 부분의 역할:

// src/db/fast-sql-dialect.ts
import {
  CompiledQuery,
  SqliteAdapter,
  SqliteIntrospector,
  SqliteQueryCompiler,
  type DatabaseConnection,
  type DatabaseIntrospector,
  type Dialect,
  type DialectAdapter,
  type Driver,
  type Kysely,
  type QueryCompiler,
  type QueryResult,
} from 'kysely';
import {
  FastSQL,
  type SQLConnection,
  type SQLConnectionOptions,
  type SQLValue,
} from '@capgo/capacitor-fast-sql';

// Kysely passes JS values as-is. Normalize them to what SQLite stores.
function toSqlValue(value: unknown): SQLValue {
  if (value === undefined || value === null) return null;
  if (typeof value === 'boolean') return value ? 1 : 0;
  if (value instanceof Date) return value.toISOString();
  if (typeof value === 'bigint') return Number(value);
  if (value instanceof Uint8Array) return value;
  if (typeof value === 'object') return JSON.stringify(value);
  return value as SQLValue;
}

class FastSqlConnection implements DatabaseConnection {
  constructor(readonly db: SQLConnection) {}

  async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
    const result = await this.db.execute(compiled.sql, compiled.parameters.map(toSqlValue));
    return {
      rows: result.rows as R[],
      numAffectedRows: result.rowsAffected != null ? BigInt(result.rowsAffected) : undefined,
      insertId: result.insertId != null ? BigInt(result.insertId) : undefined,
    };
  }

  // eslint-disable-next-line require-yield
  async *streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
    throw new Error('Streaming is not supported by the Fast SQL driver');
  }
}

// One native connection per database, so serialize access to it.
class ConnectionMutex {
  #pending: Promise<void> = Promise.resolve();
  #release?: () => void;

  async lock(): Promise<void> {
    const previous = this.#pending;
    let release!: () => void;
    this.#pending = new Promise<void>((resolve) => (release = resolve));
    await previous;
    this.#release = release;
  }

  unlock(): void {
    const release = this.#release;
    this.#release = undefined;
    release?.();
  }
}

class FastSqlDriver implements Driver {
  #connection?: FastSqlConnection;
  readonly #mutex = new ConnectionMutex();

  constructor(private readonly options: SQLConnectionOptions) {}

  async init(): Promise<void> {
    const db = await FastSQL.connect(this.options);
    this.#connection = new FastSqlConnection(db);
  }

  async acquireConnection(): Promise<DatabaseConnection> {
    await this.#mutex.lock();
    return this.#connection!;
  }

  async releaseConnection(): Promise<void> {
    this.#mutex.unlock();
  }

  async beginTransaction(connection: DatabaseConnection): Promise<void> {
    await (connection as FastSqlConnection).db.beginTransaction();
  }

  async commitTransaction(connection: DatabaseConnection): Promise<void> {
    await (connection as FastSqlConnection).db.commit();
  }

  async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
    await (connection as FastSqlConnection).db.rollback();
  }

  async destroy(): Promise<void> {
    if (this.#connection) {
      await FastSQL.disconnect(this.options.database);
      this.#connection = undefined;
    }
  }
}

export class FastSqlDialect implements Dialect {
  constructor(private readonly options: SQLConnectionOptions) {}

  createDriver(): Driver {
    return new FastSqlDriver(this.options);
  }

  createQueryCompiler(): QueryCompiler {
    return new SqliteQueryCompiler();
  }

  createAdapter(): DialectAdapter {
    return new SqliteAdapter();
  }

  createIntrospector(db: Kysely<any>): DatabaseIntrospector {
    return new SqliteIntrospector(db);
  }
}

불리언을

  • toSqlValue 날짜를 ISO 문자열로 1/0plain 객체를 JSON으로 변환합니다. SQLite에는 불리언 또는 날짜 타입이 없으며 Android에서 플러그인은 읽기 쿼리 매개 변수를 텍스트로 바인딩하므로 true 이 문자열로 도착하여 'true' 이 문자열과 일치하지 않습니다. 1.
  • FastSqlConnection.executeQuery calls execute() iOS와 Android에서 플러그인의 로컬 HTTP 채널을 통해, 웹에서 SQLite Wasm을 통해 Fast SQL 연결에서 호출됩니다. 이미 열이 객체로 반환되며 열 이름으로 키가 지정되어 있습니다. 이는 Kysely가 기대하는 정확한 형식입니다.
  • ConnectionMutex Fast SQL은 한 개의 네이티브 연결을 한 개의 데이터베이스당 유지합니다. 락이 없으면, 두 개의 동시 Kysely 트랜잭션은 한 개의 연결에서 statement를 교차합니다. Kysely의 SQLite 드라이버도 동일한 패턴을 사용합니다.
  • Transactions 플러그인의 네이티브 beginTransaction, commit 대신 raw rollback SQL을 사용하여, 플러그인은 두 개의 플랫폼 모두에서 트랜잭션 상태를 올바르게 추적합니다. begin Savepoints
  • Savepoints 을 throw합니다. Add savepoint() and The savepoint method is not supported by this driverSQL savepoint, rollbackToSavepoint 및 releaseSavepoint 컴파일 된 저장점 SQL을 실행하는 메서드가 필요할 때 사용할 수 있습니다.

테이블을 설명하세요

The Database 인터페이스는 테이블 이름을 행 타입으로 맵핑합니다. Kysely의 도우미 타입을 사용하여 데이터베이스가 채우는 것을 설명하세요.

// src/db/schema.ts
import type { ColumnType, Generated, Insertable, Selectable, Updateable } from 'kysely';

export interface ProjectTable {
  id: Generated<number>;
  name: string;
  created_at: ColumnType<string, string | undefined, never>;
}

export interface TaskTable {
  id: Generated<number>;
  project_id: number;
  title: string;
  done: ColumnType<number, number | boolean | undefined, number | boolean>;
  due_at: string | null;
}

export interface Database {
  projects: ProjectTable;
  tasks: TaskTable;
}

export type Task = Selectable<TaskTable>;
export type NewTask = Insertable<TaskTable>;
export type TaskUpdate = Updateable<TaskTable>;

Generated<number> 만들어 id 삽입 시 선택적입니다. ColumnType<Select, Insert, Update> 한 타입으로 읽을 수 있지만 다른 타입을 수락하는 열을 읽습니다. 여기서 done 숫자로 읽히지만 boolean 값을 쓰면 드라이버가 boolean 값을 숫자로 변환합니다. 1/0. never 수정 시 열을 변경할 수 없습니다.

Kysely 인스턴스를 생성하세요

// src/db/index.ts
import { Kysely } from 'kysely';
import { FastSqlDialect } from './fast-sql-dialect';
import type { Database } from './schema';
import { runMigrations } from './migrations';

let instance: Promise<Kysely<Database>> | null = null;

export function getDb(): Promise<Kysely<Database>> {
  if (!instance) {
    instance = (async () => {
      const db = new Kysely<Database>({
        dialect: new FastSqlDialect({
          database: 'tasks',
          walMode: true,
          performancePresets: true, // also enables foreign_keys
        }),
      });
      await runMigrations(db);
      return db;
    })();
  }
  return instance;
}

Kysely는 드라이버의 init() 첫 번째 쿼리에서 느리게 동작하므로 인스턴스를 생성하는 비용이 저렴합니다.

앱 내에서 마이그레이션

Kysely의 FileMigrationProvider Node의 fs웹뷰 내에서 존재하지 않는 SQLite 데이터베이스를 사용하는 경우 마이그레이션을 code 에서 제공합니다. Migrator 마이그레이션을 __CAPGO_KEEP_0__에 제공하세요. 이 kysely_migration 적용된 이름을

// src/db/migrations.ts
import { sql, type Kysely } from 'kysely';
import { Migrator, type Migration, type MigrationProvider } from 'kysely/migration';

const migrations: Record<string, Migration> = {
  '0001_init': {
    async up(db: Kysely<any>) {
      await db.schema
        .createTable('projects')
        .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
        .addColumn('name', 'text', (col) => col.notNull())
        .addColumn('created_at', 'text', (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
        .execute();

      await db.schema
        .createTable('tasks')
        .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement())
        .addColumn('project_id', 'integer', (col) =>
          col.notNull().references('projects.id').onDelete('cascade'),
        )
        .addColumn('title', 'text', (col) => col.notNull())
        .addColumn('done', 'integer', (col) => col.notNull().defaultTo(0))
        .execute();
    },
  },
  '0002_due_date': {
    async up(db: Kysely<any>) {
      await db.schema.alterTable('tasks').addColumn('due_at', 'text').execute();
      await db.schema.createIndex('idx_tasks_project').on('tasks').column('project_id').execute();
    },
  },
};

class InCodeMigrationProvider implements MigrationProvider {
  async getMigrations(): Promise<Record<string, Migration>> {
    return migrations;
  }
}

export async function runMigrations(db: Kysely<any>): Promise<void> {
  const migrator = new Migrator({ db, provider: new InCodeMigrationProvider() });
  const { error, results } = await migrator.migrateToLatest();

  results?.forEach((r) => {
    if (r.status === 'Error') console.error(`Migration ${r.migrationName} failed`);
  });
  if (error) throw error;
}

Notes:

  • 주의:
  • migrateToLatest() 이름은 알파벳 순서로 정렬되어 있으므로 0으로 패딩된 접두사를 사용하세요. { error, results }, 그래서 확인하세요 error 자신이 확인하거나 앱이 반영된 스키마로 시작하는지 확인하세요.
  • 실행할 때마다 호출하는 것은 저렴합니다. 만약 pending한 것이 없다면 마이그레이션 테이블을 읽고 반환합니다.
  • SQLite는 트랜잭션 DDL을 지원하므로 마이그레이터는 트랜잭션 내에서 실행합니다. 위의 드라이버와 함께 작동합니다.
  • 마이그레이션을 작성할 때 Kysely<any>현재 스키마와는 다르지만 Database 타입입니다. 이전 마이그레이션은 스키마가 변할 때도 컴파일이 가능해야 합니다.

쿼리

Insert

const db = await getDb();

const result = await db
  .insertInto('projects')
  .values({ name: 'Groceries' })
  .executeTakeFirst();

const projectId = Number(result.insertId);

await db
  .insertInto('tasks')
  .values([
    { project_id: projectId, title: 'Milk' },
    { project_id: projectId, title: 'Bread', done: true },
  ])
  .execute();

insertId 은 것이다. bigint 일반적인 행 id와 다릅니다. normal row id로 변환하세요. Number() 사용하지 마세요. 현재 Fast SQL 버전과 Android를 대상으로 할 때 Android는 시작하는 statement가 rows를 반환하지 않습니다.

Avoid .returning() Avoid SELECT, PRAGMA 또는 EXPLAIN, 그래서 INSERT ... RETURNING iOS와 웹은 행을 반환합니다. 읽기 insertId 모든 곳에서 작동합니다.

선택

const openTasks = await db
  .selectFrom('tasks')
  .select(['id', 'title', 'due_at'])
  .where('project_id', '=', projectId)
  .where('done', '=', 0)
  .orderBy('id')
  .execute();
// openTasks: { id: number; title: string; due_at: string | null }[]

const project = await db
  .selectFrom('projects')
  .selectAll()
  .where('id', '=', projectId)
  .executeTakeFirst(); // undefined if not found

조인 및 집계

const summary = await db
  .selectFrom('projects as p')
  .leftJoin('tasks as t', 't.project_id', 'p.id')
  .select((eb) => [
    'p.id',
    'p.name',
    eb.fn.count<number>('t.id').as('task_count'),
    eb.fn.sum<number>('t.done').as('done_count'),
  ])
  .groupBy('p.id')
  .execute();

Kysely는 조인 후 열이 존재하는지 알고 있으며, 별명 (task_count, done_count)은 결과 타입에 포함됩니다. 행이 객체로 돌아오기 때문에, 선택한 모든 열에 고유한 이름을 부여하세요. 두 개의 열이 모두 id 으로 명명되어 있다면, 객체의 행에 서로 다른 열을 덮어씁니다. 이는 SQLite 드라이버가 객체를 반환하는 경우 모두 참입니다.

JSON 도우미와의 중첩 결과

import { jsonArrayFrom } from 'kysely/helpers/sqlite';

const projects = await db
  .selectFrom('projects')
  .select((eb) => [
    'projects.id',
    'projects.name',
    jsonArrayFrom(
      eb.selectFrom('tasks')
        .select(['tasks.id', 'tasks.title'])
        .whereRef('tasks.project_id', '=', 'projects.id'),
    ).as('tasks'),
  ])
  .execute();

이것은 SQLite의 JSON 함수에 의존합니다. iOS에서 내장되어 있으며, 웹 빌드 및 최근 안드로이드 릴리스에서 내장되어 있지만, minSdkVersion 은 오래된 경우 SELECT json('{}') 가장 오래된 테스트 기기에서 먼저 테스트하세요.

업데이트 및 삭제

await db.updateTable('tasks').set({ done: true }).where('id', '=', taskId).execute();

const deleted = await db.deleteFrom('tasks').where('done', '=', 1).executeTakeFirst();
console.log(Number(deleted.numDeletedRows));

트랜잭션

await db.transaction().execute(async (trx) => {
  const { insertId } = await trx
    .insertInto('projects')
    .values({ name: 'Trip' })
    .executeTakeFirstOrThrow();

  await trx
    .insertInto('tasks')
    .values(['Passport', 'Tickets', 'Charger'].map((title) => ({
      project_id: Number(insertId),
      title,
    })))
    .execute();
});

콜백이 예외를 발생시키면 드라이버는 롤백합니다. 사용자 입력이 포함된 경우 콜백 내에서 사용하지 마십시오. trx, not db, 콜백 내에서만 사용하십시오. db 대기 중인 락을 기다리기 때문에 쿼리는 트랜잭션이 끝날 때까지 데드락이 발생합니다.

필요한 경우 Raw SQL을 사용하세요.

import { sql } from 'kysely';

const { rows } = await sql<{ journal_mode: string }>`PRAGMA journal_mode`.execute(db);

The sql 태그는 interpolated 값을 매개변수로 바인딩하기 때문에 사용자 입력과 안전합니다. 사용자 입력이 포함된 경우 사용하지 마십시오. sql.raw() 만 신뢰할 수 있는 코드 조각에만 사용하십시오.

커뮤니티 플러그인 대신 사용하십시오.

만약 당신이 @capacitor-community/sqlite그대로 유지하고, 어댑터, 컴파일러 및 뮤텍스, 그리고 연결:

import type { SQLiteDBConnection } from '@capacitor-community/sqlite';

class CommunityConnection implements DatabaseConnection {
  constructor(readonly db: SQLiteDBConnection) {}

  async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
    const params = compiled.parameters.map(toSqlValue);
    if (/^\s*(select|pragma|with)\b/i.test(compiled.sql)) {
      const res = await this.db.query(compiled.sql, params);
      return { rows: (res.values ?? []) as R[] };
    }
    const res = await this.db.run(compiled.sql, params, false, 'all');
    return {
      rows: (res.changes?.values ?? []) as R[],
      numAffectedRows: BigInt(res.changes?.changes ?? 0),
      insertId: res.changes?.lastId != null ? BigInt(res.changes.lastId) : undefined,
    };
  }

  async *streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
    throw new Error('Streaming is not supported');
  }
}

Pass false 세 번째 인수로 run()의 기본값은 statement를 자신의 트랜잭션에 wrapping합니다. 이는 Kysely 트랜잭션을 깨트립니다. beginTransaction(), commitTransaction() 및 rollbackTransaction() 드라이버에서.

문제 해결

Cannot find module 'kysely/migration'. 당신이 Kysely 0.29보다 이전 버전을 사용하고 있다면, Migrator 는 kysely 에서 직접 export됩니다.

업데이트 또는 변경하십시오. You used. db 내부에서 사용했습니다. db.transaction().execute(). 사용하세요. trx 이 매개 변수를 사용하세요.

done = true Android에서 일치하지 않습니다. 플러그인을 변환하지 않고 boolean이 도달했습니다. 모든 값이 변환되도록 하세요. toSqlValue템플릿 리터럴의 값도 포함하여 sql 첫 번째 실행 시 오류가 발생했습니다.

no such table: kysely_migration Migrator는 테이블을 자체적으로 생성하므로 일반적으로 이 경우에는 이전 단계에서 실패한 것입니다. iOS의 로컬 네트워킹, Android의 localhost 클리어 텍스트 규칙을 확인하고 첫 번째 오류를 로깅하세요. init() 타입은 열이 존재한다고 하지만 SQLite는 존재하지 않는다고 말합니다.

오류가 발생했습니다. The Database interface와 데이터베이스의 마이그레이션은 분리되었습니다. interface는 데이터베이스에서 생성되지 않으므로 동일한 커밋에서 두 가지를 업데이트해야 합니다.

Summary

Kysely는 약 80줄의 접착제가 필요하여 Capacitor 에서 실행되는 연결을 호출하는 것에 대해 execute()__CAPGO_KEEP_0__에서 Kysely를 작동시키기 위해 약 80줄의 중간 코드가 필요합니다. 이 중간 코드는 데이터베이스와의 연결, 뮤텍스, 트랜잭션 훅을 호출합니다. 이 대신에 iOS, Android 및 웹에서 Fast SQL의 SQLite를 사용하여 완전히 타입화된 SQL을 얻을 수 있습니다.

관련 글: Drizzle ORM과 Capacitor schema-first 정의를 선호하는 경우 decorator와 repository를 사용하는 경우 Capacitor와 함께 TypeORM을 사용하세요. Fast SQL 플러그인 문서 모든 연결 옵션에 대한 Fast SQL 플러그인 문서 모든 연결 옵션에 대해.

Capacitor 앱에 대한 즉각적인 업데이트

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.

최신 뉴스

Capgo는 여러분이 정말 전문적인 모바일 앱을 만들기 위해 필요한 최고의 통찰력을 제공합니다.