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