1 // Copyright 2015-present 650 Industries. All rights reserved.
2 package expo.modules.sqlite
3 
4 import android.content.Context
5 import android.database.Cursor
6 import android.database.sqlite.SQLiteDatabase
7 import expo.modules.kotlin.exception.Exceptions
8 import expo.modules.kotlin.modules.Module
9 import expo.modules.kotlin.modules.ModuleDefinition
10 import java.io.File
11 import java.io.IOException
12 import java.util.*
13 
14 private val EMPTY_ROWS = emptyArray<Array<Any?>>()
15 private val EMPTY_COLUMNS = emptyArray<String?>()
16 private val EMPTY_RESULT = SQLiteModule.SQLitePluginResult(EMPTY_ROWS, EMPTY_COLUMNS, 0, 0, null)
17 private val DATABASES: MutableMap<String, SQLiteDatabase?> = HashMap()
18 
19 class SQLiteModule : Module() {
20   private val context: Context
21     get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
22 
23   override fun definition() = ModuleDefinition {
24     Name("ExpoSQLite")
25 
26     AsyncFunction("exec") { dbName: String, queries: List<Query>, readOnly: Boolean ->
27       val db = getDatabase(dbName)
28       val results = queries.map { sqlQuery ->
29         val sql = sqlQuery.sql
30         val bindArgs = convertParamsToStringArray(sqlQuery.args)
31         try {
32           if (isSelect(sql)) {
33             doSelectInBackgroundAndPossiblyThrow(sql, bindArgs, db)
34           } else { // update/insert/delete
35             if (readOnly) {
36               SQLitePluginResult(EMPTY_ROWS, EMPTY_COLUMNS, 0, 0, ReadOnlyException())
37             } else {
38               doUpdateInBackgroundAndPossiblyThrow(sql, bindArgs, db)
39             }
40           }
41         } catch (e: Throwable) {
42           SQLitePluginResult(EMPTY_ROWS, EMPTY_COLUMNS, 0, 0, e)
43         }
44       }
45       return@AsyncFunction pluginResultsToPrimitiveData(results)
46     }
47 
48     AsyncFunction("close") { dbName: String ->
49       DATABASES
50         .remove(dbName)
51         ?.close()
52     }
53 
54     AsyncFunction("deleteAsync") { dbName: String ->
55       if (DATABASES.containsKey(dbName)) {
56         throw OpenDatabaseException(dbName)
57       }
58       val dbFile = File(pathForDatabaseName(dbName))
59       if (!dbFile.exists()) {
60         throw DatabaseNotFoundException(dbName)
61       }
62       if (!dbFile.delete()) {
63         throw DeleteDatabaseException(dbName)
64       }
65     }
66   }
67 
68   // do a update/delete/insert operation
69   private fun doUpdateInBackgroundAndPossiblyThrow(
70     sql: String,
71     bindArgs: Array<String?>?,
72     db: SQLiteDatabase
73   ): SQLitePluginResult {
74     return db.compileStatement(sql).use { statement ->
75       if (bindArgs != null) {
76         for (i in bindArgs.size downTo 1) {
77           if (bindArgs[i - 1] == null) {
78             statement.bindNull(i)
79           } else {
80             statement.bindString(i, bindArgs[i - 1])
81           }
82         }
83       }
84       if (isInsert(sql)) {
85         val insertId = statement.executeInsert()
86         val rowsAffected = if (insertId >= 0) 1 else 0
87         SQLitePluginResult(EMPTY_ROWS, EMPTY_COLUMNS, rowsAffected, insertId, null)
88       } else if (isDelete(sql) || isUpdate(sql)) {
89         val rowsAffected = statement.executeUpdateDelete()
90         SQLitePluginResult(EMPTY_ROWS, EMPTY_COLUMNS, rowsAffected, 0, null)
91       } else {
92         // in this case, we don't need rowsAffected or insertId, so we can have a slight
93         // perf boost by just executing the query
94         statement.execute()
95         EMPTY_RESULT
96       }
97     }
98   }
99 
100   // do a select operation
101   private fun doSelectInBackgroundAndPossiblyThrow(
102     sql: String,
103     bindArgs: Array<String?>,
104     db: SQLiteDatabase
105   ): SQLitePluginResult {
106     return db.rawQuery(sql, bindArgs).use { cursor ->
107       val numRows = cursor.count
108       if (numRows == 0) {
109         return EMPTY_RESULT
110       }
111       val numColumns = cursor.columnCount
112       val columnNames = cursor.columnNames
113       val rows: Array<Array<Any?>> = Array(numRows) { arrayOfNulls(numColumns) }
114       var i = 0
115       while (cursor.moveToNext()) {
116         val row = rows[i]
117         for (j in 0 until numColumns) {
118           row[j] = getValueFromCursor(cursor, j, cursor.getType(j))
119         }
120         rows[i] = row
121         i++
122       }
123       SQLitePluginResult(rows, columnNames, 0, 0, null)
124     }
125   }
126 
127   private fun getValueFromCursor(cursor: Cursor, index: Int, columnType: Int): Any? {
128     return when (columnType) {
129       Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(index)
130       Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(index)
131       Cursor.FIELD_TYPE_BLOB ->
132         // convert byte[] to binary string; it's good enough, because
133         // WebSQL doesn't support blobs anyway
134         String(cursor.getBlob(index))
135 
136       Cursor.FIELD_TYPE_STRING -> cursor.getString(index)
137       else -> null
138     }
139   }
140 
141   @Throws(IOException::class)
142   private fun pathForDatabaseName(name: String): String {
143     val directory = File("${context.filesDir}${File.separator}SQLite")
144     ensureDirExists(directory)
145     return "$directory${File.separator}$name"
146   }
147 
148   @Throws(IOException::class)
149   private fun getDatabase(name: String): SQLiteDatabase {
150     var database: SQLiteDatabase? = null
151     val path = pathForDatabaseName(name)
152     if (File(path).exists()) {
153       database = DATABASES[name]
154     }
155     if (database == null) {
156       DATABASES.remove(name)
157       database = SQLiteDatabase.openOrCreateDatabase(path, null)
158       DATABASES[name] = database
159     }
160     return database!!
161   }
162 
163   internal class SQLitePluginResult(
164     val rows: Array<Array<Any?>>,
165     val columns: Array<String?>,
166     val rowsAffected: Int,
167     val insertId: Long,
168     val error: Throwable?
169   )
170 
171   private class ReadOnlyException : Exception("could not prepare statement (23 not authorized)")
172 }
173