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