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