xref: /expo/packages/expo-sqlite/src/SQLite.ts (revision bc3b43c2)
1import './polyfillNextTick';
2
3import customOpenDatabase from '@expo/websql/custom';
4import { requireNativeModule } from 'expo-modules-core';
5import { Platform } from 'react-native';
6
7import type {
8  Query,
9  ResultSet,
10  ResultSetError,
11  SQLiteCallback,
12  SQLTransactionAsyncCallback,
13  SQLTransactionAsync,
14  SQLTransactionCallback,
15  SQLTransactionErrorCallback,
16} from './SQLite.types';
17
18const ExpoSQLite = requireNativeModule('ExpoSQLite');
19
20function zipObject(keys: string[], values: any[]) {
21  const result = {};
22  for (let i = 0; i < keys.length; i++) {
23    result[keys[i]] = values[i];
24  }
25  return result;
26}
27
28/** The database returned by `openDatabase()` */
29export class SQLiteDatabase {
30  _name: string;
31  _closed: boolean = false;
32
33  constructor(name: string) {
34    this._name = name;
35  }
36
37  /**
38   * Executes the SQL statement and returns a callback resolving with the result.
39   */
40  exec(queries: Query[], readOnly: boolean, callback: SQLiteCallback): void {
41    if (this._closed) {
42      throw new Error(`The SQLite database is closed`);
43    }
44
45    ExpoSQLite.exec(this._name, queries.map(_serializeQuery), readOnly).then(
46      (nativeResultSets) => {
47        callback(null, nativeResultSets.map(_deserializeResultSet));
48      },
49      (error) => {
50        // TODO: make the native API consistently reject with an error, not a string or other type
51        callback(error instanceof Error ? error : new Error(error));
52      }
53    );
54  }
55
56  /**
57   * Executes the SQL statement and returns a Promise resolving with the result.
58   */
59  async execAsync(queries: Query[], readOnly: boolean): Promise<(ResultSetError | ResultSet)[]> {
60    if (this._closed) {
61      throw new Error(`The SQLite database is closed`);
62    }
63
64    const nativeResultSets = await ExpoSQLite.exec(
65      this._name,
66      queries.map(_serializeQuery),
67      readOnly
68    );
69    return nativeResultSets.map(_deserializeResultSet);
70  }
71
72  /**
73   * @deprecated Use `closeAsync()` instead.
74   */
75  close = this.closeAsync;
76
77  /**
78   * Close the database.
79   */
80  closeAsync(): Promise<void> {
81    this._closed = true;
82    return ExpoSQLite.close(this._name);
83  }
84
85  /**
86   * Synchronously closes the database.
87   */
88  closeSync(): void {
89    this._closed = true;
90    return ExpoSQLite.closeSync(this._name);
91  }
92
93  /**
94   * Delete the database file.
95   * > The database has to be closed prior to deletion.
96   */
97  deleteAsync(): Promise<void> {
98    if (!this._closed) {
99      throw new Error(
100        `Unable to delete '${this._name}' database that is currently open. Close it prior to deletion.`
101      );
102    }
103
104    return ExpoSQLite.deleteAsync(this._name);
105  }
106
107  /**
108   * Creates a new transaction with Promise support.
109   * @param asyncCallback A `SQLTransactionAsyncCallback` function that can perform SQL statements in a transaction.
110   * @param readOnly true if all the SQL statements in the callback are read only.
111   */
112  async transactionAsync(
113    asyncCallback: SQLTransactionAsyncCallback,
114    readOnly: boolean = false
115  ): Promise<void> {
116    await this.execAsync([{ sql: 'BEGIN;', args: [] }], false);
117    try {
118      const transaction = new ExpoSQLTransactionAsync(this, readOnly);
119      await asyncCallback(transaction);
120      await this.execAsync([{ sql: 'END;', args: [] }], false);
121    } catch (e: unknown) {
122      await this.execAsync([{ sql: 'ROLLBACK;', args: [] }], false);
123      throw e;
124    }
125  }
126
127  // @ts-expect-error: properties that are added from websql
128  version: string;
129
130  /**
131   * Execute a database transaction.
132   * @param callback A function representing the transaction to perform. Takes a Transaction
133   * (see below) as its only parameter, on which it can add SQL statements to execute.
134   * @param errorCallback Called if an error occurred processing this transaction. Takes a single
135   * parameter describing the error.
136   * @param successCallback Called when the transaction has completed executing on the database.
137   */
138  // @ts-expect-error: properties that are added from websql
139  transaction(
140    callback: SQLTransactionCallback,
141    errorCallback?: SQLTransactionErrorCallback,
142    successCallback?: () => void
143  ): void;
144
145  // @ts-expect-error: properties that are added from websql
146  readTransaction(
147    callback: SQLTransactionCallback,
148    errorCallback?: SQLTransactionErrorCallback,
149    successCallback?: () => void
150  ): void;
151}
152
153function _serializeQuery(query: Query): Query | [string, any[]] {
154  return Platform.OS === 'android'
155    ? {
156        sql: query.sql,
157        args: query.args.map(_escapeBlob),
158      }
159    : [query.sql, query.args];
160}
161
162function _deserializeResultSet(nativeResult): ResultSet | ResultSetError {
163  const [errorMessage, insertId, rowsAffected, columns, rows] = nativeResult;
164  // TODO: send more structured error information from the native module so we can better construct
165  // a SQLException object
166  if (errorMessage !== null) {
167    return { error: new Error(errorMessage) } as ResultSetError;
168  }
169
170  return {
171    insertId,
172    rowsAffected,
173    rows: rows.map((row) => zipObject(columns, row)),
174  };
175}
176
177function _escapeBlob<T>(data: T): T {
178  if (typeof data === 'string') {
179    /* eslint-disable no-control-regex */
180    return data
181      .replace(/\u0002/g, '\u0002\u0002')
182      .replace(/\u0001/g, '\u0001\u0002')
183      .replace(/\u0000/g, '\u0001\u0001') as any;
184    /* eslint-enable no-control-regex */
185  } else {
186    return data;
187  }
188}
189
190const _openExpoSQLiteDatabase = customOpenDatabase(SQLiteDatabase);
191
192// @needsAudit @docsMissing
193/**
194 * Open a database, creating it if it doesn't exist, and return a `Database` object. On disk,
195 * the database will be created under the app's [documents directory](./filesystem), i.e.
196 * `${FileSystem.documentDirectory}/SQLite/${name}`.
197 * > The `version`, `description` and `size` arguments are ignored, but are accepted by the function
198 * for compatibility with the WebSQL specification.
199 * @param name Name of the database file to open.
200 * @param version
201 * @param description
202 * @param size
203 * @param callback
204 * @return
205 */
206export function openDatabase(
207  name: string,
208  version: string = '1.0',
209  description: string = name,
210  size: number = 1,
211  callback?: (db: SQLiteDatabase) => void
212): SQLiteDatabase {
213  if (name === undefined) {
214    throw new TypeError(`The database name must not be undefined`);
215  }
216  const db = _openExpoSQLiteDatabase(name, version, description, size, callback);
217  db.exec = db._db.exec.bind(db._db);
218  db.execAsync = db._db.execAsync.bind(db._db);
219  db.closeAsync = db._db.closeAsync.bind(db._db);
220  db.closeSync = db._db.closeSync.bind(db._db);
221  db.deleteAsync = db._db.deleteAsync.bind(db._db);
222  db.transactionAsync = db._db.transactionAsync.bind(db._db);
223  return db;
224}
225
226/**
227 * Internal data structure for the async transaction API.
228 * @internal
229 */
230export class ExpoSQLTransactionAsync implements SQLTransactionAsync {
231  constructor(private readonly db: SQLiteDatabase, private readonly readOnly: boolean) {}
232
233  async executeSqlAsync(
234    sqlStatement: string,
235    args?: (number | string)[]
236  ): Promise<ResultSetError | ResultSet> {
237    const resultSets = await this.db.execAsync(
238      [{ sql: sqlStatement, args: args ?? [] }],
239      this.readOnly
240    );
241    return resultSets[0];
242  }
243}
244