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