// Copyright 2015-present 650 Industries. All rights reserved. import ExpoModulesCore import libsql private typealias SQLiteColumnNames = [String] private typealias SQLiteColumnValues = [Any] private let MEMORY_DB_NAME = ":memory:" private let moduleQueue = DispatchQueue(label: "expo.module.sqlite.AsyncQueue", qos: .userInitiated, attributes: .concurrent) public final class SQLiteModule: Module { // Store unmanaged (SQLiteModule, Database) pairs for sqlite callbacks, // will release the pair when `closeDatabase` is called. private var contextPairs = [Unmanaged]() private static let lockQueue = DispatchQueue(label: "expo.modules.sqlite.lockQueue") private var cachedDatabases = [NativeDatabase]() private var cachedStatements = [NativeDatabase: [NativeStatement]]() private var hasListeners = false public func definition() -> ModuleDefinition { Name("ExpoSQLite") Constants { let defaultDatabaseDirectory = appContext?.config.documentDirectory?.appendingPathComponent("SQLite").standardized.path return [ "defaultDatabaseDirectory": defaultDatabaseDirectory ] } Events("onDatabaseChange") OnStartObserving { hasListeners = true } OnStopObserving { hasListeners = false } OnDestroy { removeAllCachedDatabases().forEach { do { try closeDatabase($0) } catch {} } } AsyncFunction("deleteDatabaseAsync") { (databasePath: String) in try deleteDatabase(databasePath: databasePath) }.runOnQueue(moduleQueue) Function("deleteDatabaseSync") { (databasePath: String) in try deleteDatabase(databasePath: databasePath) } AsyncFunction("importAssetDatabaseAsync") { (databasePath: String, assetDatabasePath: String, forceOverwrite: Bool) in let path = try ensureDatabasePathExists(path: databasePath) let fileManager = FileManager.default if fileManager.fileExists(atPath: path.toFilePath()) && !forceOverwrite { return } guard let assetPath = Utilities.urlFrom(string: assetDatabasePath)?.path, fileManager.fileExists(atPath: assetPath) else { throw DatabaseNotFoundException(assetDatabasePath) } try? fileManager.removeItem(atPath: path.absoluteString) try fileManager.copyItem(atPath: assetPath, toPath: path.toFilePath()) }.runOnQueue(moduleQueue) AsyncFunction("ensureDatabasePathExistsAsync") { (databasePath: String) in try ensureDatabasePathExists(path: databasePath) }.runOnQueue(moduleQueue) Function("ensureDatabasePathExistsSync") { (databasePath: String) in try ensureDatabasePathExists(path: databasePath) } AsyncFunction("backupDatabaseAsync") { (_: NativeDatabase, _: String, _: NativeDatabase, _: String) in throw UnsupportedOperationException() }.runOnQueue(moduleQueue) Function("backupDatabaseSync") { (_: NativeDatabase, _: String, _: NativeDatabase, _: String) in throw UnsupportedOperationException() } // MARK: - NativeDatabase // swiftlint:disable:next closure_body_length Class(NativeDatabase.self) { // swiftlint:disable:next closure_body_length Constructor { (databasePath: String, options: OpenDatabaseOptions, serializedData: Data?) -> NativeDatabase in var db: OpaquePointer? var errMsg: UnsafePointer? if let serializedData = serializedData { db = try deserializeDatabase(serializedData) } else { // Try to find opened database for fast refresh if let cachedDb = findCachedDatabase(where: { $0.databasePath == databasePath && $0.openOptions == options && !options.useNewConnection }) { cachedDb.addRef() return cachedDb } guard let libSQLUrl = options.libSQLUrl else { throw InvalidArgumentsException("libSQLUrl must be provided") } guard let libSQLAuthToken = options.libSQLAuthToken else { throw InvalidArgumentsException("libSQLAuthToken must be provided") } if options.libSQLRemoteOnly { if libsql_open_remote_with_webpki(libSQLUrl.absoluteString, libSQLAuthToken, &db, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } } else { let path = try ensureDatabasePathExists(path: databasePath) var result: Int32 = 0 path.toFilePath().withCString { dbPath in libSQLUrl.absoluteString.withCString { libSQLUrl in libSQLAuthToken.withCString { libSQLAuthToken in let libSQLConfig = libsql.libsql_config( db_path: dbPath, primary_url: libSQLUrl, auth_token: libSQLAuthToken, read_your_writes: 1, encryption_key: nil, sync_interval: 0, with_webpki: 1, offline: 1) result = libsql_open_sync_with_config(libSQLConfig, &db, &errMsg) } } } if result != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } } } let database = NativeDatabase(db, databasePath: databasePath, openOptions: options) var extraPointer: OpaquePointer? if libsql_connect(db, &extraPointer, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } database.extraPointer = extraPointer addCachedDatabase(database) return database } AsyncFunction("initAsync") { (database: NativeDatabase) in try initDb(database: database) }.runOnQueue(moduleQueue) Function("initSync") { (database: NativeDatabase) in try initDb(database: database) } AsyncFunction("isInTransactionAsync") { (_: NativeDatabase) -> Bool in throw UnsupportedOperationException() }.runOnQueue(moduleQueue) Function("isInTransactionSync") { (_: NativeDatabase) -> Bool in throw UnsupportedOperationException() } AsyncFunction("closeAsync") { (database: NativeDatabase) in try maybeThrowForClosedDatabase(database) if let db = removeCachedDatabase(of: database) { try closeDatabase(db) } }.runOnQueue(moduleQueue) Function("closeSync") { (database: NativeDatabase) in try maybeThrowForClosedDatabase(database) if let db = removeCachedDatabase(of: database) { try closeDatabase(db) } } AsyncFunction("execAsync") { (database: NativeDatabase, source: String) in try exec(database: database, source: source) }.runOnQueue(moduleQueue) Function("execSync") { (database: NativeDatabase, source: String) in try exec(database: database, source: source) } AsyncFunction("serializeAsync") { (database: NativeDatabase, databaseName: String) in try serialize(database: database, databaseName: databaseName) }.runOnQueue(moduleQueue) Function("serializeSync") { (database: NativeDatabase, databaseName: String) in try serialize(database: database, databaseName: databaseName) } AsyncFunction("prepareAsync") { (database: NativeDatabase, statement: NativeStatement, source: String) in try prepareStatement(database: database, statement: statement, source: source) }.runOnQueue(moduleQueue) Function("prepareSync") { (database: NativeDatabase, statement: NativeStatement, source: String) in try prepareStatement(database: database, statement: statement, source: source) } AsyncFunction("createSessionAsync") { (_: NativeDatabase, _: NativeSession, _: String) in throw UnsupportedOperationException() }.runOnQueue(moduleQueue) Function("createSessionSync") { (_: NativeDatabase, _: NativeSession, _: String) in throw UnsupportedOperationException() } AsyncFunction("syncLibSQL") { (database: NativeDatabase) in var errMsg: UnsafePointer? if libsql_sync(database.pointer, &errMsg) != 0 { let err = convertLibSqlErrorToString(errMsg) throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } }.runOnQueue(moduleQueue) } // MARK: - NativeStatement // swiftlint:disable:next closure_body_length Class(NativeStatement.self) { Constructor { return NativeStatement() } // swiftlint:disable line_length AsyncFunction("runAsync") { (statement: NativeStatement, database: NativeDatabase, bindParams: [String: Any], bindBlobParams: [String: Data], shouldPassAsArray: Bool) -> [String: Any] in return try run(statement: statement, database: database, bindParams: bindParams, bindBlobParams: bindBlobParams, shouldPassAsArray: shouldPassAsArray) }.runOnQueue(moduleQueue) Function("runSync") { (statement: NativeStatement, database: NativeDatabase, bindParams: [String: Any], bindBlobParams: [String: Data], shouldPassAsArray: Bool) -> [String: Any] in return try run(statement: statement, database: database, bindParams: bindParams, bindBlobParams: bindBlobParams, shouldPassAsArray: shouldPassAsArray) } // swiftlint:enable line_length AsyncFunction("stepAsync") { (statement: NativeStatement, database: NativeDatabase) -> SQLiteColumnValues? in return try step(statement: statement, database: database) }.runOnQueue(moduleQueue) Function("stepSync") { (statement: NativeStatement, database: NativeDatabase) -> SQLiteColumnValues? in return try step(statement: statement, database: database) } AsyncFunction("getAllAsync") { (statement: NativeStatement, database: NativeDatabase) -> [SQLiteColumnValues] in return try getAll(statement: statement, database: database) }.runOnQueue(moduleQueue) Function("getAllSync") { (statement: NativeStatement, database: NativeDatabase) -> [SQLiteColumnValues] in return try getAll(statement: statement, database: database) } AsyncFunction("resetAsync") { (statement: NativeStatement, database: NativeDatabase) in try reset(statement: statement, database: database) }.runOnQueue(moduleQueue) Function("resetSync") { (statement: NativeStatement, database: NativeDatabase) in try reset(statement: statement, database: database) } AsyncFunction("getColumnNamesAsync") { (statement: NativeStatement) -> SQLiteColumnNames in return try getColumnNames(statement: statement) }.runOnQueue(moduleQueue) Function("getColumnNamesSync") { (statement: NativeStatement) -> SQLiteColumnNames in return try getColumnNames(statement: statement) } AsyncFunction("finalizeAsync") { (statement: NativeStatement, database: NativeDatabase) in try finalize(statement: statement, database: database) }.runOnQueue(moduleQueue) Function("finalizeSync") { (statement: NativeStatement, database: NativeDatabase) in try finalize(statement: statement, database: database) } } // MARK: - NativeSession // swiftlint:disable:next closure_body_length Class(NativeSession.self) { Constructor { return NativeSession() } AsyncFunction("attachAsync") { (_: NativeSession, _: NativeDatabase, _: String?) in throw UnsupportedOperationException() }.runOnQueue(moduleQueue) Function("attachSync") { (_: NativeSession, _: NativeDatabase, _: String?) in throw UnsupportedOperationException() } AsyncFunction("enableAsync") { (_: NativeSession, _: NativeDatabase, _: Bool) in throw UnsupportedOperationException() }.runOnQueue(moduleQueue) Function("enableSync") { (_: NativeSession, _: NativeDatabase, _: Bool) in throw UnsupportedOperationException() } AsyncFunction("closeAsync") { (_: NativeSession, _: NativeDatabase) in throw UnsupportedOperationException() }.runOnQueue(moduleQueue) Function("closeSync") { (_: NativeSession, _: NativeDatabase) in throw UnsupportedOperationException() } AsyncFunction("createChangesetAsync") { (_: NativeSession, _: NativeDatabase) -> Data in throw UnsupportedOperationException() }.runOnQueue(moduleQueue) Function("createChangesetSync") { (_: NativeSession, _: NativeDatabase) -> Data in throw UnsupportedOperationException() } AsyncFunction("createInvertedChangesetAsync") { (_: NativeSession, _: NativeDatabase) -> Data in throw UnsupportedOperationException() }.runOnQueue(moduleQueue) Function("createInvertedChangesetSync") { (_: NativeSession, _: NativeDatabase) -> Data in throw UnsupportedOperationException() } AsyncFunction("applyChangesetAsync") { (_: NativeSession, _: NativeDatabase, _: Data) in throw UnsupportedOperationException() }.runOnQueue(moduleQueue) Function("applyChangesetSync") { (_: NativeSession, _: NativeDatabase, _: Data) in throw UnsupportedOperationException() } AsyncFunction("invertChangesetAsync") { (_: NativeSession, _: NativeDatabase, _: Data) -> Data in throw UnsupportedOperationException() }.runOnQueue(moduleQueue) Function("invertChangesetSync") { (_: NativeSession, _: NativeDatabase, _: Data) -> Data in throw UnsupportedOperationException() } } } private func ensureDatabasePathExists(path: String) throws -> URL { if path == MEMORY_DB_NAME { guard let url = URL(string: path) else { throw DatabaseException() } return url } guard let fileSystem = appContext?.fileSystem else { throw Exceptions.FileSystemModuleNotFound() } guard let pathUrl = URL(string: path) else { throw DatabaseInvalidPathException(path) } fileSystem.ensureDirExists(withPath: pathUrl.deletingLastPathComponent().toFilePath()) return pathUrl } private func deserializeDatabase(_ serializedData: Data) throws -> OpaquePointer? { throw UnsupportedOperationException() } private func initDb(database: NativeDatabase) throws { try maybeThrowForClosedDatabase(database) if database.openOptions.enableChangeListener { throw UnsupportedOperationException("enableChangeListener is not supported in libSQL mode") } } private func exec(database: NativeDatabase, source: String) throws { try maybeThrowForClosedDatabase(database) var errMsg: UnsafePointer? if libsql_execute(database.extraPointer, source, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } } private func serialize(database: NativeDatabase, databaseName: String) throws -> Data { throw UnsupportedOperationException() } private func prepareStatement(database: NativeDatabase, statement: NativeStatement, source: String) throws { try maybeThrowForClosedDatabase(database) try maybeThrowForFinalizedStatement(statement) let sourceString = source.cString(using: .utf8) var errMsg: UnsafePointer? if libsql_prepare(database.extraPointer, sourceString, &statement.pointer, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } maybeAddCachedStatement(database: database, statement: statement) } // swiftlint:disable line_length private func run(statement: NativeStatement, database: NativeDatabase, bindParams: [String: Any], bindBlobParams: [String: Data], shouldPassAsArray: Bool) throws -> [String: Any] { try maybeThrowForClosedDatabase(database) try maybeThrowForFinalizedStatement(statement) // The statement with parameter bindings is stateful, // we have to guard with a critical section for thread safety. statement.lock.wait() defer { statement.lock.signal() } if let rows = statement.extraPointer { libsql_free_rows(rows) statement.extraPointer = nil } var errMsg: UnsafePointer? libsql_reset_stmt(statement.pointer, &errMsg) for (key, param) in bindParams { let index = try getBindParamIndex(statement: statement, key: key, shouldPassAsArray: shouldPassAsArray) if index > 0 { try bindStatementParam(statement: statement, with: param, at: index) } } for (key, param) in bindBlobParams { let index = try getBindParamIndex(statement: statement, key: key, shouldPassAsArray: shouldPassAsArray) if index > 0 { try bindStatementParam(statement: statement, with: param, at: index) } } let rows = try maybeBindStatementRows(statement) var row: OpaquePointer? if libsql_next_row(rows, &row, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } defer { libsql_free_row(row) } let firstRowValues: SQLiteColumnValues if let row { firstRowValues = try getColumnValues(statement: statement, rows: rows, row: row) } else { firstRowValues = [] } return [ "lastInsertRowId": Int(libsql_last_insert_rowid(database.extraPointer)), "changes": Int(libsql_changes(database.extraPointer)), "firstRowValues": firstRowValues ] } // swiftlint:enable line_length private func step(statement: NativeStatement, database: NativeDatabase) throws -> SQLiteColumnValues? { try maybeThrowForClosedDatabase(database) try maybeThrowForFinalizedStatement(statement) let rows = try maybeBindStatementRows(statement) var row: OpaquePointer? var errMsg: UnsafePointer? if libsql_next_row(rows, &row, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } defer { libsql_free_row(row) } if let row { return try getColumnValues(statement: statement, rows: rows, row: row) } return nil } private func getAll(statement: NativeStatement, database: NativeDatabase) throws -> [SQLiteColumnValues] { try maybeThrowForClosedDatabase(database) try maybeThrowForFinalizedStatement(statement) var columnValuesList: [SQLiteColumnValues] = [] var errMsg: UnsafePointer? let rows = try maybeBindStatementRows(statement) while true { var row: OpaquePointer? if libsql_next_row(rows, &row, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } defer { libsql_free_row(row) } if row == nil && errMsg == nil { break } if let row { columnValuesList.append(try getColumnValues(statement: statement, rows: rows, row: row)) } } return columnValuesList } private func reset(statement: NativeStatement, database: NativeDatabase) throws { try maybeThrowForClosedDatabase(database) try maybeThrowForFinalizedStatement(statement) if let rows = statement.extraPointer { libsql_free_rows(rows) statement.extraPointer = nil } var errMsg: UnsafePointer? if libsql_reset_stmt(statement.pointer, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } } private func finalize(statement: NativeStatement, database: NativeDatabase) throws { try maybeThrowForClosedDatabase(database) try maybeThrowForFinalizedStatement(statement) maybeRemoveCachedStatement(database: database, statement: statement) if let rows = statement.extraPointer { libsql_free_rows(rows) } libsql_free_stmt(statement.pointer) statement.isFinalized = true } private func convertLibSqlErrorToString(_ errMsg: UnsafePointer?) -> String { if let errMsg { return String(cString: errMsg) } return "Unknown error" } private func closeDatabase(_ db: NativeDatabase) throws { for removedStatement in maybeRemoveAllCachedStatements(database: db) { if let rows = removedStatement.extraPointer { libsql_free_rows(rows) } libsql_free_stmt(removedStatement.pointer) } libsql_disconnect(db.extraPointer) libsql_close(db.pointer) db.isClosed = true if let index = contextPairs.firstIndex(where: { guard let pair = $0.takeUnretainedValue() as? (SQLiteModule, NativeDatabase) else { return false } if pair.1.sharedObjectId != db.sharedObjectId { return false } $0.release() return true }) { contextPairs.remove(at: index) } } private func deleteDatabase(databasePath: String) throws { if findCachedDatabase(where: { $0.databasePath == databasePath }) != nil { throw DeleteDatabaseException(databasePath) } if databasePath == MEMORY_DB_NAME { return } let path = try ensureDatabasePathExists(path: databasePath).toFilePath() if !FileManager.default.fileExists(atPath: path) { throw DatabaseNotFoundException(path) } do { try FileManager.default.removeItem(atPath: path) } catch { throw DeleteDatabaseFileException(path) } } private func getColumnNames(statement: NativeStatement) throws -> SQLiteColumnNames { try maybeThrowForFinalizedStatement(statement) let rows = try maybeBindStatementRows(statement) let columnCount = Int(libsql_column_count(rows)) var columnNames: SQLiteColumnNames = Array(repeating: "", count: columnCount) for i in 0..? var errMsg: UnsafePointer? libsql_column_name(rows, Int32(i), &name, &errMsg) if let name { columnNames[i] = String(cString: name) } else { columnNames[i] = "Unknown column name" } } return columnNames } private func getColumnValues(statement: NativeStatement, rows: OpaquePointer, row: OpaquePointer) throws -> SQLiteColumnValues { try maybeThrowForFinalizedStatement(statement) let columnCount = Int(libsql_column_count(rows)) var columnValues: SQLiteColumnValues = Array(repeating: 0, count: columnCount) for i in 0.. Any { let instance = statement.pointer var errMsg: UnsafePointer? var type: Int32 = 0 if libsql_column_type(rows, row, index, &type, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } switch type { case LIBSQL_INT: var value: Int64 = 0 if libsql_get_int(row, index, &value, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } return value case LIBSQL_FLOAT: var value: Double = 0 if libsql_get_float(row, index, &value, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } return value case LIBSQL_TEXT: var text: UnsafePointer? if libsql_get_string(row, index, &text, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } defer { libsql_free_string(text) } guard let text else { return "" } return String(cString: text) case LIBSQL_BLOB: var blob = libsql.blob() if libsql_get_blob(row, index, &blob, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } defer { libsql_free_blob(blob) } return Data(bytes: blob.ptr, count: Int(blob.len)) case LIBSQL_NULL: return NSNull() default: throw InvalidConvertibleException("Unsupported column type: \(type)") } } private func bindStatementParam(statement: NativeStatement, with param: Any, at index: Int32) throws { let instance = statement.pointer var errMsg: UnsafePointer? switch param { case Optional.none: libsql_bind_null(instance, index, &errMsg) case _ as NSNull: libsql_bind_null(instance, index, &errMsg) case let param as Int64: libsql_bind_int(instance, index, Int64(param), &errMsg) case let param as Double: libsql_bind_float(instance, index, param, &errMsg) case let param as String: libsql_bind_string(instance, index, param, &errMsg) case let param as Data: _ = param.withUnsafeBytes { libsql_bind_blob(instance, index, $0.baseAddress?.assumingMemoryBound(to: UInt8.self), Int32(param.count), &errMsg) } case let param as Bool: libsql_bind_int(instance, index, param ? 1 : 0, &errMsg) default: throw InvalidConvertibleException("Unsupported parameter type: \(type(of: param))") } if let errMsg = errMsg { throw SQLiteErrorException(String(cString: errMsg)) } } private func maybeBindStatementRows(_ statement: NativeStatement) throws -> OpaquePointer { if let rows = statement.extraPointer { return rows } var rows: OpaquePointer? var errMsg: UnsafePointer? if libsql_query_stmt(statement.pointer, &rows, &errMsg) != 0 { throw SQLiteErrorException(convertLibSqlErrorToString(errMsg)) } guard let rows else { throw SQLiteErrorException("libsql_query_stmt returns nil rows") } statement.extraPointer = rows return rows } private func maybeThrowForClosedDatabase(_ database: NativeDatabase) throws { if database.isClosed { throw AccessClosedResourceException() } } private func maybeThrowForFinalizedStatement(_ statement: NativeStatement) throws { if statement.isFinalized { throw AccessClosedResourceException() } } @inline(__always) private func getBindParamIndex(statement: NativeStatement, key: String, shouldPassAsArray: Bool) throws -> Int32 { let index: Int32 if shouldPassAsArray { guard let intKey = Int32(key) else { throw InvalidBindParameterException() } index = intKey + 1 } else { throw UnsupportedOperationException("Named parameter binding is not supported in libSQL mode.") } return index } // MARK: - cachedDatabases managements private func addCachedDatabase(_ database: NativeDatabase) { Self.lockQueue.sync { cachedDatabases.append(database) } } @discardableResult private func removeCachedDatabase(of database: NativeDatabase) -> NativeDatabase? { return Self.lockQueue.sync { if let index = cachedDatabases.firstIndex(of: database) { let db = cachedDatabases[index] if db.release() == 0 { cachedDatabases.remove(at: index) return db } } return nil } } private func findCachedDatabase(where predicate: (NativeDatabase) -> Bool) -> NativeDatabase? { return Self.lockQueue.sync { if let database = cachedDatabases.first(where: predicate) { return database } return nil } } private func removeAllCachedDatabases() -> [NativeDatabase] { return Self.lockQueue.sync { let databases = cachedDatabases cachedDatabases.removeAll() return databases } } // MARK: - cachedStatements managements private func maybeAddCachedStatement(database: NativeDatabase, statement: NativeStatement) { if !database.openOptions.finalizeUnusedStatementsBeforeClosing { return } Self.lockQueue.sync { if cachedStatements[database] != nil { cachedStatements[database]?.append(statement) } else { cachedStatements[database] = [statement] } } } private func maybeRemoveCachedStatement(database: NativeDatabase, statement: NativeStatement) { if !database.openOptions.finalizeUnusedStatementsBeforeClosing { return } Self.lockQueue.sync { if let index = cachedStatements[database]?.firstIndex(of: statement) { cachedStatements[database]?.remove(at: index) } } } private func maybeRemoveAllCachedStatements(database: NativeDatabase) -> [NativeStatement] { if !database.openOptions.finalizeUnusedStatementsBeforeClosing { return [] } return Self.lockQueue.sync { if let statements = cachedStatements[database] { cachedStatements.removeValue(forKey: database) return statements } return [] } } }