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