Feat log db query (#471)

Co-authored-by: Florent Champigny <florent@bere.al>
This commit is contained in:
Florent CHAMPIGNY 2025-12-30 15:02:29 +01:00 committed by GitHub
parent 20b7b7b29c
commit 24bb623ebd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 8659 additions and 129 deletions

View file

@ -19,6 +19,15 @@ fun floconRegisterDatabase(displayName: String, absolutePath: String) {
)
}
fun floconLogDatabaseQuery(dbName: String, sqlQuery: String, bindArgs: List<Any?>) {
FloconApp.instance?.client?.databasePlugin?.logQuery(
dbName = dbName,
sqlQuery = sqlQuery,
bindArgs = bindArgs,
)
}
interface FloconDatabasePlugin {
fun register(floconDatabaseModel: FloconDatabaseModel)
fun logQuery(dbName: String, sqlQuery: String, bindArgs: List<Any?>)
}

View file

@ -34,6 +34,7 @@ object Protocol {
object Method {
const val Query = "query"
const val GetDatabases = "getDatabases"
const val LogQuery = "logQuery"
}
}

View file

@ -13,6 +13,8 @@ import io.github.openflocon.flocon.plugins.database.model.fromdevice.QueryResult
import io.github.openflocon.flocon.plugins.database.model.fromdevice.listDeviceDataBaseDataModelToJson
import io.github.openflocon.flocon.plugins.database.model.fromdevice.toJson
import io.github.openflocon.flocon.plugins.database.model.todevice.DatabaseQueryMessage
import io.github.openflocon.flocon.plugins.database.model.fromdevice.DatabaseQueryLogModel
import io.github.openflocon.flocon.utils.currentTimeMillis
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@ -94,4 +96,21 @@ internal class FloconDatabasePluginImpl(
registeredDatabases.update { it + floconDatabaseModel }
sendAllDatabases(sender)
}
override fun logQuery(dbName: String, sqlQuery: String, bindArgs: List<Any?>) {
try {
sender.send(
plugin = Protocol.FromDevice.Database.Plugin,
method = Protocol.FromDevice.Database.Method.LogQuery,
body = DatabaseQueryLogModel(
dbName = dbName,
sqlQuery = sqlQuery,
bindArgs = bindArgs.map { it.toString() },
timestamp = currentTimeMillis(),
).toJson(),
)
} catch (t: Throwable) {
FloconLogger.logError("Database logging error", t)
}
}
}

View file

@ -0,0 +1,23 @@
package io.github.openflocon.flocon.plugins.database.model.fromdevice
import io.github.openflocon.flocon.core.FloconEncoder
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
@Serializable
internal data class DatabaseQueryLogModel(
val dbName: String,
val sqlQuery: String,
val bindArgs: List<String>?,
val timestamp: Long,
) {
fun toJson(): String {
return FloconEncoder.json.encodeToString<DatabaseQueryLogModel>(this)
}
companion object {
fun fromJson(json: String): DatabaseQueryLogModel {
return FloconEncoder.json.decodeFromString<DatabaseQueryLogModel>(json)
}
}
}

View file

@ -16,7 +16,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import io.github.openflocon.flocon.Flocon
import io.github.openflocon.flocon.FloconLogger
@ -24,6 +26,7 @@ import io.github.openflocon.flocon.myapplication.dashboard.initializeDashboard
import io.github.openflocon.flocon.myapplication.database.DogDatabase
import io.github.openflocon.flocon.myapplication.database.initializeDatabases
import io.github.openflocon.flocon.myapplication.database.initializeInMemoryDatabases
import io.github.openflocon.flocon.myapplication.database.model.DogEntity
import io.github.openflocon.flocon.myapplication.deeplinks.initializeDeeplinks
import io.github.openflocon.flocon.myapplication.graphql.GraphQlTester
import io.github.openflocon.flocon.myapplication.grpc.GrpcController
@ -95,6 +98,9 @@ class MainActivity : ComponentActivity() {
setContent {
MyApplicationTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val scope = rememberCoroutineScope()
val context = LocalContext.current
Column(Modifier.fillMaxSize().padding(innerPadding)) {
FlowRow(
modifier = Modifier
@ -188,6 +194,23 @@ class MainActivity : ComponentActivity() {
) {
Text("send analytics event")
}
Button(
onClick = {
scope.launch {
DogDatabase.getDatabase(context).dogDao().insertDog(
DogEntity(
id = System.currentTimeMillis(),
name = "Flocon",
breed = "Golden Retriever ${System.currentTimeMillis()}",
age = 6,
pictureUrl = "https://picsum.photos/501/500.jpg",
)
)
}
}
) {
Text("Insert dog in DB")
}
}
ImagesListView(modifier = Modifier.fillMaxSize())

View file

@ -8,6 +8,8 @@ import io.github.openflocon.flocon.myapplication.database.dao.DogDao
import io.github.openflocon.flocon.myapplication.database.model.DogEntity
import io.github.openflocon.flocon.myapplication.database.model.HumanEntity
import io.github.openflocon.flocon.myapplication.database.model.HumanWithDogEntity
import io.github.openflocon.flocon.plugins.database.floconLogDatabaseQuery
import java.util.concurrent.Executors
@Database(
entities = [
@ -26,12 +28,17 @@ abstract class DogDatabase : RoomDatabase() {
private var INSTANCE: DogDatabase? = null
fun getDatabase(context: Context): DogDatabase {
val dbName = "dogs_database"
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
DogDatabase::class.java,
"dogs_database" // Nom du fichier de la base de données Dogs
).fallbackToDestructiveMigration().build()
dbName
).setQueryCallback({ sqlQuery, bindArgs -> floconLogDatabaseQuery(
dbName = dbName, sqlQuery = sqlQuery, bindArgs = bindArgs
) }, Executors.newSingleThreadExecutor())
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}

View file

@ -2,20 +2,29 @@ package io.github.openflocon.flocon.myapplication.multi
import android.content.Context
import androidx.room.Room
import androidx.room.RoomDatabase
import io.github.openflocon.flocon.myapplication.multi.database.DogDatabase
import io.github.openflocon.flocon.myapplication.multi.database.FoodDatabase
import io.github.openflocon.flocon.plugins.database.floconLogDatabaseQuery
import java.util.concurrent.Executor
import java.util.concurrent.Executors
object Databases {
@Volatile
private var dogDatabase: DogDatabase? = null
fun getDogDatabase(context: Context): DogDatabase {
val dbName = "dogs_database"
return dogDatabase ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
DogDatabase::class.java,
"dogs_database"
).fallbackToDestructiveMigration().build()
)
.setQueryCallback({ sqlQuery, bindArgs -> floconLogDatabaseQuery(
dbName = dbName, sqlQuery = sqlQuery, bindArgs = bindArgs
) }, Executors.newSingleThreadExecutor())
.fallbackToDestructiveMigration().build()
dogDatabase = instance
instance
}

View file

@ -12,10 +12,13 @@ import io.github.openflocon.flocon.myapplication.multi.Databases.getDogDatabase
import io.github.openflocon.flocon.myapplication.multi.Databases.getFoodDatabase
import io.github.openflocon.flocon.myapplication.multi.database.FoodDatabase
import io.github.openflocon.flocon.myapplication.multi.database.initializeDatabases
import io.github.openflocon.flocon.myapplication.multi.database.model.DogEntity
import io.github.openflocon.flocon.myapplication.multi.sharedpreferences.initializeSharedPreferences
import io.github.openflocon.flocon.myapplication.multi.ui.App
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -43,8 +46,11 @@ class MainActivity : ComponentActivity() {
DummyHttpKtorCaller.initialize(ktorClient)
initializeSharedPreferences(applicationContext)
val dogDatabase = getDogDatabase(this)
initializeDatabases(
dogDatabase = getDogDatabase(this),
dogDatabase = dogDatabase,
foodDatabase = getFoodDatabase(this),
)

View file

@ -16,11 +16,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.github.openflocon.flocon.myapplication.multi.DummyHttpKtorCaller
import io.github.openflocon.flocon.myapplication.multi.dashboard.initializeDashboard
import io.github.openflocon.flocon.myapplication.multi.database.model.DogEntity
import io.github.openflocon.flocon.plugins.analytics.floconAnalytics
import io.github.openflocon.flocon.plugins.analytics.model.AnalyticsEvent
import io.github.openflocon.flocon.plugins.analytics.model.analyticsProperty
import io.github.openflocon.flocon.plugins.tables.floconTable
import io.github.openflocon.flocon.plugins.tables.model.toParam
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlin.random.Random
@Composable

View file

@ -19,6 +19,8 @@ import io.github.openflocon.data.local.database.dao.TablesDao
import io.github.openflocon.data.local.database.models.DatabaseTableEntity
import io.github.openflocon.data.local.database.models.FavoriteQueryEntity
import io.github.openflocon.data.local.database.models.SuccessQueryEntity
import io.github.openflocon.data.local.database.dao.DatabaseQueryLogDao
import io.github.openflocon.data.local.database.models.DatabaseQueryLogEntity
import io.github.openflocon.data.local.deeplink.dao.FloconDeeplinkDao
import io.github.openflocon.data.local.deeplink.models.DeeplinkEntity
import io.github.openflocon.data.local.device.datasource.dao.DevicesDao
@ -48,7 +50,7 @@ import io.github.openflocon.flocondesktop.common.db.converters.MapStringsConvert
import kotlinx.coroutines.Dispatchers
@Database(
version = 74,
version = 78,
entities = [
FloconNetworkCallEntity::class,
FileEntity::class,
@ -72,6 +74,7 @@ import kotlinx.coroutines.Dispatchers
DeviceAppEntity::class,
DatabaseTableEntity::class,
CrashReportEntity::class,
DatabaseQueryLogEntity::class,
],
)
@TypeConverters(
@ -96,6 +99,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract val devicesDao: DevicesDao
abstract val tablesDao: TablesDao
abstract val crashReportDao: CrashReportDao
abstract val databaseQueryLogDao: DatabaseQueryLogDao
}
fun getRoomDatabase(): AppDatabase = getDatabaseBuilder()

View file

@ -55,4 +55,7 @@ val roomModule =
single {
get<AppDatabase>().crashReportDao
}
single {
get<AppDatabase>().databaseQueryLogDao
}
}

View file

@ -1,6 +1,9 @@
package io.github.openflocon.flocondesktop.features.database
import io.github.openflocon.domain.database.usecase.GetDatabaseQueryLogsUseCase
import io.github.openflocon.flocondesktop.features.database.delegate.DatabaseSelectorDelegate
import io.github.openflocon.flocondesktop.features.database.processor.ExportDatabaseQueryLogsToCsvProcessor
import io.github.openflocon.flocondesktop.features.database.processor.ExportDatabaseQueryLogsToMarkdownProcessor
import io.github.openflocon.flocondesktop.features.database.processor.ExportDatabaseResultToCsvProcessor
import io.github.openflocon.flocondesktop.features.database.processor.ImportSqlQueryProcessor
import org.koin.core.module.dsl.factoryOf
@ -13,4 +16,8 @@ internal val databaseModule = module {
factoryOf(::DatabaseSelectorDelegate)
factoryOf(::ImportSqlQueryProcessor)
factoryOf(::ExportDatabaseResultToCsvProcessor)
factoryOf(::ExportDatabaseQueryLogsToCsvProcessor)
factoryOf(::ExportDatabaseQueryLogsToMarkdownProcessor)
factoryOf(::GetDatabaseQueryLogsUseCase)
viewModelOf(::DatabaseQueryLogsViewModel)
}

View file

@ -0,0 +1,205 @@
package io.github.openflocon.flocondesktop.features.database
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import io.github.openflocon.domain.common.DispatcherProvider
import io.github.openflocon.domain.common.combines
import io.github.openflocon.domain.database.models.DatabaseQueryLogDomainModel
import io.github.openflocon.domain.database.usecase.GetDatabaseQueryLogsUseCase
import io.github.openflocon.domain.database.usecase.ObserveDatabaseQueryLogsUseCase
import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel
import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdAndPackageNameUseCase
import io.github.openflocon.domain.feedback.FeedbackDisplayer
import io.github.openflocon.flocondesktop.features.database.model.DatabaseQueryUiModel
import io.github.openflocon.flocondesktop.features.database.model.FilterChipUiModel
import io.github.openflocon.flocondesktop.features.database.model.toDomain
import io.github.openflocon.flocondesktop.features.database.processor.ExportDatabaseQueryLogsToCsvProcessor
import io.github.openflocon.flocondesktop.features.database.processor.ExportDatabaseQueryLogsToMarkdownProcessor
import io.github.openflocon.library.designsystem.common.copyToClipboard
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import io.github.openflocon.domain.database.utils.injectSqlArgs
class DatabaseQueryLogsViewModel(
private val dbName: String,
private val observeDatabaseQueryLogsUseCase: ObserveDatabaseQueryLogsUseCase,
private val getDatabaseQueryLogsUseCase: GetDatabaseQueryLogsUseCase,
private val observeCurrentDeviceIdAndPackageNameUseCase: ObserveCurrentDeviceIdAndPackageNameUseCase,
private val feedbackDisplayer: FeedbackDisplayer,
private val exportDatabaseQueryLogsToCsvProcessor: ExportDatabaseQueryLogsToCsvProcessor,
private val exportDatabaseQueryLogsToMarkdownProcessor: ExportDatabaseQueryLogsToMarkdownProcessor,
dispatcherProvider: DispatcherProvider,
) : ViewModel() {
private val _showTransactions = MutableStateFlow(false)
val showTransactions = _showTransactions.asStateFlow()
private val _filterChips = MutableStateFlow<List<FilterChipUiModel>>(emptyList())
val filterChips = _filterChips.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery = _searchQuery.asStateFlow()
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
val logs: Flow<PagingData<DatabaseQueryUiModel>> =
combines(
_showTransactions,
_filterChips.map { it.map { it.toDomain() } }.distinctUntilChanged(),
observeCurrentDeviceIdAndPackageNameUseCase(),
).flatMapLatest { (showTransactions, filterChips, currentDeviceAndPackage) ->
observeDatabaseQueryLogsUseCase(
dbName = dbName,
showTransactions = showTransactions,
filters = filterChips,
).map {
it.map {
it.toUi(currentDeviceAndPackage = currentDeviceAndPackage)
}
}
}
.flowOn(dispatcherProvider.viewModel)
.cachedIn(viewModelScope)
fun toggleShowTransactions() {
_showTransactions.update { !it }
}
fun onSearchQueryChanged(query: String) {
_searchQuery.value = query
}
fun addIncludeFilter() {
val query = _searchQuery.value.trim()
if (query.isNotEmpty() && !_filterChips.value.any { it.text == query }) {
_filterChips.update { it + FilterChipUiModel(query, FilterChipUiModel.FilterType.INCLUDE) }
_searchQuery.value = ""
}
}
fun addExcludeFilter() {
val query = _searchQuery.value.trim()
if (query.isNotEmpty() && !_filterChips.value.any { it.text == query }) {
_filterChips.update { it + FilterChipUiModel(query, FilterChipUiModel.FilterType.EXCLUDE) }
_searchQuery.value = ""
}
}
fun toggleFilterType(chip: FilterChipUiModel) {
_filterChips.update { list ->
list.map {
if (it == chip) {
it.copy(type = if (it.type == FilterChipUiModel.FilterType.INCLUDE)
FilterChipUiModel.FilterType.EXCLUDE
else
FilterChipUiModel.FilterType.INCLUDE
)
} else {
it
}
}
}
}
fun addFilter(text: String, type: FilterChipUiModel.FilterType) {
if (text.isNotEmpty() && !_filterChips.value.any { it.text == text }) {
_filterChips.update { it + FilterChipUiModel(text, type) }
}
}
fun removeFilterChip(chip: FilterChipUiModel) {
_filterChips.update { it - chip }
}
fun copyQuery(query: String) {
copyToClipboard(query)
feedbackDisplayer.displayMessage("Query copied to clipboard")
}
fun copyArgs(args: List<String>?) {
val argsString = args?.toString() ?: "[]"
copyToClipboard(argsString)
feedbackDisplayer.displayMessage("Arguments copied to clipboard")
}
fun copyAsSql(query: String, args: List<String>?) {
val fullSql = injectSqlArgs(query, args)
copyToClipboard(fullSql)
feedbackDisplayer.displayMessage("SQL with arguments copied to clipboard")
}
fun exportToCsv() {
viewModelScope.launch {
val logs = getDatabaseQueryLogsUseCase(
dbName = dbName,
showTransactions = _showTransactions.value,
filters = _filterChips.value.map { it.toDomain() }
)
exportDatabaseQueryLogsToCsvProcessor(logs).fold(
doOnSuccess = {
feedbackDisplayer.displayMessage("Logs exported to $it")
},
doOnFailure = {
feedbackDisplayer.displayMessage("Export failed: ${it.message}")
}
)
}
}
fun exportToMarkdown() {
viewModelScope.launch {
val logs = getDatabaseQueryLogsUseCase(
dbName = dbName,
showTransactions = _showTransactions.value,
filters = _filterChips.value.map { it.toDomain() }
)
exportDatabaseQueryLogsToMarkdownProcessor(logs).fold(
doOnSuccess = {
feedbackDisplayer.displayMessage("Logs exported to $it")
},
doOnFailure = {
feedbackDisplayer.displayMessage("Export failed: ${it.message}")
}
)
}
}
}
private fun DatabaseQueryLogDomainModel.toUi(currentDeviceAndPackage: DeviceIdAndPackageNameDomainModel?) = DatabaseQueryUiModel(
sqlQuery = sqlQuery,
bindArgs = bindArgs,
dateFormatted = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timestamp)),
isTransaction = isTransaction,
isFromOldSession = currentDeviceAndPackage?.appInstance != appInstance,
type = findType(this),
)
private fun findType(model: DatabaseQueryLogDomainModel): DatabaseQueryUiModel.Type? {
return if(model.isTransaction) {
DatabaseQueryUiModel.Type.Transaction
} else if(model.sqlQuery.startsWith("SELECT")) {
DatabaseQueryUiModel.Type.Select
} else if(model.sqlQuery.startsWith("INSERT")) {
DatabaseQueryUiModel.Type.Insert
} else if(model.sqlQuery.startsWith("UPDATE")) {
DatabaseQueryUiModel.Type.Update
} else if(model.sqlQuery.startsWith("DELETE")) {
DatabaseQueryUiModel.Type.Delete
} else {
null
}
}

View file

@ -135,6 +135,11 @@ class DatabaseViewModel(
table = action.table,
query = generateInsertQuery(action.table)
)
is DatabaseScreenAction.OnSeeAllQueriesClicked -> createTabForQueryLogs(
databaseId = action.databaseId,
dbName = action.dbName
)
}
}
}
@ -154,6 +159,7 @@ class DatabaseViewModel(
val generatedName = table.name
createTab(
databaseId = databaseId,
databaseName = "", // not used here
tableName = null,
generatedName = generatedName,
favoriteId = null,
@ -161,6 +167,18 @@ class DatabaseViewModel(
)
}
private suspend fun createTabForQueryLogs(databaseId: DeviceDataBaseId, dbName: String) {
createTab(
databaseId = databaseId,
databaseName = dbName, // only used here
tableName = null,
generatedName = "Logs ($dbName)",
favoriteId = null,
query = null,
isQueryLogs = true,
)
}
private fun onTableColumnClicked(column: TableUiModel.ColumnUiModel) {
copyToClipboard(column.name)
feedbackDisplayer.displayMessage("copied: ${column.name}")
@ -275,6 +293,8 @@ class DatabaseViewModel(
favoriteId: Long?,
generatedName: String,
query: String?,
isQueryLogs: Boolean = false,
databaseName: String? = null, // only used for isQueryLogs
) {
val deviceIdAndPackageName = getCurrentDeviceIdAndPackageNameUseCase() ?: return
@ -291,12 +311,14 @@ class DatabaseViewModel(
val addedTab = DatabaseTabState(
databaseId = databaseId,
databaseName = databaseName ?: "",
tableName = tableName,
generatedName = generatedName,
index = index,
favoriteId = favoriteId,
query = query,
id = UUID.randomUUID().toString(),
isQueryLogs = isQueryLogs,
)
val newList = list.toMutableList().apply {

View file

@ -0,0 +1,21 @@
package io.github.openflocon.flocondesktop.features.database.model
import androidx.compose.runtime.Immutable
@Immutable
data class DatabaseQueryUiModel(
val sqlQuery: String,
val bindArgs: List<String>?,
val dateFormatted: String,
val isTransaction: Boolean,
val isFromOldSession: Boolean,
val type: Type?,
) {
enum class Type {
Select,
Insert,
Update,
Delete,
Transaction,
}
}

View file

@ -12,4 +12,5 @@ sealed interface DatabaseScreenAction {
data class DeleteFavorite(val favoriteQuery: DatabaseFavoriteQueryUiModel) : DatabaseScreenAction
data class OnDeleteContentClicked(val databaseId: DeviceDataBaseId, val table: TableUiModel) : DatabaseScreenAction
data class OnInsertContentClicked(val databaseId: DeviceDataBaseId, val table: TableUiModel) : DatabaseScreenAction
data class OnSeeAllQueriesClicked(val databaseId: DeviceDataBaseId, val dbName: String) : DatabaseScreenAction
}

View file

@ -5,11 +5,13 @@ import androidx.compose.runtime.Immutable
@Immutable
data class DatabaseTabState(
val databaseId: String,
val databaseName: String,
val tableName: String?,
val generatedName: String,
val index: Int,
val favoriteId: Long?,
val query: String?,
val isQueryLogs: Boolean = false,
val id: String,
) {
val displayName: String = generatedName + if (index > 0) " ($index)" else ""

View file

@ -0,0 +1,23 @@
package io.github.openflocon.flocondesktop.features.database.model
import androidx.compose.runtime.Immutable
import io.github.openflocon.domain.database.models.FilterQueryLogDomainModel
@Immutable
data class FilterChipUiModel(
val text: String,
val type: FilterType
) {
enum class FilterType {
INCLUDE,
EXCLUDE
}
}
fun FilterChipUiModel.toDomain() = FilterQueryLogDomainModel(
text = text,
type = when (type) {
FilterChipUiModel.FilterType.INCLUDE -> FilterQueryLogDomainModel.FilterType.INCLUDE
FilterChipUiModel.FilterType.EXCLUDE -> FilterQueryLogDomainModel.FilterType.EXCLUDE
}
)

View file

@ -0,0 +1,84 @@
package io.github.openflocon.flocondesktop.features.database.processor
import io.github.openflocon.domain.common.Either
import io.github.openflocon.domain.common.Failure
import io.github.openflocon.domain.common.Success
import io.github.openflocon.domain.database.models.DatabaseQueryLogDomainModel
import io.github.openflocon.domain.database.models.toFullSql
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class ExportDatabaseQueryLogsToCsvProcessor {
suspend operator fun invoke(
logs: List<DatabaseQueryLogDomainModel>
): Either<Throwable, String> {
val fileName = "database_logs_${System.currentTimeMillis()}.csv"
val file = showSaveFileDialog(defaultFileName = fileName, dialogName = "Export database logs as CSV") ?: return Failure(
Throwable("no file selected")
)
exportToCsv(
file = file,
logs = logs,
)
return Success(file.absolutePath)
}
private fun showSaveFileDialog(dialogName: String, defaultFileName: String): File? {
val parentFrame = Frame()
val dialog = FileDialog(parentFrame, dialogName, FileDialog.SAVE).apply {
file = defaultFileName
}
dialog.isVisible = true
val file = dialog.file
val directory = dialog.directory
parentFrame.dispose()
return if (file != null && directory != null) {
File(directory, file)
} else {
null
}
}
private fun exportToCsv(
file: File,
logs: List<DatabaseQueryLogDomainModel>,
) {
val columns = listOf("Date", "SQL Query", "Arguments", "Full SQL")
file.writeText(columns.joinToString(separator = ",", postfix = "\n"))
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
logs.forEach { log ->
val row = listOf(
dateFormat.format(Date(log.timestamp)),
log.sqlQuery,
log.bindArgs?.toString() ?: "[]",
log.toFullSql()
)
val escapedRow = row.map { csvEscape(it) }
file.appendText(escapedRow.joinToString(separator = ",", postfix = "\n"))
}
}
private fun csvEscape(text: String?): String {
val nonNullText = text ?: ""
val containsSpecialChars = nonNullText.contains(',') || nonNullText.contains('"') || nonNullText.contains('\n')
return if (containsSpecialChars) {
val escapedText = nonNullText.replace("\"", "\"\"")
"\"$escapedText\""
} else {
nonNullText
}
}
}

View file

@ -0,0 +1,84 @@
package io.github.openflocon.flocondesktop.features.database.processor
import io.github.openflocon.domain.common.Either
import io.github.openflocon.domain.common.Failure
import io.github.openflocon.domain.common.Success
import io.github.openflocon.domain.database.models.DatabaseQueryLogDomainModel
import io.github.openflocon.domain.database.models.toFullSql
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class ExportDatabaseQueryLogsToMarkdownProcessor {
suspend operator fun invoke(
logs: List<DatabaseQueryLogDomainModel>
): Either<Throwable, String> {
val fileName = "database_logs_${System.currentTimeMillis()}.md"
val file = showSaveFileDialog(defaultFileName = fileName, dialogName = "Export database logs as Markdown") ?: return Failure(
Throwable("no file selected")
)
exportToMarkdown(
file = file,
logs = logs,
)
return Success(file.absolutePath)
}
private fun showSaveFileDialog(dialogName: String, defaultFileName: String): File? {
val parentFrame = Frame()
val dialog = FileDialog(parentFrame, dialogName, FileDialog.SAVE).apply {
file = defaultFileName
}
dialog.isVisible = true
val file = dialog.file
val directory = dialog.directory
parentFrame.dispose()
return if (file != null && directory != null) {
File(directory, file)
} else {
null
}
}
private fun exportToMarkdown(
file: File,
logs: List<DatabaseQueryLogDomainModel>,
) {
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
val markdown = buildString {
logs.forEach { log ->
val date = dateFormat.format(Date(log.timestamp))
val sql = log.sqlQuery.replace("|", "\\|").replace("\n", " ")
val args = (log.bindArgs?.toString() ?: "[]").replace("|", "\\|")
val fullSql = log.toFullSql().replace("|", "\\|").replace("\n", " ")
appendLine("# $date")
appendLine()
appendLine("### query")
appendLine(sql)
appendLine()
appendLine("### args")
appendLine(args)
appendLine()
appendLine("### SQL")
appendLine(fullSql)
appendLine()
appendLine("--------------------------------------------------------------------------------")
appendLine()
}
}
file.writeText(markdown)
}
}

View file

@ -154,9 +154,6 @@ fun DatabaseQueryView(
isQueryEmpty = query.isBlank(),
)
val highlightedText = remember(query) {
}
FloconTextField(
value = query,
onValueChange = updateQuery,

View file

@ -1,108 +1,25 @@
package io.github.openflocon.flocondesktop.features.database.view
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.github.openflocon.flocondesktop.features.database.DatabaseTabViewModel
import io.github.openflocon.flocondesktop.features.database.model.DatabaseScreenState
import io.github.openflocon.flocondesktop.features.database.model.DatabaseTabAction
import io.github.openflocon.flocondesktop.features.database.model.DatabaseTabState
import io.github.openflocon.library.designsystem.FloconTheme
import io.github.openflocon.library.designsystem.components.FloconPageTopBar
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import io.github.openflocon.flocondesktop.features.database.view.logs.DatabaseQueryLogsView
@Composable
fun DatabaseTabView(
tab: DatabaseTabState,
favoritesTitles: Set<String>,
) {
val viewModel: DatabaseTabViewModel = koinViewModel(
key = tab.id,
parameters = {
parametersOf(
DatabaseTabViewModel.Params(
databaseId = tab.databaseId,
tableName = tab.tableName,
favoriteId = tab.favoriteId,
query = tab.query,
)
)
}
)
DisposableEffect(viewModel) {
viewModel.onVisible()
onDispose {
viewModel.onNotVisible()
}
}
val state: DatabaseScreenState by viewModel.state.collectAsStateWithLifecycle()
val autoUpdate by viewModel.isAutoUpdateEnabled.collectAsStateWithLifecycle()
val lastQueries by viewModel.lastQueries.collectAsStateWithLifecycle()
DatabaseTabViewContent(
query = viewModel.query.value,
autoUpdate = autoUpdate,
updateQuery = viewModel::updateQuery,
onAction = viewModel::onAction,
state = state,
lastQueries = lastQueries,
favoritesTitles = favoritesTitles,
)
}
@Composable
private fun DatabaseTabViewContent(
query: String,
autoUpdate: Boolean,
favoritesTitles: Set<String>,
updateQuery: (String) -> Unit,
onAction: (action: DatabaseTabAction) -> Unit,
state: DatabaseScreenState,
lastQueries: List<String>,
) {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FloconPageTopBar(
modifier = Modifier.fillMaxWidth()
.clip(FloconTheme.shapes.medium)
.border(
width = 1.dp,
color = FloconTheme.colorPalette.secondary,
shape = FloconTheme.shapes.medium
),
) { contentPadding ->
DatabaseQueryView(
query = query,
updateQuery = updateQuery,
autoUpdate = autoUpdate,
onAction = onAction,
favoritesTitles = favoritesTitles,
lastQueries = lastQueries,
modifier = Modifier
.fillMaxWidth()
)
}
DatabaseContentView(
state = state,
modifier = Modifier.fillMaxWidth(),
onExportCsvClicked = {
onAction(DatabaseTabAction.ExportCsv)
},
if (tab.isQueryLogs) {
DatabaseQueryLogsView(
dbName = tab.databaseName,
modifier = Modifier.fillMaxSize()
)
} else {
DatabaseTabQueryView(
tab = tab,
favoritesTitles = favoritesTitles,
)
}
}
}

View file

@ -0,0 +1,110 @@
package io.github.openflocon.flocondesktop.features.database.view
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.github.openflocon.flocondesktop.features.database.DatabaseTabViewModel
import io.github.openflocon.flocondesktop.features.database.model.DatabaseScreenState
import io.github.openflocon.flocondesktop.features.database.model.DatabaseTabAction
import io.github.openflocon.flocondesktop.features.database.model.DatabaseTabState
import io.github.openflocon.library.designsystem.FloconTheme
import io.github.openflocon.library.designsystem.components.FloconPageTopBar
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun DatabaseTabQueryView(
tab: DatabaseTabState,
favoritesTitles: Set<String>,
) {
val viewModel: DatabaseTabViewModel = koinViewModel(
key = tab.id,
parameters = {
parametersOf(
DatabaseTabViewModel.Params(
databaseId = tab.databaseId,
tableName = tab.tableName,
favoriteId = tab.favoriteId,
query = tab.query,
)
)
}
)
DisposableEffect(viewModel) {
viewModel.onVisible()
onDispose {
viewModel.onNotVisible()
}
}
val state: DatabaseScreenState by viewModel.state.collectAsStateWithLifecycle()
val autoUpdate by viewModel.isAutoUpdateEnabled.collectAsStateWithLifecycle()
val lastQueries by viewModel.lastQueries.collectAsStateWithLifecycle()
DatabaseTabViewContent(
query = viewModel.query.value,
autoUpdate = autoUpdate,
updateQuery = viewModel::updateQuery,
onAction = viewModel::onAction,
state = state,
lastQueries = lastQueries,
favoritesTitles = favoritesTitles,
)
}
@Composable
private fun DatabaseTabViewContent(
query: String,
autoUpdate: Boolean,
favoritesTitles: Set<String>,
updateQuery: (String) -> Unit,
onAction: (action: DatabaseTabAction) -> Unit,
state: DatabaseScreenState,
lastQueries: List<String>,
) {
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FloconPageTopBar(
modifier = Modifier.fillMaxWidth()
.clip(FloconTheme.shapes.medium)
.border(
width = 1.dp,
color = FloconTheme.colorPalette.secondary,
shape = FloconTheme.shapes.medium
),
) { contentPadding ->
DatabaseQueryView(
query = query,
updateQuery = updateQuery,
autoUpdate = autoUpdate,
onAction = onAction,
favoritesTitles = favoritesTitles,
lastQueries = lastQueries,
modifier = Modifier
.fillMaxWidth()
)
}
DatabaseContentView(
state = state,
modifier = Modifier.fillMaxWidth(),
onExportCsvClicked = {
onAction(DatabaseTabAction.ExportCsv)
},
)
}
}

View file

@ -1,8 +1,13 @@
@file:OptIn(ExperimentalFoundationApi::class)
package io.github.openflocon.flocondesktop.features.database.view.databases_tables
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -12,6 +17,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Dataset
import androidx.compose.material.icons.outlined.Preview
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -23,9 +30,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import io.github.openflocon.domain.database.models.DeviceDataBaseId
import io.github.openflocon.flocondesktop.common.ui.ContextualView
import io.github.openflocon.flocondesktop.features.database.model.DeviceDataBaseUiModel
import io.github.openflocon.flocondesktop.features.database.model.TableUiModel
import io.github.openflocon.library.designsystem.FloconTheme
import io.github.openflocon.library.designsystem.common.FloconContextMenuItem
import io.github.openflocon.library.designsystem.components.WithTooltip
@Composable
internal fun DatabaseItemView(
@ -36,6 +46,7 @@ internal fun DatabaseItemView(
onTableColumnClicked: (TableUiModel.ColumnUiModel) -> Unit,
onDeleteContentClicked: (databaseId: DeviceDataBaseId, TableUiModel) -> Unit,
onInsertContentClicked: (databaseId: DeviceDataBaseId, TableUiModel) -> Unit,
onSeeAllQueriesClicked: (DeviceDataBaseId, String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
@ -44,6 +55,7 @@ internal fun DatabaseItemView(
onSelect = onSelect,
state = state,
onDatabaseDoubleClicked = onDatabaseDoubleClicked,
onSeeAllQueriesClicked = onSeeAllQueriesClicked,
)
state.tables?.let { tables ->
Column(modifier = Modifier.fillMaxWidth()) {
@ -73,6 +85,7 @@ private fun DatabaseView(
onSelect: (DeviceDataBaseId) -> Unit,
state: DeviceDataBaseUiModel,
onDatabaseDoubleClicked: (DeviceDataBaseUiModel) -> Unit,
onSeeAllQueriesClicked: (DeviceDataBaseId, String) -> Unit,
modifier: Modifier = Modifier,
) {
val (background, textColor) = if (state.isSelected) {
@ -81,32 +94,58 @@ private fun DatabaseView(
Color.Transparent to FloconTheme.colorPalette.onSurface
}
Row(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(background)
.combinedClickable(
onClick = {
onSelect(state.id)
}, onDoubleClick = {
onDatabaseDoubleClicked(state)
}
).padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
ContextualView(
listOf(
FloconContextMenuItem.Item(
label = "See all queries",
onClick = {
onSeeAllQueriesClicked(state.id, state.name)
}
))
) {
Image(
modifier = Modifier.width(14.dp),
imageVector = Icons.Outlined.Dataset,
contentDescription = null,
colorFilter = ColorFilter.tint(textColor),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
state.name,
color = textColor,
style = FloconTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(background)
.combinedClickable(
onClick = {
onSelect(state.id)
}, onDoubleClick = {
onDatabaseDoubleClicked(state)
}
).padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
modifier = Modifier.width(14.dp),
imageVector = Icons.Outlined.Dataset,
contentDescription = null,
colorFilter = ColorFilter.tint(textColor),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
state.name,
color = textColor,
style = FloconTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
WithTooltip("See logs") {
Box(
modifier = Modifier.width(20.dp)
.clickable(onClick = {
onSeeAllQueriesClicked(state.id, state.name)
})
) {
Image(
modifier = Modifier.width(16.dp),
imageVector = Icons.Outlined.Preview,
contentDescription = null,
colorFilter = ColorFilter.tint(textColor),
)
}
}
}
}
}

View file

@ -89,6 +89,9 @@ fun DatabasesAndTablesView(
},
onInsertContentClicked = { id, table ->
onAction(DatabaseScreenAction.OnInsertContentClicked(id, table))
},
onSeeAllQueriesClicked = { id, dbName ->
onAction(DatabaseScreenAction.OnSeeAllQueriesClicked(id, dbName))
}
)
}

View file

@ -0,0 +1,65 @@
package io.github.openflocon.flocondesktop.features.database.view.logs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.github.openflocon.flocondesktop.features.database.model.DatabaseQueryUiModel
import io.github.openflocon.library.designsystem.FloconTheme
@Composable
fun DatabaseQueryLogItemView(
log: DatabaseQueryUiModel,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.then(
if (log.isFromOldSession) {
Modifier.alpha(0.8f)
} else Modifier
), horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
modifier = Modifier.width(60.dp),
text = log.dateFormatted,
style = FloconTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = FloconTheme.colorPalette.onSurface
.copy(alpha = 0.6f)
)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = log.sqlQuery,
style = FloconTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = findColor(log.type)
)
log.bindArgs?.let {
Text(
text = "Args: ${log.bindArgs}",
style = FloconTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = FloconTheme.colorPalette.onSurface.copy(alpha = 0.8f)
)
}
}
}
}
@Composable
private fun findColor(type: DatabaseQueryUiModel.Type?): Color {
return when (type) {
DatabaseQueryUiModel.Type.Select -> Color(0xFF28A745)
DatabaseQueryUiModel.Type.Insert -> Color(0xFF007BFF)
DatabaseQueryUiModel.Type.Update -> Color(0xFFFFC107)
DatabaseQueryUiModel.Type.Delete -> Color(0xFFDC3545)
DatabaseQueryUiModel.Type.Transaction,
null -> FloconTheme.colorPalette.onSurface
}
}

View file

@ -0,0 +1,304 @@
package io.github.openflocon.flocondesktop.features.database.view.logs
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Upload
import androidx.compose.material.icons.outlined.UploadFile
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.InputChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
import io.github.openflocon.flocondesktop.common.ui.ContextualView
import io.github.openflocon.flocondesktop.features.database.DatabaseQueryLogsViewModel
import io.github.openflocon.flocondesktop.features.database.model.FilterChipUiModel
import io.github.openflocon.flocondesktop.features.network.list.model.NetworkAction
import io.github.openflocon.library.designsystem.FloconTheme
import io.github.openflocon.library.designsystem.common.FloconContextMenuItem
import io.github.openflocon.library.designsystem.components.FloconDropdownMenuItem
import io.github.openflocon.library.designsystem.components.FloconIcon
import io.github.openflocon.library.designsystem.components.FloconIconButton
import io.github.openflocon.library.designsystem.components.FloconIconToggleButton
import io.github.openflocon.library.designsystem.components.FloconOverflow
import io.github.openflocon.library.designsystem.components.FloconPageTopBar
import io.github.openflocon.library.designsystem.components.FloconTextFieldWithoutM3
import io.github.openflocon.library.designsystem.components.defaultPlaceHolder
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun DatabaseQueryLogsView(
dbName: String,
modifier: Modifier = Modifier
) {
val viewModel: DatabaseQueryLogsViewModel = koinViewModel(
key = dbName,
parameters = { parametersOf(dbName) }
)
val logs = viewModel.logs.collectAsLazyPagingItems()
val showTransactions by viewModel.showTransactions.collectAsStateWithLifecycle()
val filterChips by viewModel.filterChips.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
Column(modifier = modifier) {
DatabaseLogsHeader(
modifier = Modifier.fillMaxWidth().border(
width = 1.dp,
color = FloconTheme.colorPalette.secondary,
shape = FloconTheme.shapes.medium
),
searchQuery = searchQuery,
onSearchQueryChanged = viewModel::onSearchQueryChanged,
toggleShowTransactions = viewModel::toggleShowTransactions,
removeFilterChip = viewModel::removeFilterChip,
addIncludeFilter = viewModel::addIncludeFilter,
addExcludeFilter = viewModel::addExcludeFilter,
toggleFilterType = viewModel::toggleFilterType,
showTransactions = showTransactions,
filterChips = filterChips,
exportToCsv = viewModel::exportToCsv,
exportToMarkdown = viewModel::exportToMarkdown,
)
Box(
modifier = Modifier.fillMaxSize().padding(top = 12.dp)
.clip(FloconTheme.shapes.medium)
.border(
width = 1.dp,
color = FloconTheme.colorPalette.secondary,
shape = FloconTheme.shapes.medium
)
.background(
color = FloconTheme.colorPalette.primary,
shape = FloconTheme.shapes.medium
)
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(logs.itemCount) { index ->
val log = logs[index]
if (log != null) {
ContextualView(
items = listOf(
FloconContextMenuItem.Item("Copy Query") {
viewModel.copyQuery(log.sqlQuery)
},
FloconContextMenuItem.Item("Copy Args") {
viewModel.copyArgs(log.bindArgs)
},
FloconContextMenuItem.Item("Copy as SQL") {
viewModel.copyAsSql(log.sqlQuery, log.bindArgs)
},
FloconContextMenuItem.Separator(),
FloconContextMenuItem.Item("Filter In") {
viewModel.addFilter(
log.sqlQuery,
FilterChipUiModel.FilterType.INCLUDE
)
},
FloconContextMenuItem.Item("Filter Out") {
viewModel.addFilter(
log.sqlQuery,
FilterChipUiModel.FilterType.EXCLUDE
)
}
)
) {
DatabaseQueryLogItemView(
log = log,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
)
}
HorizontalDivider(color = FloconTheme.colorPalette.secondary)
}
}
}
}
}
}
@Composable
private fun DatabaseLogsHeader(
searchQuery: String,
showTransactions: Boolean,
onSearchQueryChanged: (String) -> Unit,
addIncludeFilter: () -> Unit,
addExcludeFilter: () -> Unit,
toggleShowTransactions: () -> Unit,
toggleFilterType: (FilterChipUiModel) -> Unit,
removeFilterChip: (FilterChipUiModel) -> Unit,
exportToCsv: () -> Unit,
exportToMarkdown: () -> Unit,
filterChips: List<FilterChipUiModel>,
modifier: Modifier = Modifier,
) {
FloconPageTopBar(modifier) {
Column(Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FloconTextFieldWithoutM3(
value = searchQuery,
onValueChange = onSearchQueryChanged,
modifier = Modifier.weight(1f),
placeholder = { defaultPlaceHolder("Filter logs...") },
containerColor = FloconTheme.colorPalette.secondary,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
addIncludeFilter()
}
),
leadingComponent = {
FloconIcon(
imageVector = Icons.Outlined.Search,
modifier = Modifier.size(16.dp)
)
},
)
androidx.compose.material3.IconButton(onClick = addIncludeFilter) {
Icon(imageVector = Icons.Default.Add, contentDescription = "Include")
}
androidx.compose.material3.IconButton(onClick = addExcludeFilter) {
Icon(imageVector = Icons.Default.Remove, contentDescription = "Exclude")
}
}
FilterChip(
selected = !showTransactions,
onClick = {
toggleShowTransactions()
},
label = {
Text("Hide transactions", style = FloconTheme.typography.bodySmall)
},
leadingIcon = {
if (!showTransactions) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null
)
}
}
)
FloconOverflow {
FloconDropdownMenuItem(
text = "Export CSV",
leadingIcon = Icons.Outlined.Upload,
onClick = { exportToCsv() }
)
FloconDropdownMenuItem(
text = "Export Markdown",
leadingIcon = Icons.Outlined.UploadFile,
onClick = { exportToMarkdown() }
)
}
}
FilterChips(
modifier = Modifier.fillMaxWidth()
.padding(top = 8.dp),
filterChips = filterChips,
toggleFilterType = toggleFilterType,
removeFilterChip = removeFilterChip,
)
}
}
}
@Composable
private fun FilterChips(
modifier: Modifier = Modifier,
filterChips: List<FilterChipUiModel>,
toggleFilterType: (FilterChipUiModel) -> Unit,
removeFilterChip: (FilterChipUiModel) -> Unit
) {
val itemsColor = FloconTheme.colorPalette.onSecondary
if (filterChips.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
filterChips.fastForEach { chip ->
InputChip(
selected = true,
onClick = { toggleFilterType(chip) },
shape = RoundedCornerShape(20.dp),
label = {
Text(
chip.text,
style = FloconTheme.typography.bodySmall,
color = itemsColor,
)
},
leadingIcon = {
Icon(
imageVector = if (chip.type == FilterChipUiModel.FilterType.INCLUDE) Icons.Default.Add else Icons.Default.Remove,
contentDescription = null,
tint = itemsColor,
modifier = Modifier.size(14.dp)
)
},
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
tint = itemsColor,
contentDescription = "Remove",
modifier = Modifier.clip(CircleShape).size(14.dp)
.clickable {
removeFilterChip(chip)
}
)
}
)
}
}
}
}

View file

@ -1,9 +1,12 @@
package io.github.openflocon.data.core.database.datasource
import androidx.paging.PagingData
import io.github.openflocon.domain.common.Either
import io.github.openflocon.domain.database.models.DatabaseFavoriteQueryDomainModel
import io.github.openflocon.domain.database.models.DatabaseQueryLogDomainModel
import io.github.openflocon.domain.database.models.DatabaseTableDomainModel
import io.github.openflocon.domain.database.models.DeviceDataBaseId
import io.github.openflocon.domain.database.models.FilterQueryLogDomainModel
import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel
import kotlinx.coroutines.flow.Flow
@ -58,4 +61,23 @@ interface LocalDatabaseDataSource {
databaseId: String,
id: Long
): DatabaseFavoriteQueryDomainModel?
suspend fun saveQueryLog(
log: DatabaseQueryLogDomainModel,
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
)
fun observeQueryLogs(
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
dbName: String,
showTransactions: Boolean,
filters: List<FilterQueryLogDomainModel>
): Flow<PagingData<DatabaseQueryLogDomainModel>>
suspend fun getQueryLogs(
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
dbName: String,
showTransactions: Boolean,
filters: List<FilterQueryLogDomainModel>
): List<DatabaseQueryLogDomainModel>
}

View file

@ -2,6 +2,7 @@ package io.github.openflocon.data.core.database.datasource
import io.github.openflocon.domain.common.Either
import io.github.openflocon.domain.database.models.DatabaseExecuteSqlResponseDomainModel
import io.github.openflocon.domain.database.models.DatabaseQueryLogDomainModel
import io.github.openflocon.domain.database.models.DeviceDataBaseDomainModel
import io.github.openflocon.domain.database.models.DeviceDataBaseId
import io.github.openflocon.domain.database.models.ResponseAndRequestIdDomainModel
@ -23,4 +24,6 @@ interface QueryDatabaseRemoteDataSource {
fun getDeviceDatabases(message: FloconIncomingMessageDomainModel): List<DeviceDataBaseDomainModel>
fun getReceiveQuery(message: FloconIncomingMessageDomainModel): ResponseAndRequestIdDomainModel?
fun getQueryLogs(message: FloconIncomingMessageDomainModel): DatabaseQueryLogDomainModel?
}

View file

@ -1,5 +1,6 @@
package io.github.openflocon.data.core.database.repository
import androidx.paging.PagingData
import io.github.openflocon.data.core.database.datasource.DeviceDatabasesRemoteDataSource
import io.github.openflocon.data.core.database.datasource.LocalDatabaseDataSource
import io.github.openflocon.data.core.database.datasource.QueryDatabaseRemoteDataSource
@ -8,9 +9,11 @@ import io.github.openflocon.domain.common.DispatcherProvider
import io.github.openflocon.domain.common.Either
import io.github.openflocon.domain.database.models.DatabaseExecuteSqlResponseDomainModel
import io.github.openflocon.domain.database.models.DatabaseFavoriteQueryDomainModel
import io.github.openflocon.domain.database.models.DatabaseQueryLogDomainModel
import io.github.openflocon.domain.database.models.DatabaseTableDomainModel
import io.github.openflocon.domain.database.models.DeviceDataBaseDomainModel
import io.github.openflocon.domain.database.models.DeviceDataBaseId
import io.github.openflocon.domain.database.models.FilterQueryLogDomainModel
import io.github.openflocon.domain.database.repository.DatabaseRepository
import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel
import io.github.openflocon.domain.messages.models.FloconIncomingMessageDomainModel
@ -91,6 +94,17 @@ class DatabaseRepositoryImpl(
databases = items,
)
}
Protocol.FromDevice.Database.Method.LogQuery -> {
queryDatabaseDataSource.getQueryLogs(
message,
)?.let {
localDatabaseDataSource.saveQueryLog(
it,
deviceIdAndPackageName = deviceIdAndPackageName
)
}
}
}
}
}
@ -191,4 +205,32 @@ class DatabaseRepositoryImpl(
id = id,
)
}
override fun observeQueryLogs(
dbName: String,
showTransactions: Boolean,
filters: List<FilterQueryLogDomainModel>,
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
): Flow<PagingData<DatabaseQueryLogDomainModel>> {
return localDatabaseDataSource.observeQueryLogs(
dbName = dbName,
showTransactions = showTransactions,
filters = filters,
deviceIdAndPackageName = deviceIdAndPackageName,
)
}
override suspend fun getQueryLogs(
dbName: String,
showTransactions: Boolean,
filters: List<FilterQueryLogDomainModel>,
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
): List<DatabaseQueryLogDomainModel> {
return localDatabaseDataSource.getQueryLogs(
dbName = dbName,
showTransactions = showTransactions,
filters = filters,
deviceIdAndPackageName = deviceIdAndPackageName,
)
}
}

View file

@ -0,0 +1,23 @@
package io.github.openflocon.data.local.database.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.RoomRawQuery
import io.github.openflocon.data.local.database.models.DatabaseQueryLogEntity
@Dao
interface DatabaseQueryLogDao {
@Insert
suspend fun insert(entity: DatabaseQueryLogEntity)
@androidx.room.RawQuery(observedEntities = [DatabaseQueryLogEntity::class])
fun getPagingSource(
query: RoomRawQuery,
): PagingSource<Int, DatabaseQueryLogEntity>
@androidx.room.RawQuery(observedEntities = [DatabaseQueryLogEntity::class])
suspend fun getLogs(
query: RoomRawQuery,
): List<DatabaseQueryLogEntity>
}

View file

@ -1,26 +1,37 @@
package io.github.openflocon.data.local.database.datasource
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import io.github.openflocon.data.core.database.datasource.LocalDatabaseDataSource
import io.github.openflocon.data.local.database.dao.DatabaseQueryLogDao
import io.github.openflocon.data.local.database.dao.QueryDao
import io.github.openflocon.data.local.database.dao.TablesDao
import io.github.openflocon.data.local.database.mapper.toDomain
import io.github.openflocon.data.local.database.mapper.toEntity
import androidx.paging.map
import androidx.room.RoomRawQuery
import io.github.openflocon.data.local.database.models.DatabaseQueryLogEntity
import io.github.openflocon.data.local.database.models.FavoriteQueryEntity
import io.github.openflocon.data.local.database.models.SuccessQueryEntity
import io.github.openflocon.domain.common.Either
import io.github.openflocon.domain.common.Success
import io.github.openflocon.domain.database.models.DatabaseFavoriteQueryDomainModel
import io.github.openflocon.domain.database.models.DatabaseQueryLogDomainModel
import io.github.openflocon.domain.database.models.DatabaseTableDomainModel
import io.github.openflocon.domain.database.models.DeviceDataBaseId
import io.github.openflocon.domain.database.models.FilterQueryLogDomainModel
import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import kotlin.let
internal class LocalDatabaseDataSourceRoom(
private val queryDao: QueryDao,
private val tablesDao: TablesDao,
private val databaseQueryLogDao: DatabaseQueryLogDao,
private val json: Json,
) : LocalDatabaseDataSource {
@ -172,4 +183,146 @@ internal class LocalDatabaseDataSourceRoom(
packageName = deviceIdAndPackageName.packageName,
id = id,
)?.toDomain()
override suspend fun saveQueryLog(
log: DatabaseQueryLogDomainModel,
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
) {
databaseQueryLogDao.insert(
DatabaseQueryLogEntity(
dbName = log.dbName,
sqlQuery = log.sqlQuery,
bindArgs = log.bindArgs?.let { json.encodeToString(it) },
timestamp = log.timestamp,
isTransaction = log.isTransaction,
deviceId = deviceIdAndPackageName.deviceId,
packageName = deviceIdAndPackageName.packageName,
appInstance = log.appInstance
)
)
}
override fun observeQueryLogs(
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
dbName: String,
showTransactions: Boolean,
filters: List<FilterQueryLogDomainModel>
): Flow<PagingData<DatabaseQueryLogDomainModel>> {
return Pager(
config = PagingConfig(
pageSize = 20,
),
pagingSourceFactory = {
val query = buildQuery(
dbName = dbName,
showTransactions = showTransactions,
filters = filters,
deviceIdAndPackageName = deviceIdAndPackageName
)
databaseQueryLogDao.getPagingSource(
query = query,
)
}
).flow
.map { pagingData ->
pagingData.map { entity ->
DatabaseQueryLogDomainModel(
dbName = entity.dbName,
sqlQuery = entity.sqlQuery,
bindArgs = entity.bindArgs?.let {
json.decodeFromString(it)
} ?: emptyList(),
timestamp = entity.timestamp,
isTransaction = entity.isTransaction,
appInstance = entity.appInstance
)
}
}
}
override suspend fun getQueryLogs(
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
dbName: String,
showTransactions: Boolean,
filters: List<FilterQueryLogDomainModel>
): List<DatabaseQueryLogDomainModel> {
val query = buildQuery(
dbName = dbName,
showTransactions = showTransactions,
filters = filters,
deviceIdAndPackageName = deviceIdAndPackageName
)
return databaseQueryLogDao.getLogs(query).map { entity ->
DatabaseQueryLogDomainModel(
dbName = entity.dbName,
sqlQuery = entity.sqlQuery,
bindArgs = entity.bindArgs?.let {
json.decodeFromString(it)
} ?: emptyList(),
timestamp = entity.timestamp,
isTransaction = entity.isTransaction,
appInstance = entity.appInstance
)
}
}
private fun buildQuery(
dbName: String,
showTransactions: Boolean,
filters: List<FilterQueryLogDomainModel>,
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
): RoomRawQuery {
val queryParams = ArrayList<Any>()
var queryString = "SELECT * FROM DatabaseQueryLogEntity WHERE deviceId = ? AND packageName = ? AND dbName = ?"
queryParams.add(deviceIdAndPackageName.deviceId)
queryParams.add(deviceIdAndPackageName.packageName)
queryParams.add(dbName)
if (!showTransactions) {
queryString += " AND isTransaction = 0"
}
val includes = filters.filter { it.type == FilterQueryLogDomainModel.FilterType.INCLUDE }
val excludes = filters.filter { it.type == FilterQueryLogDomainModel.FilterType.EXCLUDE }
if (includes.isNotEmpty()) {
queryString += " AND ("
includes.forEachIndexed { index, filter ->
if (index > 0) queryString += " OR "
queryString += "(sqlQuery LIKE ? OR bindArgs LIKE ?)"
queryParams.add("%${filter.text}%")
queryParams.add("%${filter.text}%")
}
queryString += ")"
}
if (excludes.isNotEmpty()) {
excludes.forEach { filter ->
queryString += " AND NOT (sqlQuery LIKE ? OR bindArgs LIKE ?)"
queryParams.add("%${filter.text}%")
queryParams.add("%${filter.text}%")
}
}
queryString += " ORDER BY timestamp DESC"
val query = RoomRawQuery(
sql = queryString,
onBindStatement = { statement ->
queryParams.forEachIndexed { index, arg ->
when (arg) {
is String -> statement.bindText(index + 1, arg)
is Long -> statement.bindLong(index + 1, arg)
is Int -> statement.bindLong(index + 1, arg.toLong())
is Boolean -> statement.bindLong(index + 1, if (arg) 1L else 0L)
is Double -> statement.bindDouble(index + 1, arg)
is Float -> statement.bindDouble(index + 1, arg.toDouble())
else -> statement.bindText(index + 1, arg.toString())
}
}
}
)
return query
}
}

View file

@ -0,0 +1,18 @@
package io.github.openflocon.data.local.database.models
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class DatabaseQueryLogEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val dbName: String,
val sqlQuery: String,
val bindArgs: String?,
val timestamp: Long,
val isTransaction: Boolean,
val deviceId: String,
val packageName: String,
val appInstance: Long,
)

View file

@ -5,6 +5,7 @@ import com.flocon.data.remote.database.mapper.decodeDeviceDatabases
import com.flocon.data.remote.database.mapper.decodeReceivedQuery
import com.flocon.data.remote.database.models.DatabaseExecuteSqlResponseDataModel
import com.flocon.data.remote.database.models.DatabaseOutgoingQueryMessage
import com.flocon.data.remote.database.models.DatabaseQueryLogModel
import com.flocon.data.remote.database.models.ResponseAndRequestIdDataModel
import com.flocon.data.remote.database.models.toDeviceDatabasesDomain
import com.flocon.data.remote.models.FloconOutgoingMessageDataModel
@ -17,6 +18,7 @@ import io.github.openflocon.domain.common.Either
import io.github.openflocon.domain.common.Failure
import io.github.openflocon.domain.common.Success
import io.github.openflocon.domain.database.models.DatabaseExecuteSqlResponseDomainModel
import io.github.openflocon.domain.database.models.DatabaseQueryLogDomainModel
import io.github.openflocon.domain.database.models.DeviceDataBaseDomainModel
import io.github.openflocon.domain.database.models.DeviceDataBaseId
import io.github.openflocon.domain.database.models.ResponseAndRequestIdDomainModel
@ -84,6 +86,30 @@ class QueryDatabaseRemoteDataSourceImpl(
override fun getDeviceDatabases(message: FloconIncomingMessageDomainModel): List<DeviceDataBaseDomainModel> = toDeviceDatabasesDomain(json.decodeDeviceDatabases(message.body).orEmpty())
override fun getReceiveQuery(message: FloconIncomingMessageDomainModel): ResponseAndRequestIdDomainModel? = json.decodeReceivedQuery(message.body)?.toDomain()
override fun getQueryLogs(
message: FloconIncomingMessageDomainModel,
): DatabaseQueryLogDomainModel? {
return json.decodeFromString<DatabaseQueryLogModel>(message.body)?.let {
val upperQuery = it.sqlQuery.trim().uppercase()
val isTransaction = upperQuery == "BEGIN TRANSACTION" ||
upperQuery == "COMMIT" ||
upperQuery == "ROLLBACK" ||
upperQuery == "END TRANSACTION" ||
upperQuery == "TRANSACTION SUCCESSFUL" ||
upperQuery == "BEGIN IMMEDIATE TRANSACTION"
DatabaseQueryLogDomainModel(
dbName = it.dbName,
sqlQuery = it.sqlQuery,
bindArgs = it.bindArgs.orEmpty(),
timestamp = it.timestamp,
isTransaction = isTransaction,
appInstance = message.appInstance,
)
}
}
}
// TODO internal

View file

@ -0,0 +1,11 @@
package com.flocon.data.remote.database.models
import kotlinx.serialization.Serializable
@Serializable
data class DatabaseQueryLogModel(
val dbName: String,
val sqlQuery: String,
val bindArgs: List<String>?,
val timestamp: Long,
)

View file

@ -35,6 +35,7 @@ object Protocol {
object Method {
const val Query = "query"
const val GetDatabases = "getDatabases"
const val LogQuery = "logQuery"
}
}

View file

@ -3,6 +3,7 @@ package io.github.openflocon.domain.database
import io.github.openflocon.domain.database.usecase.AskForDeviceDatabasesUseCase
import io.github.openflocon.domain.database.usecase.ExecuteDatabaseQueryUseCase
import io.github.openflocon.domain.database.usecase.GetDatabaseByIdUseCase
import io.github.openflocon.domain.database.usecase.ObserveDatabaseQueryLogsUseCase
import io.github.openflocon.domain.database.usecase.GetDeviceDatabaseTablesUseCase
import io.github.openflocon.domain.database.usecase.GetTableColumnsUseCase
import io.github.openflocon.domain.database.usecase.ObserveCurrentDeviceSelectedDatabaseAndTablesUseCase
@ -28,4 +29,5 @@ internal val databaseModule = module {
factoryOf(::ObserveFavoriteQueriesUseCase)
factoryOf(::SaveQueryAsFavoriteDatabaseUseCase)
factoryOf(::GetFavoriteQueryByIdDatabaseUseCase)
factoryOf(::ObserveDatabaseQueryLogsUseCase)
}

View file

@ -0,0 +1,16 @@
package io.github.openflocon.domain.database.models
import io.github.openflocon.domain.database.utils.injectSqlArgs
data class DatabaseQueryLogDomainModel(
val dbName: String,
val sqlQuery: String,
val bindArgs: List<String>?,
val timestamp: Long,
val isTransaction: Boolean,
val appInstance: Long,
)
fun DatabaseQueryLogDomainModel.toFullSql(): String {
return injectSqlArgs(sqlQuery, bindArgs)
}

View file

@ -0,0 +1,11 @@
package io.github.openflocon.domain.database.models
data class FilterQueryLogDomainModel(
val text: String,
val type: FilterType,
) {
enum class FilterType {
INCLUDE,
EXCLUDE
}
}

View file

@ -1,11 +1,14 @@
package io.github.openflocon.domain.database.repository
import androidx.paging.PagingData
import io.github.openflocon.domain.common.Either
import io.github.openflocon.domain.database.models.DatabaseExecuteSqlResponseDomainModel
import io.github.openflocon.domain.database.models.DatabaseFavoriteQueryDomainModel
import io.github.openflocon.domain.database.models.DatabaseQueryLogDomainModel
import io.github.openflocon.domain.database.models.DatabaseTableDomainModel
import io.github.openflocon.domain.database.models.DeviceDataBaseDomainModel
import io.github.openflocon.domain.database.models.DeviceDataBaseId
import io.github.openflocon.domain.database.models.FilterQueryLogDomainModel
import io.github.openflocon.domain.device.models.DeviceIdAndPackageNameDomainModel
import kotlinx.coroutines.flow.Flow
@ -72,6 +75,20 @@ interface DatabaseRepository {
suspend fun getFavorite(
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
databaseId: String,
id: Long
id: Long,
): DatabaseFavoriteQueryDomainModel?
fun observeQueryLogs(
dbName: String,
showTransactions: Boolean,
filters: List<FilterQueryLogDomainModel>,
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
): Flow<PagingData<DatabaseQueryLogDomainModel>>
suspend fun getQueryLogs(
dbName: String,
showTransactions: Boolean,
filters: List<FilterQueryLogDomainModel>,
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel,
): List<DatabaseQueryLogDomainModel>
}

View file

@ -0,0 +1,26 @@
package io.github.openflocon.domain.database.usecase
import io.github.openflocon.domain.database.models.DatabaseQueryLogDomainModel
import io.github.openflocon.domain.database.models.FilterQueryLogDomainModel
import io.github.openflocon.domain.database.repository.DatabaseRepository
import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdAndPackageNameUseCase
import kotlinx.coroutines.flow.first
class GetDatabaseQueryLogsUseCase(
private val databaseRepository: DatabaseRepository,
private val observeCurrentDeviceIdAndPackageNameUseCase: ObserveCurrentDeviceIdAndPackageNameUseCase,
) {
suspend operator fun invoke(
dbName: String,
showTransactions: Boolean,
filters: List<FilterQueryLogDomainModel>
): List<DatabaseQueryLogDomainModel> {
val current = observeCurrentDeviceIdAndPackageNameUseCase().first() ?: return emptyList()
return databaseRepository.getQueryLogs(
dbName = dbName,
showTransactions = showTransactions,
filters = filters,
deviceIdAndPackageName = current,
)
}
}

View file

@ -0,0 +1,34 @@
package io.github.openflocon.domain.database.usecase
import androidx.paging.PagingData
import io.github.openflocon.domain.database.models.DatabaseQueryLogDomainModel
import io.github.openflocon.domain.database.models.FilterQueryLogDomainModel
import io.github.openflocon.domain.database.repository.DatabaseRepository
import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdAndPackageNameUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
class ObserveDatabaseQueryLogsUseCase(
private val databaseRepository: DatabaseRepository,
private val observeCurrentDeviceIdAndPackageNameUseCase: ObserveCurrentDeviceIdAndPackageNameUseCase,
) {
operator fun invoke(
dbName: String,
showTransactions: Boolean,
filters: List<FilterQueryLogDomainModel>
): Flow<PagingData<DatabaseQueryLogDomainModel>> {
return observeCurrentDeviceIdAndPackageNameUseCase().flatMapLatest { current ->
if (current == null) {
return@flatMapLatest flowOf(PagingData.empty())
} else {
databaseRepository.observeQueryLogs(
dbName = dbName,
showTransactions = showTransactions,
filters = filters,
deviceIdAndPackageName = current,
)
}
}
}
}

View file

@ -0,0 +1,13 @@
package io.github.openflocon.domain.database.utils
fun injectSqlArgs(sql: String, args: List<String>?): String {
return if (args.isNullOrEmpty()) {
sql
} else {
var result = sql
args.forEach { arg ->
result = result.replaceFirst("?", "'$arg'")
}
result
}
}

View file

@ -1,20 +1,25 @@
package io.github.openflocon.library.designsystem.common
import androidx.compose.foundation.ContextMenuItem
import androidx.compose.runtime.Immutable
@Immutable
sealed class FloconContextMenuItem(
label: String,
onClick: () -> Unit
) : ContextMenuItem(label = label, onClick = onClick) {
// TODO Add icon
@Immutable
class Item(label: String, onClick: () -> Unit) : FloconContextMenuItem(label, onClick)
@Immutable
class SubMenu(
label: String,
val items: List<FloconContextMenuItem>
) : FloconContextMenuItem(label, onClick = {})
@Immutable
class Separator : FloconContextMenuItem(label = "", onClick = {})
}