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