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