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