diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 4eda1970..c00bcc79 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"path_provider_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.22\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.2+2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_macos-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_linux-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_windows-0.1.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-03-21 20:27:26.235937","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni_flutter","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni_flutter-1.0.1/","native_build":true,"dependencies":["jni"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_android-2.3.1/","native_build":false,"dependencies":["jni","jni_flutter"],"dev_dependency":false},{"name":"sqflite_android","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_macos-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_linux-0.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/bitsdojo_window_windows-0.1.6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"jni","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/jni-1.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/zhuchka/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"jni","dependencies":[]},{"name":"jni_flutter","dependencies":["jni"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":["jni","jni_flutter"]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-04-11 12:30:47.798124","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/README.md b/README.md index 64d85520..4edbf11d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,13 @@ A lightweight desktop client for SQL and NoSQL databases. Connect to PostgreSQL, - **Cross-platform:** Windows, Linux, macOS (Flutter desktop). - **Multi-database:** PostgreSQL, MySQL, Redis, MongoDB (more can be added). - **UI:** Custom window (no system title bar), resizable left panel (connection tree) and bottom split (query editor / results), dark theme, [shadcn_flutter](https://pub.dev/packages/shadcn_flutter) components. -- **Flow:** Right-click “Servers” → “New connection” → pick database type → (future: connection form and query execution). +- **Flow:** Right-click “Servers” → “New connection” → pick database type → configure and save (stored in local SQLite). + +## Database drivers + +- **PostgreSQL** — `postgres` (Dart); browser, SQL workspace, table/view browsing, server stats. +- **MySQL / MariaDB** — `mysql_client` (Dart); browser (databases, tables, views), SQL workspace with configurable statement timeout, paginated table/view data (read-oriented browse SQL). +- **Redis** / **MongoDB** — see connection panels and workspace. ## Tech stack diff --git a/docs/mysql-implementation-plan.md b/docs/mysql-implementation-plan.md new file mode 100644 index 00000000..70edf84d --- /dev/null +++ b/docs/mysql-implementation-plan.md @@ -0,0 +1,316 @@ +# План: MySQL / MariaDB по образцу PostgreSQL + +Документ описывает текущую реализацию PostgreSQL в Querya Desktop, проверяет каждый пункт плана по MySQL и детализирует рекомендуемые практики, компромиссы и порядок работ. + +--- + +## Содержание + +1. [Актуальная архитектура PostgreSQL в проекте](#1-актуальная-архитектура-postgresql-в-проекте) +2. [Выбор клиентской библиотеки Dart](#2-выбор-клиентской-библиотеки-dart) +3. [Слой `MysqlConnection`](#3-слой-mysqlconnection) +4. [Пул, сервис, жизненный цикл](#4-пул-сервис-жизненный-цикл) +5. [Форма подключения и `ConnectionRow`](#5-форма-подключения-и-connectionrow) +6. [Интеграция в панель соединений и главный экран](#6-интеграция-в-панель-соединений-и-главный-экран) +7. [Завершение приложения и отмена запросов](#7-завершение-приложения-и-отмена-запросов) +8. [Функциональность UI: дерево, SQL, просмотр данных](#8-функциональность-ui-дерево-sql-просмотр-данных) +9. [Настройки, тесты, безопасность](#9-настройки-тесты-безопасность) +10. [Документация и статус драйвера](#10-документация-и-статус-драйвера) +11. [Поэтапный roadmap](#11-поэтапный-roadmap) + +--- + +## 1. Актуальная архитектура PostgreSQL в проекте + +Ниже — «эталон», от которого логично отталкиваться для MySQL. + +### 1.1 Зависимости + +| Что | Где | Заметка | +|-----|-----|---------| +| Пакет `postgres` | `pubspec.yaml` | Протокол PostgreSQL на чистом Dart, без FFI-плагинов для драйвера. | + +### 1.2 Соединение + +**Файл:** `lib/core/database/postgres_connection.dart` + +- **Конфигурация:** `PostgresConnection` и `PostgresConnection.fromConnectionRow` читают `ConnectionRow`: `host`, `port`, `username`, `password`, `databaseName`, `useSSL`, `connectionString`. +- **Два режима входа:** (1) отдельные поля + `Connection.open(endpoint, settings)`; (2) непустой `connectionString` — разбор URI, подмена имени БД через `replaceDatabaseInConnectionString`, чтобы пул мог открывать разные каталоги без дублирования учётных данных. +- **Параметры сессии:** `setSessionReadOnly` — `SET default_transaction_read_only` (важно для разделения «браузер / только чтение» и «SQL-редактор с записями»). +- **Таймауты:** в `ConnectionSettings` заданы `connectTimeout` и `queryTimeout`; отдельно `execute(..., timeout: ...)` для отдельных запросов. +- **Метаданные:** большой набор методов (`listDatabases`, `listSchemas`, `listTables`, `serverStats`, привилегии, индексы, …) — для MySQL понадобится **свой** слой запросов, а не копипаста SQL от PostgreSQL. + +### 1.3 Пул и сервис + +**Файлы:** `lib/core/database/postgres_connection_pool.dart`, `postgres_service.dart` + +- **Ключ пула:** строка вида `` `${id}::$database::${mode.name}` `` — см. `keyFor` в пуле. +- **Режимы:** `PgSessionMode.readOnly` / `readWrite`; при повторном `acquire` переоткрытие и повторная установка read-only, если сессия отвалилась. +- **Lease:** `PgLease` с `release()` — счётчик ссылок, таймер простоя `idleDisposeDelay`, LRU-вытеснение при `maxEntries`. +- **Прерывание:** `interrupt` → `forceClose` на сокете (отмена клиентского I/O; сервер может ещё какое-то время держать запрос). +- **Синглтон:** `PostgresService.instance` — единая точка для UI. + +### 1.4 Локальное хранилище + +**Файл:** `lib/core/storage/local_db.dart` + +- Таблица `connections` уже содержит все нужные поля для SQL-драйверов: `host`, `port`, `username`, `password`, `database_name`, `use_ssl`, `connection_string`. +- Тип задаётся строкой: для PG — `'postgresql'`. Для MySQL — **`'mysql'`** (уже используется в `new_connection_dialog.dart` / иконках). + +### 1.5 UI + +- **Форма:** `lib/features/postgresql/postgresql_connection_form.dart` — тест соединения перед сохранением, валидация URI vs host/db. +- **Браузер:** `lib/features/connections/connections_panel.dart` — `_PostgresConnectionTile`, вызовы `PostgresService.instance.acquire`, разворачиваемое дерево. +- **Рабочая область:** каталог `lib/features/postgresql/` (`postgres_sql_workspace.dart`, `postgres_table_view.dart`, `postgres_workspace_home.dart`, …). +- **Таймаут SQL-редактора:** `AppSettings.getPostgresSqlStmtTimeoutSeconds()` используется в `postgres_sql_workspace.dart` — для MySQL разумно завести **отдельный** ключ настроек (см. раздел 9). + +### 1.6 Завершение работы + +**Файл:** `lib/app/app_shutdown.dart` — `disconnectAllExternalServices()` вызывает `PostgresService.instance.disconnectAll()` наряду с Mongo и Redis. + +### 1.7 Заглушки и драйвер-менеджер + +- **Stub:** в `connections_panel.dart` удаляются записи с `type == 'mysql' && name == 'MySQL connection'`. +- **Driver Manager:** `lib/features/connections/driver_manager_dialog.dart` — у MySQL `fixedStatus: DriverStatus.comingSoon`, без `DownloadableDriver`. + +--- + +## 2. Выбор клиентской библиотеки Dart + +**Проверка пункта плана:** зависимость должна закрыть протокол MySQL, желательно на чистом Dart (как `postgres`), с поддержкой desktop (Linux/macOS/Windows). + +### 2.1 Критерии выбора + +1. **Лицензия** — совместимость с продуктом (BSD/MIT/Apache предпочтительно; читать полный текст). +2. **Активность** — последние релизы, совместимость с Dart 3.x. +3. **TLS** — возможность включить шифрование; для продакшена желательно проверка сертификата (хотя бы опционально `badCertificateCallback` только для dev). +4. **Аутентификация** — MySQL 8 по умолчанию `caching_sha2_password`; убедиться, что пакет её поддерживает или документировать ограничение + `mysql_native_password` на сервере. +5. **Кодировка** — явная установка `utf8mb4` на соединении (по умолчанию на многих серверах уже так; лучше зафиксировать в коде после handshake). +6. **API** — потоковый доступ к результатам для больших выборок; параметризованные запросы для метаданных и пользовательского SQL, где это уместно. + +### 2.2 Типичные кандидаты (на момент планирования) + +| Пакет | Плюсы | На что смотреть | +|-------|--------|-----------------| +| **`mysql_client`** | Современный async API, ориентация на чистый Dart | Документация по SSL, ограничения по платформам | +| **`mysql1`** | Долго в экосистеме, много примеров | Актуальность под Dart 3, API может быть менее удобным | + +**Практика:** после выбора пакета зафиксировать в `README` или в этом документе **версию** и известные ограничения (например, «не поддерживается X»). + +### 2.3 Что не забыть в `pubspec` + +- Зависимость только в `dependencies` (не дублировать конфликтующие транспорты). +- При необходимости — отдельная секция в документации про **MariaDB** (обычно тот же протокол с мелкими отличиями в `information_schema`). + +--- + +## 3. Слой `MysqlConnection` + +**Проверка пункта плана:** один класс (или небольшой модуль), инкапсулирующий низкоуровневый клиент и дающий приложению стабильный API, похожий на `PostgresConnection`. + +### 3.1 Конфигурация из `ConnectionRow` + +- Повторить паттерн `fromConnectionRow` с дефолтами: порт **3306**, имя БД по умолчанию можно оставить пустым только если библиотека позволяет подключаться без default schema — иначе требовать явную БД или `mysql` / первую доступную после `SHOW DATABASES`. +- **URI:** распространённые формы `mysql://user:pass@host:3306/db?ssl-mode=REQUIRED`. Реализовать: + - разбор query-параметров (`ssl`, `ssl-mode`, таймауты, если поддерживаются); + - функцию уровня `replaceDatabaseInMysqlConnectionString` (аналог PG), если дерево переключает каталоги на одном сохранённом URI. + +### 3.2 Методы жизненного цикла + +| Метод | Назначение | +|-------|------------| +| `connect()` | Установить соединение, применить session defaults (charset, timezone — опционально). | +| `disconnect()` | Мягкое закрытие. | +| `forceClose()` | Аналог PG: оборвать сокет для `interrupt` из пула. | + +### 3.3 Выполнение SQL + +- Единая точка `execute` / `query` с опциональным **таймаутом** на уровне приложения (таймер Future + отмена — если библиотека не поддерживает отмену нативно). +- Для **пользовательского SQL** из редактора — не подмешивать автоматически `LIMIT` без явного согласия пользователя; для **просмотра таблицы** — пагинация `LIMIT/OFFSET` или keyset (лучше для больших таблиц). + +### 3.4 Метаданные (MySQL-специфика) + +В MySQL **DATABASE и SCHEMA — синонимы**; «схема» в смысле PostgreSQL часто соответствует **отдельной базе данных**. Для дерева типичны два UX: + +1. **Одна БД на подключение** (как в многих клиентах): в форме задаётся default database, дерево показывает таблицы/вью внутри неё. +2. **Серверный уровень:** сначала `SHOW DATABASES` / `information_schema.SCHEMATA`, затем при выборе БД — переключение `USE db` или отдельное соединение с этой БД (второе согласуется с текущим пулом по ключу `database`). + +Рекомендуемые запросы каталога (с параметрами, не конкатенацией строк для имён из БД): + +- Список БД: `SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE ...` (исключить `information_schema`, `mysql`, `performance_schema`, `sys` — опционально, в настройках). +- Таблицы: `information_schema.TABLES` с фильтром `TABLE_SCHEMA = ?`. +- Колонки: `information_schema.COLUMNS`. +- Ограничения / индексы: `information_schema.STATISTICS`, `TABLE_CONSTRAINTS`, при необходимости `SHOW CREATE TABLE` для точного DDL. + +**Квотирование идентификаторов:** обратные апострофы `` `identifier` ``; экранирование `` ` `` как `` `` ` ``. + +### 3.5 Read-only сессия + +- В MySQL нет прямого аналога `default_transaction_read_only` на всю сессию как в PG. +- Практики: + - **`SET SESSION TRANSACTION READ ONLY`** — влияет на последующие транзакции в режимах, где это поддерживается; уточнить для целевых версий MySQL/MariaDB. + - Альтернатива для «безопасного браузера»: отдельный пользователь БД с правами только `SELECT` (политика на стороне сервера) — вне scope клиента, но стоит упомянуть в документации. +- Для первой версии клиента можно **дублировать семантику PG**: после `connect` выставлять read-only флаг там, где сервер позволяет, а для SQL-редактора — режим `readWrite` без этих SET. + +### 3.6 Ошибки + +- Обернуть ошибки драйвера в свой тип (как `PostgresConnectionException`), чтобы UI показывал понятные сообщения и мог классифицировать «сеть / доступ / синтаксис». + +--- + +## 4. Пул, сервис, жизненный цикл + +**Проверка пункта плана:** повторить паттерн `PostgresConnectionPool` / `PostgresService`. + +### 4.1 Структура + +- **`MysqlSessionMode`** — `readOnly` / `readWrite` (зеркало `PgSessionMode`). +- **`MysqlLease`** — `release()` с тем же ref-count и idle-таймером. +- **`MysqlConnectionPool`** — те же константы `defaultIdleDisposeDelay`, `defaultMaxEntries`, та же логика `keyFor`, `_evictIfNeededBeforeNewSlot`, `interrupt`, `disconnectAll`. + +### 4.2 Фабрика подключения + +- Вынести `createAndConnect` в тестируемую функцию (как `_defaultCreateAndConnect` в `postgres_service.dart`), чтобы в unit-тестах подставлять mock. + +### 4.3 DRY (не обязательно в первой итерации) + +- Долгосрочно можно обобщить «пул по ключу» в generic, если MySQL и PG пулы окажутся идентичны по логике; **не усложнять преждевременно** — сначала два явных класса проще сопровождать. + +--- + +## 5. Форма подключения и `ConnectionRow` + +**Файл-образец:** `lib/features/postgresql/postgresql_connection_form.dart` + +### 5.1 Поля UI + +- Имя подключения, хост, порт (3306), пользователь, пароль, имя БД (опционально в зависимости от выбранного UX), чекбокс SSL, опционально поле **Connection URL**. +- **Тест соединения** перед сохранением — обязателен для согласованности с PG. +- Маскирование пароля, опция «показать пароль». + +### 5.2 Валидация + +- URI: префиксы `mysql://`, `mariadb://` (если поддерживаете оба в одной форме). +- Без URI: обязательны хост и (по политике) база. + +### 5.3 Сохранение + +- `ConnectionRow(type: 'mysql', ...)`, `createdAt` — как у других форм. +- Пароль хранится в том же SQLite, что и для PG/Mongo — **как и сейчас в приложении**; для усиленной безопасности в будущем рассмотреть keyring OS (отдельная задача). + +--- + +## 6. Интеграция в панель соединений и главный экран + +**Проверка пункта плана:** `connections_panel.dart`, `main_screen.dart`, `workspace_panel.dart`. + +### 6.1 `connections_panel.dart` + +- В `_createConnection` добавить ветку `ConnectionType.mysql` → `showMysqlConnectionForm(...)`. +- Добавить `_MysqlConnectionTile` по аналогии с `_PostgresConnectionTile`: раскрытие, индикатор загрузки, `MysqlService.instance.acquire` при обходе дерева. +- **Колбэки:** либо расширить существующие (`onPostgresObjectSelected` можно обобщить до SQL-объектов), либо ввести `onMysqlObjectSelected` — решение за командой; важно не раздувать `main_screen` бесконечными флагами — лучше единый контракт «SQL workspace target» с полем `engine: postgresql | mysql`. + +### 6.2 Главный экран и workspace + +- В `workspace_panel.dart` / `main_screen.dart` сейчас завязка на PostgreSQL (`postgresSqlTabRequestToken` и т.д.). Для MySQL: + - либо отдельная вкладка/токен для MySQL SQL; + - либо одна вкладка «SQL» с выбором активного движка — чище для пользователя, сложнее в состоянии. + +### 6.3 Иконки и тип + +- Уже есть `'mysql'` в `_iconForType` / `_iconAssetForType` — проверить, что новый тайл использует те же ключи. + +--- + +## 7. Завершение приложения и отмена запросов + +**Файл:** `lib/app/app_shutdown.dart` + +- Добавить `await MysqlService.instance.disconnectAll()` в `disconnectAllExternalServices()` **в том же порядке**, что и остальные пулы (после или перед — главное, документировать идемпотентность). + +### 7.1 Отмена долгого запроса + +- **Клиент:** `forceClose` на пуле (уже есть паттерн). +- **Сервер (опционально, продвинуто):** выполнить `KILL QUERY` для другого thread_id нельзя без отдельного соединения с привилегией; для desktop-клиента обычно достаточно обрыва сокета. Если добавите «Отменить запрос» как в некоторых IDE — понадобится хранить `connection_id` из `SELECT CONNECTION_ID()` и второе соединение с правами — **отложить на v2**. + +--- + +## 8. Функциональность UI: дерево, SQL, просмотр данных + +**Ориентир:** каталог `lib/features/postgresql/`. + +### 8.1 Минимально жизнеспособный продукт (MVP) + +1. Подключение + тест. +2. Дерево: базы (или одна база) → таблицы → открыть таблицу. +3. Просмотр данных: сетка с пагинацией, только `SELECT` с квотированием имён. +4. SQL-редактор: выполнение произвольного запроса, отображение результата / сообщения об ошибке, таймаут из настроек. + +### 8.2 Паритет с PostgreSQL (позже) + +- Представления, процедуры/функции, индексы, привилегии — по отдельным экранам, с SQL, специфичным для MySQL. +- Статистика сервера — другие системные таблицы (`performance_schema`, `SHOW GLOBAL STATUS`), осторожно с нагрузкой на сервер. + +### 8.3 Защита от опасных запросов в браузере + +- В PG уже есть паттерны вроде проверок допустимости SELECT для просмотра таблицы — для MySQL аналогично: в read-only режиме не отправлять произвольный DML из «безопасного» UI без подтверждения. + +--- + +## 9. Настройки, тесты, безопасность + +### 9.1 Настройки + +- Зеркало `AppSettingsKeys.postgresSqlStmtTimeoutSeconds` → например `mysql_sql_stmt_timeout_seconds`. +- Опционально: лимит строк в результате, таймаут подключения в UI (если вынесете из кода). + +### 9.2 Тесты + +| Уровень | Что | +|---------|-----| +| Unit | Парсинг connection string, квотирование идентификаторов, ключ пула. | +| Widget | Форма MySQL (как `postgresql_connection_form_test.dart`). | +| Интеграция | Docker `mysql:8` в CI или локально — один тест «подключился и `SELECT 1`». | + +### 9.3 Безопасность + +- Все **динамические имена** объектов из каталога — только через квотирование или параметры (имена схем/таблиц в MySQL нельзя биндить как `?` везде — использовать whitelist после `information_schema`). +- Не логировать пароли и полные URI с паролем. +- TLS: для production включать проверку сертификата; self-signed только с явным флагом «доверять» или импортом CA. + +--- + +## 10. Документация и статус драйвера + +- **`driver_manager_dialog.dart`:** заменить `comingSoon` на `installed` или `available`, когда функциональность готова; при необходимости добавить `DownloadableDriver.mysql`, если появится отдельный артефакт (сейчас PG тянется отдельно — следовать той же модели, если она актуальна для MySQL). +- **`README.md`:** кратко описать поддерживаемые версии MySQL/MariaDB и ограничения драйвера. + +--- + +## 11. Поэтапный roadmap + +| Фаза | Содержание | +|------|------------| +| **A** | Зависимость, `MysqlConnection`, тест `SELECT 1`, форма, сохранение в SQLite. | +| **B** | `MysqlConnectionPool`, `MysqlService`, `app_shutdown`, тайл в панели, простое дерево БД/таблиц. | +| **C** | Просмотр таблицы + SQL workspace + настройки таймаута. | +| **D** | Расширенные объекты (вью, процедуры), привилегии, полировка UX, интеграционные тесты. | + +--- + +## Файлы-ориентиры в репозитории + +| Назначение | PostgreSQL (эталон) | +|------------|---------------------| +| Соединение | `lib/core/database/postgres_connection.dart` | +| Пул | `lib/core/database/postgres_connection_pool.dart` | +| Сервис | `lib/core/database/postgres_service.dart` | +| Форма | `lib/features/postgresql/postgresql_connection_form.dart` | +| Панель | `lib/features/connections/connections_panel.dart` | +| Выход | `lib/app/app_shutdown.dart` | +| Настройки SQL | `lib/core/storage/app_settings.dart`, `lib/features/postgresql/postgres_sql_workspace.dart` | +| Драйверы UI | `lib/features/connections/driver_manager_dialog.dart` | + +--- + +*Документ можно обновлять по мере реализации: зафиксировать выбранный пакет, версии MySQL/MariaDB в CI и отклонения от этого плана.* diff --git a/lib/app/app_shutdown.dart b/lib/app/app_shutdown.dart index 67f050d2..8a601419 100644 --- a/lib/app/app_shutdown.dart +++ b/lib/app/app_shutdown.dart @@ -1,11 +1,13 @@ import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; -/// Disconnects all pooled / cached client connections (PostgreSQL pool, Mongo, -/// Redis). Safe to call when no connections exist. +/// Disconnects all pooled / cached client connections (PostgreSQL pool, MySQL, +/// Mongo, Redis). Safe to call when no connections exist. Future disconnectAllExternalServices() async { await PostgresService.instance.disconnectAll(); + await MysqlService.instance.disconnectAll(); await MongoService.instance.disconnectAll(); await RedisService.instance.disconnectAll(); } diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart new file mode 100644 index 00000000..68657f16 --- /dev/null +++ b/lib/core/database/mysql_connection.dart @@ -0,0 +1,333 @@ +import 'dart:async'; + +import 'package:mysql_client/mysql_client.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// Replaces the database in a `mysql://` / `mariadb://` URI (path or `database=`). +String replaceDatabaseInMysqlConnectionString( + String connectionString, + String newDatabase, +) { + final uri = Uri.parse(connectionString.trim()); + if (uri.scheme != 'mysql' && uri.scheme != 'mariadb') { + throw ArgumentError( + 'Invalid connection string scheme: ${uri.scheme}. ' + 'Expected "mysql" or "mariadb".', + ); + } + final params = Map.from(uri.queryParameters); + if (params.containsKey('database')) { + params['database'] = newDatabase; + return uri.replace(queryParameters: params).toString(); + } + if (uri.pathSegments.isNotEmpty && uri.pathSegments.first.isNotEmpty) { + return uri.replace(path: '/$newDatabase').toString(); + } + params['database'] = newDatabase; + return uri.replace(queryParameters: params).toString(); +} + +/// Pure-Dart MySQL client wrapper around [MySQLConnection] (`mysql_client`). +class MysqlConnection { + MysqlConnection({ + required this.id, + required this.name, + required this.host, + this.port = 3306, + this.username, + this.password, + this.database, + this.useSSL = true, + this.connectionString, + }); + + factory MysqlConnection.fromConnectionRow( + ConnectionRow row, { + String? database, + }) { + return MysqlConnection( + id: row.id ?? 0, + name: row.name, + host: row.host ?? 'localhost', + port: row.port ?? 3306, + username: row.username, + password: row.password, + database: database ?? row.databaseName, + useSSL: row.useSSL, + connectionString: row.connectionString, + ); + } + + final int id; + final String name; + final String host; + final int port; + final String? username; + final String? password; + final String? database; + final bool useSSL; + final String? connectionString; + + MySQLConnection? _conn; + bool _isConnected = false; + + bool get isConnected => _isConnected && _conn != null; + + bool get _usesConnectionString => + connectionString != null && connectionString!.trim().isNotEmpty; + + static String _escapeSqlString(String s) { + return s.replaceAll(r'\', r'\\').replaceAll("'", "''"); + } + + /// MySQL identifier quoting (backticks). + static String quoteIdentifier(String id) { + return '`${id.replaceAll('`', '``')}`'; + } + + Future connect({int connectTimeoutMs = 10000}) async { + if (_isConnected && _conn != null) return; + try { + final user = username ?? ''; + final pass = password ?? ''; + if (_usesConnectionString) { + final dbName = database; + final uriStr = dbName != null && dbName.isNotEmpty + ? replaceDatabaseInMysqlConnectionString( + connectionString!.trim(), + dbName, + ) + : connectionString!.trim(); + final parsed = _parseMysqlUri(uriStr, fallbackSsl: useSSL); + _conn = await MySQLConnection.createConnection( + host: parsed.host, + port: parsed.port, + userName: parsed.userName, + password: parsed.password, + secure: parsed.secure, + databaseName: parsed.databaseName, + ); + await _conn!.connect(timeoutMs: connectTimeoutMs); + } else { + _conn = await MySQLConnection.createConnection( + host: host, + port: port, + userName: user, + password: pass, + secure: useSSL, + databaseName: database, + ); + await _conn!.connect(timeoutMs: connectTimeoutMs); + } + _isConnected = true; + } catch (e) { + _isConnected = false; + _conn = null; + rethrow; + } + } + + /// Parsed URI fields for `mysql://` / `mariadb://`. + static ({ + dynamic host, + int port, + String userName, + String password, + String? databaseName, + bool secure, + }) _parseMysqlUri(String raw, {required bool fallbackSsl}) { + final uri = Uri.parse(raw); + if (uri.scheme != 'mysql' && uri.scheme != 'mariadb') { + throw ArgumentError('Expected mysql or mariadb scheme'); + } + final hostStr = uri.host.isEmpty ? 'localhost' : uri.host; + final port = uri.hasPort ? uri.port : 3306; + + String userName = ''; + String password = ''; + final info = uri.userInfo; + if (info.isNotEmpty) { + final colon = info.indexOf(':'); + if (colon >= 0) { + userName = Uri.decodeComponent(info.substring(0, colon)); + password = Uri.decodeComponent(info.substring(colon + 1)); + } else { + userName = Uri.decodeComponent(info); + } + } + + String? db; + if (uri.pathSegments.isNotEmpty && uri.pathSegments.first.isNotEmpty) { + db = Uri.decodeComponent(uri.pathSegments.first); + } + db ??= uri.queryParameters['database']; + + final q = uri.queryParameters; + bool secure = fallbackSsl; + final ssl = (q['ssl-mode'] ?? q['sslmode'] ?? q['ssl'])?.toLowerCase(); + if (ssl == 'false' || + ssl == '0' || + ssl == 'disable' || + ssl == 'disabled' || + ssl == 'prefer') { + secure = false; + } + if (ssl == 'require' || ssl == 'verify_ca' || ssl == 'verify_identity') { + secure = true; + } + + return ( + host: hostStr, + port: port, + userName: userName, + password: password, + databaseName: db, + secure: secure, + ); + } + + Future disconnect() async { + _isConnected = false; + final c = _conn; + _conn = null; + try { + if (c != null && c.connected) { + await c.close(); + } + } catch (_) {} + } + + /// Best-effort close. The `mysql_client` driver may not allow graceful [close] + /// while a query is in progress. + Future forceClose() async { + _isConnected = false; + final c = _conn; + _conn = null; + if (c == null) return; + try { + if (c.connected) { + await c.close(); + } + } catch (_) {} + } + + /// Session hint for read-only browsing (MySQL 8+ / MariaDB — semantics differ from PostgreSQL). + Future setSessionReadOnly(bool readOnly) async { + if (!isConnected || _conn == null) return; + if (readOnly) { + await execute('SET SESSION TRANSACTION READ ONLY'); + } else { + await execute('SET SESSION TRANSACTION READ WRITE'); + } + } + + Future testConnection() async { + try { + await connect(); + if (_conn != null) { + await execute('SELECT 1'); + return true; + } + return false; + } catch (_) { + return false; + } finally { + await disconnect(); + } + } + + Future execute( + String sql, [ + Map? params, + ]) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + return _conn!.execute(sql, params); + } + + /// Runs [execute] with an application-level [timeout] (driver limits still apply). + Future executeWithTimeout( + String sql, { + Duration? timeout, + Map? params, + }) async { + final f = execute(sql, params); + if (timeout == null) return f; + return f.timeout(timeout); + } + + /// Lists user-visible databases (excludes typical system schemas). + Future> listDatabases() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + const system = {'information_schema', 'mysql', 'performance_schema', 'sys'}; + final rs = await execute('SHOW DATABASES'); + final out = []; + for (final row in rs.rows) { + final name = row.colAt(0); + if (name != null && + name.isNotEmpty && + !system.contains(name.toLowerCase())) { + out.add(name); + } + } + out.sort(); + return out; + } + + /// Lists views in [schema] (database name). + Future> listViews({required String schema}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final s = _escapeSqlString(schema); + final rs = await execute( + 'SELECT TABLE_NAME FROM information_schema.TABLES ' + "WHERE TABLE_SCHEMA = '$s' AND TABLE_TYPE = 'VIEW' " + 'ORDER BY TABLE_NAME', + ); + return rs.rows.map((r) => r.colAt(0)!).toList(); + } + + /// Column names for [table] in [database] (database = schema in MySQL). + Future> listColumnNames({ + required String database, + required String table, + }) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final d = _escapeSqlString(database); + final t = _escapeSqlString(table); + final rs = await execute( + 'SELECT COLUMN_NAME FROM information_schema.COLUMNS ' + "WHERE TABLE_SCHEMA = '$d' AND TABLE_NAME = '$t' " + 'ORDER BY ORDINAL_POSITION', + ); + return rs.rows.map((r) => r.colAt(0)!).toList(); + } + + /// Lists base tables in [schema] (MySQL: database name). + Future> listTables({required String schema}) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final s = _escapeSqlString(schema); + final rs = await execute( + 'SELECT TABLE_NAME FROM information_schema.TABLES ' + "WHERE TABLE_SCHEMA = '$s' AND TABLE_TYPE = 'BASE TABLE' " + 'ORDER BY TABLE_NAME', + ); + return rs.rows.map((r) => r.colAt(0)!).toList(); + } + + Future serverVersion() async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final rs = await execute('SELECT VERSION()'); + return rs.rows.first.colAt(0) ?? ''; + } +} diff --git a/lib/core/database/mysql_connection_pool.dart b/lib/core/database/mysql_connection_pool.dart new file mode 100644 index 00000000..941f71bc --- /dev/null +++ b/lib/core/database/mysql_connection_pool.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// Session policy for pooled connections: browse vs SQL editor (writes). +enum MysqlSessionMode { + readOnly, + readWrite, +} + +typedef MysqlPoolConnectionFactory = Future Function( + ConnectionRow row, { + required String database, + required MysqlSessionMode mode, +}); + +/// Lease for a pooled [MysqlConnection]. Call [release] when the UI is done. +class MysqlLease { + MysqlLease._(this._pool, this._key, this.connection); + + final MysqlConnectionPool _pool; + final String _key; + final MysqlConnection connection; + + bool _released = false; + + void release() { + if (_released) return; + _released = true; + _pool._release(_key); + } +} + +/// Pooled MySQL connections keyed by `(connection id, database, session mode)`. +class MysqlConnectionPool { + MysqlConnectionPool({ + required this.createAndConnect, + this.idleDisposeDelay = defaultIdleDisposeDelay, + this.maxEntries = defaultMaxEntries, + }); + + static const Duration defaultIdleDisposeDelay = Duration(seconds: 8); + static const int defaultMaxEntries = 32; + + final MysqlPoolConnectionFactory createAndConnect; + final Duration idleDisposeDelay; + final int maxEntries; + + final Map _pool = {}; + + String keyFor(int? id, String database, MysqlSessionMode mode) => + '${id ?? 0}::$database::${mode.name}'; + + Future acquire( + ConnectionRow row, { + required String database, + MysqlSessionMode mode = MysqlSessionMode.readOnly, + }) async { + final k = keyFor(row.id, database, mode); + var entry = _pool[k]; + if (entry != null) { + entry.touch(); + entry.idleTimer?.cancel(); + entry.idleTimer = null; + entry.refs++; + if (!entry.connection.isConnected) { + await entry.connection.connect(); + await entry.connection.setSessionReadOnly( + mode == MysqlSessionMode.readOnly, + ); + } + return MysqlLease._(this, k, entry.connection); + } + + _evictIfNeededBeforeNewSlot(); + + final conn = await createAndConnect(row, database: database, mode: mode); + entry = _PoolEntry(conn)..refs = 1; + _pool[k] = entry; + return MysqlLease._(this, k, conn); + } + + void _evictIfNeededBeforeNewSlot() { + while (_pool.length >= maxEntries) { + final idle = _pool.entries.where((e) => e.value.refs == 0).toList(); + if (idle.isEmpty) { + throw StateError( + 'MySQL connection pool exhausted: $maxEntries slots in use.', + ); + } + idle.sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed)); + _removeEntryClosing(idle.first.key); + } + } + + void _removeEntryClosing(String k) { + final entry = _pool.remove(k); + if (entry == null) return; + entry.idleTimer?.cancel(); + unawaited(entry.connection.forceClose()); + } + + void _release(String k) { + final entry = _pool[k]; + if (entry == null) return; + entry.refs--; + if (entry.refs > 0) return; + entry.idleTimer?.cancel(); + entry.idleTimer = Timer(idleDisposeDelay, () { + final e = _pool[k]; + if (e == null || e.refs > 0) return; + e.idleTimer = null; + unawaited(e.connection.disconnect()); + _pool.remove(k); + }); + } + + void interrupt( + ConnectionRow row, { + required String database, + MysqlSessionMode mode = MysqlSessionMode.readOnly, + }) { + final k = keyFor(row.id, database, mode); + _removeEntryClosing(k); + } + + Future disconnectAll() async { + for (final entry in _pool.values) { + entry.idleTimer?.cancel(); + await entry.connection.forceClose(); + } + _pool.clear(); + } +} + +class _PoolEntry { + _PoolEntry(this.connection) : lastUsed = DateTime.now(); + + final MysqlConnection connection; + int refs = 0; + Timer? idleTimer; + DateTime lastUsed; + + void touch() => lastUsed = DateTime.now(); +} diff --git a/lib/core/database/mysql_service.dart b/lib/core/database/mysql_service.dart new file mode 100644 index 00000000..276ccf60 --- /dev/null +++ b/lib/core/database/mysql_service.dart @@ -0,0 +1,52 @@ +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/database/mysql_connection_pool.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +export 'mysql_connection_pool.dart' + show MysqlLease, MysqlSessionMode, MysqlConnectionPool; + +Future _defaultCreateAndConnect( + ConnectionRow row, { + required String database, + required MysqlSessionMode mode, +}) async { + final conn = MysqlConnection.fromConnectionRow( + row, + database: database.isEmpty ? null : database, + ); + await conn.connect(); + await conn.setSessionReadOnly(mode == MysqlSessionMode.readOnly); + return conn; +} + +/// Global MySQL connection pool (singleton). +class MysqlService { + MysqlService._() + : _pool = MysqlConnectionPool( + createAndConnect: _defaultCreateAndConnect, + maxEntries: MysqlConnectionPool.defaultMaxEntries, + ); + + static final MysqlService instance = MysqlService._(); + + final MysqlConnectionPool _pool; + + static const Duration idleDisposeDelay = + MysqlConnectionPool.defaultIdleDisposeDelay; + + Future acquire( + ConnectionRow row, { + required String database, + MysqlSessionMode mode = MysqlSessionMode.readOnly, + }) => + _pool.acquire(row, database: database, mode: mode); + + void interrupt( + ConnectionRow row, { + required String database, + MysqlSessionMode mode = MysqlSessionMode.readOnly, + }) => + _pool.interrupt(row, database: database, mode: mode); + + Future disconnectAll() => _pool.disconnectAll(); +} diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index c58a5834..5203626a 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -4,6 +4,7 @@ import 'local_db.dart'; abstract final class AppSettingsKeys { static const postgresSqlStmtTimeoutSeconds = 'postgres_sql_stmt_timeout_seconds'; + static const mysqlSqlStmtTimeoutSeconds = 'mysql_sql_stmt_timeout_seconds'; } /// User preferences backed by [LocalDb] (SQLite). @@ -32,4 +33,26 @@ class AppSettings { ); } } + + /// `null` = use driver default for statement duration. + Future getMysqlSqlStmtTimeoutSeconds() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.mysqlSqlStmtTimeoutSeconds, + ); + if (v == null || v.isEmpty) return null; + return int.tryParse(v); + } + + Future setMysqlSqlStmtTimeoutSeconds(int? seconds) async { + if (seconds == null) { + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.mysqlSqlStmtTimeoutSeconds, + ); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.mysqlSqlStmtTimeoutSeconds, + seconds.toString(), + ); + } + } } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 3c5eca8c..5ae139f8 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' as material show AlertDialog, BoxConstraints, BuildContext, Column, ConstrainedBox, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, DefaultTextStyle, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator, Material, StatelessWidget, Colors, Tooltip, Color, LayoutBuilder, TextPainter, TextSpan, TextDirection, SelectableText, Padding, Widget, Navigator, ValueKey; import 'package:flutter/services.dart' show Clipboard, ClipboardData; import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; @@ -11,6 +12,8 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mongodb/mongo_database_dialog.dart'; import 'package:querya_desktop/features/mongodb/mongodb_connection_form.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/mysql/mysql_connection_form.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgresql_connection_form.dart'; import 'package:querya_desktop/features/redis/redis_connection_form.dart'; import 'new_connection_dialog.dart'; @@ -25,6 +28,8 @@ class ConnectionsPanel extends StatefulWidget { this.onMongoDBDatabaseSelected, this.onPostgresObjectSelected, this.onPostgresOpenSqlWorkspace, + this.onMysqlObjectSelected, + this.onMysqlOpenSqlWorkspace, /// When true, [initState] does not call [_loadData]. Widget tests that seed /// SQLite in setUp should call [ConnectionsPanelState.reloadConnectionsFromDb] /// inside [WidgetTester.runAsync] so only one load runs (avoids overlapping @@ -55,6 +60,17 @@ class ConnectionsPanel extends StatefulWidget { /// Opens the PostgreSQL workspace home and switches to the SQL tab (e.g. from tree context menu). final void Function(ConnectionRow connection)? onPostgresOpenSqlWorkspace; + /// MySQL table or view selected in the tree. + final void Function( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + )? onMysqlObjectSelected; + + /// Opens the MySQL workspace home and switches to the SQL tab. + final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; + final bool skipInitialDbLoadForTest; @override @@ -136,6 +152,8 @@ class ConnectionsPanelState extends State { if (type == ConnectionType.postgresql) { row = await showPostgresConnectionForm(context, folderId: folderId); + } else if (type == ConnectionType.mysql) { + row = await showMysqlConnectionForm(context, folderId: folderId); } else if (type == ConnectionType.mongodb) { row = await showMongoConnectionForm(context, folderId: folderId); } else if (type == ConnectionType.redis) { @@ -189,6 +207,16 @@ class ConnectionsPanelState extends State { onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, ); + } else if (conn.type == 'mysql') { + return _MysqlConnectionTile( + connection: conn, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + ); } else if (conn.type == 'redis') { return _RedisConnectionTile( connection: conn, @@ -1476,6 +1504,524 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { } } +// ─── MySQL connection tile (databases → tables) ─────────────────────────────── + +class _MysqlConnectionTile extends StatefulWidget { + const _MysqlConnectionTile({ + required this.connection, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + this.onMysqlObjectSelected, + this.onMysqlOpenSqlWorkspace, + }); + + final ConnectionRow connection; + final material.IconData icon; + final String? iconAsset; + final VoidCallback onRemove; + final VoidCallback? onTap; + final void Function( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + )? onMysqlObjectSelected; + final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; + + @override + State<_MysqlConnectionTile> createState() => _MysqlConnectionTileState(); +} + +class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { + bool _expanded = false; + bool _loading = false; + String? _error; + List _databases = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _databases.isEmpty && !_loading) { + _loadDatabases(); + } + } + + Future _loadDatabases() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + MysqlLease? lease; + try { + final c = widget.connection; + lease = await MysqlService.instance.acquire( + c, + database: c.databaseName ?? '', + mode: MysqlSessionMode.readOnly, + ); + final dbs = await lease.connection.listDatabases(); + if (!mounted) return; + setState(() { + _databases = dbs; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } finally { + lease?.release(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconWidget = widget.iconAsset != null + ? material.Image.asset( + widget.iconAsset!, + width: 16, + height: 16, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ), + ) + : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); + + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.refresh_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) { + _databases = []; + _loadDatabases(); + }, + child: const Text('Refresh databases'), + ), + if (widget.onMysqlOpenSqlWorkspace != null) + MenuButton( + leading: material.Icon(material.Icons.terminal_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onMysqlOpenSqlWorkspace!(widget.connection), + child: const Text('Open in SQL'), + ), + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onRemove(), + child: const Text('Remove connection'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ), + material.Expanded( + child: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: widget.onTap, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: + theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + if (_expanded) ...[ + if (_loading) + material.Padding( + padding: + const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Row( + children: [ + const material.SizedBox( + width: 12, + height: 12, + child: + material.CircularProgressIndicator(strokeWidth: 1.5), + ), + const Gap(8), + const Text('Loading...').muted().xSmall(), + ], + ), + ), + if (_error != null) + material.Padding( + padding: + const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Text( + 'Error', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, color: theme.colorScheme.destructive), + ), + ), + if (_databases.isNotEmpty) + _MysqlDatabasesNode( + connection: widget.connection, + databases: _databases, + onRefreshDatabases: () { + setState(() => _databases = []); + _loadDatabases(); + }, + onMysqlObjectSelected: widget.onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + ), + ], + ], + ), + ), + ); + } +} + +class _MysqlDatabasesNode extends material.StatelessWidget { + const _MysqlDatabasesNode({ + required this.connection, + required this.databases, + required this.onRefreshDatabases, + this.onMysqlObjectSelected, + this.onMysqlOpenSqlWorkspace, + }); + + final ConnectionRow connection; + final List databases; + final VoidCallback onRefreshDatabases; + final void Function( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + )? onMysqlObjectSelected; + final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 20), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + _PgTreeRow( + label: 'Databases (${databases.length})', + icon: material.Icons.dns_rounded, + iconSize: 14, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 4, + onTap: null, + connection: connection, + onContextRefresh: onRefreshDatabases, + onOpenSqlWorkspace: onMysqlOpenSqlWorkspace, + ), + for (final db in databases) + _MysqlDatabaseNode( + key: material.ValueKey('mysql-db-${connection.id ?? 0}-$db'), + connection: connection, + databaseName: db, + onMysqlObjectSelected: onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: onMysqlOpenSqlWorkspace, + ), + ], + ), + ); + } +} + +class _MysqlDatabaseNode extends StatefulWidget { + const _MysqlDatabaseNode({ + super.key, + required this.connection, + required this.databaseName, + this.onMysqlObjectSelected, + this.onMysqlOpenSqlWorkspace, + }); + + final ConnectionRow connection; + final String databaseName; + final void Function( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + )? onMysqlObjectSelected; + final void Function(ConnectionRow connection)? onMysqlOpenSqlWorkspace; + + @override + State<_MysqlDatabaseNode> createState() => _MysqlDatabaseNodeState(); +} + +class _MysqlDatabaseNodeState extends State<_MysqlDatabaseNode> { + bool _expanded = false; + bool _loading = false; + List _tables = []; + List _views = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _tables.isEmpty && _views.isEmpty && !_loading) { + _loadTables(); + } + } + + Future _loadTables() async { + if (!mounted) return; + setState(() => _loading = true); + MysqlLease? lease; + try { + final c = widget.connection; + lease = await MysqlService.instance.acquire( + c, + database: widget.databaseName, + mode: MysqlSessionMode.readOnly, + ); + final tables = + await lease.connection.listTables(schema: widget.databaseName); + final views = + await lease.connection.listViews(schema: widget.databaseName); + if (!mounted) return; + setState(() { + _tables = tables; + _views = views; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _loading = false); + } finally { + lease?.release(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + _PgTreeRow( + label: widget.databaseName, + leading: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + icon: material.Icons.storage_rounded, + iconSize: 14, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 4, + onTap: _toggle, + connection: widget.connection, + onContextRefresh: _loadTables, + onOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, + ), + if (_expanded) ...[ + if (_loading) + material.Padding( + padding: + const material.EdgeInsets.only(left: 24, top: 2, bottom: 2), + child: material.Row( + children: [ + const material.SizedBox( + width: 10, + height: 10, + child: + material.CircularProgressIndicator(strokeWidth: 1.5), + ), + const Gap(6), + const Text('Loading...').muted().xSmall(), + ], + ), + ), + if (_tables.isNotEmpty || _views.isNotEmpty) + material.Padding( + padding: const material.EdgeInsets.only(left: 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + if (_tables.isNotEmpty) ...[ + _PgTreeRow( + label: 'Tables (${_tables.length})', + icon: material.Icons.table_chart_rounded, + iconSize: 13, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + verticalPadding: 3, + onTap: null, + connection: widget.connection, + onContextRefresh: _loadTables, + onOpenSqlWorkspace: null, + ), + for (final t in _tables) + material.Padding( + padding: const material.EdgeInsets.only(left: 12), + child: _PgTreeRow( + label: t, + icon: material.Icons.grid_on_rounded, + iconSize: 12, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.foreground, + ), + verticalPadding: 2, + onTap: widget.onMysqlObjectSelected == null + ? null + : () => widget.onMysqlObjectSelected!( + widget.connection, + widget.databaseName, + t, + MysqlObjectKind.table, + ), + connection: widget.connection, + onContextRefresh: null, + onOpenSqlWorkspace: null, + ), + ), + ], + if (_views.isNotEmpty) ...[ + _PgTreeRow( + label: 'Views (${_views.length})', + icon: material.Icons.view_agenda_rounded, + iconSize: 13, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + verticalPadding: 3, + onTap: null, + connection: widget.connection, + onContextRefresh: _loadTables, + onOpenSqlWorkspace: null, + ), + for (final v in _views) + material.Padding( + padding: const material.EdgeInsets.only(left: 12), + child: _PgTreeRow( + label: v, + icon: material.Icons.view_week_rounded, + iconSize: 12, + iconColor: theme.colorScheme.mutedForeground, + textStyle: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.foreground, + ), + verticalPadding: 2, + onTap: widget.onMysqlObjectSelected == null + ? null + : () => widget.onMysqlObjectSelected!( + widget.connection, + widget.databaseName, + v, + MysqlObjectKind.view, + ), + connection: widget.connection, + onContextRefresh: null, + onOpenSqlWorkspace: null, + ), + ), + ], + ], + ), + ), + ], + ], + ), + ); + } +} + class _PgDatabasesNode extends StatefulWidget { const _PgDatabasesNode({ required this.connection, diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index 768d6a17..dc3ece3c 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -21,7 +21,7 @@ typedef _DriverInfo = ({ final _driverInfoList = <_DriverInfo>[ (type: ConnectionType.postgresql, description: 'PostgreSQL server connection (JDBC driver)', fixedStatus: null, downloadable: DownloadableDriver.postgresql), - (type: ConnectionType.mysql, description: 'MySQL / MariaDB server connection', fixedStatus: DriverStatus.comingSoon, downloadable: null), + (type: ConnectionType.mysql, description: 'MySQL / MariaDB server connection', fixedStatus: DriverStatus.installed, downloadable: null), (type: ConnectionType.redis, description: 'Redis server connection', fixedStatus: DriverStatus.installed, downloadable: null), (type: ConnectionType.mongodb, description: 'MongoDB server connection', fixedStatus: DriverStatus.installed, downloadable: null), ]; diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index acf1c8ad..e9882cf0 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; @@ -43,12 +44,19 @@ class _MainScreenState extends State { /// Bumped to tell [PostgresWorkspaceHome] to switch to the SQL tab. int _postgresSqlTabRequestToken = 0; + /// When set, user selected a MySQL table or view in the tree. + ({String database, String name, MysqlObjectKind kind})? _selectedMysqlObject; + + /// Bumped to tell [MysqlWorkspaceHome] to switch to the SQL tab. + int _mysqlSqlTabRequestToken = 0; + void _onConnectionSelected(ConnectionRow connection) { setState(() { _activeConnection = connection; _activeRedisDb = null; _activeMongoDB = null; _selectedPostgresObject = null; + _selectedMysqlObject = null; }); } @@ -63,6 +71,7 @@ class _MainScreenState extends State { _activeConnection = connection; _activeRedisDb = null; _activeMongoDB = null; + _selectedMysqlObject = null; _selectedPostgresObject = ( database: database, schema: schema, @@ -72,11 +81,32 @@ class _MainScreenState extends State { }); } + void _onMysqlObjectSelected( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + ) { + setState(() { + _activeConnection = connection; + _activeRedisDb = null; + _activeMongoDB = null; + _selectedPostgresObject = null; + _selectedMysqlObject = ( + database: database, + name: name, + kind: kind, + ); + }); + } + void _onRedisDatabaseSelected(ConnectionRow connection, int database) { setState(() { _activeConnection = connection; _activeRedisDb = database; _activeMongoDB = null; + _selectedPostgresObject = null; + _selectedMysqlObject = null; }); } @@ -85,6 +115,8 @@ class _MainScreenState extends State { _activeConnection = connection; _activeRedisDb = null; _activeMongoDB = database; + _selectedPostgresObject = null; + _selectedMysqlObject = null; }); } @@ -94,10 +126,22 @@ class _MainScreenState extends State { _activeRedisDb = null; _activeMongoDB = null; _selectedPostgresObject = null; + _selectedMysqlObject = null; _postgresSqlTabRequestToken++; }); } + void _onMysqlOpenSqlWorkspace(ConnectionRow connection) { + setState(() { + _activeConnection = connection; + _activeRedisDb = null; + _activeMongoDB = null; + _selectedPostgresObject = null; + _selectedMysqlObject = null; + _mysqlSqlTabRequestToken++; + }); + } + @override Widget build(BuildContext context) { final theme = AppTheme.dark.colorScheme; @@ -139,6 +183,8 @@ class _MainScreenState extends State { onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, onPostgresObjectSelected: _onPostgresObjectSelected, onPostgresOpenSqlWorkspace: _onPostgresOpenSqlWorkspace, + onMysqlObjectSelected: _onMysqlObjectSelected, + onMysqlOpenSqlWorkspace: _onMysqlOpenSqlWorkspace, ), ), _VerticalResizeHandle( @@ -168,6 +214,8 @@ class _MainScreenState extends State { selectedMongoDb: _activeMongoDB, selectedPostgresObject: _selectedPostgresObject, postgresSqlTabRequestToken: _postgresSqlTabRequestToken, + selectedMysqlObject: _selectedMysqlObject, + mysqlSqlTabRequestToken: _mysqlSqlTabRequestToken, ), ), ], diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index a1ab3d18..1cbad5c3 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart' as material show Axis, Container, EdgeIns import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/mysql/mysql_table_view.dart'; +import 'package:querya_desktop/features/mysql/mysql_workspace_home.dart'; import 'package:querya_desktop/features/mongodb/mongo_explorer_view.dart'; import 'package:querya_desktop/features/mongodb/mongo_stats_view.dart'; import 'package:querya_desktop/features/postgresql/postgres_browser_views.dart'; @@ -117,6 +120,8 @@ class WorkspacePanel extends StatefulWidget { this.selectedMongoDb, this.selectedPostgresObject, this.postgresSqlTabRequestToken = 0, + this.selectedMysqlObject, + this.mysqlSqlTabRequestToken = 0, }); /// Currently selected connection from the sidebar. @@ -138,6 +143,13 @@ class WorkspacePanel extends StatefulWidget { /// Incremented by [MainScreen] to switch the PostgreSQL home view to the SQL tab. final int postgresSqlTabRequestToken; + /// When set, the user selected a MySQL table or view in the sidebar tree. + final ({String database, String name, MysqlObjectKind kind})? + selectedMysqlObject; + + /// Incremented by [MainScreen] to switch the MySQL home view to the SQL tab. + final int mysqlSqlTabRequestToken; + @override State createState() => _WorkspacePanelState(); } @@ -172,6 +184,32 @@ class _WorkspacePanelState extends State { ); } + // MySQL / MariaDB + if (widget.activeConnection != null && + widget.activeConnection!.type == 'mysql') { + final my = widget.selectedMysqlObject; + return material.Container( + color: theme.colorScheme.background, + child: material.SizedBox.expand( + child: my == null + ? MysqlWorkspaceHome( + key: ValueKey('mysql_home_${widget.activeConnection!.id}'), + connectionRow: widget.activeConnection!, + sqlTabRequestToken: widget.mysqlSqlTabRequestToken, + ) + : MysqlTableView( + key: ValueKey( + 'mysql_${widget.activeConnection!.id}_${my.database}_${my.name}_${my.kind}', + ), + connectionRow: widget.activeConnection!, + database: my.database, + tableName: my.name, + isView: my.kind == MysqlObjectKind.view, + ), + ), + ); + } + // If a MongoDB connection is selected if (widget.activeConnection != null && widget.activeConnection!.type == 'mongodb') { diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart new file mode 100644 index 00000000..92ba4cee --- /dev/null +++ b/lib/features/mysql/mysql_connection_form.dart @@ -0,0 +1,485 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows MySQL / MariaDB connection form dialog. +/// Returns [ConnectionRow] if saved, null if cancelled. +Future showMysqlConnectionForm( + BuildContext context, { + int? folderId, +}) async { + return showAppDialog( + context: context, + builder: (context) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: + const material.EdgeInsets.symmetric(horizontal: 40, vertical: 24), + child: _MysqlConnectionFormContent(folderId: folderId), + ), + ); +} + +class _MysqlConnectionFormContent extends material.StatefulWidget { + const _MysqlConnectionFormContent({this.folderId}); + + final int? folderId; + + @override + material.State<_MysqlConnectionFormContent> createState() => + _MysqlConnectionFormContentState(); +} + +class _MysqlConnectionFormContentState + extends material.State<_MysqlConnectionFormContent> { + final _nameController = material.TextEditingController(); + final _hostController = material.TextEditingController(text: 'localhost'); + final _portController = material.TextEditingController(text: '3306'); + final _databaseController = material.TextEditingController(); + final _usernameController = material.TextEditingController(text: 'root'); + final _passwordController = material.TextEditingController(); + final _connectionStringController = material.TextEditingController(); + + bool _useSSL = true; + bool _showPassword = false; + bool _isTesting = false; + String? _testResult; + Timer? _dismissTimer; + + @override + void initState() { + super.initState(); + _nameController.addListener(_onFieldChanged); + _hostController.addListener(_onFieldChanged); + _portController.addListener(_onFieldChanged); + _databaseController.addListener(_onFieldChanged); + _usernameController.addListener(_onFieldChanged); + _connectionStringController.addListener(_onFieldChanged); + } + + void _onFieldChanged() => setState(() {}); + + bool _looksLikeMysqlUri(String s) { + final t = s.trim().toLowerCase(); + return t.startsWith('mysql://') || t.startsWith('mariadb://'); + } + + bool get _formValid { + final uri = _connectionStringController.text.trim(); + if (uri.isNotEmpty) { + return _looksLikeMysqlUri(uri); + } + final host = _hostController.text.trim(); + return host.isNotEmpty; + } + + void _showTestResult(String result) { + _dismissTimer?.cancel(); + setState(() { + _isTesting = false; + _testResult = result; + }); + _dismissTimer = Timer(const Duration(seconds: 5), () { + if (mounted) setState(() => _testResult = null); + }); + } + + void _dismissResult() { + _dismissTimer?.cancel(); + _dismissTimer = null; + setState(() => _testResult = null); + } + + Future _testConnection() async { + if (!_formValid) return; + _dismissTimer?.cancel(); + _dismissTimer = null; + setState(() { + _isTesting = true; + _testResult = null; + }); + try { + final uri = _connectionStringController.text.trim(); + final dbText = _databaseController.text.trim(); + final conn = MysqlConnection( + id: 0, + name: _nameController.text.trim().isEmpty + ? 'test' + : _nameController.text.trim(), + host: uri.isNotEmpty ? 'localhost' : _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 3306, + database: dbText.isEmpty ? null : dbText, + username: _usernameController.text.trim().isEmpty + ? null + : _usernameController.text.trim(), + password: _passwordController.text.isEmpty + ? null + : _passwordController.text, + useSSL: _useSSL, + connectionString: uri.isEmpty ? null : uri, + ); + final ok = await conn.testConnection(); + if (mounted) _showTestResult(ok ? 'success' : 'failed'); + } catch (e) { + if (mounted) _showTestResult('error: $e'); + } + } + + void _save() { + if (!_formValid) return; + final name = _nameController.text.trim(); + final host = _hostController.text.trim(); + final port = int.tryParse(_portController.text.trim()) ?? 3306; + final database = _databaseController.text.trim(); + final uri = _connectionStringController.text.trim(); + final displayName = name.isNotEmpty + ? name + : (uri.isNotEmpty + ? 'MySQL (URI)' + : 'MySQL $host:$port${database.isNotEmpty ? '/$database' : ''}'); + final row = ConnectionRow( + type: 'mysql', + name: displayName, + host: uri.isNotEmpty ? null : host, + port: uri.isNotEmpty ? null : port, + username: _usernameController.text.trim().isEmpty + ? null + : _usernameController.text.trim(), + password: + _passwordController.text.isEmpty ? null : _passwordController.text, + databaseName: uri.isNotEmpty + ? null + : (database.isEmpty ? null : database), + useSSL: _useSSL, + connectionString: uri.isEmpty ? null : uri, + folderId: widget.folderId, + createdAt: DateTime.now().toUtc().toIso8601String(), + ); + material.Navigator.of(context).pop(row); + } + + @override + void dispose() { + _dismissTimer?.cancel(); + _nameController.removeListener(_onFieldChanged); + _hostController.removeListener(_onFieldChanged); + _portController.removeListener(_onFieldChanged); + _databaseController.removeListener(_onFieldChanged); + _usernameController.removeListener(_onFieldChanged); + _connectionStringController.removeListener(_onFieldChanged); + _nameController.dispose(); + _hostController.dispose(); + _portController.dispose(); + _databaseController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + _connectionStringController.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + + return material.Container( + constraints: + const material.BoxConstraints(maxWidth: 600, maxHeight: 640), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + children: [ + material.SizedBox( + width: 24, + height: 24, + child: material.Image.asset( + 'assets/images/mysql_icon.png', + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + material.Icons.table_chart_rounded, + size: 24, + color: theme.primary, + ), + ), + ), + const Gap(12), + const Text('MySQL Connection').large().semiBold(), + ], + ), + const Gap(8), + const Text('Configure your MySQL or MariaDB connection') + .muted() + .small(), + ], + ), + ), + const material.Divider(height: 1), + material.Expanded( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Connection Name').small().semiBold(), + const Gap(8), + TextField( + controller: _nameController, + placeholder: const Text('My MySQL Server'), + ), + const Gap(16), + const Text('Connection URI (optional)').small().semiBold(), + const Gap(4), + const Text( + 'If set, overrides Host / Port / Database below.', + ).muted().small(), + const Gap(4), + const Text( + 'Query params: ssl-mode (disable, require), database.', + ).muted().small(), + const Gap(8), + TextField( + controller: _connectionStringController, + placeholder: const Text( + 'mysql://user:pass@host:3306/dbname?ssl-mode=disable', + ), + ), + const Gap(16), + material.Row( + children: [ + material.Expanded( + flex: 3, + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Host').small().semiBold(), + const Gap(8), + TextField( + controller: _hostController, + placeholder: const Text('localhost'), + ), + ], + ), + ), + const Gap(12), + material.Expanded( + flex: 1, + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Port').small().semiBold(), + const Gap(8), + TextField( + controller: _portController, + placeholder: const Text('3306'), + ), + ], + ), + ), + ], + ), + const Gap(16), + const Text('Database (optional)').small().semiBold(), + const Gap(8), + TextField( + controller: _databaseController, + placeholder: const Text('Leave empty to browse all'), + ), + const Gap(16), + const Text('Username').small().semiBold(), + const Gap(8), + TextField( + controller: _usernameController, + placeholder: const Text('root'), + ), + const Gap(16), + const Text('Password').small().semiBold(), + const Gap(8), + material.Stack( + children: [ + TextField( + controller: _passwordController, + placeholder: const Text('Password'), + obscureText: !_showPassword, + ), + material.Positioned( + right: 8, + top: 0, + bottom: 0, + child: material.Center( + child: material.IconButton( + icon: material.Icon( + _showPassword + ? material.Icons.visibility_off + : material.Icons.visibility, + size: 20, + ), + onPressed: () => + setState(() => _showPassword = !_showPassword), + padding: material.EdgeInsets.zero, + constraints: const material.BoxConstraints(), + ), + ), + ), + ], + ), + const Gap(16), + material.Row( + children: [ + material.Checkbox( + value: _useSSL, + onChanged: (v) => + setState(() => _useSSL = v ?? true), + ), + const Gap(8), + const Text('Use SSL/TLS').small(), + ], + ), + ], + ), + ), + ), + const material.Divider(height: 1), + if (_testResult != null) + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 8, 16, 8), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: _dismissResult, + borderRadius: material.BorderRadius.circular(8), + child: material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: material.BoxDecoration( + color: _testResult == 'success' + ? theme.primary.withValues(alpha: 0.12) + : theme.destructive.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: _testResult == 'success' + ? theme.primary.withValues(alpha: 0.35) + : theme.destructive.withValues(alpha: 0.35), + width: 1, + ), + ), + child: material.Row( + children: [ + material.Icon( + _testResult == 'success' + ? material.Icons.check_circle_outline + : material.Icons.info_outline_rounded, + size: 18, + color: _testResult == 'success' + ? theme.primary + : theme.destructive, + ), + const Gap(10), + material.Expanded( + child: Text( + _testResult == 'success' + ? 'Connection successful!' + : _testResult!.startsWith('error:') + ? _testResult!.substring(7) + : 'Connection failed', + style: material.TextStyle( + fontSize: 13, + color: theme.foreground, + ), + ).small(), + ), + material.IconButton( + icon: material.Icon( + material.Icons.close, + size: 18, + color: theme.mutedForeground, + ), + onPressed: _dismissResult, + style: material.IconButton.styleFrom( + minimumSize: const material.Size(28, 28), + padding: material.EdgeInsets.zero, + ), + ), + ], + ), + ), + ), + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, vertical: 16), + child: material.Wrap( + spacing: 12, + runSpacing: 8, + alignment: material.WrapAlignment.spaceBetween, + children: [ + OutlineButton( + onPressed: + _formValid && !_isTesting ? _testConnection : null, + leading: _isTesting + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), + ) + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: _formValid + ? theme.primary + : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: + _formValid ? theme.primary : theme.mutedForeground, + ), + ), + ), + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: _formValid ? _save : null, + child: const Text('Save'), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/mysql/mysql_object_kind.dart b/lib/features/mysql/mysql_object_kind.dart new file mode 100644 index 00000000..f7c4cf32 --- /dev/null +++ b/lib/features/mysql/mysql_object_kind.dart @@ -0,0 +1,5 @@ +/// Object selected in the MySQL browser tree. +enum MysqlObjectKind { + table, + view, +} diff --git a/lib/features/mysql/mysql_sql_editor_dialog.dart b/lib/features/mysql/mysql_sql_editor_dialog.dart new file mode 100644 index 00000000..b75d472a --- /dev/null +++ b/lib/features/mysql/mysql_sql_editor_dialog.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/features/mysql/mysql_table_utils.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Dialog to view/edit SQL and run it (read-only SELECT for table browse). +Future showMysqlSqlEditorDialog({ + required material.BuildContext context, + required String initialSql, + required String browseSql, + required void Function(String sql) onRun, +}) { + return showAppDialog( + context: context, + builder: (ctx) => _MysqlSqlEditorDialog( + initialSql: initialSql, + browseSql: browseSql, + onRun: onRun, + ), + ); +} + +class _MysqlSqlEditorDialog extends material.StatefulWidget { + const _MysqlSqlEditorDialog({ + required this.initialSql, + required this.browseSql, + required this.onRun, + }); + + final String initialSql; + final String browseSql; + final void Function(String sql) onRun; + + @override + material.State<_MysqlSqlEditorDialog> createState() => + _MysqlSqlEditorDialogState(); +} + +class _MysqlSqlEditorDialogState extends material.State<_MysqlSqlEditorDialog> { + late final material.TextEditingController _controller; + String? _error; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController(text: widget.initialSql); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final sql = _controller.text.trim(); + if (sql.isEmpty) { + setState(() => _error = 'Enter a SQL query'); + return; + } + if (!isAllowedMysqlSelectQuery(sql)) { + setState(() => + _error = 'Only a single SELECT (or WITH …) statement is allowed.'); + return; + } + material.Navigator.of(context).pop(); + widget.onRun(sql); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + return material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: + const material.EdgeInsets.symmetric(horizontal: 32, vertical: 24), + child: material.Container( + constraints: const material.BoxConstraints( + maxWidth: 720, + minWidth: 480, + maxHeight: 520, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 20, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('SQL query').large().semiBold(), + const material.SizedBox(height: 6), + const Text( + 'Table browse uses SELECT with LIMIT/OFFSET. ' + 'Edit or write your own SELECT. Reset restores the browse query.', + ).muted().xSmall(), + ], + ), + ), + material.Padding( + padding: const material.EdgeInsets.symmetric(horizontal: 24), + child: material.SizedBox( + height: 280, + child: material.Container( + decoration: material.BoxDecoration( + color: theme.muted.withValues(alpha: 0.15), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: theme.border.withValues(alpha: 0.4)), + ), + child: material.TextField( + controller: _controller, + maxLines: null, + expands: true, + textAlignVertical: material.TextAlignVertical.top, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: theme.foreground, + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + contentPadding: material.EdgeInsets.all(12), + hintText: 'SELECT …', + ), + ), + ), + ), + ), + if (_error != null) + material.Padding( + padding: + const material.EdgeInsets.fromLTRB(24, 8, 24, 0), + child: material.Text( + _error!, + style: material.TextStyle( + color: theme.destructive, fontSize: 12), + ), + ), + material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + OutlineButton( + onPressed: () => + material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(8), + OutlineButton( + onPressed: () { + setState(() { + _error = null; + _controller.text = widget.browseSql; + }); + }, + child: const Text('Reset'), + ), + const Gap(8), + PrimaryButton( + onPressed: _submit, + child: const Text('Run'), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart new file mode 100644 index 00000000..4aad3a38 --- /dev/null +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -0,0 +1,406 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:querya_desktop/core/database/mysql_service.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; +import 'package:querya_desktop/features/main_screen/results_tab.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +const _maxDisplayRows = 5000; + +/// Ad-hoc SQL editor + results for MySQL / MariaDB. +class MysqlSqlWorkspace extends material.StatefulWidget { + const MysqlSqlWorkspace({ + super.key, + required this.connectionRow, + }); + + final ConnectionRow connectionRow; + + @override + material.State createState() => + _MysqlSqlWorkspaceState(); +} + +class _MysqlSqlWorkspaceState extends material.State { + final _sqlController = material.TextEditingController(); + double _topFractionState = 0.65; + + MysqlLease? _lease; + + bool _running = false; + String? _error; + List _columns = []; + List> _rows = []; + int? _affectedRows; + String? _statusLine; + + int? _queryTimeoutSeconds; + + @override + void initState() { + super.initState(); + material.WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_loadStmtTimeoutSetting()); + }); + } + + Future _loadStmtTimeoutSetting() async { + final t = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); + if (!mounted) return; + setState(() => _queryTimeoutSeconds = t); + } + + void _onStmtTimeoutChanged(int? v) { + setState(() => _queryTimeoutSeconds = v); + unawaited(AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(v)); + } + + String _poolDatabaseKey() => widget.connectionRow.databaseName ?? ''; + + Future _ensureLease() async { + if (_lease != null && _lease!.connection.isConnected) return; + _lease?.release(); + _lease = null; + final lease = await MysqlService.instance.acquire( + widget.connectionRow, + database: _poolDatabaseKey(), + mode: MysqlSessionMode.readWrite, + ); + if (!mounted) { + lease.release(); + return; + } + _lease = lease; + } + + Duration? _statementTimeout() => + _queryTimeoutSeconds == null ? null : Duration(seconds: _queryTimeoutSeconds!); + + @override + void dispose() { + if (_running) { + MysqlService.instance.interrupt( + widget.connectionRow, + database: _poolDatabaseKey(), + mode: MysqlSessionMode.readWrite, + ); + } + _lease?.release(); + _sqlController.dispose(); + super.dispose(); + } + + Future _execute() async { + final sql = _sqlController.text.trim(); + if (sql.isEmpty) return; + + setState(() { + _running = true; + _error = null; + _columns = []; + _rows = []; + _affectedRows = null; + _statusLine = null; + }); + + try { + await _ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + if (mounted) { + setState(() { + _error = 'Could not connect to MySQL.'; + _running = false; + }); + } + return; + } + + final to = _statementTimeout(); + final rs = await conn.executeWithTimeout(sql, timeout: to); + + if (!mounted) return; + + final cols = []; + for (final c in rs.cols) { + cols.add(c.name.isNotEmpty ? c.name : 'col_${cols.length}'); + } + + final outRows = >[]; + var n = 0; + for (final row in rs.rows) { + if (n >= _maxDisplayRows) break; + outRows.add( + List.generate( + row.numOfColumns, + (i) => row.colAt(i) ?? 'NULL', + ), + ); + n++; + } + + int? affected; + if (cols.isEmpty && outRows.isEmpty) { + affected = _affectedInt(rs.affectedRows); + } + + setState(() { + _columns = cols; + _rows = outRows; + _affectedRows = affected; + if (cols.isEmpty && outRows.isEmpty) { + _statusLine = affected != null + ? 'OK. Rows affected: $affected.' + : 'Command completed.'; + } else { + final total = rs.numOfRows; + final truncated = total > _maxDisplayRows; + _statusLine = truncated + ? 'Showing first $_maxDisplayRows of $total row(s).' + : '$total row(s).'; + } + _running = false; + }); + } on TimeoutException catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _running = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _running = false; + }); + } + } + } + + static int? _affectedInt(BigInt v) { + if (v == BigInt.zero) return null; + return v.toInt(); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final topFlex = (_topFractionState * 100).round().clamp(20, 80); + final bottomFlex = 100 - topFlex; + + return material.LayoutBuilder( + builder: (context, constraints) { + final totalHeight = constraints.maxHeight; + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_running) { + unawaited(_execute()); + } + }, + }, + child: material.Focus( + autofocus: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: topFlex, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _MysqlSqlToolbar( + onExecute: _running ? null : _execute, + running: _running, + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, + ), + const Divider(height: 1), + Expanded( + child: QueryEditorTab(controller: _sqlController), + ), + ], + ), + ), + _HorizontalResizeHandle( + totalHeight: totalHeight, + onDrag: (dy) { + if (totalHeight <= 0) return; + setState(() { + _topFractionState = (_topFractionState + dy / totalHeight) + .clamp(0.2, 0.85); + }); + }, + ), + Expanded( + flex: bottomFlex, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + ), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + alignment: material.Alignment.centerLeft, + child: const Text('Data Output').semiBold().small(), + ), + const Divider(height: 1), + Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + affectedRows: _affectedRows, + statusLine: _statusLine, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _MysqlSqlToolbar extends material.StatelessWidget { + const _MysqlSqlToolbar({ + required this.onExecute, + required this.running, + required this.queryTimeoutSeconds, + required this.onQueryTimeoutChanged, + }); + + final Future Function()? onExecute; + final bool running; + final int? queryTimeoutSeconds; + final void Function(int?) onQueryTimeoutChanged; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + child: material.Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + const Text('Query').semiBold().small(), + const Spacer(), + OutlineButton( + onPressed: onExecute, + leading: running + ? material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ) + : const material.Icon( + material.Icons.play_arrow_rounded, + size: 18, + ), + child: const Text('Execute (F5)'), + ), + ], + ), + const Gap(8), + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('Stmt timeout').small(), + const Gap(6), + material.DropdownButton( + value: queryTimeoutSeconds, + onChanged: running ? null : onQueryTimeoutChanged, + items: const [ + material.DropdownMenuItem( + value: null, + child: material.Text('No limit'), + ), + material.DropdownMenuItem( + value: 10, + child: material.Text('10 s'), + ), + material.DropdownMenuItem( + value: 30, + child: material.Text('30 s'), + ), + material.DropdownMenuItem( + value: 60, + child: material.Text('60 s'), + ), + material.DropdownMenuItem( + value: 120, + child: material.Text('2 min'), + ), + material.DropdownMenuItem( + value: 300, + child: material.Text('5 min'), + ), + material.DropdownMenuItem( + value: 600, + child: material.Text('10 min'), + ), + ], + ), + ], + ), + ], + ), + ], + ), + ); + } +} + +class _HorizontalResizeHandle extends material.StatelessWidget { + const _HorizontalResizeHandle({ + required this.totalHeight, + required this.onDrag, + }); + + final double totalHeight; + final void Function(double dy) onDrag; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + return material.MouseRegion( + cursor: material.SystemMouseCursors.resizeRow, + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onVerticalDragUpdate: (e) => onDrag(e.delta.dy), + child: material.Container( + height: 6, + color: theme.border.withValues(alpha: 0.15), + ), + ), + ); + } +} diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart new file mode 100644 index 00000000..b04729b8 --- /dev/null +++ b/lib/features/mysql/mysql_stats_view.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/mysql_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Summary when a MySQL connection is selected without a tree object. +class MysqlStatsView extends material.StatefulWidget { + const MysqlStatsView({ + super.key, + required this.connectionRow, + }); + + final ConnectionRow connectionRow; + + @override + material.State createState() => _MysqlStatsViewState(); +} + +class _MysqlStatsViewState extends material.State { + MysqlLease? _lease; + String? _version; + int? _databaseCount; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant MysqlStatsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _disconnect(); + _load(); + } + } + + @override + void dispose() { + _disconnect(); + super.dispose(); + } + + void _disconnect() { + _lease?.release(); + _lease = null; + } + + Future _load() async { + _disconnect(); + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _version = null; + _databaseCount = null; + }); + try { + final lease = await MysqlService.instance.acquire( + widget.connectionRow, + database: widget.connectionRow.databaseName ?? '', + mode: MysqlSessionMode.readOnly, + ); + if (!mounted) { + lease.release(); + return; + } + _lease = lease; + final v = await lease.connection.serverVersion(); + final dbs = await lease.connection.listDatabases(); + if (!mounted) return; + setState(() { + _version = v; + _databaseCount = dbs.length; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final cs = theme.colorScheme; + + if (_loading) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.SizedBox( + width: 28, + height: 28, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), + ), + const Gap(12), + const Text('Loading server info...').muted().small(), + ], + ), + ); + } + + if (_error != null) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.error_outline_rounded, + size: 48, + color: cs.destructive, + ), + const Gap(16), + const Text('Could not load server info').large().semiBold(), + const Gap(8), + material.SelectableText( + _error!, + style: material.TextStyle( + color: cs.mutedForeground, + fontSize: 13, + ), + ), + const Gap(24), + OutlineButton( + onPressed: _load, + leading: const material.Icon( + material.Icons.refresh_rounded, + size: 18, + ), + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + return material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Server').large().semiBold(), + const Gap(16), + material.Container( + width: double.infinity, + padding: const material.EdgeInsets.all(16), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Version').small().muted(), + const Gap(4), + material.SelectableText( + _version ?? '—', + style: material.TextStyle( + fontSize: 13, + color: cs.foreground, + ), + ), + const Gap(16), + const Text('User databases (approx.)').small().muted(), + const Gap(4), + Text( + '${_databaseCount ?? 0}', + style: material.TextStyle( + fontSize: 20, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/mysql/mysql_table_utils.dart b/lib/features/mysql/mysql_table_utils.dart new file mode 100644 index 00000000..771dced1 --- /dev/null +++ b/lib/features/mysql/mysql_table_utils.dart @@ -0,0 +1,12 @@ +/// Whether [sql] is allowed for the table browser "custom SQL" path (read-only SELECT). +bool isAllowedMysqlSelectQuery(String sql) { + final t = sql.trim(); + if (t.isEmpty) return false; + final lower = t.toLowerCase(); + if (!lower.startsWith('select') && !lower.startsWith('with')) { + return false; + } + // Reject naive multi-statement (semicolon-separated) scripts. + final parts = t.split(';').where((s) => s.trim().isNotEmpty).toList(); + return parts.length <= 1; +} diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart new file mode 100644 index 00000000..b2fe14f3 --- /dev/null +++ b/lib/features/mysql/mysql_table_view.dart @@ -0,0 +1,652 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:mysql_client/mysql_client.dart'; +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/database/mysql_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_editor_dialog.dart'; +import 'package:querya_desktop/features/mysql/mysql_table_utils.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +const _defaultLimit = 200; + +class MysqlTableView extends material.StatefulWidget { + const MysqlTableView({ + super.key, + required this.connectionRow, + required this.database, + required this.tableName, + this.isView = false, + this.limit = _defaultLimit, + }); + + final ConnectionRow connectionRow; + final String database; + final String tableName; + final bool isView; + final int limit; + + @override + material.State createState() => _MysqlTableViewState(); +} + +class _MysqlTableViewState extends material.State { + MysqlLease? _lease; + MysqlConnection? get _connection => _lease?.connection; + + bool _loading = true; + String? _error; + + List _columnNames = []; + List> _rows = []; + int _rowsOnPage = 0; + int? _totalRowCount; + int _offset = 0; + bool _customSqlActive = false; + String? _customSql; + + final _verticalController = material.ScrollController(); + final _horizontalController = material.ScrollController(); + + String _qualifiedFrom() { + final d = MysqlConnection.quoteIdentifier(widget.database); + final t = MysqlConnection.quoteIdentifier(widget.tableName); + return '$d.$t'; + } + + String _browseDataSql() { + return 'SELECT * FROM ${_qualifiedFrom()} LIMIT ${widget.limit} OFFSET $_offset'; + } + + @override + void initState() { + super.initState(); + _connectAndLoad(); + } + + @override + void didUpdateWidget(covariant MysqlTableView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id || + oldWidget.database != widget.database || + oldWidget.tableName != widget.tableName || + oldWidget.isView != widget.isView) { + _customSqlActive = false; + _customSql = null; + _disconnectCurrent(interruptIfBusy: true); + _connectAndLoad(); + } + } + + @override + void dispose() { + _verticalController.dispose(); + _horizontalController.dispose(); + _disconnectCurrent(interruptIfBusy: true); + super.dispose(); + } + + void _disconnectCurrent({bool interruptIfBusy = false}) { + if (interruptIfBusy && _loading) { + MysqlService.instance.interrupt( + widget.connectionRow, + database: widget.database, + mode: MysqlSessionMode.readWrite, + ); + } + _lease?.release(); + _lease = null; + } + + Future _connectAndLoad() async { + _disconnectCurrent(); + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _columnNames = []; + _rows = []; + _rowsOnPage = 0; + _totalRowCount = null; + _offset = 0; + _customSqlActive = false; + _customSql = null; + }); + try { + final lease = await MysqlService.instance.acquire( + widget.connectionRow, + database: widget.database, + mode: MysqlSessionMode.readWrite, + ); + if (!mounted) { + lease.release(); + return; + } + _lease = lease; + await _fetch(refreshCount: true); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + static int _asInt(String? v) { + if (v == null) return 0; + return int.tryParse(v) ?? 0; + } + + List _resultColumns(IResultSet rs) { + return rs.cols.map((c) => c.name.isNotEmpty ? c.name : 'col').toList(); + } + + List> _resultRows(IResultSet rs) { + final out = >[]; + for (final row in rs.rows) { + out.add( + List.generate( + row.numOfColumns, + (i) => row.colAt(i) ?? 'NULL', + ), + ); + } + return out; + } + + Future _fetch({bool refreshCount = false}) async { + final conn = _connection; + if (conn == null || !conn.isConnected) return; + if (_customSqlActive) { + await _fetchCustom(); + return; + } + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + final from = _qualifiedFrom(); + final countSql = 'SELECT COUNT(*) AS c FROM $from'; + final dataSql = _browseDataSql(); + try { + int totalRows; + if (refreshCount || _totalRowCount == null) { + final countRs = await conn.execute(countSql); + totalRows = countRs.rows.isEmpty + ? 0 + : _asInt(countRs.rows.first.colAt(0)); + } else { + totalRows = _totalRowCount!; + } + + final result = await conn.execute(dataSql); + if (!mounted) return; + + final colNames = _resultColumns(result); + final stringRows = _resultRows(result); + + setState(() { + _columnNames = colNames; + _rows = stringRows; + _rowsOnPage = stringRows.length; + if (refreshCount || _totalRowCount == null) { + _totalRowCount = totalRows; + } + _loading = false; + }); + if (_verticalController.hasClients) { + _verticalController.jumpTo(0); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + Future _fetchCustom() async { + final conn = _connection; + if (conn == null || !conn.isConnected) return; + final sql = _customSql; + if (sql == null || sql.isEmpty) return; + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final result = await conn.execute(sql); + if (!mounted) return; + setState(() { + _columnNames = _resultColumns(result); + _rows = _resultRows(result); + _rowsOnPage = _rows.length; + _totalRowCount = null; + _loading = false; + }); + if (_verticalController.hasClients) { + _verticalController.jumpTo(0); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + void _onSqlRun(String sql) { + final trimmed = sql.trim(); + if (!isAllowedMysqlSelectQuery(trimmed)) return; + final browse = _browseDataSql().trim(); + if (trimmed == browse) { + setState(() { + _customSqlActive = false; + _customSql = null; + }); + unawaited(_fetch(refreshCount: true)); + } else { + setState(() { + _customSqlActive = true; + _customSql = trimmed; + }); + unawaited(_fetchCustom()); + } + } + + void _openSqlEditor() { + showMysqlSqlEditorDialog( + context: context, + initialSql: (_customSqlActive && _customSql != null) + ? _customSql! + : _browseDataSql(), + browseSql: _browseDataSql(), + onRun: _onSqlRun, + ); + } + + void _exitCustomMode() { + setState(() { + _customSqlActive = false; + _customSql = null; + }); + unawaited(_fetch(refreshCount: true)); + } + + void _goToPreviousPage() { + if (_customSqlActive) return; + if (_offset <= 0 || _loading) return; + setState(() { + final next = _offset - widget.limit; + _offset = next < 0 ? 0 : next; + }); + unawaited(_fetch()); + } + + void _goToNextPage() { + if (_customSqlActive) return; + if (_loading) return; + final total = _totalRowCount; + final limit = widget.limit; + if (total != null && _offset + _rowsOnPage >= total) return; + if (total == null && _rowsOnPage < limit) return; + setState(() { + _offset += limit; + }); + unawaited(_fetch()); + } + + bool get _canGoPrevious => + !_customSqlActive && _offset > 0 && !_loading; + + bool get _canGoNext { + if (_customSqlActive) return false; + if (_loading) return false; + final total = _totalRowCount; + final limit = widget.limit; + if (total != null) { + return _offset + _rowsOnPage < total; + } + return _rowsOnPage >= limit; + } + + String _paginationLabel() { + if (_customSqlActive) { + if (_rowsOnPage == 0) return '0 rows (custom SQL)'; + return '$_rowsOnPage row${_rowsOnPage == 1 ? '' : 's'} (custom SQL)'; + } + if (_rowsOnPage == 0) { + final t = _totalRowCount; + if (t == null) return '0 rows'; + return '0 of $t'; + } + final start = _offset + 1; + final end = _offset + _rowsOnPage; + final total = _totalRowCount; + if (total != null) { + return '$start–$end of $total'; + } + return '$start–$end'; + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_loading) { + return material.Container( + color: cs.background, + child: material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 28, + height: 28, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(12), + const Text('Loading data...').muted().small(), + ], + ), + ), + ); + } + + if (_error != null) { + return material.Container( + color: cs.background, + child: material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), + const Gap(16), + const Text('Query Error').large().semiBold(), + const Gap(8), + material.SelectableText(_error!, + style: material.TextStyle( + color: cs.mutedForeground, fontSize: 13)), + const Gap(24), + OutlineButton( + onPressed: () { + if (_customSqlActive) { + unawaited(_fetchCustom()); + } else { + unawaited(_fetch(refreshCount: true)); + } + }, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } + + if (_columnNames.isEmpty) { + return material.Container(color: cs.background); + } + + const double rowHeight = 36; + const double headerHeight = 40; + final colCount = _columnNames.length; + final title = + '${widget.database}.${widget.tableName}${widget.isView ? ' (view)' : ''}'; + + return material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Container( + height: 48, + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.4), + ), + ), + ), + child: material.Row( + children: [ + material.Icon( + widget.isView + ? material.Icons.view_agenda_rounded + : material.Icons.table_chart_rounded, + size: 20, + color: cs.primary, + ), + const Gap(8), + material.Expanded( + child: material.Text( + title, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 13, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + ), + ), + material.Text( + _paginationLabel(), + style: material.TextStyle( + fontSize: 11, + color: cs.mutedForeground, + ), + ), + const Gap(8), + OutlineButton( + onPressed: _loading + ? null + : () { + if (_customSqlActive) { + unawaited(_fetchCustom()); + } else { + unawaited(_fetch(refreshCount: true)); + } + }, + child: const Text('Refresh'), + ), + const Gap(6), + OutlineButton( + onPressed: _openSqlEditor, + child: const Text('SQL'), + ), + if (_customSqlActive) ...[ + const Gap(6), + OutlineButton( + onPressed: _exitCustomMode, + child: const Text('Browse'), + ), + ], + const Gap(6), + GhostButton( + onPressed: (!_canGoPrevious || _loading) + ? null + : _goToPreviousPage, + child: const Icon(material.Icons.chevron_left_rounded, size: 20), + ), + GhostButton( + onPressed: (!_canGoNext || _loading) ? null : _goToNextPage, + child: const Icon(material.Icons.chevron_right_rounded, size: 20), + ), + ], + ), + ), + material.Expanded( + child: material.LayoutBuilder( + builder: (context, constraints) { + return material.Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + notificationPredicate: (_) => true, + child: material.SingleChildScrollView( + controller: _horizontalController, + scrollDirection: material.Axis.horizontal, + child: material.SizedBox( + width: _calcTableWidth(colCount, constraints.maxWidth), + child: material.Column( + children: [ + material.Container( + height: headerHeight, + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.35), + border: material.Border( + bottom: material.BorderSide( + color: cs.border.withValues(alpha: 0.5)), + ), + ), + child: material.Row( + children: [ + _rowNumberCell(cs, '#', isHeader: true), + for (var i = 0; i < colCount; i++) + _headerCell(cs, _columnNames[i]), + ], + ), + ), + material.Expanded( + child: material.Scrollbar( + controller: _verticalController, + thumbVisibility: true, + child: material.ListView.builder( + controller: _verticalController, + itemCount: _rowsOnPage, + itemExtent: rowHeight, + itemBuilder: (context, rowIdx) { + final row = _rows[rowIdx]; + final isEven = rowIdx % 2 == 0; + final displayRowNum = _customSqlActive + ? rowIdx + 1 + : _offset + rowIdx + 1; + return material.Container( + height: rowHeight, + decoration: material.BoxDecoration( + color: isEven + ? material.Colors.transparent + : cs.muted.withValues(alpha: 0.12), + border: material.Border( + bottom: material.BorderSide( + color: + cs.border.withValues(alpha: 0.15), + ), + ), + ), + child: material.Row( + children: [ + _rowNumberCell(cs, '$displayRowNum'), + for (var c = 0; c < colCount; c++) + _dataCell( + cs, + row.length > c ? row[c] : ''), + ], + ), + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + double _calcTableWidth(int colCount, double availableWidth) { + const double rowNumWidth = 52; + const double minColWidth = 150; + final calculated = rowNumWidth + colCount * minColWidth; + return calculated > availableWidth ? calculated : availableWidth; + } + + material.Widget _rowNumberCell(ColorScheme cs, String text, + {bool isHeader = false}) { + return material.Container( + width: 52, + padding: const material.EdgeInsets.symmetric(horizontal: 8), + alignment: material.Alignment.centerRight, + decoration: material.BoxDecoration( + border: material.Border( + right: material.BorderSide(color: cs.border.withValues(alpha: 0.3)), + ), + ), + child: material.Text( + text, + style: material.TextStyle( + fontSize: 11, + fontWeight: + isHeader ? material.FontWeight.w600 : material.FontWeight.normal, + color: cs.mutedForeground.withValues(alpha: 0.7), + fontFamily: 'monospace', + ), + ), + ); + } + + material.Widget _headerCell(ColorScheme cs, String name) { + return material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + child: material.Text( + name, + style: material.TextStyle( + fontSize: 12, + fontWeight: material.FontWeight.w600, + color: cs.foreground, + ), + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ); + } + + material.Widget _dataCell(ColorScheme cs, String value) { + final isNull = value == 'NULL'; + return material.Expanded( + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 10), + alignment: material.Alignment.centerLeft, + child: material.Text( + value, + style: material.TextStyle( + fontSize: 12, + color: isNull + ? cs.mutedForeground.withValues(alpha: 0.5) + : cs.foreground, + fontStyle: + isNull ? material.FontStyle.italic : material.FontStyle.normal, + ), + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ); + } +} diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart new file mode 100644 index 00000000..94132a29 --- /dev/null +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -0,0 +1,127 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_workspace.dart'; +import 'package:querya_desktop/features/mysql/mysql_stats_view.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// When a MySQL connection is selected but no tree object: server summary or SQL editor. +class MysqlWorkspaceHome extends material.StatefulWidget { + const MysqlWorkspaceHome({ + super.key, + required this.connectionRow, + this.sqlTabRequestToken = 0, + }); + + final ConnectionRow connectionRow; + + /// Parent increments to switch to the SQL tab (e.g. context menu on connection). + final int sqlTabRequestToken; + + @override + material.State createState() => + _MysqlWorkspaceHomeState(); +} + +class _MysqlWorkspaceHomeState extends material.State { + int _tab = 0; + int _lastAppliedSqlTabToken = 0; + + @override + void initState() { + super.initState(); + _lastAppliedSqlTabToken = widget.sqlTabRequestToken; + } + + @override + void didUpdateWidget(covariant MysqlWorkspaceHome oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _lastAppliedSqlTabToken = widget.sqlTabRequestToken; + return; + } + final t = widget.sqlTabRequestToken; + if (t > _lastAppliedSqlTabToken) { + _lastAppliedSqlTabToken = t; + material.WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(_selectTab(1)); + }); + } + } + + Future _selectTab(int i) async { + if (i == _tab) return; + setState(() => _tab = i); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + child: material.Row( + children: [ + const Text('MySQL').semiBold().small(), + const Spacer(), + ...List.generate(2, (i) { + final labels = ['Server', 'SQL']; + final selected = _tab == i; + return material.Padding( + padding: const material.EdgeInsets.only(left: 6), + child: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.GestureDetector( + onTap: () => unawaited(_selectTab(i)), + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: material.BoxDecoration( + color: selected + ? theme.colorScheme.background + : material.Colors.transparent, + borderRadius: material.BorderRadius.circular(6), + ), + child: selected + ? Text(labels[i]).small().semiBold() + : Text(labels[i]).small().muted(), + ), + ), + ), + ); + }), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: material.IndexedStack( + index: _tab, + sizing: material.StackFit.expand, + children: [ + MysqlStatsView( + key: ValueKey('mysql_stats_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + ), + MysqlSqlWorkspace( + key: ValueKey('mysql_sql_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + ), + ], + ), + ), + ], + ); + } +} diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index cfdc2985..e7b0802d 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/pubspec.yaml b/pubspec.yaml index 2f3c6169..212f020c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: redis: ^4.0.0 postgres: ^3.5.6 fl_chart: ^0.69.0 + mysql_client: ^0.0.27 dev_dependencies: flutter_test: diff --git a/run_linux.sh b/run_linux.sh index 978d34c0..2c068ab8 100755 --- a/run_linux.sh +++ b/run_linux.sh @@ -1,5 +1,10 @@ #!/usr/bin/env bash # Run on Linux with X11 backend to avoid Gdk-CRITICAL (gdk_device_get_source) # when the cursor enters/leaves the window. On Wayland those warnings are harmless. +# +# If build fails with: Failed to find any of [ld.lld, ld] in .../snap/flutter/.../llvm-10/bin +# the Flutter snap ships an incomplete LLVM bin dir. Fix: install Flutter outside snap +# (git clone https://github.com/flutter/flutter.git + add .../flutter/bin to PATH), or see +# https://github.com/canonical/flutter-snap/issues/123 export GDK_BACKEND=x11 exec flutter run -d linux "$@" diff --git a/test/core/database/mysql_connection_test.dart b/test/core/database/mysql_connection_test.dart new file mode 100644 index 00000000..848a8e08 --- /dev/null +++ b/test/core/database/mysql_connection_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mysql_connection.dart'; + +void main() { + group('replaceDatabaseInMysqlConnectionString', () { + test('replaces path segment', () { + expect( + replaceDatabaseInMysqlConnectionString( + 'mysql://u:p@h:3306/olddb', + 'newdb', + ), + 'mysql://u:p@h:3306/newdb', + ); + }); + + test('replaces database query param', () { + expect( + replaceDatabaseInMysqlConnectionString( + 'mysql://h:3306/?database=old', + 'new', + ), + contains('database=new'), + ); + }); + + test('throws on wrong scheme', () { + expect( + () => replaceDatabaseInMysqlConnectionString('postgres://h/db', 'x'), + throwsArgumentError, + ); + }); + }); + + group('MysqlConnection.quoteIdentifier', () { + test('escapes backticks', () { + expect( + MysqlConnection.quoteIdentifier('a`b'), + '`a``b`', + ); + }); + }); +} diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 72e48cc7..bb803b20 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -59,6 +59,7 @@ void main() { tearDown(() async { await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(null); + await AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(null); }); group('AppSettings', () { @@ -79,5 +80,23 @@ void main() { ); expect(await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(), isNull); }); + + test('getMysqlSqlStmtTimeoutSeconds roundtrip', () async { + expect(await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(), isNull); + + await AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(60); + expect(await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(), 60); + + await AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(null); + expect(await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(), isNull); + }); + + test('getMysqlSqlStmtTimeoutSeconds returns null for invalid stored value', () async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.mysqlSqlStmtTimeoutSeconds, + 'not-a-number', + ); + expect(await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(), isNull); + }); }); } diff --git a/test/features/mysql/mysql_table_utils_test.dart b/test/features/mysql/mysql_table_utils_test.dart new file mode 100644 index 00000000..ef992048 --- /dev/null +++ b/test/features/mysql/mysql_table_utils_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/mysql/mysql_table_utils.dart'; + +void main() { + group('isAllowedMysqlSelectQuery', () { + test('allows single SELECT', () { + expect(isAllowedMysqlSelectQuery('SELECT * FROM t'), isTrue); + }); + + test('allows WITH', () { + expect(isAllowedMysqlSelectQuery('WITH x AS (SELECT 1) SELECT * FROM x'), isTrue); + }); + + test('rejects multi-statement', () { + expect(isAllowedMysqlSelectQuery('SELECT 1; SELECT 2'), isFalse); + }); + + test('rejects empty', () { + expect(isAllowedMysqlSelectQuery(''), isFalse); + }); + }); +} diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index fe90529b..84cb9102 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)