1 import ExpoModulesCore
2 import SQLite3
3 
4 public final class SQLiteModule: Module {
5   private var cachedDatabases = [String: OpaquePointer]()
6 
7   public func definition() -> ModuleDefinition {
8     Name("ExpoSQLite")
9 
10     AsyncFunction("exec") { (dbName: String, queries: [[Any]], readOnly: Bool) -> [Any?] in
11       guard let db = openDatabase(dbName: dbName) else {
12         throw DatabaseException()
13       }
14 
15       let results = try queries.map { query in
16         guard let sql = query[0] as? String else {
17           throw InvalidSqlException()
18         }
19 
20         guard let args = query[1] as? [Any] else {
21           throw InvalidArgumentsException()
22         }
23 
24         return executeSql(sql: sql, with: args, for: db, readOnly: readOnly)
25       }
26 
27       return results
28     }
29 
30     AsyncFunction("close") { (dbName: String) in
31       cachedDatabases.removeValue(forKey: dbName)
32     }
33 
34     Function("closeSync") { (dbName: String) in
35       cachedDatabases.removeValue(forKey: dbName)
36     }
37 
38     AsyncFunction("deleteAsync") { (dbName: String) in
39       if cachedDatabases[dbName] != nil {
40         throw DeleteDatabaseException(dbName)
41       }
42 
43       guard let path = self.pathForDatabaseName(name: dbName) else {
44         throw Exceptions.FileSystemModuleNotFound()
45       }
46 
47       if !FileManager.default.fileExists(atPath: path.absoluteString) {
48         throw DatabaseNotFoundException(dbName)
49       }
50 
51       do {
52         try FileManager.default.removeItem(atPath: path.absoluteString)
53       } catch {
54         throw DeleteDatabaseFileException(dbName)
55       }
56     }
57 
58     OnDestroy {
59       cachedDatabases.values.forEach {
60         sqlite3_close($0)
61       }
62     }
63   }
64 
65   private func pathForDatabaseName(name: String) -> URL? {
66     guard let fileSystem = appContext?.fileSystem else {
67       return nil
68     }
69 
70     var directory = URL(string: fileSystem.documentDirectory)?.appendingPathComponent("SQLite")
71     fileSystem.ensureDirExists(withPath: directory?.absoluteString)
72 
73     return directory?.appendingPathComponent(name)
74   }
75 
76   private func openDatabase(dbName: String) -> OpaquePointer? {
77     var db: OpaquePointer?
78     guard let path = try pathForDatabaseName(name: dbName) else {
79       return nil
80     }
81 
82     let fileExists = FileManager.default.fileExists(atPath: path.absoluteString)
83 
84     if fileExists {
85       db = cachedDatabases[dbName]
86     }
87 
88     if db == nil {
89       cachedDatabases.removeValue(forKey: dbName)
90       if sqlite3_open(path.absoluteString, &db) != SQLITE_OK {
91         return nil
92       }
93 
94       cachedDatabases[dbName] = db
95     }
96     return db
97   }
98 
99   private func executeSql(sql: String, with args: [Any], for db: OpaquePointer, readOnly: Bool) -> [Any?] {
100     var resultRows = [Any]()
101     var statement: OpaquePointer?
102     var rowsAffected: Int32 = 0
103     var insertId: Int64 = 0
104     var error: String?
105 
106     if sqlite3_prepare_v2(db, sql, -1, &statement, nil) != SQLITE_OK {
107       return [convertSqlLiteErrorToString(db: db)]
108     }
109 
110     let queryIsReadOnly = sqlite3_stmt_readonly(statement) > 0
111 
112     if readOnly && !queryIsReadOnly {
113       return ["could not prepare \(sql)"]
114     }
115 
116     for (index, arg) in args.enumerated() {
117       guard let obj = arg as? NSObject else { continue }
118       bindStatement(statement: statement, with: obj, at: Int32(index + 1))
119     }
120 
121     var columnCount: Int32 = 0
122     var columnNames = [String]()
123     var columnType: Int32
124     var fetchedColumns = false
125     var value: Any?
126     var hasMore = true
127 
128     while hasMore {
129       let result = sqlite3_step(statement)
130 
131       switch result {
132       case SQLITE_ROW:
133         if !fetchedColumns {
134           columnCount = sqlite3_column_count(statement)
135 
136           for i in 0..<Int(columnCount) {
137             let columnName = NSString(format: "%s", sqlite3_column_name(statement, Int32(i))) as String
138             columnNames.append(columnName)
139           }
140           fetchedColumns = true
141         }
142 
143         var entry = [Any]()
144 
145         for i in 0..<Int(columnCount) {
146           columnType = sqlite3_column_type(statement, Int32(i))
147           value = getSqlValue(for: columnType, with: statement, index: Int32(i))
148           entry.append(value)
149         }
150 
151         resultRows.append(entry)
152       case SQLITE_DONE:
153         hasMore = false
154       default:
155         error = convertSqlLiteErrorToString(db: db)
156         hasMore = false
157       }
158     }
159 
160     if !queryIsReadOnly {
161       rowsAffected = sqlite3_changes(db)
162       if rowsAffected > 0 {
163         insertId = sqlite3_last_insert_rowid(db)
164       }
165     }
166 
167     sqlite3_finalize(statement)
168 
169     if error != nil {
170       return [error]
171     }
172 
173     return [nil, insertId, rowsAffected, columnNames, resultRows]
174   }
175 
176   private func bindStatement(statement: OpaquePointer?, with arg: NSObject, at index: Int32) {
177     if arg == NSNull() {
178       sqlite3_bind_null(statement, index)
179     } else if arg is Double {
180       sqlite3_bind_double(statement, index, arg as? Double ?? 0.0)
181     } else {
182       var stringArg: NSString
183 
184       if arg is NSString {
185         stringArg = NSString(format: "%@", arg)
186       } else {
187         stringArg = arg.description as NSString
188       }
189 
190       let SQLITE_TRANSIENT = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self)
191 
192       let data = stringArg.data(using: NSUTF8StringEncoding)
193       sqlite3_bind_text(statement, index, stringArg.utf8String, Int32(data?.count ?? 0), SQLITE_TRANSIENT)
194     }
195   }
196 
197   private func getSqlValue(for columnType: Int32, with statement: OpaquePointer?, index: Int32) -> Any? {
198     switch columnType {
199     case SQLITE_INTEGER:
200       return sqlite3_column_int64(statement, index)
201     case SQLITE_FLOAT:
202       return sqlite3_column_double(statement, index)
203     case SQLITE_BLOB, SQLITE_TEXT:
204       return NSString(bytes: sqlite3_column_text(statement, index), length: Int(sqlite3_column_bytes(statement, index)), encoding: NSUTF8StringEncoding)
205     default:
206       return nil
207     }
208   }
209 
210   private func convertSqlLiteErrorToString(db: OpaquePointer?) -> String {
211     let code = sqlite3_errcode(db)
212     let message = NSString(utf8String: sqlite3_errmsg(db)) ?? ""
213     return NSString(format: "Error code %i: %@", code, message) as String
214   }
215 }
216