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