From ad30a9c9ab85b1b106435b0180583b5709c189ad Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 11:30:29 +0300 Subject: [PATCH 01/52] fix(grid): omit generated columns on INSERT and enable SQLite WAL Table Browser no longer sends GENERATED ALWAYS / AUTO_INCREMENT ids or empty serial cells. Writable SQLite files use WAL so Overview and the table view can share a file without SQLITE_BUSY. Closes #792 Closes #810 Closes #801 --- CHANGELOG.md | 5 + lib/core/database/mysql_connection.dart | 10 +- lib/core/database/postgres_connection.dart | 13 +- lib/core/database/sqlite_connection.dart | 21 +++ lib/core/database/table_mutation_engine.dart | 31 +++- lib/core/database/table_schema_meta.dart | 12 ++ lib/features/mysql/mysql_table_view.dart | 7 + .../postgresql/postgres_table_view.dart | 7 + lib/features/sqlite/sqlite_table_view.dart | 7 + .../workspace/data_grid_staging_buffer.dart | 3 + .../workspace/table_view_staging.dart | 7 + .../core/database/sqlite_connection_test.dart | 22 +++ .../database/table_mutation_engine_test.dart | 169 ++++++++++++++++++ .../workspace/table_view_staging_test.dart | 14 ++ 14 files changed, 323 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c62686..80b759dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Table Browser INSERT (#792, #810)** — Skip `GENERATED ALWAYS` / identity-always / `AUTO_INCREMENT` columns and omit empty cells that have a server default, so new rows do not send `id` or `''`. +- **SQLite WAL (#801)** — Open writable user databases with `PRAGMA journal_mode=WAL` (falls back if the FS cannot create the WAL file) and surface `SQLITE_BUSY` as a retryable error instead of a generic execute failure. + ## [0.4.16] - 2026-09-20 In-place editing in Table Browser (same DML staging as SQL Workspace), Command Palette and Quick Switcher, workspace chrome parity across drivers, connections-tree visual + FPS work, MongoDB per-field Save to DB, and a hardened in-app updater. diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index bd0782bd..20360f61 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -382,7 +382,7 @@ class MysqlConnection { throw StateError('Not connected to MySQL'); } final colsRs = await execute( - 'SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT ' + 'SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA ' 'FROM information_schema.COLUMNS ' 'WHERE TABLE_SCHEMA = :database AND TABLE_NAME = :table ' 'ORDER BY ORDINAL_POSITION', @@ -413,6 +413,12 @@ class MysqlConnection { final isPk = primaryKeys.contains(name); final pkPos = isPk ? primaryKeys.indexOf(name) + 1 : null; final dflt = r.colByName('COLUMN_DEFAULT'); + final extra = (r.colByName('EXTRA') ?? '').toLowerCase(); + final omitOnInsert = extra.contains('auto_increment') || + extra.contains('virtual generated') || + extra.contains('stored generated'); + final hasServerDefault = + (dflt != null && dflt.isNotEmpty) || extra.contains('auto_increment'); columns.add( TableColumnMeta( @@ -422,6 +428,8 @@ class MysqlConnection { isPrimaryKey: isPk, primaryKeyPosition: pkPos, defaultValue: dflt, + omitOnInsert: omitOnInsert, + hasServerDefault: hasServerDefault, ), ); } diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index 87831f55..dfb45cee 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -398,7 +398,8 @@ class PostgresConnection { } final colsRs = await _conn!.execute( Sql.named( - 'SELECT column_name, data_type, is_nullable, column_default ' + 'SELECT column_name, data_type, is_nullable, column_default, ' + 'is_generated, is_identity, identity_generation ' 'FROM information_schema.columns ' 'WHERE table_schema = @schema AND table_name = @table ' 'ORDER BY ordinal_position', @@ -431,6 +432,14 @@ class PostgresConnection { final isPk = primaryKeys.contains(name); final pkPos = isPk ? primaryKeys.indexOf(name) + 1 : null; final dflt = r[3]?.toString(); + final isGenerated = + (r[4] as String? ?? 'NEVER').toUpperCase() == 'ALWAYS'; + final isIdentity = (r[5] as String? ?? 'NO').toUpperCase() == 'YES'; + final identityGeneration = (r[6] as String? ?? '').toUpperCase(); + final omitOnInsert = + isGenerated || (isIdentity && identityGeneration == 'ALWAYS'); + final hasServerDefault = (dflt != null && dflt.isNotEmpty) || + (isIdentity && identityGeneration == 'BY DEFAULT'); columns.add( TableColumnMeta( @@ -440,6 +449,8 @@ class PostgresConnection { isPrimaryKey: isPk, primaryKeyPosition: pkPos, defaultValue: dflt, + omitOnInsert: omitOnInsert, + hasServerDefault: hasServerDefault, ), ); } diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index 153bdf98..4c633883 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -48,6 +48,11 @@ class SqliteConnection { await db.execute('PRAGMA busy_timeout = 5000'); if (!readOnly) { await db.execute('PRAGMA foreign_keys = ON'); + try { + await db.execute('PRAGMA journal_mode = WAL'); + } catch (e) { + debugPrint('SqliteConnection WAL not available: $e'); + } } }, ), @@ -126,6 +131,13 @@ class SqliteConnection { } on TimeoutException { unawaited(forceClose()); rethrow; + } on DatabaseException catch (e) { + if (_isSqliteBusy(e)) { + throw StateError( + 'SQLite is busy (another connection is writing). Retry in a moment.', + ); + } + rethrow; } } @@ -260,6 +272,15 @@ class SqliteConnection { }; } + static bool _isSqliteBusy(DatabaseException e) { + final code = e.getResultCode(); + if (code == 5 || code == 6) return true; + final msg = e.toString().toLowerCase(); + return msg.contains('database is locked') || + msg.contains('database busy') || + msg.contains('sqlite_busy'); + } + /// Helper to quote SQLite identifiers safely. static String quoteIdentifier(String id) { return '"${id.replaceAll('"', '""')}"'; diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart index 772e21de..2742f25c 100644 --- a/lib/core/database/table_mutation_engine.dart +++ b/lib/core/database/table_mutation_engine.dart @@ -1,3 +1,5 @@ +import 'package:querya_desktop/core/database/table_schema_meta.dart'; + /// Supported SQL dialects for DML mutation generation. enum SqlDialect { postgres, @@ -256,6 +258,7 @@ abstract final class TableMutationEngine { required List> insertedRows, required Set deletedRowIndices, Map? columnDataTypes, + Map? columnMeta, }) { final statements = []; final tableRef = quoteQualifiedTable( @@ -316,14 +319,31 @@ abstract final class TableMutationEngine { for (var c = 0; c < columns.length; c++) { final colName = columns[c]; + final meta = columnMeta?[colName]; + if (meta?.omitOnInsert == true) continue; + + final cellVal = c < row.length ? row[c] : (meta == null ? 'NULL' : ''); + if (meta != null && + _isBlankInsertCell(cellVal) && + (meta.hasServerDefault || meta.isNullable)) { + continue; + } + final quotedCol = quoteIdentifier(colName, dialect); - final cellVal = c < row.length ? row[c] : 'NULL'; - final colType = columnDataTypes?[colName]; + final colType = columnDataTypes?[colName] ?? meta?.dataType; colNames.add(quotedCol); values.add(formatLiteral(cellVal, dialect, dataTypeName: colType)); } - final sql = 'INSERT INTO $tableRef (${colNames.join(', ')}) VALUES (${values.join(', ')})'; + final String sql; + if (colNames.isEmpty) { + sql = dialect == SqlDialect.mysql + ? 'INSERT INTO $tableRef () VALUES ()' + : 'INSERT INTO $tableRef DEFAULT VALUES'; + } else { + sql = + 'INSERT INTO $tableRef (${colNames.join(', ')}) VALUES (${values.join(', ')})'; + } statements.add( TableMutationStatement( type: MutationType.insert, @@ -407,4 +427,9 @@ abstract final class TableMutationEngine { return clauses.join(' AND '); } + + static bool _isBlankInsertCell(String value) { + if (value == kNullSentinel) return true; + return value.trim().isEmpty; + } } diff --git a/lib/core/database/table_schema_meta.dart b/lib/core/database/table_schema_meta.dart index 0043a62e..73d41cc3 100644 --- a/lib/core/database/table_schema_meta.dart +++ b/lib/core/database/table_schema_meta.dart @@ -7,6 +7,8 @@ class TableColumnMeta { this.isPrimaryKey = false, this.primaryKeyPosition, this.defaultValue, + this.omitOnInsert = false, + this.hasServerDefault = false, }); final String name; @@ -16,6 +18,12 @@ class TableColumnMeta { final int? primaryKeyPosition; final String? defaultValue; + /// Never send on INSERT (GENERATED ALWAYS, identity always, AUTO_INCREMENT). + final bool omitOnInsert; + + /// Empty INSERT cells should omit the column so DEFAULT / serial applies. + final bool hasServerDefault; + Map toJson() => { 'name': name, 'dataType': dataType, @@ -23,6 +31,8 @@ class TableColumnMeta { 'isPrimaryKey': isPrimaryKey, 'primaryKeyPosition': primaryKeyPosition, 'defaultValue': defaultValue, + 'omitOnInsert': omitOnInsert, + 'hasServerDefault': hasServerDefault, }; factory TableColumnMeta.fromJson(Map json) => @@ -33,6 +43,8 @@ class TableColumnMeta { isPrimaryKey: json['isPrimaryKey'] as bool? ?? false, primaryKeyPosition: json['primaryKeyPosition'] as int?, defaultValue: json['defaultValue'] as String?, + omitOnInsert: json['omitOnInsert'] as bool? ?? false, + hasServerDefault: json['hasServerDefault'] as bool? ?? false, ); } diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index ad22e733..439961cf 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -7,6 +7,7 @@ import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.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'; @@ -55,6 +56,7 @@ class _MysqlTableViewState extends material.State { DataGridStagingBuffer? _stagingBuffer; List _primaryKeys = []; Map _columnDataTypes = {}; + Map _columnMeta = {}; bool _schemaLoaded = false; bool _isSaving = false; @@ -111,6 +113,7 @@ class _MysqlTableViewState extends material.State { _stagingBuffer = null; _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; _schemaLoaded = false; _isSaving = false; } @@ -205,6 +208,7 @@ class _MysqlTableViewState extends material.State { _schemaLoaded = true; _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; return; } try { @@ -214,9 +218,11 @@ class _MysqlTableViewState extends material.State { ); _primaryKeys = List.from(schema.primaryKeys); _columnDataTypes = columnDataTypesFromSchema(schema); + _columnMeta = columnMetaFromSchema(schema); } catch (_) { _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; } _schemaLoaded = true; } @@ -473,6 +479,7 @@ class _MysqlTableViewState extends material.State { schema: widget.database, primaryKeys: _primaryKeys, columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, + columnMeta: _columnMeta.isEmpty ? null : _columnMeta, execute: (plan) async { final conn = _connection; if (conn == null || !conn.isConnected) { diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index ade09570..f4f118d4 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_editor_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_privileges_dialog.dart'; @@ -68,6 +69,7 @@ class _PostgresTableViewState extends material.State { DataGridStagingBuffer? _stagingBuffer; List _primaryKeys = []; Map _columnDataTypes = {}; + Map _columnMeta = {}; bool _schemaLoaded = false; bool _isSaving = false; @@ -116,6 +118,7 @@ class _PostgresTableViewState extends material.State { _stagingBuffer = null; _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; _schemaLoaded = false; _isSaving = false; } @@ -198,6 +201,7 @@ class _PostgresTableViewState extends material.State { _schemaLoaded = true; _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; return; } try { @@ -207,9 +211,11 @@ class _PostgresTableViewState extends material.State { ); _primaryKeys = List.from(schema.primaryKeys); _columnDataTypes = columnDataTypesFromSchema(schema); + _columnMeta = columnMetaFromSchema(schema); } catch (_) { _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; } _schemaLoaded = true; } @@ -519,6 +525,7 @@ class _PostgresTableViewState extends material.State { schema: widget.schema, primaryKeys: _primaryKeys, columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, + columnMeta: _columnMeta.isEmpty ? null : _columnMeta, execute: (plan) async { final conn = _connection; if (conn == null || !conn.isConnected) { diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 4310615b..c72f6d7c 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/database/sqlite_connection.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -49,6 +50,7 @@ class _SqliteTableViewState extends material.State { DataGridStagingBuffer? _stagingBuffer; List _primaryKeys = []; Map _columnDataTypes = {}; + Map _columnMeta = {}; bool _schemaLoaded = false; bool _isSaving = false; @@ -98,6 +100,7 @@ class _SqliteTableViewState extends material.State { _stagingBuffer = null; _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; _schemaLoaded = false; _isSaving = false; } @@ -154,15 +157,18 @@ class _SqliteTableViewState extends material.State { _schemaLoaded = true; _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; return; } try { final schema = await conn.getTableSchema(table: widget.tableName); _primaryKeys = List.from(schema.primaryKeys); _columnDataTypes = columnDataTypesFromSchema(schema); + _columnMeta = columnMetaFromSchema(schema); } catch (_) { _primaryKeys = []; _columnDataTypes = {}; + _columnMeta = {}; } _schemaLoaded = true; } @@ -296,6 +302,7 @@ class _SqliteTableViewState extends material.State { tableName: widget.tableName, primaryKeys: _primaryKeys, columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, + columnMeta: _columnMeta.isEmpty ? null : _columnMeta, execute: (plan) async { final conn = _connection; if (conn == null || !conn.isConnected) { diff --git a/lib/features/workspace/data_grid_staging_buffer.dart b/lib/features/workspace/data_grid_staging_buffer.dart index 23399db6..f9cccb8a 100644 --- a/lib/features/workspace/data_grid_staging_buffer.dart +++ b/lib/features/workspace/data_grid_staging_buffer.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/unsaved_work_registry.dart'; /// Status of a row within the staging buffer. @@ -297,6 +298,7 @@ class DataGridStagingBuffer extends ChangeNotifier { String? schema, List primaryKeys = const [], Map? columnDataTypes, + Map? columnMeta, }) { return TableMutationEngine.generatePlan( dialect: dialect, @@ -309,6 +311,7 @@ class DataGridStagingBuffer extends ChangeNotifier { insertedRows: _insertedRows, deletedRowIndices: _deletedRowIndices, columnDataTypes: columnDataTypes, + columnMeta: columnMeta, ); } diff --git a/lib/features/workspace/table_view_staging.dart b/lib/features/workspace/table_view_staging.dart index 87c2daa8..3ec287cb 100644 --- a/lib/features/workspace/table_view_staging.dart +++ b/lib/features/workspace/table_view_staging.dart @@ -40,6 +40,11 @@ Map columnDataTypesFromSchema(TableSchemaMeta schema) { }; } +/// Column name → schema flags used by INSERT (generated / defaults). +Map columnMetaFromSchema(TableSchemaMeta schema) { + return {for (final c in schema.columns) c.name: c}; +} + /// Disposes [previous] and returns a new buffer when [enabled]. DataGridStagingBuffer? replaceTableViewStagingBuffer({ DataGridStagingBuffer? previous, @@ -186,6 +191,7 @@ Future applyTableViewStagedChanges({ String? schema, required List primaryKeys, Map? columnDataTypes, + Map? columnMeta, required Future Function(TableMutationPlan plan) execute, }) async { if (!buffer.isDirty) return const TableViewApplyOutcome.noop(); @@ -201,6 +207,7 @@ Future applyTableViewStagedChanges({ schema: schema, primaryKeys: primaryKeys, columnDataTypes: columnDataTypes, + columnMeta: columnMeta, ); if (plan.isEmpty) return const TableViewApplyOutcome.noop(); diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index d00cfbe9..d05575c3 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -76,6 +78,26 @@ void main() { expect(res.first['timeout'], 5000); }); + test('enables WAL on a file database', () async { + final dir = await Directory.systemTemp.createTemp('querya_sqlite_wal_'); + final path = '${dir.path}/t.db'; + final fileConn = SqliteConnection( + id: 9, + name: 'wal_file', + path: path, + ); + addTearDown(() async { + await fileConn.disconnect(); + await dir.delete(recursive: true); + }); + await fileConn.connect(); + final res = await fileConn.execute('PRAGMA journal_mode'); + expect( + res.first['journal_mode']?.toString().toLowerCase(), + 'wal', + ); + }); + test('testConnection connects, queries and cleans up', () async { final ok = await conn.testConnection(); expect(ok, true); diff --git a/test/core/database/table_mutation_engine_test.dart b/test/core/database/table_mutation_engine_test.dart index 65d662cd..7f494404 100644 --- a/test/core/database/table_mutation_engine_test.dart +++ b/test/core/database/table_mutation_engine_test.dart @@ -35,6 +35,24 @@ void main() { expect(restored.columns.length, 2); expect(restored.getColumn('id')?.isPrimaryKey, isTrue); expect(restored.getColumn('email')?.isNullable, isTrue); + expect(restored.getColumn('id')?.omitOnInsert, isFalse); + }); + + test('round-trips omitOnInsert and hasServerDefault', () { + const meta = TableSchemaMeta( + tableName: 'users', + columns: [ + TableColumnMeta( + name: 'id', + dataType: 'integer', + omitOnInsert: true, + hasServerDefault: true, + ), + ], + ); + final restored = TableSchemaMeta.fromJson(meta.toJson()); + expect(restored.getColumn('id')?.omitOnInsert, isTrue); + expect(restored.getColumn('id')?.hasServerDefault, isTrue); }); }); @@ -352,5 +370,156 @@ void main() { r"'C:\Program Files\'", ); }); + + test('omits GENERATED ALWAYS identity column on Postgres INSERT', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'users', + schema: 'public', + columns: ['id', 'email'], + primaryKeys: ['id'], + originalRows: const [], + modifiedCells: const {}, + insertedRows: [ + ['', 'ada@example.com'], + ], + deletedRowIndices: const {}, + columnMeta: { + 'id': const TableColumnMeta( + name: 'id', + dataType: 'integer', + isNullable: false, + isPrimaryKey: true, + omitOnInsert: true, + hasServerDefault: true, + ), + 'email': const TableColumnMeta( + name: 'email', + dataType: 'varchar', + isNullable: false, + ), + }, + ); + + expect(plan.statementCount, 1); + expect( + plan.statements.first.sql, + 'INSERT INTO "public"."users" ("email") VALUES (\'ada@example.com\')', + ); + }); + + test('omits AUTO_INCREMENT id on MySQL INSERT', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.mysql, + tableName: 'users', + schema: 'app', + columns: ['id', 'name'], + primaryKeys: ['id'], + originalRows: const [], + modifiedCells: const {}, + insertedRows: [ + ['', 'bob'], + ], + deletedRowIndices: const {}, + columnMeta: { + 'id': const TableColumnMeta( + name: 'id', + dataType: 'int', + isNullable: false, + isPrimaryKey: true, + omitOnInsert: true, + hasServerDefault: true, + ), + 'name': const TableColumnMeta( + name: 'name', + dataType: 'varchar', + isNullable: false, + ), + }, + ); + + expect( + plan.statements.first.sql, + 'INSERT INTO `app`.`users` (`name`) VALUES (\'bob\')', + ); + }); + + test('omits empty serial cell when hasServerDefault', () { + final plan = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 'items', + columns: ['id', 'label'], + primaryKeys: ['id'], + originalRows: const [], + modifiedCells: const {}, + insertedRows: [ + ['', 'x'], + ], + deletedRowIndices: const {}, + columnMeta: { + 'id': const TableColumnMeta( + name: 'id', + dataType: 'integer', + isNullable: false, + isPrimaryKey: true, + hasServerDefault: true, + ), + 'label': const TableColumnMeta( + name: 'label', + dataType: 'text', + isNullable: false, + ), + }, + ); + + expect( + plan.statements.first.sql, + 'INSERT INTO "items" ("label") VALUES (\'x\')', + ); + }); + + test('DEFAULT VALUES when every INSERT column is omitted', () { + final pg = TableMutationEngine.generatePlan( + dialect: SqlDialect.postgres, + tableName: 't', + columns: ['id'], + primaryKeys: ['id'], + originalRows: const [], + modifiedCells: const {}, + insertedRows: [ + [''], + ], + deletedRowIndices: const {}, + columnMeta: { + 'id': const TableColumnMeta( + name: 'id', + dataType: 'integer', + omitOnInsert: true, + ), + }, + ); + expect(pg.statements.first.sql, 'INSERT INTO "t" DEFAULT VALUES'); + + final mysql = TableMutationEngine.generatePlan( + dialect: SqlDialect.mysql, + tableName: 't', + columns: ['id'], + primaryKeys: ['id'], + originalRows: const [], + modifiedCells: const {}, + insertedRows: [ + [''], + ], + deletedRowIndices: const {}, + columnMeta: { + 'id': const TableColumnMeta( + name: 'id', + dataType: 'int', + omitOnInsert: true, + ), + }, + ); + expect(mysql.statements.first.sql, 'INSERT INTO `t` () VALUES ()'); + }); }); } diff --git a/test/features/workspace/table_view_staging_test.dart b/test/features/workspace/table_view_staging_test.dart index 58c436ca..fcd0b56d 100644 --- a/test/features/workspace/table_view_staging_test.dart +++ b/test/features/workspace/table_view_staging_test.dart @@ -132,6 +132,20 @@ void main() { {'id': 'integer', 'name': 'text'}, ); }); + + test('columnMetaFromSchema indexes columns by name', () { + const schema = TableSchemaMeta( + tableName: 'users', + columns: [ + TableColumnMeta( + name: 'id', + dataType: 'integer', + omitOnInsert: true, + ), + ], + ); + expect(columnMetaFromSchema(schema)['id']?.omitOnInsert, isTrue); + }); }); group('replaceTableViewStagingBuffer', () { From 2caa38b80c2a75c4d2f213e773fe84de89e9d138 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 11:38:41 +0300 Subject: [PATCH 02/52] fix(mongo): keep session auth after scrubCredentials Find/update/list still authenticate from an in-memory session URI after getters are nulled, and one Db is reused per database instead of opening a socket on every call. Closes #780 --- CHANGELOG.md | 1 + lib/core/database/mongodb_connection.dart | 139 +++++++++++++----- lib/core/database/mongodb_service.dart | 13 +- .../database/mongodb_connection_test.dart | 36 +++++ .../mongodb_uri_replacement_test.dart | 17 +++ 5 files changed, 161 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b759dc..0a497935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Mongo session auth (#780)** — After `scrubCredentials()`, find/update/list still authenticate from an in-memory session URI (getters stay null; nothing is written back). One `Db` is reused per database instead of open/close on every call. - **Table Browser INSERT (#792, #810)** — Skip `GENERATED ALWAYS` / identity-always / `AUTO_INCREMENT` columns and omit empty cells that have a server default, so new rows do not send `id` or `''`. - **SQLite WAL (#801)** — Open writable user databases with `PRAGMA journal_mode=WAL` (falls back if the FS cannot create the WAL file) and surface `SQLITE_BUSY` as a retryable error instead of a generic execute failure. diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 1e5fb26c..c49b0e12 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -32,6 +32,13 @@ class MongoConnection { final String? replicaSet; String? _connectionString; + /// Handshake URI for this live session (includes auth). Not persisted; not + /// exposed via [password] / [connectionString] after [scrubCredentials]. + String? _sessionUri; + + final Map _openedDbs = {}; + final Map> _openingDbs = {}; + String? get password => _password; String? get connectionString => _connectionString; @@ -39,7 +46,11 @@ class MongoConnection { bool _isConnected = false; /// Scrubs sensitive in-memory credentials once the network handshake completes. + /// + /// Getters [password] and [connectionString] become null. The live session + /// still authenticates via [_sessionUri] (in-memory only, never written back). void scrubCredentials() { + _sessionUri ??= buildConnectionUri(); _password = null; _connectionString = null; } @@ -50,6 +61,12 @@ class MongoConnection { if (effectiveConnStr != null && effectiveConnStr.isNotEmpty) { return effectiveConnStr; } + if (pass == null && + _sessionUri != null && + _sessionUri!.isNotEmpty && + (_password == null || _password!.isEmpty)) { + return _sessionUri!; + } final buffer = StringBuffer('mongodb://'); @@ -153,7 +170,12 @@ class MongoConnection { ); _db = await Db.create(uri); await _db!.open(); + _sessionUri = uri; _isConnected = true; + final defaultName = _databaseNameFromUri(uri); + if (defaultName != null && defaultName.isNotEmpty) { + _openedDbs[defaultName] = _db!; + } scrubCredentials(); } catch (e) { _isConnected = false; @@ -193,15 +215,66 @@ class MongoConnection { /// Disconnects from MongoDB server. Future disconnect() async { _isConnected = false; - final db = _db; + _sessionUri = null; + _openingDbs.clear(); + final toClose = { + ..._openedDbs.values, + if (_db != null) _db!, + }; + _openedDbs.clear(); _db = null; - try { - await db?.close(); - } catch (e) { - debugPrint('MongoConnection.disconnect: $e'); + for (final db in toClose) { + try { + await db.close(); + } catch (e) { + debugPrint('MongoConnection.disconnect: $e'); + } } } + /// Opens (or reuses) a [Db] for [databaseName] on this live session. + /// + /// Auth comes from [_sessionUri], not from the scrubbed [password] getter. + Future openDatabase(String databaseName) async { + if (!isConnected) { + throw StateError('Not connected to MongoDB'); + } + final existing = _openedDbs[databaseName]; + if (existing != null && existing.isConnected) { + return existing; + } + if (existing != null) { + _openedDbs.remove(databaseName); + try { + await existing.close(); + } catch (_) {} + } + return _openingDbs.putIfAbsent(databaseName, () async { + try { + final dbUri = buildUriForDatabase(databaseName); + final db = await Db.create(dbUri); + try { + await db.open(); + } catch (_) { + try { + await db.close(); + } catch (_) {} + rethrow; + } + _openedDbs[databaseName] = db; + return db; + } finally { + _openingDbs.remove(databaseName); + } + }); + } + + static String? _databaseNameFromUri(String uri) { + final path = Uri.tryParse(uri)?.path.replaceFirst(RegExp(r'^/'), '') ?? ''; + if (path.isEmpty) return null; + return path.split('/').first; + } + /// Checks if connection is active. bool get isConnected => _isConnected && _db != null && _db!.isConnected; @@ -215,22 +288,15 @@ class MongoConnection { } try { - // Switch to admin database to list all databases - final adminUri = buildUriForDatabase('admin'); - final adminDb = await Db.create(adminUri); - await adminDb.open(); - try { - final result = await adminDb.runCommand({'listDatabases': 1}); - final databases = result['databases'] as List?; - if (databases == null) return []; - - return databases - .map((db) => (db as Map)['name'] as String) - .where((name) => name.isNotEmpty) - .toList(); - } finally { - await adminDb.close(); - } + final adminDb = await openDatabase('admin'); + final result = await adminDb.runCommand({'listDatabases': 1}); + final databases = result['databases'] as List?; + if (databases == null) return []; + + return databases + .map((db) => (db as Map)['name'] as String) + .where((name) => name.isNotEmpty) + .toList(); } catch (e) { rethrow; } @@ -243,16 +309,9 @@ class MongoConnection { } try { - // Create a new Db connection to the specified database - final dbUri = buildUriForDatabase(databaseName); - final db = await Db.create(dbUri); - await db.open(); - try { - final collections = await db.getCollectionNames(); - return collections.whereType().toList(); - } finally { - await db.close(); - } + final db = await openDatabase(databaseName); + final collections = await db.getCollectionNames(); + return collections.whereType().toList(); } catch (e) { rethrow; } @@ -279,13 +338,21 @@ class MongoConnection { } try { - final dbUri = buildUriForDatabase(databaseName); - final db = await Db.create(dbUri); - await db.open(); + final db = await openDatabase(databaseName); + await db.drop(); + _openedDbs.remove(databaseName); try { - await db.drop(); - } finally { await db.close(); + } catch (_) {} + if (identical(_db, db)) { + _db = null; + for (final other in _openedDbs.values) { + if (other.isConnected) { + _db = other; + break; + } + } + _isConnected = _db != null; } } catch (e) { rethrow; diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 631af8d1..4b09bab6 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -173,7 +173,8 @@ class MongoService { }); } - /// Opens a temporary [Db] for the given [database], runs [action], then closes. + /// Reuses a pooled [Db] for [database] on the live session (auth via the + /// in-memory session URI, not the scrubbed password getter). Future _withDb( MongoConnection connection, String database, @@ -182,14 +183,8 @@ class MongoService { if (!connection.isConnected) { throw StateError('Not connected to MongoDB'); } - final dbUri = connection.buildUriForDatabase(database); - final db = await Db.create(dbUri); - await db.open(); - try { - return await action(db); - } finally { - await db.close(); - } + final db = await connection.openDatabase(database); + return await action(db); } /// Returns the document count for a collection (with optional filter). diff --git a/test/core/database/mongodb_connection_test.dart b/test/core/database/mongodb_connection_test.dart index 614ab618..d986a864 100644 --- a/test/core/database/mongodb_connection_test.dart +++ b/test/core/database/mongodb_connection_test.dart @@ -182,6 +182,42 @@ void main() { ); }); + test('buildUriForDatabase still contains auth after scrubCredentials', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'atlas.example.com', + username: 'app', + password: 's3cret', + database: 'prod', + authSource: 'admin', + ); + + conn.scrubCredentials(); + + expect(conn.password, isNull); + expect(conn.connectionString, isNull); + + final uri = conn.buildUriForDatabase('analytics'); + expect(uri, contains('app:${Uri.encodeComponent('s3cret')}@')); + expect(uri, contains('/analytics')); + expect(uri, contains('authSource=admin')); + }); + + test('scrubCredentials does not persist session URI onto connectionString getter', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + username: 'u', + password: 'p', + connectionString: 'mongodb://u:p@localhost/db', + ); + conn.scrubCredentials(); + expect(conn.connectionString, isNull); + expect(conn.buildConnectionUri(), 'mongodb://u:p@localhost/db'); + }); + test('empty connectionString falls back to building URI', () { final conn = MongoConnection( id: 1, diff --git a/test/core/database/mongodb_uri_replacement_test.dart b/test/core/database/mongodb_uri_replacement_test.dart index 2ac7531c..6e5fd33b 100644 --- a/test/core/database/mongodb_uri_replacement_test.dart +++ b/test/core/database/mongodb_uri_replacement_test.dart @@ -199,5 +199,22 @@ void main() { expect(uri, contains('mongodb://')); expect(Uri.parse(uri).path, '/'); }); + + test('password remains in URI after scrubCredentials', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + database: 'mydb', + ); + conn.scrubCredentials(); + expect(conn.password, isNull); + final uri = conn.buildUriForDatabase('otherdb'); + expect(uri, contains('root:root')); + expect(uri, contains('/otherdb')); + expect(uri, contains('authSource=mydb')); + }); }); } From ca5562e915212e91671a2cfccfb1e07db5bd539d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 11:41:22 +0300 Subject: [PATCH 03/52] fix(postgres): execute Save DML as separate statements Extended protocol Parse cannot contain multiple commands, so BEGIN/UPDATE/COMMIT must not go through one execute. Closes #784 --- CHANGELOG.md | 1 + lib/core/database/postgres_sql.dart | 22 +++++++ .../postgresql/postgres_sql_workspace.dart | 12 ++-- .../postgresql/postgres_table_view.dart | 8 ++- test/core/database/postgres_sql_test.dart | 58 +++++++++++++++++++ 5 files changed, 96 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a497935..c046a891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Postgres Save (#784)** — Table Browser and SQL-grid Save run `BEGIN`, each DML statement, and `COMMIT` as separate extended-protocol executes (Parse cannot contain multiple commands). Autocommit-off SQL starts a transaction with its own `BEGIN` instead of concatenating `BEGIN;` onto the user query. - **Mongo session auth (#780)** — After `scrubCredentials()`, find/update/list still authenticate from an in-memory session URI (getters stay null; nothing is written back). One `Db` is reused per database instead of open/close on every call. - **Table Browser INSERT (#792, #810)** — Skip `GENERATED ALWAYS` / identity-always / `AUTO_INCREMENT` columns and omit empty cells that have a server default, so new rows do not send `id` or `''`. - **SQLite WAL (#801)** — Open writable user databases with `PRAGMA journal_mode=WAL` (falls back if the FS cannot create the WAL file) and surface `SQLITE_BUSY` as a retryable error instead of a generic execute failure. diff --git a/lib/core/database/postgres_sql.dart b/lib/core/database/postgres_sql.dart index 0eff57f6..d5655063 100644 --- a/lib/core/database/postgres_sql.dart +++ b/lib/core/database/postgres_sql.dart @@ -26,3 +26,25 @@ bool shouldSkipImplicitBegin(String sql) { return false; } + +/// Runs [statements] as separate extended-protocol executes inside BEGIN/COMMIT. +/// +/// PostgreSQL Parse cannot contain multiple commands, so +/// `BEGIN; UPDATE …; COMMIT;` in one [execute] fails. +Future runPostgresStatementsInTransaction( + Future Function(String sql) execute, + Iterable statements, +) async { + await execute('BEGIN'); + try { + for (final sql in statements) { + await execute(sql); + } + await execute('COMMIT'); + } catch (_) { + try { + await execute('ROLLBACK'); + } catch (_) {} + rethrow; + } +} diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index fcaf0544..757729e8 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -443,14 +443,14 @@ class _PostgresSqlWorkspaceState extends material.State { return; } + final to = _statementTimeout(); if (!_autocommit) { final inTx = await conn.inOpenTransaction() ?? false; if (!inTx && !shouldSkipImplicitBegin(sql)) { - sql = 'BEGIN;\n$sql'; + await conn.execute('BEGIN', timeout: to); } } - final to = _statementTimeout(); final result = await conn.execute(sql, timeout: to); if (!mounted) return; @@ -584,9 +584,13 @@ class _PostgresSqlWorkspaceState extends material.State { throw StateError('Could not connect to PostgreSQL.'); } - final txSql = plan.toTransactionSql(); final to = _statementTimeout(); - await conn.execute(txSql, timeout: to); + await runPostgresStatementsInTransaction( + (sql) async { + await conn.execute(sql, timeout: to); + }, + plan.statements.map((s) => s.sql), + ); if (!mounted) return; final newRows = session.stagingBuffer!.effectiveRows; diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index f4f118d4..bb183a30 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; +import 'package:querya_desktop/core/database/postgres_sql.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; @@ -531,7 +532,12 @@ class _PostgresTableViewState extends material.State { if (conn == null || !conn.isConnected) { throw StateError('Could not connect to PostgreSQL.'); } - await conn.execute(plan.toTransactionSql()); + await runPostgresStatementsInTransaction( + (sql) async { + await conn.execute(sql); + }, + plan.statements.map((s) => s.sql), + ); }, ); if (!mounted) return; diff --git a/test/core/database/postgres_sql_test.dart b/test/core/database/postgres_sql_test.dart index 85bf320a..fd69b469 100644 --- a/test/core/database/postgres_sql_test.dart +++ b/test/core/database/postgres_sql_test.dart @@ -80,6 +80,64 @@ void main() { }); }); + group('runPostgresStatementsInTransaction', () { + test('executes BEGIN, each statement, COMMIT as separate calls', () async { + final calls = []; + await runPostgresStatementsInTransaction( + (sql) async => calls.add(sql), + [ + 'UPDATE public.t SET a = 1 WHERE id = 1', + 'DELETE FROM public.t WHERE id = 2', + ], + ); + expect(calls, [ + 'BEGIN', + 'UPDATE public.t SET a = 1 WHERE id = 1', + 'DELETE FROM public.t WHERE id = 2', + 'COMMIT', + ]); + }); + + test('ROLLBACK then rethrows when a statement fails', () async { + final calls = []; + await expectLater( + runPostgresStatementsInTransaction( + (sql) async { + calls.add(sql); + if (sql.startsWith('UPDATE')) { + throw StateError( + 'cannot insert multiple commands into a prepared statement', + ); + } + }, + ['UPDATE t SET a = 1 WHERE id = 1'], + ), + throwsA(isA()), + ); + expect(calls, [ + 'BEGIN', + 'UPDATE t SET a = 1 WHERE id = 1', + 'ROLLBACK', + ]); + }); + + test('ROLLBACK failure does not hide the original error', () async { + await expectLater( + runPostgresStatementsInTransaction( + (sql) async { + if (sql == 'BEGIN') return; + if (sql == 'ROLLBACK') throw StateError('already aborted'); + throw StateError('multi-command'); + }, + ['UPDATE t SET a = 1'], + ), + throwsA( + predicate((e) => e.message == 'multi-command'), + ), + ); + }); + }); + group('injectSqlLimit', () { test('appends LIMIT to select query without limit', () { expect(injectSqlLimit('SELECT * FROM users', 5000), From f5212c6915734f0548a8c74e93183a628378983f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 11:47:11 +0300 Subject: [PATCH 04/52] fix(mysql): cap SELECT on the server and drain rowsStream Cancelling the iterable result at the UI cap left COM_QUERY unread; the next statement waited for connectionEstablished. Inject LIMIT like Postgres and drain leftover rows so the session is ready. Closes #808 --- CHANGELOG.md | 1 + lib/core/database/sql_limit.dart | 2 +- lib/core/database/stream_take_drain.dart | 25 ++++ lib/features/mysql/mysql_sql_workspace.dart | 38 ++--- lib/features/mysql/mysql_table_view.dart | 6 +- .../core/database/stream_take_drain_test.dart | 132 ++++++++++++++++++ 6 files changed, 185 insertions(+), 19 deletions(-) create mode 100644 lib/core/database/stream_take_drain.dart create mode 100644 test/core/database/stream_take_drain_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index c046a891..f12a9f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MySQL SELECT cap (#808)** — SQL Workspace injects/clamps `LIMIT` like Postgres and drains leftover `rowsStream` rows instead of cancelling, so the session is ready for the next statement. Table Browser custom SQL gets the same LIMIT clamp so `execute` does not buffer an unbounded result. - **Postgres Save (#784)** — Table Browser and SQL-grid Save run `BEGIN`, each DML statement, and `COMMIT` as separate extended-protocol executes (Parse cannot contain multiple commands). Autocommit-off SQL starts a transaction with its own `BEGIN` instead of concatenating `BEGIN;` onto the user query. - **Mongo session auth (#780)** — After `scrubCredentials()`, find/update/list still authenticate from an in-memory session URI (getters stay null; nothing is written back). One `Db` is reused per database instead of open/close on every call. - **Table Browser INSERT (#792, #810)** — Skip `GENERATED ALWAYS` / identity-always / `AUTO_INCREMENT` columns and omit empty cells that have a server default, so new rows do not send `id` or `''`. diff --git a/lib/core/database/sql_limit.dart b/lib/core/database/sql_limit.dart index cbb16c3f..08856086 100644 --- a/lib/core/database/sql_limit.dart +++ b/lib/core/database/sql_limit.dart @@ -1,4 +1,4 @@ -// Shared helpers for bounding ad-hoc SQL result sets (Postgres, SQLite, …). +// Shared helpers for bounding ad-hoc SQL result sets (Postgres, SQLite, MySQL, …). /// Removes leading whitespace and `--` line comments (not `/* */`). String stripLeadingWhitespaceAndLineComments(String sql) { diff --git a/lib/core/database/stream_take_drain.dart b/lib/core/database/stream_take_drain.dart new file mode 100644 index 00000000..9353bb28 --- /dev/null +++ b/lib/core/database/stream_take_drain.dart @@ -0,0 +1,25 @@ +/// Keeps the first [cap] events from [stream], then drains the rest. +/// +/// Cancelling a MySQL `rowsStream` at the cap leaves COM_QUERY unread +/// (`waitingCommandResponse`); the next execute waits for +/// `connectionEstablished` and can time out. Draining lets the producer +/// finish (EOF) so the session returns to ready. +Future<({List items, bool truncated})> takeThenDrain( + Stream stream, + int cap, { + Future Function(int count)? onProgress, +}) async { + final items = []; + var truncated = false; + await for (final item in stream) { + if (items.length >= cap) { + truncated = true; + continue; + } + items.add(item); + if (onProgress != null) { + await onProgress(items.length); + } + } + return (items: items, truncated: truncated); +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 99473c67..37a12e70 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -9,7 +9,9 @@ import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; +import 'package:querya_desktop/core/database/stream_take_drain.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; @@ -288,8 +290,10 @@ class _MysqlSqlWorkspaceState extends material.State { } final to = _statementTimeout(); + final cap = _resultMaxRows; + final sql = injectSqlLimit(userSql, cap); final rs = - await conn.executeWithTimeout(userSql, timeout: to, iterable: true); + await conn.executeWithTimeout(sql, timeout: to, iterable: true); if (!mounted) return; @@ -299,26 +303,26 @@ class _MysqlSqlWorkspaceState extends material.State { } // Convert while streaming — no Object? matrix + isolate double-copy (#421). - final outRows = >[]; - var n = 0; - final cap = _resultMaxRows; - var truncated = false; - await for (final row in rs.rowsStream) { - if (n >= cap) { - truncated = true; - break; - } - outRows.add( + // Drain leftover rows so COM_QUERY reaches EOF (#808); do not cancel. + final taken = await takeThenDrain( + rs.rowsStream, + cap, + onProgress: (n) async { + if (n % kResultStringConvertYieldEvery == 0) { + await Future.delayed(Duration.zero); + } + }, + ); + final outRows = [ + for (final row in taken.items) List.generate( row.numOfColumns, (i) => resultCellToDisplayString(row.colAt(i)), ), - ); - n++; - if (n % kResultStringConvertYieldEvery == 0) { - await Future.delayed(Duration.zero); - } - } + ]; + final truncated = + taken.truncated || (sql != userSql && outRows.length >= cap); + final n = outRows.length; int? affected; if (cols.isEmpty && outRows.isEmpty) { diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index 439961cf..ea29afda 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -6,6 +6,8 @@ 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/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -318,7 +320,9 @@ class _MysqlTableViewState extends material.State { _error = null; }); try { - final result = await conn.execute(sql); + final result = await conn.execute( + injectSqlLimit(sql, kDefaultSqlResultMaxRows), + ); if (!mounted) return; final stringRows = await _resultRowsAsync(result); if (!mounted) return; diff --git a/test/core/database/stream_take_drain_test.dart b/test/core/database/stream_take_drain_test.dart new file mode 100644 index 00000000..270a10bb --- /dev/null +++ b/test/core/database/stream_take_drain_test.dart @@ -0,0 +1,132 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart'; +import 'package:querya_desktop/core/database/stream_take_drain.dart'; + +void main() { + group('takeThenDrain', () { + test('keeps first cap items and drains the rest so the producer can finish', + () async { + final controller = StreamController(); + final future = takeThenDrain(controller.stream, 2); + + controller.add(1); + controller.add(2); + // Must not throw: cancelling at the cap would make this add fail. + controller.add(3); + await controller.close(); + + final taken = await future; + expect(taken.items, [1, 2]); + expect(taken.truncated, isTrue); + expect(controller.hasListener, isFalse); + }); + + test('does not mark truncated when the stream ends at the cap', () async { + final taken = await takeThenDrain(Stream.fromIterable([1, 2]), 2); + expect(taken.items, [1, 2]); + expect(taken.truncated, isFalse); + }); + + test('empty stream', () async { + final taken = await takeThenDrain(const Stream.empty(), 5); + expect(taken.items, isEmpty); + expect(taken.truncated, isFalse); + }); + + test('after drain, a following stream can be consumed (next query)', + () async { + final first = await takeThenDrain( + Stream.fromIterable([1, 2, 3]), + 2, + ); + expect(first.items, [1, 2]); + + final second = await takeThenDrain( + Stream.fromIterable([99]), + 5000, + ); + expect(second.items, [99]); + expect(second.truncated, isFalse); + }); + }); + + group('injectSqlLimit + takeThenDrain (MySQL SELECT cap)', () { + test('SELECT of N+1 rows with cap N; following SELECT 1 succeeds', + () async { + const userSql = 'SELECT n FROM t'; + const cap = 2; + final executed = injectSqlLimit(userSql, cap); + expect(executed, contains('LIMIT 2')); + + // Server would send at most [cap] rows after LIMIT injection. + // Simulate a producer that still has leftover rows (no LIMIT / SHOW). + final leftover = StreamController(); + final capped = takeThenDrain(leftover.stream, cap); + leftover + ..add(1) + ..add(2) + ..add(3); + await leftover.close(); + final taken = await capped; + expect(taken.items.length, cap); + + final followUp = await takeThenDrain( + Stream.fromIterable([1]), + cap, + ); + expect(followUp.items, [1]); + }); + }); + + group('live MySQL COM_QUERY drain', () { + test( + 'SELECT of N+1 rows with cap N; following SELECT 1 succeeds on same connection', + () async { + final port = + int.tryParse(Platform.environment['MYSQL_PORT'] ?? '') ?? 3306; + try { + final probe = await Socket.connect( + '127.0.0.1', + port, + timeout: const Duration(milliseconds: 200), + ); + await probe.close(); + } catch (_) { + markTestSkipped('MySQL is not listening on 127.0.0.1:$port'); + return; + } + + final conn = MysqlConnection( + id: 0, + name: 'live-808', + host: '127.0.0.1', + port: port, + username: Platform.environment['MYSQL_USER'] ?? 'querya', + password: Platform.environment['MYSQL_PASSWORD'] ?? 'querya', + database: Platform.environment['MYSQL_DATABASE'] ?? 'querya', + useSSL: false, + ); + await conn.connect(connectTimeoutMs: 3000); + addTearDown(() => conn.disconnect()); + + const cap = 2; + // Unbounded SELECT (no injectSqlLimit) so leftover protocol rows exist. + final rs = await conn.execute( + 'SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3', + null, + true, + ); + final taken = await takeThenDrain(rs.rowsStream, cap); + expect(taken.items.length, cap); + expect(taken.truncated, isTrue); + + final one = await conn.execute('SELECT 1 AS x'); + expect(one.rows.first.colAt(0), '1'); + }, + ); + }); +} From acf36561616c97e44a5d6da60117e5b1841e789e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 11:56:19 +0300 Subject: [PATCH 05/52] fix(redis): keep existing TTL when saving a string key SET without KEEPTTL or EX clears expiry, so session keys became immortal after an edit. Redis 6+ uses KEEPTTL XX; older servers fall back to TTL then SET EX. Expired keys are not recreated. Closes #811 --- CHANGELOG.md | 1 + lib/core/database/redis_connection.dart | 49 ++++++- lib/features/redis/redis_key_editor.dart | 6 +- .../core/database/redis_set_keepttl_test.dart | 122 ++++++++++++++++++ 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 test/core/database/redis_set_keepttl_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index f12a9f4e..ff466afb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Redis SET TTL (#811)** — Saving a string key uses `SET … KEEPTTL XX` (Redis 6+) so an existing expiry is not cleared. Older servers fall back to `TTL` then `SET … EX`. A key that already expired is not recreated. - **MySQL SELECT cap (#808)** — SQL Workspace injects/clamps `LIMIT` like Postgres and drains leftover `rowsStream` rows instead of cancelling, so the session is ready for the next statement. Table Browser custom SQL gets the same LIMIT clamp so `execute` does not buffer an unbounded result. - **Postgres Save (#784)** — Table Browser and SQL-grid Save run `BEGIN`, each DML statement, and `COMMIT` as separate extended-protocol executes (Parse cannot contain multiple commands). Autocommit-off SQL starts a transaction with its own `BEGIN` instead of concatenating `BEGIN;` onto the user query. - **Mongo session auth (#780)** — After `scrubCredentials()`, find/update/list still authenticate from an in-memory session URI (getters stay null; nothing is written back). One `Db` is reused per database instead of open/close on every call. diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index 98ea8ce3..007cfa7d 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -288,9 +288,46 @@ class RedisConnection { } /// SET key value [EX seconds]. - Future set(String key, String value, {int? ttlSeconds}) async { + /// + /// When [ttlSeconds] is omitted and [keepTtl] is true (the default), the + /// existing expiry is kept (`KEEPTTL`, Redis 6+). Falls back to `TTL` then + /// `SET … EX` on older servers. Does not recreate a key that is already gone + /// (TTL `-2` / `XX` miss). + Future set( + String key, + String value, { + int? ttlSeconds, + bool keepTtl = true, + }) async { if (ttlSeconds != null && ttlSeconds > 0) { await sendCommand(['SET', key, value, 'EX', ttlSeconds]); + return; + } + if (!keepTtl) { + await sendCommand(['SET', key, value]); + return; + } + await _setPreservingTtl(key, value); + } + + Future _setPreservingTtl(String key, String value) async { + try { + final result = await sendCommand(['SET', key, value, 'KEEPTTL', 'XX']); + if (_isRedisNil(result)) { + throw StateError('Key no longer exists'); + } + return; + } catch (e) { + if (e is StateError) rethrow; + if (!_isKeepTtlUnsupported(e)) rethrow; + } + + final existingTtl = await ttl(key); + if (existingTtl == -2) { + throw StateError('Key no longer exists'); + } + if (existingTtl > 0) { + await sendCommand(['SET', key, value, 'EX', existingTtl]); } else { await sendCommand(['SET', key, value]); } @@ -512,6 +549,16 @@ class RedisConnectionTestFake extends RedisConnection { } } +bool _isRedisNil(Object? result) => + result == null || result.toString().toLowerCase() == 'null'; + +bool _isKeepTtlUnsupported(Object error) { + final s = error.toString().toLowerCase(); + return s.contains('syntax') || + s.contains('keepttl') || + s.contains('wrong number of arguments'); +} + class RedisConnectionException implements Exception { RedisConnectionException(this.message); final String message; diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index 5a0d1415..1fe4b8f7 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -112,7 +112,11 @@ class _RedisKeyEditorState extends material.State { Future _saveString() async { try { await widget.connection.selectDatabase(widget.database); - await widget.connection.set(widget.keyName, _stringController.text); + await widget.connection.set( + widget.keyName, + _stringController.text, + keepTtl: true, + ); if (!mounted) return; setState(() => _success = 'Value saved'); _clearSuccessAfterDelay(); diff --git a/test/core/database/redis_set_keepttl_test.dart b/test/core/database/redis_set_keepttl_test.dart new file mode 100644 index 00000000..a5cce05f --- /dev/null +++ b/test/core/database/redis_set_keepttl_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; + +class _SetCommandFake extends RedisConnectionTestFake { + final List> commands = []; + bool rejectKeepTtl = false; + int ttlResult = 300; + Object? setResult = 'OK'; + + @override + Future sendCommand(List args) async { + commands.add(List.from(args)); + final op = args.first.toString().toUpperCase(); + if (op == 'SET') { + if (rejectKeepTtl && + args.any((a) => a.toString().toUpperCase() == 'KEEPTTL')) { + throw RedisConnectionException('ERR syntax error'); + } + return setResult; + } + if (op == 'TTL') return ttlResult; + return super.sendCommand(args); + } +} + +void main() { + group('RedisConnection.set KEEPTTL', () { + test('Save without EX uses SET KEEPTTL XX', () async { + final fake = _SetCommandFake(); + await fake.connect(); + + await fake.set('session:1', 'new-value'); + + expect(fake.commands, [ + ['SET', 'session:1', 'new-value', 'KEEPTTL', 'XX'], + ]); + }); + + test('does not recreate a key that already expired (XX miss)', () async { + final fake = _SetCommandFake()..setResult = null; + await fake.connect(); + + await expectLater( + fake.set('session:1', 'new-value'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Key no longer exists', + ), + ), + ); + expect(fake.commands, [ + ['SET', 'session:1', 'new-value', 'KEEPTTL', 'XX'], + ]); + }); + + test('Redis < 6: TTL then SET EX when KEEPTTL is rejected', () async { + final fake = _SetCommandFake() + ..rejectKeepTtl = true + ..ttlResult = 300; + await fake.connect(); + + await fake.set('session:1', 'new-value'); + + expect(fake.commands, [ + ['SET', 'session:1', 'new-value', 'KEEPTTL', 'XX'], + ['TTL', 'session:1'], + ['SET', 'session:1', 'new-value', 'EX', 300], + ]); + }); + + test('Redis < 6: does not SET when TTL is -2', () async { + final fake = _SetCommandFake() + ..rejectKeepTtl = true + ..ttlResult = -2; + await fake.connect(); + + await expectLater( + fake.set('session:1', 'new-value'), + throwsA(isA()), + ); + expect(fake.commands, [ + ['SET', 'session:1', 'new-value', 'KEEPTTL', 'XX'], + ['TTL', 'session:1'], + ]); + }); + + test('Redis < 6: plain SET when key has no expiry (TTL -1)', () async { + final fake = _SetCommandFake() + ..rejectKeepTtl = true + ..ttlResult = -1; + await fake.connect(); + + await fake.set('session:1', 'new-value'); + + expect(fake.commands.last, ['SET', 'session:1', 'new-value']); + }); + + test('explicit ttlSeconds still sends SET EX', () async { + final fake = _SetCommandFake(); + await fake.connect(); + + await fake.set('session:1', 'new-value', ttlSeconds: 10); + + expect(fake.commands, [ + ['SET', 'session:1', 'new-value', 'EX', 10], + ]); + }); + + test('keepTtl: false sends a plain SET', () async { + final fake = _SetCommandFake(); + await fake.connect(); + + await fake.set('session:1', 'new-value', keepTtl: false); + + expect(fake.commands, [ + ['SET', 'session:1', 'new-value'], + ]); + }); + }); +} From 059dedc30fa27fb8720b20d997b66d8ba46dabbe Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 12:03:32 +0300 Subject: [PATCH 06/52] fix(grid): confirm before discarding Table Browser edits on navigate Home, Close workspace, tree/object changes, and the matching palette commands go through UnsavedWorkRegistry so a dirty staging buffer is not torn down until Discard. Closes #771 --- CHANGELOG.md | 1 + lib/core/unsaved_work_guard.dart | 59 ++++++++ lib/features/main_screen/main_screen.dart | 167 +++++++++++++++++++++- test/core/unsaved_work_guard_test.dart | 77 ++++++++++ 4 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 lib/core/unsaved_work_guard.dart create mode 100644 test/core/unsaved_work_guard_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ff466afb..850782c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Table Browser navigation (#771)** — Home, Close workspace, tree/object changes, and the matching Command Palette actions confirm before discarding staged grid or SQL edits. Cancel leaves the staging buffer registered. - **Redis SET TTL (#811)** — Saving a string key uses `SET … KEEPTTL XX` (Redis 6+) so an existing expiry is not cleared. Older servers fall back to `TTL` then `SET … EX`. A key that already expired is not recreated. - **MySQL SELECT cap (#808)** — SQL Workspace injects/clamps `LIMIT` like Postgres and drains leftover `rowsStream` rows instead of cancelling, so the session is ready for the next statement. Table Browser custom SQL gets the same LIMIT clamp so `execute` does not buffer an unbounded result. - **Postgres Save (#784)** — Table Browser and SQL-grid Save run `BEGIN`, each DML statement, and `COMMIT` as separate extended-protocol executes (Parse cannot contain multiple commands). Autocommit-off SQL starts a transaction with its own `BEGIN` instead of concatenating `BEGIN;` onto the user query. diff --git a/lib/core/unsaved_work_guard.dart b/lib/core/unsaved_work_guard.dart new file mode 100644 index 00000000..f8d63029 --- /dev/null +++ b/lib/core/unsaved_work_guard.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Confirms before Home / Close / tree navigation discards SQL or staged grid +/// edits. Returns true when it is safe to tear down the current view. +Future confirmDiscardUnsavedWorkIfNeeded( + material.BuildContext context, +) async { + if (!UnsavedWorkRegistry.instance.hasUnsaved) return true; + final confirmed = await showDiscardUnsavedWorkDialog(context); + return confirmed == true; +} + +/// Prompt when leaving a workspace that has unsaved SQL or table edits. +Future showDiscardUnsavedWorkDialog(material.BuildContext context) { + return showAppDialog( + context: context, + builder: (ctx) { + return QueryaDialogCard( + constraints: const material.BoxConstraints(maxWidth: 420), + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Unsaved changes').semiBold().large(), + const Gap(8), + const Text( + 'You have unsaved SQL or staged table edits. ' + 'Continuing will discard them.', + ).muted().small(), + const Gap(20), + material.Align( + alignment: material.Alignment.centerRight, + child: material.Wrap( + alignment: material.WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + OutlineButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + DestructiveButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const Text('Discard'), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index a84997d5..28d2a237 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -28,6 +28,7 @@ import 'package:querya_desktop/features/connections/new_connection_url_dialog.da import 'package:querya_desktop/features/connections/connections_panel.dart'; import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; import 'package:querya_desktop/core/ui/querya_shell_status.dart'; +import 'package:querya_desktop/core/unsaved_work_guard.dart'; import 'package:querya_desktop/features/main_screen/connections_panel_width_persist.dart'; import 'package:querya_desktop/features/main_screen/querya_status_bar.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; @@ -262,7 +263,17 @@ class _MainScreenState extends State { return false; } + Future _allowUnsavedNavigation() { + return confirmDiscardUnsavedWorkIfNeeded(context); + } + void _onConnectionSelected(ConnectionRow connection) { + unawaited(_selectConnection(connection)); + } + + Future _selectConnection(ConnectionRow connection) async { + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; final prevId = _workspace.value.activeConnection?.id; _workspace.value = _workspace.value.selectConnection(connection); if (prevId != connection.id) { @@ -282,6 +293,33 @@ class _MainScreenState extends State { String name, PostgresObjectKind kind, ) { + unawaited(_selectPostgresObject( + connection, + database, + schema, + name, + kind, + )); + } + + Future _selectPostgresObject( + ConnectionRow connection, + String database, + String schema, + String name, + PostgresObjectKind kind, + ) async { + final ws = _workspace.value; + final cur = ws.selectedPostgresObject; + final same = ws.activeConnection?.id == connection.id && + cur != null && + cur.database == database && + cur.schema == schema && + cur.name == name && + cur.kind == kind; + if (same) return; + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.selectPostgresObject( connection, database, @@ -297,6 +335,25 @@ class _MainScreenState extends State { String name, MysqlObjectKind kind, ) { + unawaited(_selectMysqlObject(connection, database, name, kind)); + } + + Future _selectMysqlObject( + ConnectionRow connection, + String database, + String name, + MysqlObjectKind kind, + ) async { + final ws = _workspace.value; + final cur = ws.selectedMysqlObject; + final same = ws.activeConnection?.id == connection.id && + cur != null && + cur.database == database && + cur.name == name && + cur.kind == kind; + if (same) return; + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.selectMysqlObject( connection, database, @@ -306,10 +363,38 @@ class _MainScreenState extends State { } void _onRedisDatabaseSelected(ConnectionRow connection, int database) { + unawaited(_selectRedisDatabase(connection, database)); + } + + Future _selectRedisDatabase( + ConnectionRow connection, + int database, + ) async { + final ws = _workspace.value; + if (ws.activeConnection?.id == connection.id && + ws.activeRedisDb == database) { + return; + } + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.selectRedisDb(connection, database); } void _onMongoDBDatabaseSelected(ConnectionRow connection, String database) { + unawaited(_selectMongoDatabase(connection, database)); + } + + Future _selectMongoDatabase( + ConnectionRow connection, + String database, + ) async { + final ws = _workspace.value; + if (ws.activeConnection?.id == connection.id && + ws.activeMongoDB == database) { + return; + } + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.selectMongoDb(connection, database); } @@ -320,6 +405,24 @@ class _MainScreenState extends State { String? name, PostgresObjectKind? kind, }) { + unawaited(_openPostgresSqlWorkspace( + connection, + database: database, + schema: schema, + name: name, + kind: kind, + )); + } + + Future _openPostgresSqlWorkspace( + ConnectionRow connection, { + String? database, + String? schema, + String? name, + PostgresObjectKind? kind, + }) async { + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.openPostgresSqlWorkspace( connection, seedDatabase: database, @@ -330,6 +433,12 @@ class _MainScreenState extends State { } void _onMysqlOpenSqlWorkspace(ConnectionRow connection) { + unawaited(_openMysqlSqlWorkspace(connection)); + } + + Future _openMysqlSqlWorkspace(ConnectionRow connection) async { + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.openMysqlSqlWorkspace(connection); } @@ -338,6 +447,23 @@ class _MainScreenState extends State { String name, SqliteObjectKind kind, ) { + unawaited(_selectSqliteObject(connection, name, kind)); + } + + Future _selectSqliteObject( + ConnectionRow connection, + String name, + SqliteObjectKind kind, + ) async { + final ws = _workspace.value; + final cur = ws.selectedSqliteObject; + final same = ws.activeConnection?.id == connection.id && + cur != null && + cur.name == name && + cur.kind == kind; + if (same) return; + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.selectSqliteObject( connection, name, @@ -346,6 +472,12 @@ class _MainScreenState extends State { } void _onSqliteOpenSqlWorkspace(ConnectionRow connection) { + unawaited(_openSqliteSqlWorkspace(connection)); + } + + Future _openSqliteSqlWorkspace(ConnectionRow connection) async { + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.openSqliteSqlWorkspace(connection); } @@ -354,6 +486,23 @@ class _MainScreenState extends State { String database, String name, ) { + unawaited(_selectExtensionObject(connection, database, name)); + } + + Future _selectExtensionObject( + ConnectionRow connection, + String database, + String name, + ) async { + final ws = _workspace.value; + final cur = ws.selectedExtensionObject; + final same = ws.activeConnection?.id == connection.id && + cur != null && + cur.database == database && + cur.name == name; + if (same) return; + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = _workspace.value.selectExtensionObject( connection, database, @@ -449,7 +598,7 @@ class _MainScreenState extends State { _onSqliteOpenSqlWorkspace(connection); default: if (ExtensionDriverCatalog.isExtensionDriverConnection(connection)) { - _workspace.value = _workspace.value.selectConnection(connection); + unawaited(_selectConnection(connection)); } } } @@ -500,10 +649,22 @@ class _MainScreenState extends State { } void _onGoHome() { + unawaited(_goHome()); + } + + Future _goHome() async { + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; _workspace.value = MainScreenWorkspaceState.empty; QueryaShellStatus.instance.clear(); } + Future _closeWorkspace() async { + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; + _workspace.value = _workspace.value.unselectActiveObject(); + } + bool _hasCloseableWorkspace(MainScreenWorkspaceState ws) { return ws.selectedPostgresObject != null || ws.selectedMysqlObject != null || @@ -602,9 +763,7 @@ class _MainScreenState extends State { onGoHome: workspace.activeConnection != null ? _onGoHome : null, onCloseWorkspace: _hasCloseableWorkspace(workspace) - ? () { - _workspace.value = _workspace.value.unselectActiveObject(); - } + ? () => unawaited(_closeWorkspace()) : null, onConnect: workspace.activeConnection?.id != null ? () => _connectionsPanelKey.currentState diff --git a/test/core/unsaved_work_guard_test.dart b/test/core/unsaved_work_guard_test.dart new file mode 100644 index 00000000..c8a58b79 --- /dev/null +++ b/test/core/unsaved_work_guard_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/core/unsaved_work_guard.dart'; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + tearDown(UnsavedWorkRegistry.instance.resetForTest); + + testWidgets('returns true immediately when nothing is unsaved', + (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: const material.Scaffold(body: material.SizedBox()), + ), + ); + final ctx = tester.element(find.byType(material.Scaffold)); + expect(await confirmDiscardUnsavedWorkIfNeeded(ctx), isTrue); + }); + + testWidgets( + 'dirty buffer + Home/Close does not dispose until Discard is confirmed', + (tester) async { + final buffer = DataGridStagingBuffer( + columns: const ['id'], + rows: const [ + ['1'], + ], + ); + buffer.setCell(0, 0, '2'); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isTrue); + + var navigated = false; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.Builder( + builder: (context) { + return material.TextButton( + onPressed: () async { + if (!await confirmDiscardUnsavedWorkIfNeeded(context)) { + return; + } + buffer.dispose(); + navigated = true; + }, + child: const material.Text('Home'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Home')); + await tester.pumpAndSettle(); + expect(navigated, isFalse); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isTrue); + expect(find.text('Discard'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(navigated, isFalse); + expect(buffer.isDirty, isTrue); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isTrue); + + await tester.tap(find.text('Home')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + expect(navigated, isTrue); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isFalse); + }, + ); +} From 44827f6d1cdd7d17faf871f3178f3adf21ca8399 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 12:06:49 +0300 Subject: [PATCH 07/52] fix(grid): treat 0-row DML as a failed save A concurrent edit or stale PK can make UPDATE match no rows; the transaction now rolls back, the staging buffer is kept, and Save Failed is shown instead of toasting success from statement count. Closes #773 --- CHANGELOG.md | 1 + lib/core/database/sqlite_connection.dart | 11 ++++ lib/features/mysql/mysql_sql_workspace.dart | 14 ++++- lib/features/mysql/mysql_table_view.dart | 3 +- .../postgresql/postgres_sql_workspace.dart | 5 +- .../postgresql/postgres_table_view.dart | 5 +- lib/features/sqlite/sqlite_sql_workspace.dart | 13 ++++- lib/features/sqlite/sqlite_table_view.dart | 2 +- .../workspace/table_view_staging.dart | 13 +++++ .../workspace/table_view_staging_test.dart | 56 +++++++++++++++++++ 10 files changed, 115 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 850782c7..e903f77c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **0-row DML Save (#773)** — Table Browser and SQL-grid Save fail (and roll back) if any statement matches 0 rows instead of toasting success from statement count. The staging buffer is kept. - **Table Browser navigation (#771)** — Home, Close workspace, tree/object changes, and the matching Command Palette actions confirm before discarding staged grid or SQL edits. Cancel leaves the staging buffer registered. - **Redis SET TTL (#811)** — Saving a string key uses `SET … KEEPTTL XX` (Redis 6+) so an existing expiry is not cleared. Older servers fall back to `TTL` then `SET … EX`. A key that already expired is not recreated. - **MySQL SELECT cap (#808)** — SQL Workspace injects/clamps `LIMIT` like Postgres and drains leftover `rowsStream` rows instead of cancelling, so the session is ready for the next statement. Table Browser custom SQL gets the same LIMIT clamp so `execute` does not buffer an unbounded result. diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index 4c633883..23565934 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -141,6 +141,17 @@ class SqliteConnection { } } + /// Runs DML and returns sqlite `changes()` for the last INSERT/UPDATE/DELETE. + Future executeAffected(String sql) async { + await execute(sql); + if (!isConnected || _db == null) return 0; + final rows = await _db!.rawQuery('SELECT changes() AS c'); + if (rows.isEmpty) return 0; + final v = rows.first['c']; + if (v is int) return v; + return int.tryParse('$v') ?? 0; + } + /// Runs [execute] with an application-level [timeout]. Future>> executeWithTimeout( String sql, { diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 37a12e70..8e2ced5e 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -427,8 +427,18 @@ class _MysqlSqlWorkspaceState extends material.State { throw StateError('Could not connect to MySQL.'); } - for (final stmt in plan.statements) { - await conn.execute(stmt.sql); + await conn.execute('START TRANSACTION'); + try { + for (final stmt in plan.statements) { + final rs = await conn.execute(stmt.sql); + expectDmlMatchedRows(rs.affectedRows.toInt()); + } + await conn.execute('COMMIT'); + } catch (e) { + try { + await conn.execute('ROLLBACK'); + } catch (_) {} + rethrow; } if (!mounted) return; diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index ea29afda..9d81197c 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -492,7 +492,8 @@ class _MysqlTableViewState extends material.State { await conn.execute('START TRANSACTION'); try { for (final stmt in plan.statements) { - await conn.execute(stmt.sql); + final rs = await conn.execute(stmt.sql); + expectDmlMatchedRows(rs.affectedRows.toInt()); } await conn.execute('COMMIT'); } catch (e) { diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 757729e8..7bb600aa 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -587,7 +587,10 @@ class _PostgresSqlWorkspaceState extends material.State { final to = _statementTimeout(); await runPostgresStatementsInTransaction( (sql) async { - await conn.execute(sql, timeout: to); + final result = await conn.execute(sql, timeout: to); + if (sql != 'BEGIN' && sql != 'COMMIT' && sql != 'ROLLBACK') { + expectDmlMatchedRows(result.affectedRows); + } }, plan.statements.map((s) => s.sql), ); diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index bb183a30..d4e6999b 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -534,7 +534,10 @@ class _PostgresTableViewState extends material.State { } await runPostgresStatementsInTransaction( (sql) async { - await conn.execute(sql); + final result = await conn.execute(sql); + if (sql != 'BEGIN' && sql != 'COMMIT' && sql != 'ROLLBACK') { + expectDmlMatchedRows(result.affectedRows); + } }, plan.statements.map((s) => s.sql), ); diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 2a16d554..30bd6f73 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -386,8 +386,17 @@ class _SqliteSqlWorkspaceState extends material.State { throw StateError('Could not connect to SQLite.'); } - for (final stmt in plan.statements) { - await conn.execute(stmt.sql); + await conn.execute('BEGIN TRANSACTION'); + try { + for (final stmt in plan.statements) { + expectDmlMatchedRows(await conn.executeAffected(stmt.sql)); + } + await conn.execute('COMMIT'); + } catch (e) { + try { + await conn.execute('ROLLBACK'); + } catch (_) {} + rethrow; } if (!mounted) return; diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index c72f6d7c..79388a88 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -311,7 +311,7 @@ class _SqliteTableViewState extends material.State { await conn.execute('BEGIN TRANSACTION'); try { for (final stmt in plan.statements) { - await conn.execute(stmt.sql); + expectDmlMatchedRows(await conn.executeAffected(stmt.sql)); } await conn.execute('COMMIT'); } catch (e) { diff --git a/lib/features/workspace/table_view_staging.dart b/lib/features/workspace/table_view_staging.dart index 3ec287cb..8c60274f 100644 --- a/lib/features/workspace/table_view_staging.dart +++ b/lib/features/workspace/table_view_staging.dart @@ -124,6 +124,15 @@ Future showDiscardTableEditsDialog({ ); } +/// Throws if a DML statement matched no rows (stale PK / concurrent delete). +void expectDmlMatchedRows(int affectedRows) { + if (affectedRows >= 1) return; + throw StateError( + 'Save failed: a statement matched 0 rows. ' + 'The row may have been changed or deleted. Refresh and try again.', + ); +} + Future showTableViewSaveFailedDialog({ required material.BuildContext context, required Object error, @@ -183,6 +192,10 @@ class TableViewApplyOutcome { } /// Preview + execute a staging-buffer mutation plan for Table Browser. +/// +/// [execute] must throw if any statement matched 0 rows (see +/// [expectDmlMatchedRows]) so this returns [TableViewApplyOutcome.failed] +/// and the caller keeps the staging buffer. Future applyTableViewStagedChanges({ required material.BuildContext context, required DataGridStagingBuffer buffer, diff --git a/test/features/workspace/table_view_staging_test.dart b/test/features/workspace/table_view_staging_test.dart index fcd0b56d..77fadabf 100644 --- a/test/features/workspace/table_view_staging_test.dart +++ b/test/features/workspace/table_view_staging_test.dart @@ -248,6 +248,62 @@ void main() { expect(outcome.error.toString(), contains('no primary key')); expect(executed, isFalse); }); + + testWidgets('0-row DML is failed and the staging buffer stays dirty', + (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: const material.Scaffold(body: material.SizedBox()), + ), + ); + final ctx = tester.element(find.byType(material.Scaffold)); + final buffer = DataGridStagingBuffer( + columns: ['id', 'name'], + rows: [ + ['1', 'Ada'], + ], + ); + buffer.setCell(0, 1, 'Grace'); + addTearDown(buffer.dispose); + + final future = applyTableViewStagedChanges( + context: ctx, + buffer: buffer, + dialect: SqlDialect.postgres, + tableName: 'users', + schema: 'public', + primaryKeys: ['id'], + execute: (_) async => expectDmlMatchedRows(0), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply Changes')); + final outcome = await future; + + expect(outcome.isFailed, isTrue); + expect(outcome.error.toString(), contains('matched 0 rows')); + expect(buffer.isDirty, isTrue); + }); + }); + + group('expectDmlMatchedRows', () { + test('allows 1+ affected rows', () { + expect(() => expectDmlMatchedRows(1), returnsNormally); + expect(() => expectDmlMatchedRows(3), returnsNormally); + }); + + test('throws on 0-row DML so Save is a failure', () { + expect( + () => expectDmlMatchedRows(0), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('matched 0 rows'), + ), + ), + ); + }); }); group('TableBrowserPendingActions', () { From 6379aad30061dc1c6c4c788edf7b54c559d6181a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 12:12:14 +0300 Subject: [PATCH 08/52] fix(mongo): round-trip BSON types through Extended JSON The document editor used JsonEncoder (ObjectId became a string via toJson) or toString() for DateTime/BinData, so Save could not rebuild the _id filter. Relaxed EJSON keeps ObjectId and dates typed. Closes #779 --- CHANGELOG.md | 1 + .../mongodb/mongo_document_editor.dart | 26 +++------ lib/features/mongodb/mongo_ejson.dart | 24 ++++++++ test/features/mongodb/mongo_ejson_test.dart | 58 +++++++++++++++++++ 4 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 lib/features/mongodb/mongo_ejson.dart create mode 100644 test/features/mongodb/mongo_ejson_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e903f77c..8da6c48e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Mongo document editor (#779)** — Documents encode as relaxed Extended JSON (`$oid`, `$date`, …) instead of `JsonEncoder` / `toString()`. Save decodes EJSON so the `_id` filter stays an `ObjectId`, not a string. - **0-row DML Save (#773)** — Table Browser and SQL-grid Save fail (and roll back) if any statement matches 0 rows instead of toasting success from statement count. The staging buffer is kept. - **Table Browser navigation (#771)** — Home, Close workspace, tree/object changes, and the matching Command Palette actions confirm before discarding staged grid or SQL edits. Cancel leaves the staging buffer registered. - **Redis SET TTL (#811)** — Saving a string key uses `SET … KEEPTTL XX` (Redis 6+) so an existing expiry is not cleared. Older servers fall back to `TTL` then `SET … EX`. A key that already expired is not recreated. diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index 6cc9dafa..941e2335 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -1,10 +1,9 @@ -import 'dart:convert'; - import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; -import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -51,7 +50,7 @@ class _MongoDocumentEditorState extends material.State { void initState() { super.initState(); _controller = material.TextEditingController( - text: _prettyJson(widget.document), + text: mongoDocumentToEjson(widget.document), ); _controller.addListener(_onTextChanged); } @@ -87,7 +86,7 @@ class _MongoDocumentEditorState extends material.State { } final doc = rows.first; _controller.removeListener(_onTextChanged); - _controller.text = _prettyJson(doc); + _controller.text = mongoDocumentToEjson(doc); _controller.addListener(_onTextChanged); setState(() { _dirty = false; @@ -114,8 +113,8 @@ class _MongoDocumentEditorState extends material.State { void _format() { try { - final parsed = json.decode(_controller.text) as Map; - _controller.text = _prettyJson(parsed); + final parsed = mongoDocumentFromEjson(_controller.text); + _controller.text = mongoDocumentToEjson(parsed); setState(() => _error = null); } catch (e) { setState(() => _error = 'Invalid JSON: $e'); @@ -131,13 +130,14 @@ class _MongoDocumentEditorState extends material.State { Map parsed; try { - parsed = json.decode(_controller.text) as Map; + parsed = mongoDocumentFromEjson(_controller.text); } catch (e) { setState(() => _error = 'Invalid JSON: $e'); return; } - // Remove _id from the update payload (can't change _id) + // Remove _id from the update payload (can't change _id). Filter uses the + // original BSON _id, not a stringified copy from the editor. final updateDoc = Map.from(parsed); updateDoc.remove('_id'); @@ -203,14 +203,6 @@ class _MongoDocumentEditorState extends material.State { } } - String _prettyJson(Map doc) { - try { - return const JsonEncoder.withIndent(' ').convert(doc); - } catch (_) { - return doc.toString(); - } - } - @override material.Widget build(material.BuildContext context) { final cs = Theme.of(context).colorScheme; diff --git a/lib/features/mongodb/mongo_ejson.dart b/lib/features/mongodb/mongo_ejson.dart new file mode 100644 index 00000000..9a825299 --- /dev/null +++ b/lib/features/mongodb/mongo_ejson.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; + +import 'package:mongo_dart/mongo_dart.dart'; + +/// Pretty-print a MongoDB document as relaxed Extended JSON. +/// +/// ObjectId, DateTime, NumberLong, NumberDecimal, BinData, and Timestamp +/// stay typed (`$oid`, `$date`, …) instead of falling back to `toString()`. +String mongoDocumentToEjson(Map doc) { + final ejson = EJsonCodec.deserialize( + BsonCodec.serialize(Map.from(doc)), + relaxed: true, + ); + return const JsonEncoder.withIndent(' ').convert(ejson); +} + +/// Parses Extended JSON (canonical or relaxed) back into Dart BSON values. +Map mongoDocumentFromEjson(String text) { + final decoded = json.decode(text); + if (decoded is! Map) { + throw const FormatException('Document JSON must be an object'); + } + return EJsonCodec.eJson2Doc(Map.from(decoded)); +} diff --git a/test/features/mongodb/mongo_ejson_test.dart b/test/features/mongodb/mongo_ejson_test.dart new file mode 100644 index 00000000..bb65f987 --- /dev/null +++ b/test/features/mongodb/mongo_ejson_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; + +void main() { + group('mongoDocumentToEjson / mongoDocumentFromEjson', () { + test('round-trips ObjectId _id and DateTime', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + final created = DateTime.utc(2024, 1, 15, 12, 30, 0); + final doc = { + '_id': id, + 'created': created, + 'name': 'Ada', + }; + + final json = mongoDocumentToEjson(doc); + expect(json, contains(r'$oid')); + expect(json, contains('507f1f77bcf86cd799439011')); + expect(json, contains(r'$date')); + expect(json, isNot(contains('ObjectId('))); + + final back = mongoDocumentFromEjson(json); + expect(back['_id'], isA()); + expect((back['_id'] as ObjectId).oid, id.oid); + expect(back['created'], isA()); + expect( + (back['created'] as DateTime).toUtc(), + created, + ); + expect(back['name'], 'Ada'); + }); + + test('_id filter value keeps BSON ObjectId, not a string', () { + final id = ObjectId.fromHexString('507f191e810c19729de860ea'); + final json = mongoDocumentToEjson({'_id': id, 'n': 1}); + final back = mongoDocumentFromEjson(json); + expect(back['_id'], isA()); + expect(back['_id'], isNot(isA())); + }); + + test('relaxed EJSON keeps plain numbers and strings', () { + final json = mongoDocumentToEjson({'a': 1, 'b': 'x'}); + expect(json, contains('"a"')); + expect(json, contains('1')); + expect(json, isNot(contains(r'$numberInt'))); + final back = mongoDocumentFromEjson(json); + expect(back['a'], 1); + expect(back['b'], 'x'); + }); + + test('rejects a JSON array', () { + expect( + () => mongoDocumentFromEjson('[1, 2]'), + throwsA(isA()), + ); + }); + }); +} From 21a1b63ff968d2d8e4fde2eef1cc73c0157cbd36 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 12:21:33 +0300 Subject: [PATCH 09/52] fix(mongo): round-trip inspector fields by BSON type --- CHANGELOG.md | 1 + .../mongodb/mongo_documents_view.dart | 9 +- lib/features/mongodb/mongo_field_codec.dart | 143 ++++++++++++++++-- .../mongodb/mongo_field_codec_test.dart | 88 ++++++++++- 4 files changed, 223 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da6c48e..cc5d4c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Mongo field codec (#777)** — Inspector Save keeps BSON type: strings that look like numbers stay strings; ObjectId, DateTime, Int64, and Decimal128 round-trip. `$set` of `_id` stays blocked. - **Mongo document editor (#779)** — Documents encode as relaxed Extended JSON (`$oid`, `$date`, …) instead of `JsonEncoder` / `toString()`. Save decodes EJSON so the `_id` filter stays an `ObjectId`, not a string. - **0-row DML Save (#773)** — Table Browser and SQL-grid Save fail (and roll back) if any statement matches 0 rows instead of toasting success from statement count. The staging buffer is kept. - **Table Browser navigation (#771)** — Home, Close workspace, tree/object changes, and the matching Command Palette actions confirm before discarding staged grid or SQL edits. Cancel leaves the staging buffer registered. diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index e38f6e27..020475aa 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -161,22 +161,25 @@ class _MongoDocumentsViewState extends material.State { } Future _inspectField(Map doc, String field) async { - if (field == '_id') return; + if (mongoFieldIsReadOnly(field)) return; final id = doc['_id']; if (id == null) return; await showGridCellInspectorDialog( context: context, columnName: field, initialValue: mongoFieldToDisplay(doc[field]), - dataTypeName: 'MongoDB field', + dataTypeName: doc[field]?.runtimeType.toString() ?? 'MongoDB field', onSaveToDatabase: (value) async { + mongoAssertFieldEditable(field); await MongoService.instance.updateDocument( widget.connection, widget.database, widget.collection, {'_id': id}, { - r'$set': {field: mongoDisplayToValue(value)} + r'$set': { + field: mongoDisplayToValue(value, original: doc[field]), + } }, ); if (!mounted) return; diff --git a/lib/features/mongodb/mongo_field_codec.dart b/lib/features/mongodb/mongo_field_codec.dart index 30df2b5c..401cb3b8 100644 --- a/lib/features/mongodb/mongo_field_codec.dart +++ b/lib/features/mongodb/mongo_field_codec.dart @@ -1,36 +1,153 @@ import 'dart:convert'; +import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; + +/// Mongo forbids `$set` of `_id` on an existing document. +bool mongoFieldIsReadOnly(String field) => field == '_id'; + +/// Throws if [field] cannot be updated with `$set` on an existing document. +void mongoAssertFieldEditable(String field) { + if (mongoFieldIsReadOnly(field)) { + throw StateError( + 'MongoDB forbids \$set of _id on an existing document', + ); + } +} + /// Display string for a MongoDB document field in the Cell Inspector. String mongoFieldToDisplay(Object? value) { if (value == null) return 'NULL'; - if (value is Map || value is List) { - try { - return const JsonEncoder.withIndent(' ').convert(value); - } catch (_) { - return value.toString(); + if (value is String) return value; + if (value is bool || value is num) return value.toString(); + if (value is ObjectId) return value.oid; + if (value is DateTime) return value.toUtc().toIso8601String(); + try { + final ejson = EJsonCodec.deserialize( + BsonCodec.serialize({'v': value}), + relaxed: true, + )['v']; + if (ejson is Map || ejson is List) { + return const JsonEncoder.withIndent(' ').convert(ejson); } + return ejson?.toString() ?? value.toString(); + } catch (_) { + return value.toString(); } - if (value is bool || value is num) return value.toString(); - if (value is String) return value; - return value.toString(); } -/// Parses an inspector string back into a BSON-JSON-friendly Dart value. -Object? mongoDisplayToValue(String text) { +/// Parses an inspector string back into a BSON value of [original]'s type. +/// +/// Strings stay strings even when they look like numbers. ObjectId, DateTime, +/// Timestamp, Decimal128, Int32/Int64, and Double keep their width. +Object? mongoDisplayToValue(String text, {Object? original}) { if (text == 'NULL') return null; + + if (original is String) return text; + final trimmed = text.trim(); + + if (original is bool) { + if (trimmed == 'true') return true; + if (trimmed == 'false') return false; + throw FormatException('Expected true or false, got "$trimmed"'); + } + if (original is int) return int.parse(trimmed); + if (original is double) return double.parse(trimmed); + if (original is ObjectId) return _parseObjectId(trimmed); + if (original is DateTime) return _parseDateTime(trimmed); + if (original is Timestamp) return _parseTimestamp(trimmed); + if (original is Map || original is List) { + return _parseWrappedEjson(trimmed); + } + if (original != null) { + return _parseKeepingRuntimeType(trimmed, original); + } + if (trimmed.isEmpty) return ''; if (trimmed == 'true') return true; if (trimmed == 'false') return false; - final asNum = num.tryParse(trimmed); - if (asNum != null) return asNum; if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { try { - return json.decode(trimmed); + return _parseWrappedEjson(trimmed); } catch (_) { return text; } } return text; } + +ObjectId _parseObjectId(String trimmed) { + if (ObjectId.isValidHexId(trimmed)) { + return ObjectId.fromHexString(trimmed); + } + final fromLiteral = _objectIdFromShellLiteral(trimmed); + if (fromLiteral != null) return fromLiteral; + if (trimmed.startsWith('{')) { + final decoded = json.decode(trimmed); + if (decoded is Map && decoded[r'$oid'] is String) { + return ObjectId.fromHexString(decoded[r'$oid'] as String); + } + } + throw FormatException('Not a valid ObjectId: $trimmed'); +} + +ObjectId? _objectIdFromShellLiteral(String trimmed) { + const prefix = 'ObjectId('; + if (!trimmed.startsWith(prefix) || !trimmed.endsWith(')')) return null; + final inner = trimmed.substring(prefix.length, trimmed.length - 1).trim(); + if (inner.length < 2) return null; + final quote = inner[0]; + if (quote != '"' && quote != "'") return null; + if (!inner.endsWith(quote)) return null; + final hex = inner.substring(1, inner.length - 1); + if (!ObjectId.isValidHexId(hex)) return null; + return ObjectId.fromHexString(hex); +} + +DateTime _parseDateTime(String trimmed) { + if (trimmed.startsWith('{')) { + final value = _parseWrappedEjson(trimmed); + if (value is DateTime) return value; + } + return DateTime.parse(trimmed).toUtc(); +} + +Timestamp _parseTimestamp(String trimmed) { + if (trimmed.startsWith('{')) { + final value = _parseWrappedEjson(trimmed); + if (value is Timestamp) return value; + } + throw FormatException('Not a valid Timestamp: $trimmed'); +} + +Object? _parseWrappedEjson(String trimmed) { + return mongoDocumentFromEjson('{"v": $trimmed}')['v']; +} + +Object? _parseKeepingRuntimeType(String trimmed, Object original) { + final typeName = original.runtimeType.toString(); + final candidates = [ + if (trimmed.startsWith('{') || trimmed.startsWith('[')) '{"v": $trimmed}', + '{"v": {"\$numberLong": ${json.encode(trimmed)}}}', + '{"v": {"\$numberDecimal": ${json.encode(trimmed)}}}', + '{"v": {"\$numberInt": ${json.encode(trimmed)}}}', + '{"v": {"\$numberDouble": ${json.encode(trimmed)}}}', + ]; + if (typeName == 'Int64') { + candidates.insert(0, '{"v": {"\$numberLong": ${json.encode(trimmed)}}}'); + } + if (typeName == 'Decimal') { + candidates.insert(0, '{"v": {"\$numberDecimal": ${json.encode(trimmed)}}}'); + } + for (final wrapped in candidates) { + try { + final v = mongoDocumentFromEjson(wrapped)['v']; + if (v.runtimeType == original.runtimeType) return v; + } catch (_) {} + } + throw FormatException( + 'Could not parse "$trimmed" as $typeName', + ); +} diff --git a/test/features/mongodb/mongo_field_codec_test.dart b/test/features/mongodb/mongo_field_codec_test.dart index fed3b167..3fd2114a 100644 --- a/test/features/mongodb/mongo_field_codec_test.dart +++ b/test/features/mongodb/mongo_field_codec_test.dart @@ -1,4 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/features/mongodb/mongo_field_codec.dart'; void main() { @@ -14,18 +16,100 @@ void main() { ); expect(mongoFieldToDisplay([1, 2]), '[\n 1,\n 2\n]'); }); + + test('ObjectId is hex, not ObjectId("…")', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + expect(mongoFieldToDisplay(id), '507f1f77bcf86cd799439011'); + expect(mongoFieldToDisplay(id), isNot(contains('ObjectId('))); + }); + + test('DateTime is ISO-8601 UTC', () { + final dt = DateTime.utc(2024, 6, 1, 12, 0, 0); + expect(mongoFieldToDisplay(dt), '2024-06-01T12:00:00.000Z'); + }); }); group('mongoDisplayToValue', () { - test('round-trips inspector strings', () { + test('round-trips inspector strings without an original type', () { expect(mongoDisplayToValue('NULL'), isNull); expect(mongoDisplayToValue('true'), isTrue); expect(mongoDisplayToValue('false'), isFalse); - expect(mongoDisplayToValue('3.5'), 3.5); expect(mongoDisplayToValue('Ada'), 'Ada'); expect(mongoDisplayToValue('{"ok":true}'), {'ok': true}); expect(mongoDisplayToValue('[1,2]'), [1, 2]); expect(mongoDisplayToValue('{not json'), '{not json'); }); + + test('numeric-looking strings stay strings', () { + expect(mongoDisplayToValue('42', original: '42'), '42'); + expect(mongoDisplayToValue('42', original: '42'), isA()); + expect(mongoDisplayToValue('02115', original: '02115'), '02115'); + expect(mongoDisplayToValue('3.5'), isA()); + }); + + test('int and double keep their width', () { + expect(mongoDisplayToValue('42', original: 42), 42); + expect(mongoDisplayToValue('42', original: 42), isA()); + expect(mongoDisplayToValue('3.5', original: 3.5), 3.5); + expect(mongoDisplayToValue('3.5', original: 3.5), isA()); + }); + + test('ObjectId round-trips from hex, shell literal, and \$oid', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + expect(mongoDisplayToValue(mongoFieldToDisplay(id), original: id), id); + expect( + mongoDisplayToValue('ObjectId("507f1f77bcf86cd799439011")', + original: id), + id, + ); + expect( + mongoDisplayToValue( + '{"\$oid":"507f1f77bcf86cd799439011"}', + original: id, + ), + id, + ); + }); + + test('DateTime round-trips from ISO-8601', () { + final dt = DateTime.utc(2024, 6, 1, 12, 0, 0); + final back = mongoDisplayToValue( + mongoFieldToDisplay(dt), + original: dt, + ); + expect(back, isA()); + expect((back as DateTime).toUtc(), dt); + }); + + test('Decimal128 round-trips', () { + final original = mongoDocumentFromEjson( + '{"v": {"\$numberDecimal": "10.50"}}', + )['v']; + expect(original, isNotNull); + final display = mongoFieldToDisplay(original); + final back = mongoDisplayToValue(display, original: original); + expect(back.runtimeType, original.runtimeType); + expect('$back', '$original'); + }); + + test('Int64 round-trips', () { + final original = mongoDocumentFromEjson( + '{"v": {"\$numberLong": "9007199254740993"}}', + )['v']; + expect(original, isNotNull); + final display = mongoFieldToDisplay(original); + final back = mongoDisplayToValue(display, original: original); + expect(back.runtimeType, original.runtimeType); + expect('$back', '$original'); + }); + }); + + group('mongoFieldIsReadOnly', () { + test('blocks _id', () { + expect(mongoFieldIsReadOnly('_id'), isTrue); + expect(mongoFieldIsReadOnly('name'), isFalse); + expect(() => mongoAssertFieldEditable('_id'), throwsStateError); + expect(() => mongoAssertFieldEditable('name'), returnsNormally); + }); }); } From 318dcff9613ff94301a7f698613634e8a6fec720 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 12:28:47 +0300 Subject: [PATCH 10/52] fix(mongo): persist inspector Apply and Ctrl+Enter --- CHANGELOG.md | 1 + .../grid_cell_popover_inspector.dart | 10 +++ .../workspace/grid_cell_editor_test.dart | 81 +++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc5d4c77..ee0cf039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Mongo inspector Apply (#775)** — With **Save to DB** wired, **Apply** and **Ctrl/Cmd+Enter** persist through the same `$set` callback instead of only closing the dialog. SQL-grid Apply (no callback) still stages locally. - **Mongo field codec (#777)** — Inspector Save keeps BSON type: strings that look like numbers stay strings; ObjectId, DateTime, Int64, and Decimal128 round-trip. `$set` of `_id` stays blocked. - **Mongo document editor (#779)** — Documents encode as relaxed Extended JSON (`$oid`, `$date`, …) instead of `JsonEncoder` / `toString()`. Save decodes EJSON so the `_id` filter stays an `ObjectId`, not a string. - **0-row DML Save (#773)** — Table Browser and SQL-grid Save fail (and roll back) if any statement matches 0 rows instead of toasting success from statement count. The staging buffer is kept. diff --git a/lib/features/workspace/grid_cell_popover_inspector.dart b/lib/features/workspace/grid_cell_popover_inspector.dart index ec0d1132..9d3b6199 100644 --- a/lib/features/workspace/grid_cell_popover_inspector.dart +++ b/lib/features/workspace/grid_cell_popover_inspector.dart @@ -1,3 +1,4 @@ +import 'dart:async' show unawaited; import 'dart:convert'; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; @@ -7,6 +8,11 @@ import 'package:querya_desktop/features/workspace/xml_html_formatter.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Opens a rich modal inspector for viewing and editing large text, JSON, XML, or BLOB values. +/// +/// **Apply** and **Ctrl/Cmd+Enter** return the edited string for local staging +/// (SQL grid). When [onSaveToDatabase] is set (Mongo field inspector), those +/// same actions persist through the callback — they are not a local-only +/// dismiss. **Cancel** still closes without writing. Future showGridCellInspectorDialog({ required material.BuildContext context, required String columnName, @@ -201,6 +207,10 @@ class _GridCellInspectorDialogState } void _apply() { + if (widget.onSaveToDatabase != null) { + unawaited(_saveToDatabase()); + return; + } final result = _isNull ? 'NULL' : _controller.text; material.Navigator.of(context).pop(result); } diff --git a/test/features/workspace/grid_cell_editor_test.dart b/test/features/workspace/grid_cell_editor_test.dart index 2347d386..56f70abd 100644 --- a/test/features/workspace/grid_cell_editor_test.dart +++ b/test/features/workspace/grid_cell_editor_test.dart @@ -342,6 +342,87 @@ void main() { expect(result, 'Ada'); expect(find.text('Save to DB'), findsNothing); }); + + testWidgets('Apply persists through onSaveToDatabase after edit', + (tester) async { + String? saved; + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'name', + initialValue: 'Ada', + onSaveToDatabase: (value) async { + saved = value; + }, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(material.TextField), 'Bob'); + await tester.pumpAndSettle(); + + expect(find.text('Apply'), findsOneWidget); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(saved, 'Bob'); + expect(result, 'Bob'); + expect(find.text('Save to DB'), findsNothing); + }); + + testWidgets('Ctrl+Enter persists through onSaveToDatabase', (tester) async { + String? saved; + String? result; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showGridCellInspectorDialog( + context: context, + columnName: 'name', + initialValue: 'Ada', + onSaveToDatabase: (value) async { + saved = value; + }, + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(material.TextField), 'Bob'); + await tester.pumpAndSettle(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + + expect(saved, 'Bob'); + expect(result, 'Bob'); + }); }); group('VirtualResultGrid Inline Editing Integration', () { From 843cd52b05cf9acc2bd7b77408414720c5b2e116 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 12:34:40 +0300 Subject: [PATCH 11/52] fix(postgres): isolate Table Browser from the SQL-editor socket --- CHANGELOG.md | 1 + .../database/postgres_connection_pool.dart | 43 ++++++- lib/core/database/postgres_service.dart | 18 ++- .../connections/connections_panel.dart | 8 +- lib/features/main_screen/main_screen.dart | 31 ++++- .../postgresql/postgres_sql_tx_guard.dart | 56 ++++++++ .../postgresql/postgres_table_view.dart | 61 ++++++--- .../postgresql/postgres_workspace_home.dart | 24 +--- .../postgres_connection_pool_test.dart | 121 +++++++++++++++++- .../postgres_sql_tx_guard_test.dart | 73 +++++++++++ 10 files changed, 379 insertions(+), 57 deletions(-) create mode 100644 lib/features/postgresql/postgres_sql_tx_guard.dart create mode 100644 test/features/postgresql/postgres_sql_tx_guard_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ee0cf039..912bb9f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Postgres Table Browser session (#785)** — Browse uses a read-only pool slot; Save and `REFRESH MATERIALIZED VIEW` use a dedicated `tableWrite` socket so they do not share the SQL editor’s TCP session. Statement-timeout `forceClose` on SQL no longer kills the grid. Opening a table while SQL has an open transaction on the same database warns, matching Overview↔SQL. - **Mongo inspector Apply (#775)** — With **Save to DB** wired, **Apply** and **Ctrl/Cmd+Enter** persist through the same `$set` callback instead of only closing the dialog. SQL-grid Apply (no callback) still stages locally. - **Mongo field codec (#777)** — Inspector Save keeps BSON type: strings that look like numbers stay strings; ObjectId, DateTime, Int64, and Decimal128 round-trip. `$set` of `_id` stays blocked. - **Mongo document editor (#779)** — Documents encode as relaxed Extended JSON (`$oid`, `$date`, …) instead of `JsonEncoder` / `toString()`. Save decodes EJSON so the `_id` filter stays an `ObjectId`, not a string. diff --git a/lib/core/database/postgres_connection_pool.dart b/lib/core/database/postgres_connection_pool.dart index 8ed85b32..df5469f5 100644 --- a/lib/core/database/postgres_connection_pool.dart +++ b/lib/core/database/postgres_connection_pool.dart @@ -7,10 +7,19 @@ import 'package:querya_desktop/core/storage/local_db.dart'; /// Session policy for pooled connections: browse-only vs ad-hoc SQL (writes). enum PgSessionMode { /// `SET default_transaction_read_only = ON` after connect. + /// Tree catalog, stats, and Table Browser SELECT/COUNT. readOnly, - /// Read-write session (SQL editor, probes that need catalog writes — rare). + /// SQL editor read-write session. Must not be shared with Table Browser. readWrite, + + /// Table Browser Save / `REFRESH MATERIALIZED VIEW` (own TCP session). + tableWrite, +} + +extension PgSessionModeReadOnly on PgSessionMode { + /// Whether this slot should `SET default_transaction_read_only = ON`. + bool get isReadOnlySession => this == PgSessionMode.readOnly; } /// Creates a connected [PostgresConnection] for the pool (real or fake in tests). @@ -81,8 +90,7 @@ class PostgresConnectionPool { entry.refs++; if (!entry.connection.isConnected) { await entry.connection.connect(); - await entry.connection - .setSessionReadOnly(mode == PgSessionMode.readOnly); + await entry.connection.setSessionReadOnly(mode.isReadOnlySession); } return PgLease._(this, k, entry.connection); } @@ -90,7 +98,8 @@ class PostgresConnectionPool { try { await _creationLock.createIfAbsent(k, () async { _evictIfNeededBeforeNewSlot(); - final conn = await createAndConnect(row, database: database, mode: mode); + final conn = + await createAndConnect(row, database: database, mode: mode); _pool[k] = _PoolEntry(conn); return conn; }); @@ -116,8 +125,7 @@ class PostgresConnectionPool { entry.refs++; if (!entry.connection.isConnected) { await entry.connection.connect(); - await entry.connection - .setSessionReadOnly(mode == PgSessionMode.readOnly); + await entry.connection.setSessionReadOnly(mode.isReadOnlySession); } return PgLease._(this, k, entry.connection); } @@ -169,6 +177,29 @@ class PostgresConnectionPool { _removeEntryClosing(k); } + /// Force-closes every session mode for this connection+database. + void interruptAllModes( + ConnectionRow row, { + required String database, + }) { + for (final mode in PgSessionMode.values) { + interrupt(row, database: database, mode: mode); + } + } + + /// Whether the SQL-editor slot currently has an open transaction. + /// + /// Does not acquire a new connection; returns false if the slot is idle or + /// disconnected. + Future hasOpenSqlTransaction( + ConnectionRow row, { + required String database, + }) async { + final entry = _pool[keyFor(row.id, database, PgSessionMode.readWrite)]; + if (entry == null || !entry.connection.isConnected) return false; + return await entry.connection.inOpenTransaction() ?? false; + } + /// Closes all pooled connections (e.g. app shutdown). Future disconnectAll() async { for (final entry in _pool.values) { diff --git a/lib/core/database/postgres_service.dart b/lib/core/database/postgres_service.dart index 940e1338..e695f933 100644 --- a/lib/core/database/postgres_service.dart +++ b/lib/core/database/postgres_service.dart @@ -3,7 +3,7 @@ import 'package:querya_desktop/core/database/postgres_connection_pool.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; export 'postgres_connection_pool.dart' - show PgLease, PgSessionMode, PostgresConnectionPool; + show PgLease, PgSessionMode, PgSessionModeReadOnly, PostgresConnectionPool; Future _defaultCreateAndConnect( ConnectionRow row, { @@ -12,7 +12,7 @@ Future _defaultCreateAndConnect( }) async { final conn = PostgresConnection.fromConnectionRow(row, database: database); await conn.connect(); - await conn.setSessionReadOnly(mode == PgSessionMode.readOnly); + await conn.setSessionReadOnly(mode.isReadOnlySession); return conn; } @@ -51,6 +51,20 @@ class PostgresService { }) => _pool.interrupt(row, database: database, mode: mode); + /// Force-closes every session mode for this connection+database. + void interruptAllModes( + ConnectionRow row, { + required String database, + }) => + _pool.interruptAllModes(row, database: database); + + /// Whether the SQL-editor read-write slot has an open transaction. + Future hasOpenSqlTransaction( + ConnectionRow row, { + required String database, + }) => + _pool.hasOpenSqlTransaction(row, database: database); + /// Closes all pooled connections (e.g. app shutdown). Future disconnectAll() => _pool.disconnectAll(); } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index e0b193be..113d5aea 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -706,12 +706,8 @@ class ConnectionsPanelState extends State { _expandedConnections.remove(id); }); if (conn.type == 'postgresql') { - PostgresService.instance.interrupt(conn, - database: conn.databaseName ?? 'postgres', - mode: PgSessionMode.readOnly); - PostgresService.instance.interrupt(conn, - database: conn.databaseName ?? 'postgres', - mode: PgSessionMode.readWrite); + PostgresService.instance.interruptAllModes(conn, + database: conn.databaseName ?? 'postgres'); } else if (conn.type == 'mysql') { MysqlService.instance.interrupt(conn, database: conn.databaseName ?? '', mode: MysqlSessionMode.readOnly); diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 28d2a237..2028fe8c 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -36,6 +36,7 @@ import 'package:querya_desktop/features/macos/querya_platform_menu_bar.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/postgresql/postgres_sql_tx_guard.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'main_screen_workspace_state.dart'; @@ -320,6 +321,15 @@ class _MainScreenState extends State { if (same) return; if (!await _allowUnsavedNavigation()) return; if (!mounted) return; + if (!await confirmOpenPostgresTableIfSqlTx( + context, + connection: connection, + database: database, + kind: kind, + )) { + return; + } + if (!mounted) return; _workspace.value = _workspace.value.selectPostgresObject( connection, database, @@ -1178,6 +1188,24 @@ class _MainContentSplitState extends State<_MainContentSplit> unawaited(_restoreState()); } + Future _restoreLastSelectedObject() async { + final ws = widget.workspace.value; + final conn = ws.activeConnection; + final pg = ws.lastSelectedPostgresObject; + if (conn != null && conn.type == 'postgresql' && pg != null) { + if (!await confirmOpenPostgresTableIfSqlTx( + context, + connection: conn, + database: pg.database, + kind: pg.kind, + )) { + return; + } + } + if (!mounted) return; + widget.workspace.value = widget.workspace.value.restoreLastSelectedObject(); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -1416,8 +1444,7 @@ class _MainContentSplitState extends State<_MainContentSplit> widget.workspace.value.unselectActiveObject(); }, onRestoreLastSelectedObject: () { - widget.workspace.value = - widget.workspace.value.restoreLastSelectedObject(); + unawaited(_restoreLastSelectedObject()); }, isReadOnly: ws.isReadOnly, onRequestNewConnection: widget.onRequestNewConnection, diff --git a/lib/features/postgresql/postgres_sql_tx_guard.dart b/lib/features/postgresql/postgres_sql_tx_guard.dart new file mode 100644 index 00000000..4c05d9eb --- /dev/null +++ b/lib/features/postgresql/postgres_sql_tx_guard.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/postgres_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Kinds that open Table Browser (SELECT on a dedicated session). +bool postgresObjectOpensTableBrowser(PostgresObjectKind kind) { + return kind == PostgresObjectKind.table || + kind == PostgresObjectKind.view || + kind == PostgresObjectKind.materializedView; +} + +/// Confirms leaving the SQL tab (or opening a table) while a transaction is open. +Future confirmLeaveOpenPostgresTransaction( + material.BuildContext context, +) async { + final ok = await showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Open transaction'), + content: const material.Text( + 'The SQL tab has an open transaction. Leave anyway? ' + 'Uncommitted work may be lost if the session ends.', + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const material.Text('Stay'), + ), + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const material.Text('Leave'), + ), + ], + ), + ); + return ok == true; +} + +/// Warns before opening Table Browser on a database whose SQL editor has a tx. +Future confirmOpenPostgresTableIfSqlTx( + material.BuildContext context, { + required ConnectionRow connection, + required String database, + required PostgresObjectKind kind, +}) async { + if (!postgresObjectOpensTableBrowser(kind)) return true; + final open = await PostgresService.instance.hasOpenSqlTransaction( + connection, + database: database, + ); + if (!open) return true; + if (!context.mounted) return false; + return confirmLeaveOpenPostgresTransaction(context); +} diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index d4e6999b..48fe547b 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -129,7 +129,14 @@ class _PostgresTableViewState extends material.State { PostgresService.instance.interrupt( widget.connectionRow, database: widget.database, - mode: PgSessionMode.readWrite, + mode: PgSessionMode.readOnly, + ); + } + if (interruptIfBusy && _isSaving) { + PostgresService.instance.interrupt( + widget.connectionRow, + database: widget.database, + mode: PgSessionMode.tableWrite, ); } _lease?.release(); @@ -155,8 +162,7 @@ class _PostgresTableViewState extends material.State { final lease = await PostgresService.instance.acquire( widget.connectionRow, database: widget.database, - // Custom SQL + REFRESH MATERIALIZED VIEW need a read-write session. - mode: PgSessionMode.readWrite, + mode: PgSessionMode.readOnly, ); if (!mounted) { lease.release(); @@ -188,6 +194,21 @@ class _PostgresTableViewState extends material.State { return 'SELECT * FROM $schemaQ.$tableQ LIMIT ${widget.limit} OFFSET $_offset'; } + Future _withTableWrite( + Future Function(PostgresConnection conn) fn, + ) async { + final lease = await PostgresService.instance.acquire( + widget.connectionRow, + database: widget.database, + mode: PgSessionMode.tableWrite, + ); + try { + return await fn(lease.connection); + } finally { + lease.release(); + } + } + Future _confirmDiscardIfNeeded() { return confirmDiscardTableEditsIfDirty( context: context, @@ -378,12 +399,13 @@ class _PostgresTableViewState extends material.State { } Future _refreshMaterializedView() async { - final conn = _connection; - if (conn == null || !conn.isConnected || _loading) return; + if (_loading) return; if (!await _confirmDiscardIfNeeded()) return; if (!mounted) return; try { - await conn.refreshMaterializedView(widget.schema, widget.tableName); + await _withTableWrite((conn) { + return conn.refreshMaterializedView(widget.schema, widget.tableName); + }); if (!mounted) return; await _fetch(refreshCount: true); } catch (e) { @@ -528,19 +550,20 @@ class _PostgresTableViewState extends material.State { columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, columnMeta: _columnMeta.isEmpty ? null : _columnMeta, execute: (plan) async { - final conn = _connection; - if (conn == null || !conn.isConnected) { - throw StateError('Could not connect to PostgreSQL.'); - } - await runPostgresStatementsInTransaction( - (sql) async { - final result = await conn.execute(sql); - if (sql != 'BEGIN' && sql != 'COMMIT' && sql != 'ROLLBACK') { - expectDmlMatchedRows(result.affectedRows); - } - }, - plan.statements.map((s) => s.sql), - ); + await _withTableWrite((conn) async { + if (!conn.isConnected) { + throw StateError('Could not connect to PostgreSQL.'); + } + await runPostgresStatementsInTransaction( + (sql) async { + final result = await conn.execute(sql); + if (sql != 'BEGIN' && sql != 'COMMIT' && sql != 'ROLLBACK') { + expectDmlMatchedRows(result.affectedRows); + } + }, + plan.statements.map((s) => s.sql), + ); + }); }, ); if (!mounted) return; diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index 095f35fa..53a6de0e 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/postgresql/postgres_sql_tx_guard.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_workspace.dart'; import 'package:querya_desktop/features/postgresql/postgres_stats_view.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -91,27 +92,8 @@ class _PostgresWorkspaceHomeState Future _selectTab(int i) async { if (i == _tab) return; if (_tab == 1 && i == 0 && _sqlTxNotifier.value == true) { - final ok = await showAppDialog( - context: context, - builder: (ctx) => material.AlertDialog( - title: const material.Text('Open transaction'), - content: const material.Text( - 'The SQL tab has an open transaction. Leave anyway? ' - 'Uncommitted work may be lost if the session ends.', - ), - actions: [ - material.TextButton( - onPressed: () => material.Navigator.of(ctx).pop(false), - child: const material.Text('Stay'), - ), - material.TextButton( - onPressed: () => material.Navigator.of(ctx).pop(true), - child: const material.Text('Leave'), - ), - ], - ), - ); - if (ok != true) return; + final ok = await confirmLeaveOpenPostgresTransaction(context); + if (!ok) return; } setState(() => _tab = i); } diff --git a/test/core/database/postgres_connection_pool_test.dart b/test/core/database/postgres_connection_pool_test.dart index afbb18cd..b439ac63 100644 --- a/test/core/database/postgres_connection_pool_test.dart +++ b/test/core/database/postgres_connection_pool_test.dart @@ -26,6 +26,7 @@ class FakePostgresConnection extends PostgresConnection { int forceCloseCount = 0; int setReadOnlyCount = 0; bool? lastReadOnly; + bool? openTransaction; @override bool get isConnected => _connected; @@ -53,6 +54,9 @@ class FakePostgresConnection extends PostgresConnection { setReadOnlyCount++; lastReadOnly = readOnly; } + + @override + Future inOpenTransaction() async => openTransaction; } void main() { @@ -107,6 +111,40 @@ void main() { rw.release(); }); + test('tableWrite is a separate key from SQL readWrite', () async { + final created = []; + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final c = FakePostgresConnection(); + await c.connect(); + await c.setSessionReadOnly(mode.isReadOnlySession); + created.add(c); + return c; + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + final r = _row(); + final sql = await pool.acquire(r, + database: 'postgres', mode: PgSessionMode.readWrite); + final grid = await pool.acquire(r, + database: 'postgres', mode: PgSessionMode.tableWrite); + final browse = await pool.acquire(r, + database: 'postgres', mode: PgSessionMode.readOnly); + expect(identical(sql.connection, grid.connection), isFalse); + expect(identical(sql.connection, browse.connection), isFalse); + expect(identical(grid.connection, browse.connection), isFalse); + expect(created.length, 3); + expect((grid.connection as FakePostgresConnection).lastReadOnly, isFalse); + expect( + (browse.connection as FakePostgresConnection).lastReadOnly, isTrue); + sql.release(); + grid.release(); + browse.release(); + }); + test('keyFor matches pool slot identity', () { final pool = PostgresConnectionPool( createAndConnect: (_, {required database, required mode}) async => @@ -120,6 +158,10 @@ void main() { pool.keyFor(null, 'postgres', PgSessionMode.readWrite), '0::postgres::readWrite', ); + expect( + pool.keyFor(1, 'app', PgSessionMode.tableWrite), + '1::app::tableWrite', + ); }); }); @@ -288,6 +330,82 @@ void main() { lease2.release(); }); + test('interrupt of SQL readWrite does not kill tableWrite or readOnly', + () async { + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final c = FakePostgresConnection(); + await c.connect(); + await c.setSessionReadOnly(mode.isReadOnlySession); + return c; + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + final r = _row(); + final sql = + await pool.acquire(r, database: 'app', mode: PgSessionMode.readWrite); + final grid = await pool.acquire(r, + database: 'app', mode: PgSessionMode.tableWrite); + final browse = + await pool.acquire(r, database: 'app', mode: PgSessionMode.readOnly); + final sqlFake = sql.connection as FakePostgresConnection; + final gridFake = grid.connection as FakePostgresConnection; + final browseFake = browse.connection as FakePostgresConnection; + + pool.interrupt(r, database: 'app', mode: PgSessionMode.readWrite); + expect(sqlFake.forceCloseCount, 1); + expect(gridFake.forceCloseCount, 0); + expect(browseFake.forceCloseCount, 0); + + pool.interruptAllModes(r, database: 'app'); + expect(gridFake.forceCloseCount, 1); + expect(browseFake.forceCloseCount, 1); + sql.release(); + grid.release(); + browse.release(); + }); + + test('hasOpenSqlTransaction reads the SQL slot only', () async { + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + final c = FakePostgresConnection(); + await c.connect(); + await c.setSessionReadOnly(mode.isReadOnlySession); + return c; + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + final r = _row(); + expect( + await pool.hasOpenSqlTransaction(r, database: 'app'), + isFalse, + ); + + final sql = + await pool.acquire(r, database: 'app', mode: PgSessionMode.readWrite); + final sqlFake = sql.connection as FakePostgresConnection; + sqlFake.openTransaction = true; + expect(await pool.hasOpenSqlTransaction(r, database: 'app'), isTrue); + expect( + await pool.hasOpenSqlTransaction(r, database: 'other'), + isFalse, + ); + + final grid = await pool.acquire(r, + database: 'app', mode: PgSessionMode.tableWrite); + (grid.connection as FakePostgresConnection).openTransaction = true; + sqlFake.openTransaction = false; + expect(await pool.hasOpenSqlTransaction(r, database: 'app'), isFalse); + sql.release(); + grid.release(); + }); + test('disconnectAll forceCloses every pooled connection', () async { final fakes = []; Future factory( @@ -396,7 +514,8 @@ void main() { }); group('PostgresConnectionPool error wrapping', () { - test('wraps unexpected factory errors in PostgresConnectionException', () async { + test('wraps unexpected factory errors in PostgresConnectionException', + () async { Future factory( ConnectionRow row, { required String database, diff --git a/test/features/postgresql/postgres_sql_tx_guard_test.dart b/test/features/postgresql/postgres_sql_tx_guard_test.dart new file mode 100644 index 00000000..07ef0b4d --- /dev/null +++ b/test/features/postgresql/postgres_sql_tx_guard_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/postgresql/postgres_sql_tx_guard.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + test('postgresObjectOpensTableBrowser covers grid kinds only', () { + expect(postgresObjectOpensTableBrowser(PostgresObjectKind.table), isTrue); + expect(postgresObjectOpensTableBrowser(PostgresObjectKind.view), isTrue); + expect( + postgresObjectOpensTableBrowser(PostgresObjectKind.materializedView), + isTrue, + ); + expect( + postgresObjectOpensTableBrowser(PostgresObjectKind.function), isFalse); + expect( + postgresObjectOpensTableBrowser(PostgresObjectKind.sequence), isFalse); + }); + + testWidgets('Stay keeps the caller on the SQL session', (tester) async { + var left = false; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.Builder( + builder: (context) { + return material.TextButton( + onPressed: () async { + left = await confirmLeaveOpenPostgresTransaction(context); + }, + child: const material.Text('Go'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Open transaction'), findsOneWidget); + + await tester.tap(find.text('Stay')); + await tester.pumpAndSettle(); + expect(left, isFalse); + }); + + testWidgets('Leave confirms abandoning the open transaction', (tester) async { + var left = false; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.Builder( + builder: (context) { + return material.TextButton( + onPressed: () async { + left = await confirmLeaveOpenPostgresTransaction(context); + }, + child: const material.Text('Go'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Leave')); + await tester.pumpAndSettle(); + expect(left, isTrue); + }); +} From 57c6cd3e9d83176b03bf7af7763e52e8a7a0e4c5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 12:45:16 +0300 Subject: [PATCH 12/52] fix(sqlite): isolate Table Browser from the SQL-editor Database --- CHANGELOG.md | 1 + lib/core/database/sqlite_connection.dart | 65 ++++++++++++-- lib/core/database/sqlite_connection_pool.dart | 27 +++++- lib/core/database/sqlite_service.dart | 15 +++- .../connections/connections_panel.dart | 10 +-- lib/features/main_screen/main_screen.dart | 20 +++++ lib/features/sqlite/sqlite_sql_tx_guard.dart | 50 +++++++++++ lib/features/sqlite/sqlite_sql_workspace.dart | 66 ++++++++++---- lib/features/sqlite/sqlite_table_view.dart | 56 ++++++++---- .../sqlite/sqlite_workspace_home.dart | 24 ++++-- .../database/sqlite_connection_pool_test.dart | 86 ++++++++++++++++++- .../core/database/sqlite_connection_test.dart | 51 +++++++++-- .../sqlite/sqlite_sql_tx_guard_test.dart | 41 +++++++++ 13 files changed, 439 insertions(+), 73 deletions(-) create mode 100644 lib/features/sqlite/sqlite_sql_tx_guard.dart create mode 100644 test/features/sqlite/sqlite_sql_tx_guard_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 912bb9f8..43ca5391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **SQLite Table Browser session (#794)** — Browse uses a read-only `Database`; Save uses a dedicated `tableWrite` handle so SQL `BEGIN` / `ATTACH` do not leak into the grid (no nested `BEGIN`). SQL-grid Save joins an already-open transaction instead of starting another. Opening a table (and Overview↔SQL) warns while SQL has an open `BEGIN`. - **Postgres Table Browser session (#785)** — Browse uses a read-only pool slot; Save and `REFRESH MATERIALIZED VIEW` use a dedicated `tableWrite` socket so they do not share the SQL editor’s TCP session. Statement-timeout `forceClose` on SQL no longer kills the grid. Opening a table while SQL has an open transaction on the same database warns, matching Overview↔SQL. - **Mongo inspector Apply (#775)** — With **Save to DB** wired, **Apply** and **Ctrl/Cmd+Enter** persist through the same `$set` callback instead of only closing the dialog. SQL-grid Apply (no callback) still stages locally. - **Mongo field codec (#777)** — Inspector Save keeps BSON type: strings that look like numbers stay strings; ObjectId, DateTime, Int64, and Decimal128 round-trip. `$set` of `_id` stays blocked. diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index 23565934..054a0f27 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -22,7 +22,8 @@ class SqliteConnection { id: row.id ?? 0, name: row.name, path: row.host ?? '', // Store absolute file path in the 'host' field - readOnly: readOnly ?? row.useSSL, // Store read-only flag in the 'useSSL' field + readOnly: + readOnly ?? row.useSSL, // Store read-only flag in the 'useSSL' field ); } @@ -33,6 +34,7 @@ class SqliteConnection { Database? _db; bool _isConnected = false; + bool _inTransaction = false; bool get isConnected => _isConnected && _db != null; @@ -67,6 +69,7 @@ class SqliteConnection { Future disconnect() async { _isConnected = false; + _inTransaction = false; final d = _db; _db = null; try { @@ -107,7 +110,7 @@ class SqliteConnection { .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), '') .trim() .toLowerCase(); - + // SQLite can execute PRAGMA, SELECT, EXPLAIN statements, which return data final isReadOnlyQuery = sqlLower.startsWith('select') || sqlLower.startsWith('pragma') || @@ -126,6 +129,7 @@ class SqliteConnection { return await _db!.rawQuery(sql, arguments); } else { await _db!.execute(sql, arguments); + _noteTransactionSql(sqlLower); return []; } } on TimeoutException { @@ -168,6 +172,32 @@ class SqliteConnection { } } + /// Whether this session has an open `BEGIN` (tracked from executed SQL). + Future inOpenTransaction() async { + if (!isConnected) return null; + return _inTransaction; + } + + /// Runs [body] inside a transaction. If the session already has a `BEGIN`, + /// DML joins it instead of nesting `BEGIN TRANSACTION`. + Future runInTransaction(Future Function() body) async { + final join = _inTransaction; + if (!join) { + await execute('BEGIN TRANSACTION'); + } + try { + await body(); + if (!join) await execute('COMMIT'); + } catch (e) { + if (!join) { + try { + await execute('ROLLBACK'); + } catch (_) {} + } + rethrow; + } + } + /// Lists tables in the database. Future> listTables() async { final rows = await execute( @@ -189,10 +219,12 @@ class SqliteConnection { final rows = await execute( "SELECT name, tbl_name FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name", ); - return rows.map((r) => { - 'name': r['name'] as String, - 'table': r['tbl_name'] as String, - }).toList(); + return rows + .map((r) => { + 'name': r['name'] as String, + 'table': r['tbl_name'] as String, + }) + .toList(); } /// Lists column names for a table. @@ -271,8 +303,10 @@ class SqliteConnection { final jmRows = await execute('PRAGMA journal_mode'); final jm = (jmRows.isNotEmpty ? jmRows.first.values.first : '') ?? ''; - final tblCountRows = await execute("SELECT count(*) AS c FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"); - final tblCount = (tblCountRows.isNotEmpty ? tblCountRows.first['c'] : 0) ?? 0; + final tblCountRows = await execute( + "SELECT count(*) AS c FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"); + final tblCount = + (tblCountRows.isNotEmpty ? tblCountRows.first['c'] : 0) ?? 0; return { 'version': ver, @@ -283,6 +317,21 @@ class SqliteConnection { }; } + void _noteTransactionSql(String sqlLower) { + if (sqlLower.startsWith('begin')) { + _inTransaction = true; + return; + } + if (sqlLower.startsWith('commit') || sqlLower.startsWith('end')) { + _inTransaction = false; + return; + } + if (sqlLower.startsWith('rollback') && + !RegExp(r'^rollback\s+to\b').hasMatch(sqlLower)) { + _inTransaction = false; + } + } + static bool _isSqliteBusy(DatabaseException e) { final code = e.getResultCode(); if (code == 5 || code == 6) return true; diff --git a/lib/core/database/sqlite_connection_pool.dart b/lib/core/database/sqlite_connection_pool.dart index 672e63dc..6ed042e4 100644 --- a/lib/core/database/sqlite_connection_pool.dart +++ b/lib/core/database/sqlite_connection_pool.dart @@ -6,8 +6,18 @@ import 'package:querya_desktop/core/storage/local_db.dart'; /// Session policy for pooled SQLite connections. enum SqliteSessionMode { + /// Tree catalog, overview, and Table Browser SELECT/COUNT. readOnly, + + /// SQL editor. Must not be shared with Table Browser. readWrite, + + /// Table Browser Save (own `Database` so BEGIN/ATTACH do not leak). + tableWrite, +} + +extension SqliteSessionModeReadOnly on SqliteSessionMode { + bool get isReadOnlySession => this == SqliteSessionMode.readOnly; } /// Factory to build a connected SQLite connection. @@ -63,8 +73,7 @@ class SqliteConnectionPool { final Map _pool = {}; final PoolEntryLock _creationLock = PoolEntryLock(); - String keyFor(int? id, SqliteSessionMode mode) => - '${id ?? 0}::${mode.name}'; + String keyFor(int? id, SqliteSessionMode mode) => '${id ?? 0}::${mode.name}'; Future acquire( ConnectionRow row, { @@ -161,6 +170,20 @@ class SqliteConnectionPool { _removeEntryClosing(k); } + /// Force-closes every session mode for this connection id. + void interruptAllModes(ConnectionRow row) { + for (final mode in SqliteSessionMode.values) { + interrupt(row, mode: mode); + } + } + + /// Whether the SQL-editor slot currently has an open transaction. + Future hasOpenSqlTransaction(ConnectionRow row) async { + final entry = _pool[keyFor(row.id, SqliteSessionMode.readWrite)]; + if (entry == null || !entry.connection.isConnected) return false; + return await entry.connection.inOpenTransaction() ?? false; + } + Future disconnectAll() async { final entries = _pool.values.toList(); _pool.clear(); diff --git a/lib/core/database/sqlite_service.dart b/lib/core/database/sqlite_service.dart index 09385875..a43090c6 100644 --- a/lib/core/database/sqlite_service.dart +++ b/lib/core/database/sqlite_service.dart @@ -3,7 +3,11 @@ import 'package:querya_desktop/core/database/sqlite_connection_pool.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; export 'sqlite_connection_pool.dart' - show SqliteLease, SqliteSessionMode, SqliteConnectionPool; + show + SqliteLease, + SqliteSessionMode, + SqliteSessionModeReadOnly, + SqliteConnectionPool; Future _defaultCreateAndConnect( ConnectionRow row, { @@ -11,7 +15,7 @@ Future _defaultCreateAndConnect( }) async { final conn = SqliteConnection.fromConnectionRow( row, - readOnly: mode == SqliteSessionMode.readOnly, + readOnly: mode.isReadOnlySession, ); await conn.connect(); return conn; @@ -44,5 +48,12 @@ class SqliteService { }) => _pool.interrupt(row, mode: mode); + /// Force-closes every session mode for this connection id. + void interruptAllModes(ConnectionRow row) => _pool.interruptAllModes(row); + + /// Whether the SQL-editor read-write slot has an open transaction. + Future hasOpenSqlTransaction(ConnectionRow row) => + _pool.hasOpenSqlTransaction(row); + Future disconnectAll() => _pool.disconnectAll(); } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 113d5aea..7a2b6e9f 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -679,13 +679,8 @@ class ConnectionsPanelState extends State { Future _removeConnection(int id) async { await MongoService.instance.disconnectByConnectionId(id); await ExtensionDriverSession.instance.disconnect(id); - SqliteService.instance.interrupt( + SqliteService.instance.interruptAllModes( ConnectionRow(id: id, type: 'sqlite', name: '', createdAt: ''), - mode: SqliteSessionMode.readOnly, - ); - SqliteService.instance.interrupt( - ConnectionRow(id: id, type: 'sqlite', name: '', createdAt: ''), - mode: SqliteSessionMode.readWrite, ); await LocalDb.instance.removeConnection(id); await _loadData(); @@ -714,8 +709,7 @@ class ConnectionsPanelState extends State { MysqlService.instance.interrupt(conn, database: conn.databaseName ?? '', mode: MysqlSessionMode.readWrite); } else if (conn.type == 'sqlite') { - SqliteService.instance.interrupt(conn, mode: SqliteSessionMode.readOnly); - SqliteService.instance.interrupt(conn, mode: SqliteSessionMode.readWrite); + SqliteService.instance.interruptAllModes(conn); } else if (conn.type == 'redis') { final redisConn = RedisService.instance.getConnection(id); if (redisConn != null) { diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 2028fe8c..6d13a523 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -37,6 +37,7 @@ import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_tx_guard.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_sql_tx_guard.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'main_screen_workspace_state.dart'; @@ -474,6 +475,14 @@ class _MainScreenState extends State { if (same) return; if (!await _allowUnsavedNavigation()) return; if (!mounted) return; + if (!await confirmOpenSqliteTableIfSqlTx( + context, + connection: connection, + kind: kind, + )) { + return; + } + if (!mounted) return; _workspace.value = _workspace.value.selectSqliteObject( connection, name, @@ -1203,6 +1212,17 @@ class _MainContentSplitState extends State<_MainContentSplit> } } if (!mounted) return; + final sq = ws.lastSelectedSqliteObject; + if (conn != null && conn.type == 'sqlite' && sq != null) { + if (!await confirmOpenSqliteTableIfSqlTx( + context, + connection: conn, + kind: sq.kind, + )) { + return; + } + } + if (!mounted) return; widget.workspace.value = widget.workspace.value.restoreLastSelectedObject(); } diff --git a/lib/features/sqlite/sqlite_sql_tx_guard.dart b/lib/features/sqlite/sqlite_sql_tx_guard.dart new file mode 100644 index 00000000..f55ed589 --- /dev/null +++ b/lib/features/sqlite/sqlite_sql_tx_guard.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart' + show SqliteObjectKind; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +bool sqliteObjectOpensTableBrowser(SqliteObjectKind kind) { + return kind == SqliteObjectKind.table || kind == SqliteObjectKind.view; +} + +/// Confirms leaving the SQL tab (or opening a table) while a transaction is open. +Future confirmLeaveOpenSqliteTransaction( + material.BuildContext context, +) async { + final ok = await showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Open transaction'), + content: const material.Text( + 'The SQL tab has an open transaction. Leave anyway? ' + 'Uncommitted work may be lost if the session ends.', + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const material.Text('Stay'), + ), + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const material.Text('Leave'), + ), + ], + ), + ); + return ok == true; +} + +/// Warns before opening Table Browser while the SQL editor has a `BEGIN`. +Future confirmOpenSqliteTableIfSqlTx( + material.BuildContext context, { + required ConnectionRow connection, + required SqliteObjectKind kind, +}) async { + if (!sqliteObjectOpensTableBrowser(kind)) return true; + final open = await SqliteService.instance.hasOpenSqlTransaction(connection); + if (!open) return true; + if (!context.mounted) return false; + return confirmLeaveOpenSqliteTransaction(context); +} diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 30bd6f73..4130c575 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -24,14 +24,19 @@ class SqliteSqlWorkspace extends material.StatefulWidget { const SqliteSqlWorkspace({ super.key, required this.connectionRow, + this.transactionOpenNotifier, this.isReadOnly = false, }); final ConnectionRow connectionRow; final bool isReadOnly; + /// Updated when transaction state changes (for tab-switch warnings). + final material.ValueNotifier? transactionOpenNotifier; + @override - material.State createState() => _SqliteSqlWorkspaceState(); + material.State createState() => + _SqliteSqlWorkspaceState(); } class _SqliteSqlWorkspaceState extends material.State { @@ -42,6 +47,7 @@ class _SqliteSqlWorkspaceState extends material.State { SqlQueryTabSession get _activeSession => _sessions[_activeSessionIndex]; SqliteLease? _lease; + bool? _txOpen; int _resultMaxRows = kDefaultSqlResultMaxRows; int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; @@ -190,7 +196,9 @@ class _SqliteSqlWorkspaceState extends material.State { _lease = null; final lease = await SqliteService.instance.acquire( widget.connectionRow, - mode: widget.isReadOnly ? SqliteSessionMode.readOnly : SqliteSessionMode.readWrite, + mode: widget.isReadOnly + ? SqliteSessionMode.readOnly + : SqliteSessionMode.readWrite, ); if (!mounted) { lease.release(); @@ -199,6 +207,22 @@ class _SqliteSqlWorkspaceState extends material.State { _lease = lease; } + void _notifyTransactionOpen() { + widget.transactionOpenNotifier?.value = _txOpen; + } + + Future _refreshTxStatus() async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + if (mounted) setState(() => _txOpen = null); + _notifyTransactionOpen(); + return; + } + final v = await conn.inOpenTransaction(); + if (mounted) setState(() => _txOpen = v); + _notifyTransactionOpen(); + } + @override void dispose() { SqlEditorCommandBridge.instance @@ -344,6 +368,8 @@ class _SqliteSqlWorkspaceState extends material.State { }); QueryaShellStatus.instance.endBusy(); } + } finally { + await _refreshTxStatus(); } } @@ -386,18 +412,12 @@ class _SqliteSqlWorkspaceState extends material.State { throw StateError('Could not connect to SQLite.'); } - await conn.execute('BEGIN TRANSACTION'); - try { + await conn.runInTransaction(() async { for (final stmt in plan.statements) { expectDmlMatchedRows(await conn.executeAffected(stmt.sql)); } - await conn.execute('COMMIT'); - } catch (e) { - try { - await conn.execute('ROLLBACK'); - } catch (_) {} - rethrow; - } + }); + await _refreshTxStatus(); if (!mounted) return; final newRows = session.stagingBuffer!.effectiveRows; @@ -568,16 +588,22 @@ class _SqliteSqlWorkspaceState extends material.State { }, child: material.CallbackShortcuts( bindings: { - const material.SingleActivator(LogicalKeyboardKey.keyT, control: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyT, meta: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyW, control: true): () { + const material.SingleActivator(LogicalKeyboardKey.keyT, + control: true): _addNewTab, + const material.SingleActivator(LogicalKeyboardKey.keyT, meta: true): + _addNewTab, + const material.SingleActivator(LogicalKeyboardKey.keyW, + control: true): () { if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); }, - const material.SingleActivator(LogicalKeyboardKey.keyW, meta: true): () { + const material.SingleActivator(LogicalKeyboardKey.keyW, meta: true): + () { if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); }, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true): _nextTab, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true, shift: true): _prevTab, + const material.SingleActivator(LogicalKeyboardKey.tab, control: true): + _nextTab, + const material.SingleActivator(LogicalKeyboardKey.tab, + control: true, shift: true): _prevTab, const material.SingleActivator(LogicalKeyboardKey.f5): () { if (!_activeSession.running) unawaited(_execute(_activeSession)); }, @@ -626,7 +652,8 @@ class _SqliteSqlWorkspaceState extends material.State { SqlQueryTabBar( sessions: _sessions, selectedIndex: _activeSessionIndex, - onSelect: (index) => setState(() => _activeSessionIndex = index), + onSelect: (index) => + setState(() => _activeSessionIndex = index), onAdd: _addNewTab, onClose: _sessions.length > 1 ? (index) => unawaited(_closeTab(index)) @@ -707,7 +734,8 @@ class _SqliteSqlWorkspaceState extends material.State { affectedRows: session.affectedRows, statusLine: session.statusLine, stagingBuffer: session.stagingBuffer, - onApplyChanges: widget.isReadOnly ? null : () => _applyStagedChanges(session), + onApplyChanges: + widget.isReadOnly ? null : () => _applyStagedChanges(session), isSaving: session.savingChanges, ), ), diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 79388a88..1c22be05 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -91,7 +91,7 @@ class _SqliteTableViewState extends material.State { @override void dispose() { _resetStaging(); - _disconnectCurrent(); + _disconnectCurrent(interruptIfBusy: true); super.dispose(); } @@ -105,7 +105,19 @@ class _SqliteTableViewState extends material.State { _isSaving = false; } - void _disconnectCurrent() { + void _disconnectCurrent({bool interruptIfBusy = false}) { + if (interruptIfBusy && _loading) { + SqliteService.instance.interrupt( + widget.connectionRow, + mode: SqliteSessionMode.readOnly, + ); + } + if (interruptIfBusy && _isSaving) { + SqliteService.instance.interrupt( + widget.connectionRow, + mode: SqliteSessionMode.tableWrite, + ); + } _lease?.release(); _lease = null; } @@ -126,7 +138,7 @@ class _SqliteTableViewState extends material.State { try { final lease = await SqliteService.instance.acquire( widget.connectionRow, - mode: SqliteSessionMode.readWrite, + mode: SqliteSessionMode.readOnly, ); if (!mounted) { lease.release(); @@ -143,6 +155,20 @@ class _SqliteTableViewState extends material.State { } } + Future _withTableWrite( + Future Function(SqliteConnection conn) fn, + ) async { + final lease = await SqliteService.instance.acquire( + widget.connectionRow, + mode: SqliteSessionMode.tableWrite, + ); + try { + return await fn(lease.connection); + } finally { + lease.release(); + } + } + Future _confirmDiscardIfNeeded() { return confirmDiscardTableEditsIfDirty( context: context, @@ -304,22 +330,16 @@ class _SqliteTableViewState extends material.State { columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, columnMeta: _columnMeta.isEmpty ? null : _columnMeta, execute: (plan) async { - final conn = _connection; - if (conn == null || !conn.isConnected) { - throw StateError('Could not connect to SQLite.'); - } - await conn.execute('BEGIN TRANSACTION'); - try { - for (final stmt in plan.statements) { - expectDmlMatchedRows(await conn.executeAffected(stmt.sql)); + await _withTableWrite((conn) async { + if (!conn.isConnected) { + throw StateError('Could not connect to SQLite.'); } - await conn.execute('COMMIT'); - } catch (e) { - try { - await conn.execute('ROLLBACK'); - } catch (_) {} - rethrow; - } + await conn.runInTransaction(() async { + for (final stmt in plan.statements) { + expectDmlMatchedRows(await conn.executeAffected(stmt.sql)); + } + }); + }); }, ); if (!mounted) return; diff --git a/lib/features/sqlite/sqlite_workspace_home.dart b/lib/features/sqlite/sqlite_workspace_home.dart index 45307009..19aefd46 100644 --- a/lib/features/sqlite/sqlite_workspace_home.dart +++ b/lib/features/sqlite/sqlite_workspace_home.dart @@ -4,6 +4,7 @@ import 'package:querya_desktop/core/storage/local_db.dart' show ConnectionRow; import 'package:querya_desktop/features/connections/connections_panel.dart' show SqliteObjectKind; import 'package:querya_desktop/features/sqlite/sqlite_overview_tab.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_sql_tx_guard.dart'; import 'package:querya_desktop/features/sqlite/sqlite_sql_workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -22,10 +23,7 @@ class SqliteWorkspaceHome extends material.StatefulWidget { final bool isReadOnly; /// Remembers the last visited table/view for 1-click return. - final ({ - String name, - SqliteObjectKind kind - })? lastSelectedSqliteObject; + final ({String name, SqliteObjectKind kind})? lastSelectedSqliteObject; final VoidCallback? onRestoreLastSelectedObject; @override @@ -35,11 +33,13 @@ class SqliteWorkspaceHome extends material.StatefulWidget { class _SqliteWorkspaceHomeState extends material.State { int _tab = 0; + late final material.ValueNotifier _sqlTxNotifier; int _lastAppliedSqlTabToken = 0; @override void initState() { super.initState(); + _sqlTxNotifier = material.ValueNotifier(null); _lastAppliedSqlTabToken = widget.sqlTabRequestToken; } @@ -60,8 +60,18 @@ class _SqliteWorkspaceHomeState extends material.State { } } - void _selectTab(int i) { + @override + void dispose() { + _sqlTxNotifier.dispose(); + super.dispose(); + } + + Future _selectTab(int i) async { if (i == _tab) return; + if (_tab == 1 && i == 0 && _sqlTxNotifier.value == true) { + final ok = await confirmLeaveOpenSqliteTransaction(context); + if (!ok) return; + } setState(() => _tab = i); } @@ -98,7 +108,8 @@ class _SqliteWorkspaceHomeState extends material.State { material.Icons.table_chart_outlined, size: 14, ), - child: Text('Return to ${widget.lastSelectedSqliteObject!.name}'), + child: Text( + 'Return to ${widget.lastSelectedSqliteObject!.name}'), ), ], const Spacer(), @@ -122,6 +133,7 @@ class _SqliteWorkspaceHomeState extends material.State { SqliteSqlWorkspace( key: ValueKey('sqlite_sql_${widget.connectionRow.id}'), connectionRow: widget.connectionRow, + transactionOpenNotifier: _sqlTxNotifier, isReadOnly: widget.isReadOnly, ), ], diff --git a/test/core/database/sqlite_connection_pool_test.dart b/test/core/database/sqlite_connection_pool_test.dart index 1b2b978e..c476f29f 100644 --- a/test/core/database/sqlite_connection_pool_test.dart +++ b/test/core/database/sqlite_connection_pool_test.dart @@ -22,6 +22,7 @@ class FakeSqliteConnection extends SqliteConnection { int connectCount = 0; int disconnectCount = 0; int forceCloseCount = 0; + bool? openTransaction; @override bool get isConnected => _connected; @@ -43,6 +44,9 @@ class FakeSqliteConnection extends SqliteConnection { forceCloseCount++; _connected = false; } + + @override + Future inOpenTransaction() async => openTransaction; } void main() { @@ -99,7 +103,8 @@ void main() { ); }); - test('rethrows SqliteConnectionException wrapped on acquire error', () async { + test('rethrows SqliteConnectionException wrapped on acquire error', + () async { final pool = SqliteConnectionPool( createAndConnect: (row, {required mode}) async { throw Exception('network boom'); @@ -117,7 +122,8 @@ void main() { final fake2 = FakeSqliteConnection(id: 2); var i = 0; final pool = SqliteConnectionPool( - createAndConnect: (row, {required mode}) async => i++ == 0 ? fake1 : fake2, + createAndConnect: (row, {required mode}) async => + i++ == 0 ? fake1 : fake2, ); await pool.acquire(_row(id: 1)); @@ -127,5 +133,81 @@ void main() { expect(fake1.forceCloseCount, 1); expect(fake2.forceCloseCount, 1); }); + + test('tableWrite is a separate key from SQL readWrite', () async { + final created = []; + final pool = SqliteConnectionPool( + createAndConnect: (row, {required mode}) async { + final c = FakeSqliteConnection(); + await c.connect(); + created.add(c); + return c; + }, + ); + final r = _row(); + final sql = await pool.acquire(r, mode: SqliteSessionMode.readWrite); + final grid = await pool.acquire(r, mode: SqliteSessionMode.tableWrite); + final browse = await pool.acquire(r, mode: SqliteSessionMode.readOnly); + expect(identical(sql.connection, grid.connection), isFalse); + expect(identical(sql.connection, browse.connection), isFalse); + expect(created.length, 3); + expect(pool.keyFor(1, SqliteSessionMode.tableWrite), '1::tableWrite'); + sql.release(); + grid.release(); + browse.release(); + }); + + test('interrupt of SQL readWrite does not kill tableWrite or readOnly', + () async { + final pool = SqliteConnectionPool( + createAndConnect: (row, {required mode}) async { + final c = FakeSqliteConnection(); + await c.connect(); + return c; + }, + ); + final r = _row(); + final sql = await pool.acquire(r, mode: SqliteSessionMode.readWrite); + final grid = await pool.acquire(r, mode: SqliteSessionMode.tableWrite); + final browse = await pool.acquire(r, mode: SqliteSessionMode.readOnly); + final sqlFake = sql.connection as FakeSqliteConnection; + final gridFake = grid.connection as FakeSqliteConnection; + final browseFake = browse.connection as FakeSqliteConnection; + + pool.interrupt(r, mode: SqliteSessionMode.readWrite); + expect(sqlFake.forceCloseCount, 1); + expect(gridFake.forceCloseCount, 0); + expect(browseFake.forceCloseCount, 0); + + pool.interruptAllModes(r); + expect(gridFake.forceCloseCount, 1); + expect(browseFake.forceCloseCount, 1); + sql.release(); + grid.release(); + browse.release(); + }); + + test('hasOpenSqlTransaction reads the SQL slot only', () async { + final pool = SqliteConnectionPool( + createAndConnect: (row, {required mode}) async { + final c = FakeSqliteConnection(); + await c.connect(); + return c; + }, + ); + final r = _row(); + expect(await pool.hasOpenSqlTransaction(r), isFalse); + + final sql = await pool.acquire(r, mode: SqliteSessionMode.readWrite); + (sql.connection as FakeSqliteConnection).openTransaction = true; + expect(await pool.hasOpenSqlTransaction(r), isTrue); + + final grid = await pool.acquire(r, mode: SqliteSessionMode.tableWrite); + (grid.connection as FakeSqliteConnection).openTransaction = true; + (sql.connection as FakeSqliteConnection).openTransaction = false; + expect(await pool.hasOpenSqlTransaction(r), isFalse); + sql.release(); + grid.release(); + }); }); } diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index d05575c3..08b6bb97 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -107,7 +107,8 @@ void main() { test('lists tables, views, and columns correctly', () async { await conn.connect(); - await conn.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + await conn + .execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); await conn.execute('CREATE VIEW user_names AS SELECT name FROM users'); final tables = await conn.listTables(); @@ -120,25 +121,30 @@ void main() { expect(columns, containsAll(['id', 'name'])); }); - test('executes INSERT, UPDATE, DELETE with RETURNING clause correctly', () async { + test('executes INSERT, UPDATE, DELETE with RETURNING clause correctly', + () async { await conn.connect(); - await conn.execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); + await conn.execute( + 'CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); // INSERT with RETURNING - final insertRes = await conn.execute("INSERT INTO users (name) VALUES ('Alice') RETURNING id, name"); + final insertRes = await conn.execute( + "INSERT INTO users (name) VALUES ('Alice') RETURNING id, name"); expect(insertRes, isNotEmpty); expect(insertRes.first['id'], 1); expect(insertRes.first['name'], 'Alice'); // UPDATE with RETURNING - final updateRes = await conn.execute("UPDATE users SET name = 'Bob' WHERE id = 1 RETURNING id, name"); + final updateRes = await conn.execute( + "UPDATE users SET name = 'Bob' WHERE id = 1 RETURNING id, name"); expect(updateRes, isNotEmpty); expect(updateRes.first['id'], 1); expect(updateRes.first['name'], 'Bob'); // DELETE with RETURNING - final deleteRes = await conn.execute("DELETE FROM users WHERE id = 1 RETURNING id, name"); + final deleteRes = await conn + .execute("DELETE FROM users WHERE id = 1 RETURNING id, name"); expect(deleteRes, isNotEmpty); expect(deleteRes.first['id'], 1); expect(deleteRes.first['name'], 'Bob'); @@ -167,9 +173,38 @@ void main() { await roConn.disconnect(); }); + test('tracks BEGIN/COMMIT and refuses nested BEGIN via runInTransaction', + () async { + await conn.connect(); + await conn.execute( + 'CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)', + ); + await conn.execute('BEGIN'); + expect(await conn.inOpenTransaction(), isTrue); + + await conn.runInTransaction(() async { + await conn.execute("INSERT INTO t (name) VALUES ('a')"); + }); + expect(await conn.inOpenTransaction(), isTrue); + await conn.execute('COMMIT'); + expect(await conn.inOpenTransaction(), isFalse); + + final rows = await conn.execute('SELECT COUNT(*) AS c FROM t'); + expect(rows.first['c'], 1); + + await conn.runInTransaction(() async { + await conn.execute("INSERT INTO t (name) VALUES ('b')"); + }); + expect(await conn.inOpenTransaction(), isFalse); + final rows2 = await conn.execute('SELECT COUNT(*) AS c FROM t'); + expect(rows2.first['c'], 2); + }); + test('handles quotes in quoteIdentifier helper', () { - expect(SqliteConnection.quoteIdentifier('normal_table'), '"normal_table"'); - expect(SqliteConnection.quoteIdentifier('table"with"quotes'), '"table""with""quotes"'); + expect( + SqliteConnection.quoteIdentifier('normal_table'), '"normal_table"'); + expect(SqliteConnection.quoteIdentifier('table"with"quotes'), + '"table""with""quotes"'); }); }); } diff --git a/test/features/sqlite/sqlite_sql_tx_guard_test.dart b/test/features/sqlite/sqlite_sql_tx_guard_test.dart new file mode 100644 index 00000000..7eb47fe3 --- /dev/null +++ b/test/features/sqlite/sqlite_sql_tx_guard_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart' + show SqliteObjectKind; +import 'package:querya_desktop/features/sqlite/sqlite_sql_tx_guard.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + test('sqliteObjectOpensTableBrowser covers table and view', () { + expect(sqliteObjectOpensTableBrowser(SqliteObjectKind.table), isTrue); + expect(sqliteObjectOpensTableBrowser(SqliteObjectKind.view), isTrue); + }); + + testWidgets('Stay keeps the caller on the SQL session', (tester) async { + var left = false; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.Builder( + builder: (context) { + return material.TextButton( + onPressed: () async { + left = await confirmLeaveOpenSqliteTransaction(context); + }, + child: const material.Text('Go'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Open transaction'), findsOneWidget); + + await tester.tap(find.text('Stay')); + await tester.pumpAndSettle(); + expect(left, isFalse); + }); +} From 8bca69cebf93ca591524ec0e782f6a94dd05c099 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 12:50:32 +0300 Subject: [PATCH 13/52] fix(mysql): isolate Table Browser from the SQL-editor socket --- CHANGELOG.md | 1 + lib/core/database/mysql_connection.dart | 59 +++++++++- lib/core/database/mysql_connection_pool.dart | 38 ++++++- lib/core/database/mysql_service.dart | 22 +++- .../connections/connections_panel.dart | 6 +- lib/features/main_screen/main_screen.dart | 22 ++++ lib/features/mysql/mysql_sql_tx_guard.dart | 54 +++++++++ lib/features/mysql/mysql_sql_workspace.dart | 71 ++++++++---- lib/features/mysql/mysql_table_view.dart | 52 ++++++--- lib/features/mysql/mysql_workspace_home.dart | 17 ++- .../database/mysql_connection_pool_test.dart | 106 +++++++++++++++++- .../mysql/mysql_sql_tx_guard_test.dart | 42 +++++++ 12 files changed, 434 insertions(+), 56 deletions(-) create mode 100644 lib/features/mysql/mysql_sql_tx_guard.dart create mode 100644 test/features/mysql/mysql_sql_tx_guard_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ca5391..45371234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MySQL Table Browser session (#802)** — Browse uses a read-only pool slot; Save uses a dedicated `tableWrite` socket so SQL `START TRANSACTION` / `SET` / `USE` do not leak into the grid. SQL-grid Save joins an already-open transaction instead of nesting `START TRANSACTION`. Opening a table on the same database (and Overview↔SQL) warns while SQL has an open transaction. - **SQLite Table Browser session (#794)** — Browse uses a read-only `Database`; Save uses a dedicated `tableWrite` handle so SQL `BEGIN` / `ATTACH` do not leak into the grid (no nested `BEGIN`). SQL-grid Save joins an already-open transaction instead of starting another. Opening a table (and Overview↔SQL) warns while SQL has an open `BEGIN`. - **Postgres Table Browser session (#785)** — Browse uses a read-only pool slot; Save and `REFRESH MATERIALIZED VIEW` use a dedicated `tableWrite` socket so they do not share the SQL editor’s TCP session. Statement-timeout `forceClose` on SQL no longer kills the grid. Opening a table while SQL has an open transaction on the same database warns, matching Overview↔SQL. - **Mongo inspector Apply (#775)** — With **Save to DB** wired, **Apply** and **Ctrl/Cmd+Enter** persist through the same `$set` callback instead of only closing the dialog. SQL-grid Apply (no callback) still stages locally. diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 20360f61..c3dc10e0 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -78,6 +78,7 @@ class MysqlConnection { MySQLConnection? _conn; bool _isConnected = false; + bool _inTransaction = false; bool get isConnected => _isConnected && _conn != null; @@ -102,7 +103,8 @@ class MysqlConnection { var effectiveConnectionString = _connectionString; if ((effectivePassword == null || effectivePassword.isEmpty) && - (effectiveConnectionString == null || effectiveConnectionString.isEmpty) && + (effectiveConnectionString == null || + effectiveConnectionString.isEmpty) && id > 0) { try { final secrets = await ConnectionSecretsStore.readForConnection(id); @@ -136,7 +138,8 @@ class MysqlConnection { ); await _conn!.connect(timeoutMs: connectTimeoutMs); } else { - final sslPaths = extractSslCertificatePathsFromString(effectiveConnectionString); + final sslPaths = + extractSslCertificatePathsFromString(effectiveConnectionString); final securityContext = buildSecurityContext(sslPaths); _conn = await MySQLConnection.createConnection( host: host, @@ -233,6 +236,7 @@ class MysqlConnection { Future disconnect() async { _isConnected = false; + _inTransaction = false; final c = _conn; _conn = null; try { @@ -248,6 +252,7 @@ class MysqlConnection { /// while a query is in progress. Future forceClose() async { _isConnected = false; + _inTransaction = false; final c = _conn; _conn = null; if (c == null) return; @@ -295,13 +300,58 @@ class MysqlConnection { throw StateError('Not connected to MySQL'); } try { - return await _conn!.execute(sql, params, iterable); + final rs = await _conn!.execute(sql, params, iterable); + _noteTransactionSql(sql); + return rs; } on TimeoutException { unawaited(forceClose()); rethrow; } } + /// Whether this session has an open `START TRANSACTION` / `BEGIN`. + Future inOpenTransaction() async { + if (!isConnected) return null; + return _inTransaction; + } + + /// Runs [body] inside a transaction. If the session already has one, DML + /// joins it instead of nesting `START TRANSACTION`. + Future runInTransaction(Future Function() body) async { + final join = _inTransaction; + if (!join) { + await execute('START TRANSACTION'); + } + try { + await body(); + if (!join) await execute('COMMIT'); + } catch (e) { + if (!join) { + try { + await execute('ROLLBACK'); + } catch (_) {} + } + rethrow; + } + } + + void _noteTransactionSql(String sql) { + final sqlLower = sql.trim().toLowerCase(); + if (sqlLower.startsWith('start transaction') || + sqlLower.startsWith('begin')) { + _inTransaction = true; + return; + } + if (sqlLower.startsWith('commit')) { + _inTransaction = false; + return; + } + if (sqlLower.startsWith('rollback') && + !RegExp(r'^rollback\s+to\b').hasMatch(sqlLower)) { + _inTransaction = false; + } + } + /// Runs [execute] with an application-level [timeout] (driver limits still apply). Future executeWithTimeout( String sql, { @@ -409,7 +459,8 @@ class MysqlConnection { for (final r in colsRs.rows) { final name = r.colByName('COLUMN_NAME') ?? ''; final dataType = r.colByName('DATA_TYPE') ?? ''; - final isNullable = (r.colByName('IS_NULLABLE') ?? 'YES').toUpperCase() == 'YES'; + final isNullable = + (r.colByName('IS_NULLABLE') ?? 'YES').toUpperCase() == 'YES'; final isPk = primaryKeys.contains(name); final pkPos = isPk ? primaryKeys.indexOf(name) + 1 : null; final dflt = r.colByName('COLUMN_DEFAULT'); diff --git a/lib/core/database/mysql_connection_pool.dart b/lib/core/database/mysql_connection_pool.dart index 1f7a4216..74903ff1 100644 --- a/lib/core/database/mysql_connection_pool.dart +++ b/lib/core/database/mysql_connection_pool.dart @@ -6,8 +6,18 @@ import 'package:querya_desktop/core/storage/local_db.dart'; /// Session policy for pooled connections: browse vs SQL editor (writes). enum MysqlSessionMode { + /// Tree catalog, stats, and Table Browser SELECT/COUNT. readOnly, + + /// SQL editor. Must not be shared with Table Browser. readWrite, + + /// Table Browser Save (own TCP session so START TRANSACTION / SET / USE do not leak). + tableWrite, +} + +extension MysqlSessionModeReadOnly on MysqlSessionMode { + bool get isReadOnlySession => this == MysqlSessionMode.readOnly; } typedef MysqlPoolConnectionFactory = Future Function( @@ -68,9 +78,7 @@ class MysqlConnectionPool { entry.refs++; if (!entry.connection.isConnected) { await entry.connection.connect(); - await entry.connection.setSessionReadOnly( - mode == MysqlSessionMode.readOnly, - ); + await entry.connection.setSessionReadOnly(mode.isReadOnlySession); } return MysqlLease._(this, k, entry.connection); } @@ -105,9 +113,7 @@ class MysqlConnectionPool { entry.refs++; if (!entry.connection.isConnected) { await entry.connection.connect(); - await entry.connection.setSessionReadOnly( - mode == MysqlSessionMode.readOnly, - ); + await entry.connection.setSessionReadOnly(mode.isReadOnlySession); } return MysqlLease._(this, k, entry.connection); } @@ -156,6 +162,26 @@ class MysqlConnectionPool { _removeEntryClosing(k); } + /// Force-closes every session mode for this connection+database. + void interruptAllModes( + ConnectionRow row, { + required String database, + }) { + for (final mode in MysqlSessionMode.values) { + interrupt(row, database: database, mode: mode); + } + } + + /// Whether the SQL-editor slot currently has an open transaction. + Future hasOpenSqlTransaction( + ConnectionRow row, { + required String database, + }) async { + final entry = _pool[keyFor(row.id, database, MysqlSessionMode.readWrite)]; + if (entry == null || !entry.connection.isConnected) return false; + return await entry.connection.inOpenTransaction() ?? false; + } + Future disconnectAll() async { for (final entry in _pool.values) { entry.idleTimer?.cancel(); diff --git a/lib/core/database/mysql_service.dart b/lib/core/database/mysql_service.dart index 276ccf60..416a160d 100644 --- a/lib/core/database/mysql_service.dart +++ b/lib/core/database/mysql_service.dart @@ -3,7 +3,11 @@ 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; + show + MysqlLease, + MysqlSessionMode, + MysqlSessionModeReadOnly, + MysqlConnectionPool; Future _defaultCreateAndConnect( ConnectionRow row, { @@ -15,7 +19,7 @@ Future _defaultCreateAndConnect( database: database.isEmpty ? null : database, ); await conn.connect(); - await conn.setSessionReadOnly(mode == MysqlSessionMode.readOnly); + await conn.setSessionReadOnly(mode.isReadOnlySession); return conn; } @@ -48,5 +52,19 @@ class MysqlService { }) => _pool.interrupt(row, database: database, mode: mode); + /// Force-closes every session mode for this connection+database. + void interruptAllModes( + ConnectionRow row, { + required String database, + }) => + _pool.interruptAllModes(row, database: database); + + /// Whether the SQL-editor read-write slot has an open transaction. + Future hasOpenSqlTransaction( + ConnectionRow row, { + required String database, + }) => + _pool.hasOpenSqlTransaction(row, database: database); + Future disconnectAll() => _pool.disconnectAll(); } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 7a2b6e9f..dea12ee7 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -704,10 +704,8 @@ class ConnectionsPanelState extends State { PostgresService.instance.interruptAllModes(conn, database: conn.databaseName ?? 'postgres'); } else if (conn.type == 'mysql') { - MysqlService.instance.interrupt(conn, - database: conn.databaseName ?? '', mode: MysqlSessionMode.readOnly); - MysqlService.instance.interrupt(conn, - database: conn.databaseName ?? '', mode: MysqlSessionMode.readWrite); + MysqlService.instance.interruptAllModes(conn, + database: conn.databaseName ?? ''); } else if (conn.type == 'sqlite') { SqliteService.instance.interruptAllModes(conn); } else if (conn.type == 'redis') { diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 6d13a523..18170f37 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -34,6 +34,7 @@ import 'package:querya_desktop/features/main_screen/querya_status_bar.dart'; import 'package:querya_desktop/features/main_screen/querya_window_title_bar.dart'; import 'package:querya_desktop/features/macos/querya_platform_menu_bar.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_tx_guard.dart'; import 'package:querya_desktop/features/onboarding/welcome_tour_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_tx_guard.dart'; @@ -365,6 +366,15 @@ class _MainScreenState extends State { if (same) return; if (!await _allowUnsavedNavigation()) return; if (!mounted) return; + if (!await confirmOpenMysqlTableIfSqlTx( + context, + connection: connection, + database: database, + kind: kind, + )) { + return; + } + if (!mounted) return; _workspace.value = _workspace.value.selectMysqlObject( connection, database, @@ -1223,6 +1233,18 @@ class _MainContentSplitState extends State<_MainContentSplit> } } if (!mounted) return; + final my = ws.lastSelectedMysqlObject; + if (conn != null && conn.type == 'mysql' && my != null) { + if (!await confirmOpenMysqlTableIfSqlTx( + context, + connection: conn, + database: my.database, + kind: my.kind, + )) { + return; + } + } + if (!mounted) return; widget.workspace.value = widget.workspace.value.restoreLastSelectedObject(); } diff --git a/lib/features/mysql/mysql_sql_tx_guard.dart b/lib/features/mysql/mysql_sql_tx_guard.dart new file mode 100644 index 00000000..5f24b3ef --- /dev/null +++ b/lib/features/mysql/mysql_sql_tx_guard.dart @@ -0,0 +1,54 @@ +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/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +bool mysqlObjectOpensTableBrowser(MysqlObjectKind kind) { + return kind == MysqlObjectKind.table || kind == MysqlObjectKind.view; +} + +/// Confirms leaving the SQL tab (or opening a table) while a transaction is open. +Future confirmLeaveOpenMysqlTransaction( + material.BuildContext context, +) async { + final ok = await showAppDialog( + context: context, + builder: (ctx) => material.AlertDialog( + title: const material.Text('Open transaction'), + content: const material.Text( + 'The SQL tab has an open transaction. Leave anyway? ' + 'Uncommitted work may be lost if the session ends.', + ), + actions: [ + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(false), + child: const material.Text('Stay'), + ), + material.TextButton( + onPressed: () => material.Navigator.of(ctx).pop(true), + child: const material.Text('Leave'), + ), + ], + ), + ); + return ok == true; +} + +/// Warns before opening Table Browser while SQL has an open transaction +/// on the same database. +Future confirmOpenMysqlTableIfSqlTx( + material.BuildContext context, { + required ConnectionRow connection, + required String database, + required MysqlObjectKind kind, +}) async { + if (!mysqlObjectOpensTableBrowser(kind)) return true; + final open = await MysqlService.instance.hasOpenSqlTransaction( + connection, + database: database, + ); + if (!open) return true; + if (!context.mounted) return false; + return confirmLeaveOpenMysqlTransaction(context); +} diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 8e2ced5e..e7b194c9 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -27,12 +27,16 @@ class MysqlSqlWorkspace extends material.StatefulWidget { const MysqlSqlWorkspace({ super.key, required this.connectionRow, + this.transactionOpenNotifier, this.isReadOnly = false, }); final ConnectionRow connectionRow; final bool isReadOnly; + /// Updated when transaction state changes (for tab-switch warnings). + final material.ValueNotifier? transactionOpenNotifier; + @override material.State createState() => _MysqlSqlWorkspaceState(); } @@ -45,6 +49,7 @@ class _MysqlSqlWorkspaceState extends material.State { SqlQueryTabSession get _activeSession => _sessions[_activeSessionIndex]; MysqlLease? _lease; + bool? _txOpen; int? _queryTimeoutSeconds; @@ -203,7 +208,9 @@ class _MysqlSqlWorkspaceState extends material.State { final lease = await MysqlService.instance.acquire( widget.connectionRow, database: _poolDatabaseKey(), - mode: widget.isReadOnly ? MysqlSessionMode.readOnly : MysqlSessionMode.readWrite, + mode: widget.isReadOnly + ? MysqlSessionMode.readOnly + : MysqlSessionMode.readWrite, ); if (!mounted) { lease.release(); @@ -212,6 +219,22 @@ class _MysqlSqlWorkspaceState extends material.State { _lease = lease; } + void _notifyTransactionOpen() { + widget.transactionOpenNotifier?.value = _txOpen; + } + + Future _refreshTxStatus() async { + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + if (mounted) setState(() => _txOpen = null); + _notifyTransactionOpen(); + return; + } + final v = await conn.inOpenTransaction(); + if (mounted) setState(() => _txOpen = v); + _notifyTransactionOpen(); + } + Duration? _statementTimeout() => _queryTimeoutSeconds == null ? null : Duration(seconds: _queryTimeoutSeconds!); @@ -227,7 +250,9 @@ class _MysqlSqlWorkspaceState extends material.State { MysqlService.instance.interrupt( widget.connectionRow, database: _poolDatabaseKey(), - mode: widget.isReadOnly ? MysqlSessionMode.readOnly : MysqlSessionMode.readWrite, + mode: widget.isReadOnly + ? MysqlSessionMode.readOnly + : MysqlSessionMode.readWrite, ); } _lease?.release(); @@ -384,6 +409,8 @@ class _MysqlSqlWorkspaceState extends material.State { }); QueryaShellStatus.instance.endBusy(); } + } finally { + await _refreshTxStatus(); } } @@ -427,19 +454,13 @@ class _MysqlSqlWorkspaceState extends material.State { throw StateError('Could not connect to MySQL.'); } - await conn.execute('START TRANSACTION'); - try { + await conn.runInTransaction(() async { for (final stmt in plan.statements) { final rs = await conn.execute(stmt.sql); expectDmlMatchedRows(rs.affectedRows.toInt()); } - await conn.execute('COMMIT'); - } catch (e) { - try { - await conn.execute('ROLLBACK'); - } catch (_) {} - rethrow; - } + }); + await _refreshTxStatus(); if (!mounted) return; final newRows = session.stagingBuffer!.effectiveRows; @@ -623,16 +644,22 @@ class _MysqlSqlWorkspaceState extends material.State { }, child: material.CallbackShortcuts( bindings: { - const material.SingleActivator(LogicalKeyboardKey.keyT, control: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyT, meta: true): _addNewTab, - const material.SingleActivator(LogicalKeyboardKey.keyW, control: true): () { + const material.SingleActivator(LogicalKeyboardKey.keyT, + control: true): _addNewTab, + const material.SingleActivator(LogicalKeyboardKey.keyT, meta: true): + _addNewTab, + const material.SingleActivator(LogicalKeyboardKey.keyW, + control: true): () { if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); }, - const material.SingleActivator(LogicalKeyboardKey.keyW, meta: true): () { + const material.SingleActivator(LogicalKeyboardKey.keyW, meta: true): + () { if (_sessions.length > 1) unawaited(_closeTab(_activeSessionIndex)); }, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true): _nextTab, - const material.SingleActivator(LogicalKeyboardKey.tab, control: true, shift: true): _prevTab, + const material.SingleActivator(LogicalKeyboardKey.tab, control: true): + _nextTab, + const material.SingleActivator(LogicalKeyboardKey.tab, + control: true, shift: true): _prevTab, const material.SingleActivator(LogicalKeyboardKey.f5): () { if (!_activeSession.running) unawaited(_execute(_activeSession)); }, @@ -681,7 +708,8 @@ class _MysqlSqlWorkspaceState extends material.State { SqlQueryTabBar( sessions: _sessions, selectedIndex: _activeSessionIndex, - onSelect: (index) => setState(() => _activeSessionIndex = index), + onSelect: (index) => + setState(() => _activeSessionIndex = index), onAdd: _addNewTab, onClose: _sessions.length > 1 ? (index) => unawaited(_closeTab(index)) @@ -730,9 +758,12 @@ class _MysqlSqlWorkspaceState extends material.State { ); } : null, - onBegin: session.running ? null : () => _runTxCommand('START TRANSACTION;'), + onBegin: session.running + ? null + : () => _runTxCommand('START TRANSACTION;'), onCommit: session.running ? null : () => _runTxCommand('COMMIT;'), - onRollback: session.running ? null : () => _runTxCommand('ROLLBACK;'), + onRollback: + session.running ? null : () => _runTxCommand('ROLLBACK;'), ), const Divider(height: 1), Expanded( diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index 9d81197c..9d7779c2 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -125,7 +125,14 @@ class _MysqlTableViewState extends material.State { MysqlService.instance.interrupt( widget.connectionRow, database: widget.database, - mode: MysqlSessionMode.readWrite, + mode: MysqlSessionMode.readOnly, + ); + } + if (interruptIfBusy && _isSaving) { + MysqlService.instance.interrupt( + widget.connectionRow, + database: widget.database, + mode: MysqlSessionMode.tableWrite, ); } _lease?.release(); @@ -151,7 +158,7 @@ class _MysqlTableViewState extends material.State { final lease = await MysqlService.instance.acquire( widget.connectionRow, database: widget.database, - mode: MysqlSessionMode.readWrite, + mode: MysqlSessionMode.readOnly, ); if (!mounted) { lease.release(); @@ -196,6 +203,21 @@ class _MysqlTableViewState extends material.State { return out; } + Future _withTableWrite( + Future Function(MysqlConnection conn) fn, + ) async { + final lease = await MysqlService.instance.acquire( + widget.connectionRow, + database: widget.database, + mode: MysqlSessionMode.tableWrite, + ); + try { + return await fn(lease.connection); + } finally { + lease.release(); + } + } + Future _confirmDiscardIfNeeded() { return confirmDiscardTableEditsIfDirty( context: context, @@ -485,23 +507,17 @@ class _MysqlTableViewState extends material.State { columnDataTypes: _columnDataTypes.isEmpty ? null : _columnDataTypes, columnMeta: _columnMeta.isEmpty ? null : _columnMeta, execute: (plan) async { - final conn = _connection; - if (conn == null || !conn.isConnected) { - throw StateError('Could not connect to MySQL.'); - } - await conn.execute('START TRANSACTION'); - try { - for (final stmt in plan.statements) { - final rs = await conn.execute(stmt.sql); - expectDmlMatchedRows(rs.affectedRows.toInt()); + await _withTableWrite((conn) async { + if (!conn.isConnected) { + throw StateError('Could not connect to MySQL.'); } - await conn.execute('COMMIT'); - } catch (e) { - try { - await conn.execute('ROLLBACK'); - } catch (_) {} - rethrow; - } + await conn.runInTransaction(() async { + for (final stmt in plan.statements) { + final rs = await conn.execute(stmt.sql); + expectDmlMatchedRows(rs.affectedRows.toInt()); + } + }); + }); }, ); if (!mounted) return; diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart index 04333594..7d8f9821 100644 --- a/lib/features/mysql/mysql_workspace_home.dart +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_tx_guard.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'; @@ -40,11 +41,13 @@ class MysqlWorkspaceHome extends material.StatefulWidget { class _MysqlWorkspaceHomeState extends material.State { int _tab = 0; + late final material.ValueNotifier _sqlTxNotifier; int _lastAppliedSqlTabToken = 0; @override void initState() { super.initState(); + _sqlTxNotifier = material.ValueNotifier(null); _lastAppliedSqlTabToken = widget.sqlTabRequestToken; } @@ -65,8 +68,18 @@ class _MysqlWorkspaceHomeState extends material.State { } } + @override + void dispose() { + _sqlTxNotifier.dispose(); + super.dispose(); + } + Future _selectTab(int i) async { if (i == _tab) return; + if (_tab == 1 && i == 0 && _sqlTxNotifier.value == true) { + final ok = await confirmLeaveOpenMysqlTransaction(context); + if (!ok) return; + } setState(() => _tab = i); } @@ -103,7 +116,8 @@ class _MysqlWorkspaceHomeState extends material.State { material.Icons.table_chart_outlined, size: 14, ), - child: Text('Return to ${widget.lastSelectedMysqlObject!.name}'), + child: + Text('Return to ${widget.lastSelectedMysqlObject!.name}'), ), ], const Spacer(), @@ -127,6 +141,7 @@ class _MysqlWorkspaceHomeState extends material.State { MysqlSqlWorkspace( key: ValueKey('mysql_sql_${widget.connectionRow.id}'), connectionRow: widget.connectionRow, + transactionOpenNotifier: _sqlTxNotifier, isReadOnly: widget.isReadOnly, ), ], diff --git a/test/core/database/mysql_connection_pool_test.dart b/test/core/database/mysql_connection_pool_test.dart index 0448b593..cdafe6a9 100644 --- a/test/core/database/mysql_connection_pool_test.dart +++ b/test/core/database/mysql_connection_pool_test.dart @@ -24,6 +24,8 @@ class FakeMysqlConnection extends MysqlConnection { int disconnectCount = 0; int forceCloseCount = 0; int setReadOnlyCount = 0; + bool? lastReadOnly; + bool? openTransaction; @override bool get isConnected => _connected; @@ -49,7 +51,11 @@ class FakeMysqlConnection extends MysqlConnection { @override Future setSessionReadOnly(bool readOnly) async { setReadOnlyCount++; + lastReadOnly = readOnly; } + + @override + Future inOpenTransaction() async => openTransaction; } void main() { @@ -57,7 +63,8 @@ void main() { test('acquire increments refs and connects if needed', () async { final fake = FakeMysqlConnection(); final pool = MysqlConnectionPool( - createAndConnect: (row, {required database, required mode}) async => fake, + createAndConnect: (row, {required database, required mode}) async => + fake, ); final lease = await pool.acquire(_row(id: 1), database: 'testdb'); @@ -95,5 +102,102 @@ void main() { throwsA(isA()), ); }); + + test('tableWrite is a separate key from SQL readWrite', () async { + final created = []; + final pool = MysqlConnectionPool( + createAndConnect: (row, {required database, required mode}) async { + final c = FakeMysqlConnection(); + await c.connect(); + await c.setSessionReadOnly(mode.isReadOnlySession); + created.add(c); + return c; + }, + ); + final r = _row(); + final sql = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.readWrite); + final grid = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.tableWrite); + final browse = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.readOnly); + expect(identical(sql.connection, grid.connection), isFalse); + expect(identical(sql.connection, browse.connection), isFalse); + expect(created.length, 3); + expect( + pool.keyFor(1, 'app', MysqlSessionMode.tableWrite), + '1::app::tableWrite', + ); + expect((browse.connection as FakeMysqlConnection).lastReadOnly, isTrue); + expect((grid.connection as FakeMysqlConnection).lastReadOnly, isFalse); + sql.release(); + grid.release(); + browse.release(); + }); + + test('interrupt of SQL readWrite does not kill tableWrite or readOnly', + () async { + final pool = MysqlConnectionPool( + createAndConnect: (row, {required database, required mode}) async { + final c = FakeMysqlConnection(); + await c.connect(); + return c; + }, + ); + final r = _row(); + final sql = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.readWrite); + final grid = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.tableWrite); + final browse = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.readOnly); + final sqlFake = sql.connection as FakeMysqlConnection; + final gridFake = grid.connection as FakeMysqlConnection; + final browseFake = browse.connection as FakeMysqlConnection; + + pool.interrupt(r, database: 'app', mode: MysqlSessionMode.readWrite); + expect(sqlFake.forceCloseCount, 1); + expect(gridFake.forceCloseCount, 0); + expect(browseFake.forceCloseCount, 0); + + pool.interruptAllModes(r, database: 'app'); + expect(gridFake.forceCloseCount, 1); + expect(browseFake.forceCloseCount, 1); + sql.release(); + grid.release(); + browse.release(); + }); + + test('hasOpenSqlTransaction reads the SQL slot only', () async { + final pool = MysqlConnectionPool( + createAndConnect: (row, {required database, required mode}) async { + final c = FakeMysqlConnection(); + await c.connect(); + return c; + }, + ); + final r = _row(); + expect( + await pool.hasOpenSqlTransaction(r, database: 'app'), + isFalse, + ); + + final sql = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.readWrite); + (sql.connection as FakeMysqlConnection).openTransaction = true; + expect(await pool.hasOpenSqlTransaction(r, database: 'app'), isTrue); + expect( + await pool.hasOpenSqlTransaction(r, database: 'other'), + isFalse, + ); + + final grid = await pool.acquire(r, + database: 'app', mode: MysqlSessionMode.tableWrite); + (grid.connection as FakeMysqlConnection).openTransaction = true; + (sql.connection as FakeMysqlConnection).openTransaction = false; + expect(await pool.hasOpenSqlTransaction(r, database: 'app'), isFalse); + sql.release(); + grid.release(); + }); }); } diff --git a/test/features/mysql/mysql_sql_tx_guard_test.dart b/test/features/mysql/mysql_sql_tx_guard_test.dart new file mode 100644 index 00000000..615fff6c --- /dev/null +++ b/test/features/mysql/mysql_sql_tx_guard_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/mysql/mysql_object_kind.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_tx_guard.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + test('mysqlObjectOpensTableBrowser covers table and view only', () { + expect(mysqlObjectOpensTableBrowser(MysqlObjectKind.table), isTrue); + expect(mysqlObjectOpensTableBrowser(MysqlObjectKind.view), isTrue); + expect(mysqlObjectOpensTableBrowser(MysqlObjectKind.procedure), isFalse); + expect(mysqlObjectOpensTableBrowser(MysqlObjectKind.function), isFalse); + }); + + testWidgets('Stay keeps the caller on the SQL session', (tester) async { + var left = false; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + home: material.Builder( + builder: (context) { + return material.TextButton( + onPressed: () async { + left = await confirmLeaveOpenMysqlTransaction(context); + }, + child: const material.Text('Go'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('Open transaction'), findsOneWidget); + + await tester.tap(find.text('Stay')); + await tester.pumpAndSettle(); + expect(left, isFalse); + }); +} From 5ca923ab6c7d0849d7e3e5d33c494fcc81cfbd8f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 13:03:34 +0300 Subject: [PATCH 14/52] fix(redis): keep Overview and Explorer on separate sockets Opening a DB and Home Refresh were replacing the singleton Redis socket, so keep-alive Overview and Explorer killed each other. --- CHANGELOG.md | 1 + lib/core/database/redis_service.dart | 77 +++++++++++-- .../connections/connections_panel.dart | 5 +- lib/features/redis/redis_explorer_view.dart | 21 +++- lib/features/redis/redis_view.dart | 50 +++++++-- test/core/database/redis_service_test.dart | 104 ++++++++++++++++++ 6 files changed, 226 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45371234..ac55dd59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Redis Overview/Explorer sockets (#812)** — Overview INFO and Explorer SCAN/GET/`SELECT` use separate pooled sockets (`stats` vs `explorer`) so opening a DB no longer kills keep-alive Overview, and Home Refresh no longer steals Explorer. User Disconnect closes both roles. - **MySQL Table Browser session (#802)** — Browse uses a read-only pool slot; Save uses a dedicated `tableWrite` socket so SQL `START TRANSACTION` / `SET` / `USE` do not leak into the grid. SQL-grid Save joins an already-open transaction instead of nesting `START TRANSACTION`. Opening a table on the same database (and Overview↔SQL) warns while SQL has an open transaction. - **SQLite Table Browser session (#794)** — Browse uses a read-only `Database`; Save uses a dedicated `tableWrite` handle so SQL `BEGIN` / `ATTACH` do not leak into the grid (no nested `BEGIN`). SQL-grid Save joins an already-open transaction instead of starting another. Opening a table (and Overview↔SQL) warns while SQL has an open `BEGIN`. - **Postgres Table Browser session (#785)** — Browse uses a read-only pool slot; Save and `REFRESH MATERIALIZED VIEW` use a dedicated `tableWrite` socket so they do not share the SQL editor’s TCP session. Statement-timeout `forceClose` on SQL no longer kills the grid. Opening a table while SQL has an open transaction on the same database warns, matching Overview↔SQL. diff --git a/lib/core/database/redis_service.dart b/lib/core/database/redis_service.dart index 0b7bf2c2..1ad1044d 100644 --- a/lib/core/database/redis_service.dart +++ b/lib/core/database/redis_service.dart @@ -1,48 +1,101 @@ +import 'dart:async'; + import '../storage/local_db.dart'; import 'redis_connection.dart'; +/// Which workspace surface owns a Redis TCP session for a connection id. +/// +/// Overview INFO polling and Explorer SCAN/GET/SELECT cannot share a socket: +/// keep-alive Overview stays mounted under Explorer, and Explorer issues +/// `SELECT` while Overview polls `INFO`. +enum RedisSessionRole { + /// Overview INFO polling (kept alive under Explorer). + stats, + + /// Keys/editor SCAN/GET/SET plus `SELECT`. + explorer, +} + /// Service for Redis connections (Dart redis package, no Java). class RedisService { RedisService._(); static final RedisService instance = RedisService._(); - final Map _connections = {}; + final Map _connections = {}; + + String _key(int id, RedisSessionRole role) => '$id::${role.name}'; + + /// Returns the existing socket for [role], or creates one without touching + /// the other role. + RedisConnection acquire( + ConnectionRow row, { + required RedisSessionRole role, + }) { + if (row.type != 'redis') { + throw ArgumentError('Connection type must be redis'); + } + final id = row.id ?? 0; + final k = _key(id, role); + final existing = _connections[k]; + if (existing != null) return existing; + final conn = RedisConnection.fromConnectionRow(row); + _connections[k] = conn; + return conn; + } - /// Creates (or replaces) a [RedisConnection] for the given [ConnectionRow]. - /// If a connection with the same ID already exists it is disconnected first. - RedisConnection createConnection(ConnectionRow row) { + /// Creates (or replaces) a [RedisConnection] for [role] (default stats). + /// The other role for this id is left alone. + RedisConnection createConnection( + ConnectionRow row, { + RedisSessionRole role = RedisSessionRole.stats, + }) { if (row.type != 'redis') { throw ArgumentError('Connection type must be redis'); } final id = row.id ?? 0; + final k = _key(id, role); - // Disconnect previous connection for this ID, if any. - final existing = _connections[id]; + final existing = _connections.remove(k); if (existing != null) { - existing.disconnect(); // fire-and-forget; disconnect is safe + unawaited(existing.disconnect()); } final conn = RedisConnection.fromConnectionRow(row); - _connections[id] = conn; + _connections[k] = conn; return conn; } - RedisConnection? getConnection(int id) => _connections[id]; + RedisConnection? getConnection( + int id, { + RedisSessionRole role = RedisSessionRole.stats, + }) => + _connections[_key(id, role)]; Future connect(RedisConnection connection) async { await connection.connect(); } + /// Drops [connection] from the pool immediately, then awaits the TCP close. Future disconnect(RedisConnection connection) async { + _connections.removeWhere((_, c) => identical(c, connection)); await connection.disconnect(); - _connections.remove(connection.id); + } + + /// Awaits close of every role for [id] (user Disconnect). + Future disconnectByConnectionId(int id) async { + for (final role in RedisSessionRole.values) { + final conn = _connections.remove(_key(id, role)); + if (conn != null) await conn.disconnect(); + } } /// Disconnects all Redis connections (e.g. app shutdown). Future disconnectAll() async { - for (final connection in _connections.values.toList()) { - await disconnect(connection); + final all = _connections.values.toList(); + _connections.clear(); + for (final connection in all) { + await connection.disconnect(); } } } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index dea12ee7..3e74b305 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -709,10 +709,7 @@ class ConnectionsPanelState extends State { } else if (conn.type == 'sqlite') { SqliteService.instance.interruptAllModes(conn); } else if (conn.type == 'redis') { - final redisConn = RedisService.instance.getConnection(id); - if (redisConn != null) { - await RedisService.instance.disconnect(redisConn); - } + await RedisService.instance.disconnectByConnectionId(id); } else if (conn.type == 'mongodb') { await MongoService.instance.disconnectByConnectionId(id); } diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index 5a5e1632..18f084bc 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; @@ -78,12 +80,16 @@ class _RedisExplorerViewState extends material.State { final conn = _connection; _connection = null; if (conn != null) { - conn.disconnect(); + unawaited(RedisService.instance.disconnect(conn)); } } Future _connect() async { - _disconnectCurrent(); + final old = _connection; + _connection = null; + if (old != null) { + await RedisService.instance.disconnect(old); + } if (!mounted) return; setState(() { _connecting = true; @@ -93,10 +99,15 @@ class _RedisExplorerViewState extends material.State { _showStats = false; }); try { - final conn = RedisService.instance.createConnection(widget.connectionRow); - await conn.connect(); + final conn = RedisService.instance.acquire( + widget.connectionRow, + role: RedisSessionRole.explorer, + ); + if (!conn.isConnected) { + await conn.connect(); + } if (!mounted) { - conn.disconnect(); + unawaited(RedisService.instance.disconnect(conn)); return; } setState(() { diff --git a/lib/features/redis/redis_view.dart b/lib/features/redis/redis_view.dart index b5c98bad..fa990ff9 100644 --- a/lib/features/redis/redis_view.dart +++ b/lib/features/redis/redis_view.dart @@ -102,33 +102,50 @@ class _RedisViewState extends material.State { final conn = _connection; _connection = null; if (conn != null && _ownsConnection) { - conn.disconnect(); + unawaited(RedisService.instance.disconnect(conn)); } _ownsConnection = false; } - Future _load() async { + Future _load({bool reconnect = false}) async { _timer?.cancel(); - _disconnectCurrent(); if (!mounted) return; setState(() { _loading = true; _error = null; - _info = null; + if (reconnect) _info = null; }); try { final supplied = widget.connection; RedisConnection conn; - if (supplied != null && supplied.isConnected) { + if (supplied != null) { conn = supplied; _ownsConnection = false; + if (!conn.isConnected) { + await conn.connect(); + } } else { - conn = RedisService.instance.createConnection(widget.connectionRow); - await conn.connect(); + if (reconnect && _ownsConnection) { + final old = _connection; + _connection = null; + _ownsConnection = false; + if (old != null) { + await RedisService.instance.disconnect(old); + } + } + conn = RedisService.instance.acquire( + widget.connectionRow, + role: RedisSessionRole.stats, + ); + if (!conn.isConnected) { + await conn.connect(); + } _ownsConnection = true; } if (!mounted) { - if (_ownsConnection) conn.disconnect(); + if (_ownsConnection) { + unawaited(RedisService.instance.disconnect(conn)); + } return; } _connection = conn; @@ -144,6 +161,17 @@ class _RedisViewState extends material.State { } } + /// Header Refresh: reuse a live stats socket; do not recreate (or steal + /// Explorer's) TCP session. Retry after an error reconnects stats only. + Future _refresh() async { + final c = _connection; + if (c != null && c.isConnected) { + await _fetch(); + return; + } + await _load(reconnect: true); + } + Future _fetch() async { final c = _connection; if (c == null) return; @@ -220,7 +248,7 @@ class _RedisViewState extends material.State { color: cs.mutedForeground, fontSize: 13)), const Gap(24), OutlineButton( - onPressed: _load, + onPressed: () => unawaited(_load(reconnect: true)), leading: const material.Icon(material.Icons.refresh_rounded, size: 18), child: const Text('Retry'), @@ -247,7 +275,7 @@ class _RedisViewState extends material.State { const Text('Redis INFO returned no data.').muted().small(), const Gap(24), OutlineButton( - onPressed: _load, + onPressed: () => unawaited(_load(reconnect: true)), leading: const material.Icon(material.Icons.refresh_rounded, size: 18), child: const Text('Retry'), @@ -407,7 +435,7 @@ class _RedisViewState extends material.State { const Gap(8), ], OutlineButton( - onPressed: _load, + onPressed: () => unawaited(_refresh()), leading: const material.Icon(material.Icons.refresh_rounded, size: 18), child: const Text('Refresh'), diff --git a/test/core/database/redis_service_test.dart b/test/core/database/redis_service_test.dart index b14e1a23..5408cc52 100644 --- a/test/core/database/redis_service_test.dart +++ b/test/core/database/redis_service_test.dart @@ -161,4 +161,108 @@ void main() { expect(RedisService.instance.getConnection(202), isNull); }); }); + + group('RedisService role pool', () { + ConnectionRow row(int id, {String name = 'r'}) => ConnectionRow( + id: id, + type: 'redis', + name: name, + createdAt: '2026-01-01T00:00:00Z', + ); + + test('stats and explorer are independent sockets', () { + const id = 8120; + final stats = RedisService.instance.acquire( + row(id, name: 'stats'), + role: RedisSessionRole.stats, + ); + final explorer = RedisService.instance.acquire( + row(id, name: 'explorer'), + role: RedisSessionRole.explorer, + ); + + expect(stats, isNot(same(explorer))); + expect( + RedisService.instance.getConnection(id), + same(stats), + ); + expect( + RedisService.instance + .getConnection(id, role: RedisSessionRole.explorer), + same(explorer), + ); + }); + + test('acquire reuses the socket for the same role', () { + const id = 8121; + final first = RedisService.instance.acquire( + row(id), + role: RedisSessionRole.stats, + ); + final second = RedisService.instance.acquire( + row(id), + role: RedisSessionRole.stats, + ); + expect(second, same(first)); + }); + + test('createConnection replaces stats without dropping explorer', () { + const id = 8122; + final explorer = RedisService.instance.acquire( + row(id, name: 'explorer'), + role: RedisSessionRole.explorer, + ); + RedisService.instance.createConnection(row(id, name: 'old-stats')); + final stats2 = RedisService.instance.createConnection( + row(id, name: 'new-stats'), + ); + + expect( + RedisService.instance.getConnection(id), + same(stats2), + ); + expect(stats2.name, 'new-stats'); + expect( + RedisService.instance + .getConnection(id, role: RedisSessionRole.explorer), + same(explorer), + ); + }); + + test('disconnect removes only that instance', () async { + const id = 8123; + final stats = RedisService.instance.acquire( + row(id), + role: RedisSessionRole.stats, + ); + final explorer = RedisService.instance.acquire( + row(id), + role: RedisSessionRole.explorer, + ); + + await RedisService.instance.disconnect(stats); + + expect(RedisService.instance.getConnection(id), isNull); + expect( + RedisService.instance + .getConnection(id, role: RedisSessionRole.explorer), + same(explorer), + ); + }); + + test('disconnectByConnectionId closes every role', () async { + const id = 8124; + RedisService.instance.acquire(row(id), role: RedisSessionRole.stats); + RedisService.instance.acquire(row(id), role: RedisSessionRole.explorer); + + await RedisService.instance.disconnectByConnectionId(id); + + expect(RedisService.instance.getConnection(id), isNull); + expect( + RedisService.instance + .getConnection(id, role: RedisSessionRole.explorer), + isNull, + ); + }); + }); } From 9fb8351873feecdb31a062529badf7e4f02f7c93 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 13:10:54 +0300 Subject: [PATCH 15/52] fix(postgres): SQL-grid Save uses PK and column types JOIN, comma-FROM, and subquery results stay read-only instead of building WHERE from every displayed column (or the literal table name). --- CHANGELOG.md | 1 + .../database/sql_table_target_extractor.dart | 236 ++++++++++++++++-- .../postgresql/postgres_sql_workspace.dart | 56 ++++- .../workspace/sql_query_tab_session.dart | 6 + .../sql_table_target_extractor_test.dart | 144 ++++++++++- 5 files changed, 414 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac55dd59..bc883595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Postgres SQL-grid Save (#786)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK present in the result (`getTableSchema`). JOIN, comma-`FROM`, subqueries, and VALUES stay read-only. DML `WHERE` uses the PK and `columnDataTypes` instead of every displayed column. - **Redis Overview/Explorer sockets (#812)** — Overview INFO and Explorer SCAN/GET/`SELECT` use separate pooled sockets (`stats` vs `explorer`) so opening a DB no longer kills keep-alive Overview, and Home Refresh no longer steals Explorer. User Disconnect closes both roles. - **MySQL Table Browser session (#802)** — Browse uses a read-only pool slot; Save uses a dedicated `tableWrite` socket so SQL `START TRANSACTION` / `SET` / `USE` do not leak into the grid. SQL-grid Save joins an already-open transaction instead of nesting `START TRANSACTION`. Opening a table on the same database (and Overview↔SQL) warns while SQL has an open transaction. - **SQLite Table Browser session (#794)** — Browse uses a read-only `Database`; Save uses a dedicated `tableWrite` handle so SQL `BEGIN` / `ATTACH` do not leak into the grid (no nested `BEGIN`). SQL-grid Save joins an already-open transaction instead of starting another. Opening a table (and Overview↔SQL) warns while SQL has an open `BEGIN`. diff --git a/lib/core/database/sql_table_target_extractor.dart b/lib/core/database/sql_table_target_extractor.dart index 2010fe85..52f0c693 100644 --- a/lib/core/database/sql_table_target_extractor.dart +++ b/lib/core/database/sql_table_target_extractor.dart @@ -21,32 +21,234 @@ class SqlTableTarget { /// Helper utility to infer the primary target table from a simple SELECT query. abstract final class SqlTableTargetExtractor { - static final _fromTableRegex = RegExp( - r'\bfrom\s+(?:(?:"([^"]+)"|`([^`]+)`|([a-zA-Z_]\w*))\.)?(?:"([^"]+)"|`([^`]+)`|([a-zA-Z_]\w*))', - caseSensitive: false, - ); + static const _fromFollowers = { + 'where', + 'group', + 'order', + 'limit', + 'offset', + 'having', + 'window', + 'fetch', + 'for', + 'union', + 'except', + 'intersect', + 'returning', + 'on', + 'using', + }; + + static const _joinStarters = { + 'join', + 'inner', + 'left', + 'right', + 'full', + 'cross', + 'natural', + }; /// Extracts the target schema and table name from [sql]. - /// Returns `null` if no simple target table can be determined (e.g. subqueries, joins). + /// + /// Returns `null` if no simple single-table target can be determined + /// (JOIN, comma-FROM, subquery, VALUES, set operations, CTE). static SqlTableTarget? extract(String sql) { final trimmed = sql.trim(); if (trimmed.isEmpty) return null; + if (_startsWithKeyword(trimmed, 'with')) return null; + if (_indexOfTopLevelKeyword(trimmed, 'join') >= 0) return null; + if (_indexOfTopLevelKeyword(trimmed, 'union') >= 0) return null; + if (_indexOfTopLevelKeyword(trimmed, 'except') >= 0) return null; + if (_indexOfTopLevelKeyword(trimmed, 'intersect') >= 0) return null; + + final fromAt = _indexOfTopLevelKeyword(trimmed, 'from'); + if (fromAt < 0) return null; + + var i = fromAt + 4; + i = _skipTrivia(trimmed, i); + if (i >= trimmed.length) return null; + if (trimmed[i] == '(') return null; + + final first = _readIdent(trimmed, i); + if (first == null) return null; + i = first.next; + + String? schema; + String table; + i = _skipTrivia(trimmed, i); + if (i < trimmed.length && trimmed[i] == '.') { + i++; + i = _skipTrivia(trimmed, i); + final second = _readIdent(trimmed, i); + if (second == null) return null; + schema = first.name; + table = second.name; + i = second.next; + } else { + table = first.name; + } + + i = _skipTrivia(trimmed, i); + if (i < trimmed.length) { + final asKw = _readBareWord(trimmed, i); + if (asKw != null && asKw.word.toLowerCase() == 'as') { + i = _skipTrivia(trimmed, asKw.next); + final alias = _readIdent(trimmed, i); + if (alias == null) return null; + i = alias.next; + } else if (asKw != null && + !_fromFollowers.contains(asKw.word.toLowerCase()) && + !_joinStarters.contains(asKw.word.toLowerCase())) { + i = asKw.next; + } + } + + i = _skipTrivia(trimmed, i); + if (i < trimmed.length && trimmed[i] == ',') return null; - // If query has JOIN or multiple tables separated by comma, avoid auto-generating DML - final hasJoin = RegExp(r'\bjoin\b', caseSensitive: false).hasMatch(trimmed); - if (hasJoin) return null; + if (table.isEmpty) return null; + return SqlTableTarget(tableName: table, schema: schema); + } +} - final match = _fromTableRegex.firstMatch(trimmed); - if (match == null) return null; +bool _startsWithKeyword(String sql, String keyword) { + final i = _skipTrivia(sql, 0); + if (i >= sql.length || !_isIdentStart(sql[i])) return false; + final word = _readBareWord(sql, i); + return word != null && word.word.toLowerCase() == keyword.toLowerCase(); +} - final schema = match.group(1) ?? match.group(2) ?? match.group(3); - final table = match.group(4) ?? match.group(5) ?? match.group(6); +/// Whether SQL-grid Save may run for this result set. +/// +/// Requires a simple single-table SELECT, a non-empty primary key, and every +/// PK column present in [resultColumns] (so WHERE can address the row). +bool sqlResultGridSaveEnabled({ + required String? sql, + required List resultColumns, + required List primaryKeys, +}) { + if (sql == null || SqlTableTargetExtractor.extract(sql) == null) { + return false; + } + if (primaryKeys.isEmpty) return false; + final cols = resultColumns.toSet(); + return primaryKeys.every(cols.contains); +} - if (table == null || table.isEmpty) return null; +int _indexOfTopLevelKeyword(String sql, String keyword) { + final want = keyword.toLowerCase(); + var i = 0; + var depth = 0; + while (i < sql.length) { + i = _skipTrivia(sql, i); + if (i >= sql.length) break; + final c = sql[i]; + if (c == "'" || c == '"' || c == '`') { + final end = _skipQuoted(sql, i, c); + if (end < 0) return -1; + i = end; + continue; + } + if (c == '(') { + depth++; + i++; + continue; + } + if (c == ')') { + if (depth > 0) depth--; + i++; + continue; + } + if (depth == 0 && _isIdentStart(c)) { + final start = i; + i++; + while (i < sql.length && _isIdentPart(sql[i])) { + i++; + } + if (sql.substring(start, i).toLowerCase() == want) { + final beforeOk = start == 0 || !_isIdentPart(sql[start - 1]); + final afterOk = i >= sql.length || !_isIdentPart(sql[i]); + if (beforeOk && afterOk) return start; + } + continue; + } + i++; + } + return -1; +} - return SqlTableTarget( - tableName: table, - schema: schema, - ); +int _skipTrivia(String sql, int i) { + while (i < sql.length) { + final c = sql[i]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + i++; + continue; + } + if (c == '-' && i + 1 < sql.length && sql[i + 1] == '-') { + i += 2; + while (i < sql.length && sql[i] != '\n') { + i++; + } + continue; + } + if (c == '/' && i + 1 < sql.length && sql[i + 1] == '*') { + final end = sql.indexOf('*/', i + 2); + i = end < 0 ? sql.length : end + 2; + continue; + } + break; } + return i; +} + +({String name, int next})? _readIdent(String sql, int i) { + if (i >= sql.length) return null; + final c = sql[i]; + if (c == '"' || c == '`' || c == '[') { + final close = c == '[' ? ']' : c; + final end = _skipQuoted(sql, i, close); + if (end < 0) return null; + return (name: sql.substring(i + 1, end - 1), next: end); + } + final word = _readBareWord(sql, i); + if (word == null) return null; + return (name: word.word, next: word.next); +} + +({String word, int next})? _readBareWord(String sql, int i) { + if (i >= sql.length || !_isIdentStart(sql[i])) return null; + final start = i; + i++; + while (i < sql.length && _isIdentPart(sql[i])) { + i++; + } + return (word: sql.substring(start, i), next: i); +} + +int _skipQuoted(String sql, int i, String quote) { + final open = sql[i]; + i++; + while (i < sql.length) { + if (sql[i] == quote) { + if (quote != ']' && i + 1 < sql.length && sql[i + 1] == quote) { + i += 2; + continue; + } + return i + 1; + } + if (open == '[' && sql[i] == ']') return i + 1; + i++; + } + return -1; +} + +bool _isIdentStart(String c) { + final u = c.codeUnitAt(0); + return (u >= 65 && u <= 90) || (u >= 97 && u <= 122) || u == 95; +} + +bool _isIdentPart(String c) { + final u = c.codeUnitAt(0); + return _isIdentStart(c) || (u >= 48 && u <= 57); } diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 7bb600aa..cddbe194 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -425,6 +425,8 @@ class _PostgresSqlWorkspaceState extends material.State { session.rows = []; session.affectedRows = null; session.statusLine = null; + session.resultGridPrimaryKeys = const []; + session.resultGridColumnDataTypes = null; }); QueryaShellStatus.instance.beginBusy(message: 'Running query…'); final sw = Stopwatch()..start(); @@ -473,16 +475,39 @@ class _PostgresSqlWorkspaceState extends material.State { n++; } - // Adaptive convert offloads to background compute for large row sets (#522). final outRows = await convertResultRowsToStringsAdaptive(rawRows); + final target = SqlTableTargetExtractor.extract(userSql); + var pks = const []; + Map? types; + if (target != null && cols.isNotEmpty) { + try { + final meta = await conn.getTableSchema( + schema: target.schema ?? 'public', + table: target.tableName, + ); + pks = List.from(meta.primaryKeys); + types = columnDataTypesFromSchema(meta); + } catch (_) { + pks = const []; + types = null; + } + } + final canSave = sqlResultGridSaveEnabled( + sql: userSql, + resultColumns: cols, + primaryKeys: pks, + ); + setState(() { session.columns = cols; session.rows = outRows; session.affectedRows = result.affectedRows; session.lastExecutedSql = userSql; + session.resultGridPrimaryKeys = canSave ? pks : const []; + session.resultGridColumnDataTypes = types; session.stagingBuffer?.dispose(); - session.stagingBuffer = cols.isNotEmpty + session.stagingBuffer = canSave ? DataGridStagingBuffer(columns: cols, rows: outRows) : null; if (cols.isEmpty && outRows.isEmpty) { @@ -554,15 +579,23 @@ class _PostgresSqlWorkspaceState extends material.State { final target = session.lastExecutedSql != null ? SqlTableTargetExtractor.extract(session.lastExecutedSql!) : null; - final tableName = target?.tableName ?? 'table'; - final schemaName = target?.schema; + if (target == null || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + )) { + return; + } setState(() => session.savingChanges = true); try { final plan = session.stagingBuffer!.generateMutationPlan( dialect: SqlDialect.postgres, - tableName: tableName, - schema: schemaName, + tableName: target.tableName, + schema: target.schema ?? 'public', + primaryKeys: session.resultGridPrimaryKeys, + columnDataTypes: session.resultGridColumnDataTypes, ); if (plan.isEmpty) { setState(() => session.savingChanges = false); @@ -914,8 +947,15 @@ class _PostgresSqlWorkspaceState extends material.State { affectedRows: session.affectedRows, statusLine: session.statusLine, stagingBuffer: session.stagingBuffer, - onApplyChanges: - widget.isReadOnly ? null : () => _applyStagedChanges(session), + columnDataTypes: session.resultGridColumnDataTypes, + onApplyChanges: widget.isReadOnly || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + ) + ? null + : () => _applyStagedChanges(session), isSaving: session.savingChanges, ), ), diff --git a/lib/features/workspace/sql_query_tab_session.dart b/lib/features/workspace/sql_query_tab_session.dart index ef8f15f0..53c9f97b 100644 --- a/lib/features/workspace/sql_query_tab_session.dart +++ b/lib/features/workspace/sql_query_tab_session.dart @@ -39,6 +39,12 @@ class SqlQueryTabSession { bool savingChanges = false; bool isModified = false; + /// PK columns for SQL-grid Save, empty when Save is disabled. + List resultGridPrimaryKeys = const []; + + /// Column types from [getTableSchema] for DML literals, if resolved. + Map? resultGridColumnDataTypes; + void formatSql() { final next = formatSqlScript(controller.text); controller.value = material.TextEditingValue( diff --git a/test/core/database/sql_table_target_extractor_test.dart b/test/core/database/sql_table_target_extractor_test.dart index 8bf3a4f7..cf016bcb 100644 --- a/test/core/database/sql_table_target_extractor_test.dart +++ b/test/core/database/sql_table_target_extractor_test.dart @@ -1,5 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; void main() { group('SqlTableTargetExtractor', () { @@ -11,27 +13,161 @@ void main() { }); test('extracts schema and table from quoted identifiers', () { - final target = SqlTableTargetExtractor.extract('SELECT id, name FROM "public"."accounts" WHERE id > 10'); + final target = SqlTableTargetExtractor.extract( + 'SELECT id, name FROM "public"."accounts" WHERE id > 10'); expect(target, isNotNull); expect(target!.tableName, 'accounts'); expect(target.schema, 'public'); }); test('extracts schema and table from MySQL backticks', () { - final target = SqlTableTargetExtractor.extract('SELECT * FROM `shop_db`.`orders` ORDER BY date DESC'); + final target = SqlTableTargetExtractor.extract( + 'SELECT * FROM `shop_db`.`orders` ORDER BY date DESC'); expect(target, isNotNull); expect(target!.tableName, 'orders'); expect(target.schema, 'shop_db'); }); - test('returns null for queries with JOINs to avoid ambiguous mutations', () { - final target = SqlTableTargetExtractor.extract('SELECT u.id, o.amount FROM users u JOIN orders o ON u.id = o.user_id'); + test('extracts public.t from a simple qualified FROM', () { + final target = SqlTableTargetExtractor.extract('SELECT * FROM public.t'); + expect(target, const SqlTableTarget(tableName: 't', schema: 'public')); + }); + + test('returns null for queries with JOINs to avoid ambiguous mutations', + () { + final target = SqlTableTargetExtractor.extract( + 'SELECT u.id, o.amount FROM users u JOIN orders o ON u.id = o.user_id'); expect(target, isNull); }); + test('returns null for comma FROM', () { + expect( + SqlTableTargetExtractor.extract('SELECT * FROM a, b'), + isNull, + ); + expect( + SqlTableTargetExtractor.extract( + 'SELECT * FROM users u, orders o WHERE u.id = o.user_id'), + isNull, + ); + }); + + test('returns null for subquery FROM', () { + expect( + SqlTableTargetExtractor.extract( + 'SELECT * FROM (SELECT * FROM users) x'), + isNull, + ); + }); + + test('returns null for VALUES', () { + expect(SqlTableTargetExtractor.extract('VALUES (1, 2)'), isNull); + expect( + SqlTableTargetExtractor.extract('SELECT * FROM (VALUES (1)) v'), + isNull, + ); + }); + test('returns null for empty or non-FROM queries', () { expect(SqlTableTargetExtractor.extract(''), isNull); expect(SqlTableTargetExtractor.extract('SELECT 1 + 1'), isNull); }); + + test('ignores FROM inside extract() and string literals', () { + final target = SqlTableTargetExtractor.extract( + "SELECT extract(epoch FROM created_at), name FROM events WHERE name = 'join'", + ); + expect(target, const SqlTableTarget(tableName: 'events')); + }); + }); + + group('sqlResultGridSaveEnabled', () { + test('true for simple SELECT when PK columns are in the result', () { + expect( + sqlResultGridSaveEnabled( + sql: 'SELECT * FROM public.t', + resultColumns: ['id', 'name'], + primaryKeys: ['id'], + ), + isTrue, + ); + }); + + test('false for JOIN and comma FROM', () { + expect( + sqlResultGridSaveEnabled( + sql: 'SELECT * FROM a JOIN b ON a.id = b.id', + resultColumns: ['id'], + primaryKeys: ['id'], + ), + isFalse, + ); + expect( + sqlResultGridSaveEnabled( + sql: 'SELECT * FROM a, b', + resultColumns: ['id'], + primaryKeys: ['id'], + ), + isFalse, + ); + }); + + test('false when PK is missing from the result or empty', () { + expect( + sqlResultGridSaveEnabled( + sql: 'SELECT name FROM public.t', + resultColumns: ['name'], + primaryKeys: ['id'], + ), + isFalse, + ); + expect( + sqlResultGridSaveEnabled( + sql: 'SELECT * FROM public.t', + resultColumns: ['id', 'name'], + primaryKeys: [], + ), + isFalse, + ); + }); + }); + + group('SQL-grid DML from extracted target', () { + test('JOIN and comma FROM do not produce DML', () { + for (final sql in [ + 'SELECT * FROM a JOIN b ON a.id = b.id', + 'SELECT * FROM a, b', + ]) { + expect(SqlTableTargetExtractor.extract(sql), isNull); + } + }); + + test('simple SELECT * FROM public.t uses the PK and column types', () { + final target = SqlTableTargetExtractor.extract('SELECT * FROM public.t'); + expect(target, const SqlTableTarget(tableName: 't', schema: 'public')); + + final buffer = DataGridStagingBuffer( + columns: const ['id', 'zip'], + rows: const [ + ['1', '01234'], + ], + ); + buffer.setCell(0, 1, '00789'); + + final plan = buffer.generateMutationPlan( + dialect: SqlDialect.postgres, + tableName: target!.tableName, + schema: target.schema, + primaryKeys: const ['id'], + columnDataTypes: const {'id': 'integer', 'zip': 'text'}, + ); + + expect(plan.statementCount, 1); + expect( + plan.statements.single.sql, + 'UPDATE "public"."t" SET "zip" = \'00789\' WHERE "id" = 1', + ); + expect(plan.statements.single.sql, isNot(contains('"zip" = \'01234\''))); + }); }); } From 743523f2dafb6610bca984913f1e6fcb3ffbae54 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 13:13:17 +0300 Subject: [PATCH 16/52] fix(sqlite): SQL-grid Save uses PK, types, and a transaction JOIN and comma-FROM results stay read-only. A mid-save error rolls back instead of leaving a partial write. --- CHANGELOG.md | 1 + lib/features/sqlite/sqlite_sql_workspace.dart | 52 ++++++++++++++++--- .../sql_table_target_extractor_test.dart | 28 ++++++++++ .../core/database/sqlite_connection_test.dart | 30 +++++++++++ 4 files changed, 104 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc883595..d01c2ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **SQLite SQL-grid Save (#795)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK in the result (`getTableSchema`). JOIN, comma-`FROM`, and no-PK results stay read-only. DML uses the PK and `columnDataTypes`; applies still wrap `BEGIN TRANSACTION` / `COMMIT` / `ROLLBACK` (join an already-open `BEGIN`). - **Postgres SQL-grid Save (#786)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK present in the result (`getTableSchema`). JOIN, comma-`FROM`, subqueries, and VALUES stay read-only. DML `WHERE` uses the PK and `columnDataTypes` instead of every displayed column. - **Redis Overview/Explorer sockets (#812)** — Overview INFO and Explorer SCAN/GET/`SELECT` use separate pooled sockets (`stats` vs `explorer`) so opening a DB no longer kills keep-alive Overview, and Home Refresh no longer steals Explorer. User Disconnect closes both roles. - **MySQL Table Browser session (#802)** — Browse uses a read-only pool slot; Save uses a dedicated `tableWrite` socket so SQL `START TRANSACTION` / `SET` / `USE` do not leak into the grid. SQL-grid Save joins an already-open transaction instead of nesting `START TRANSACTION`. Opening a table on the same database (and Overview↔SQL) warns while SQL has an open transaction. diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 4130c575..61df5f13 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -271,6 +271,8 @@ class _SqliteSqlWorkspaceState extends material.State { session.rows = []; session.affectedRows = null; session.statusLine = null; + session.resultGridPrimaryKeys = const []; + session.resultGridColumnDataTypes = null; }); QueryaShellStatus.instance.beginBusy(message: 'Running query…'); final sw = Stopwatch()..start(); @@ -310,16 +312,36 @@ class _SqliteSqlWorkspaceState extends material.State { return cols.map((col) => row[col]).toList(); }).toList(); - // Adaptive convert offloads to background compute for large row sets (#522). final outRows = await convertResultRowsToStringsAdaptive(rawRows); + final target = SqlTableTargetExtractor.extract(userSql); + var pks = const []; + Map? types; + if (target != null && cols.isNotEmpty) { + try { + final meta = await conn.getTableSchema(table: target.tableName); + pks = List.from(meta.primaryKeys); + types = columnDataTypesFromSchema(meta); + } catch (_) { + pks = const []; + types = null; + } + } + final canSave = sqlResultGridSaveEnabled( + sql: userSql, + resultColumns: cols, + primaryKeys: pks, + ); + setState(() { session.columns = cols; session.rows = outRows; session.affectedRows = null; session.lastExecutedSql = userSql; + session.resultGridPrimaryKeys = canSave ? pks : const []; + session.resultGridColumnDataTypes = types; session.stagingBuffer?.dispose(); - session.stagingBuffer = cols.isNotEmpty + session.stagingBuffer = canSave ? DataGridStagingBuffer(columns: cols, rows: outRows) : null; if (cols.isEmpty && outRows.isEmpty) { @@ -383,14 +405,23 @@ class _SqliteSqlWorkspaceState extends material.State { final target = session.lastExecutedSql != null ? SqlTableTargetExtractor.extract(session.lastExecutedSql!) : null; - final tableName = target?.tableName ?? 'table'; + if (target == null || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + )) { + return; + } setState(() => session.savingChanges = true); try { final plan = session.stagingBuffer!.generateMutationPlan( dialect: SqlDialect.sqlite, - tableName: tableName, - schema: target?.schema, + tableName: target.tableName, + schema: target.schema, + primaryKeys: session.resultGridPrimaryKeys, + columnDataTypes: session.resultGridColumnDataTypes, ); if (plan.isEmpty) { setState(() => session.savingChanges = false); @@ -734,8 +765,15 @@ class _SqliteSqlWorkspaceState extends material.State { affectedRows: session.affectedRows, statusLine: session.statusLine, stagingBuffer: session.stagingBuffer, - onApplyChanges: - widget.isReadOnly ? null : () => _applyStagedChanges(session), + columnDataTypes: session.resultGridColumnDataTypes, + onApplyChanges: widget.isReadOnly || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + ) + ? null + : () => _applyStagedChanges(session), isSaving: session.savingChanges, ), ), diff --git a/test/core/database/sql_table_target_extractor_test.dart b/test/core/database/sql_table_target_extractor_test.dart index cf016bcb..50f1bab3 100644 --- a/test/core/database/sql_table_target_extractor_test.dart +++ b/test/core/database/sql_table_target_extractor_test.dart @@ -169,5 +169,33 @@ void main() { ); expect(plan.statements.single.sql, isNot(contains('"zip" = \'01234\''))); }); + + test('simple SELECT * FROM t uses the SQLite PK and column types', () { + final target = SqlTableTargetExtractor.extract('SELECT * FROM t'); + expect(target, const SqlTableTarget(tableName: 't')); + + final buffer = DataGridStagingBuffer( + columns: const ['id', 'zip'], + rows: const [ + ['1', '01234'], + ], + ); + buffer.setCell(0, 1, '00789'); + + final plan = buffer.generateMutationPlan( + dialect: SqlDialect.sqlite, + tableName: target!.tableName, + primaryKeys: const ['id'], + columnDataTypes: const {'id': 'INTEGER', 'zip': 'TEXT'}, + ); + + expect(plan.statementCount, 1); + expect( + plan.statements.single.sql, + 'UPDATE "t" SET "zip" = \'00789\' WHERE "id" = 1', + ); + expect(plan.toTransactionSql(), startsWith('BEGIN TRANSACTION;')); + expect(plan.statements.single.sql, isNot(contains('"zip" = \'01234\''))); + }); }); } diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index 08b6bb97..640b7253 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/database/sqlite_connection.dart'; +import 'package:querya_desktop/features/workspace/table_view_staging.dart'; void main() { setUpAll(() { @@ -200,6 +201,35 @@ void main() { expect(rows2.first['c'], 2); }); + test('runInTransaction rolls back when a later statement fails', () async { + await conn.connect(); + await conn.execute( + 'CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL)', + ); + await conn.execute("INSERT INTO t (id, name) VALUES (1, 'keep')"); + + await expectLater( + conn.runInTransaction(() async { + expectDmlMatchedRows( + await conn.executeAffected( + "UPDATE t SET name = 'changed' WHERE id = 1", + ), + ); + expectDmlMatchedRows( + await conn.executeAffected( + "UPDATE t SET name = 'ghost' WHERE id = 999", + ), + ); + }), + throwsA(isA()), + ); + + expect(await conn.inOpenTransaction(), isFalse); + final rows = await conn.execute('SELECT id, name FROM t'); + expect(rows, hasLength(1)); + expect(rows.first['name'], 'keep'); + }); + test('handles quotes in quoteIdentifier helper', () { expect( SqliteConnection.quoteIdentifier('normal_table'), '"normal_table"'); From 8e330e180dca3f0b98a0fb7ccd3df0d395726c2e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 13:15:17 +0300 Subject: [PATCH 17/52] fix(mysql): SQL-grid Save uses PK, types, and a transaction JOIN and comma-FROM results stay read-only instead of matching every displayed column. Applies still wrap START TRANSACTION / ROLLBACK. --- CHANGELOG.md | 1 + lib/features/mysql/mysql_sql_workspace.dart | 58 +++++++++++++++++-- .../sql_table_target_extractor_test.dart | 30 ++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d01c2ca4..9d8a0748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MySQL SQL-grid Save (#804)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK in the result (`getTableSchema`). JOIN, comma-`FROM`, and no-PK results stay read-only. DML uses the PK and `columnDataTypes`; applies still wrap `START TRANSACTION` / `COMMIT` / `ROLLBACK` (join an already-open SQL transaction). - **SQLite SQL-grid Save (#795)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK in the result (`getTableSchema`). JOIN, comma-`FROM`, and no-PK results stay read-only. DML uses the PK and `columnDataTypes`; applies still wrap `BEGIN TRANSACTION` / `COMMIT` / `ROLLBACK` (join an already-open `BEGIN`). - **Postgres SQL-grid Save (#786)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK present in the result (`getTableSchema`). JOIN, comma-`FROM`, subqueries, and VALUES stay read-only. DML `WHERE` uses the PK and `columnDataTypes` instead of every displayed column. - **Redis Overview/Explorer sockets (#812)** — Overview INFO and Explorer SCAN/GET/`SELECT` use separate pooled sockets (`stats` vs `explorer`) so opening a DB no longer kills keep-alive Overview, and Home Refresh no longer steals Explorer. User Disconnect closes both roles. diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index e7b194c9..67d1f51b 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -296,6 +296,8 @@ class _MysqlSqlWorkspaceState extends material.State { session.rows = []; session.affectedRows = null; session.statusLine = null; + session.resultGridPrimaryKeys = const []; + session.resultGridColumnDataTypes = null; }); QueryaShellStatus.instance.beginBusy(message: 'Running query…'); final sw = Stopwatch()..start(); @@ -354,13 +356,38 @@ class _MysqlSqlWorkspaceState extends material.State { affected = _affectedInt(rs.affectedRows); } + final target = SqlTableTargetExtractor.extract(userSql); + var pks = const []; + Map? types; + final schemaName = target?.schema ?? _poolDatabaseKey(); + if (target != null && cols.isNotEmpty && schemaName.isNotEmpty) { + try { + final meta = await conn.getTableSchema( + database: schemaName, + table: target.tableName, + ); + pks = List.from(meta.primaryKeys); + types = columnDataTypesFromSchema(meta); + } catch (_) { + pks = const []; + types = null; + } + } + final canSave = sqlResultGridSaveEnabled( + sql: userSql, + resultColumns: cols, + primaryKeys: pks, + ); + setState(() { session.columns = cols; session.rows = outRows; session.affectedRows = affected; session.lastExecutedSql = userSql; + session.resultGridPrimaryKeys = canSave ? pks : const []; + session.resultGridColumnDataTypes = types; session.stagingBuffer?.dispose(); - session.stagingBuffer = cols.isNotEmpty + session.stagingBuffer = canSave ? DataGridStagingBuffer(columns: cols, rows: outRows) : null; if (cols.isEmpty && outRows.isEmpty) { @@ -424,15 +451,27 @@ class _MysqlSqlWorkspaceState extends material.State { final target = session.lastExecutedSql != null ? SqlTableTargetExtractor.extract(session.lastExecutedSql!) : null; - final tableName = target?.tableName ?? 'table'; - final schemaName = target?.schema; + if (target == null || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + )) { + return; + } + final schemaName = target.schema ?? + (widget.connectionRow.databaseName?.trim().isNotEmpty == true + ? widget.connectionRow.databaseName!.trim() + : null); setState(() => session.savingChanges = true); try { final plan = session.stagingBuffer!.generateMutationPlan( dialect: SqlDialect.mysql, - tableName: tableName, + tableName: target.tableName, schema: schemaName, + primaryKeys: session.resultGridPrimaryKeys, + columnDataTypes: session.resultGridColumnDataTypes, ); if (plan.isEmpty) { setState(() => session.savingChanges = false); @@ -798,8 +837,15 @@ class _MysqlSqlWorkspaceState extends material.State { affectedRows: session.affectedRows, statusLine: session.statusLine, stagingBuffer: session.stagingBuffer, - onApplyChanges: - widget.isReadOnly ? null : () => _applyStagedChanges(session), + columnDataTypes: session.resultGridColumnDataTypes, + onApplyChanges: widget.isReadOnly || + !sqlResultGridSaveEnabled( + sql: session.lastExecutedSql, + resultColumns: session.columns, + primaryKeys: session.resultGridPrimaryKeys, + ) + ? null + : () => _applyStagedChanges(session), isSaving: session.savingChanges, ), ), diff --git a/test/core/database/sql_table_target_extractor_test.dart b/test/core/database/sql_table_target_extractor_test.dart index 50f1bab3..378a3bac 100644 --- a/test/core/database/sql_table_target_extractor_test.dart +++ b/test/core/database/sql_table_target_extractor_test.dart @@ -197,5 +197,35 @@ void main() { expect(plan.toTransactionSql(), startsWith('BEGIN TRANSACTION;')); expect(plan.statements.single.sql, isNot(contains('"zip" = \'01234\''))); }); + + test('simple SELECT * FROM db.t uses the MySQL PK and column types', () { + final target = + SqlTableTargetExtractor.extract('SELECT * FROM db.t'); + expect(target, const SqlTableTarget(tableName: 't', schema: 'db')); + + final buffer = DataGridStagingBuffer( + columns: const ['id', 'zip'], + rows: const [ + ['1', '01234'], + ], + ); + buffer.setCell(0, 1, '00789'); + + final plan = buffer.generateMutationPlan( + dialect: SqlDialect.mysql, + tableName: target!.tableName, + schema: target.schema, + primaryKeys: const ['id'], + columnDataTypes: const {'id': 'int', 'zip': 'varchar'}, + ); + + expect(plan.statementCount, 1); + expect( + plan.statements.single.sql, + 'UPDATE `db`.`t` SET `zip` = \'00789\' WHERE `id` = 1', + ); + expect(plan.toTransactionSql(), startsWith('START TRANSACTION;')); + expect(plan.statements.single.sql, isNot(contains('`zip` = \'01234\''))); + }); }); } From c0fda9e5787501b5f839ecfc842293d87f54a382 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 13:18:21 +0300 Subject: [PATCH 18/52] fix(mysql): do not overwrite the editor with START TRANSACTION Begin/Commit/Rollback run on the SQL socket like Postgres and show open/none in the toolbar. Table Browser Save stays on tableWrite. --- CHANGELOG.md | 1 + lib/features/mysql/mysql_sql_tx_guard.dart | 6 ++ lib/features/mysql/mysql_sql_workspace.dart | 64 ++++++++++++++++--- .../mysql/mysql_sql_tx_guard_test.dart | 6 ++ 4 files changed, 68 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8a0748..4e83a485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MySQL SQL tx toolbar (#809)** — Begin / Commit / Rollback run `START TRANSACTION` / `COMMIT` / `ROLLBACK` on the SQL socket without replacing the editor buffer. The toolbar shows Transaction open/none. Table Browser Save stays on the `tableWrite` socket from #802 (no nested `START TRANSACTION`); opening a table still warns while SQL has an open transaction. - **MySQL SQL-grid Save (#804)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK in the result (`getTableSchema`). JOIN, comma-`FROM`, and no-PK results stay read-only. DML uses the PK and `columnDataTypes`; applies still wrap `START TRANSACTION` / `COMMIT` / `ROLLBACK` (join an already-open SQL transaction). - **SQLite SQL-grid Save (#795)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK in the result (`getTableSchema`). JOIN, comma-`FROM`, and no-PK results stay read-only. DML uses the PK and `columnDataTypes`; applies still wrap `BEGIN TRANSACTION` / `COMMIT` / `ROLLBACK` (join an already-open `BEGIN`). - **Postgres SQL-grid Save (#786)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK present in the result (`getTableSchema`). JOIN, comma-`FROM`, subqueries, and VALUES stay read-only. DML `WHERE` uses the PK and `columnDataTypes` instead of every displayed column. diff --git a/lib/features/mysql/mysql_sql_tx_guard.dart b/lib/features/mysql/mysql_sql_tx_guard.dart index 5f24b3ef..8261e9c2 100644 --- a/lib/features/mysql/mysql_sql_tx_guard.dart +++ b/lib/features/mysql/mysql_sql_tx_guard.dart @@ -8,6 +8,12 @@ bool mysqlObjectOpensTableBrowser(MysqlObjectKind kind) { return kind == MysqlObjectKind.table || kind == MysqlObjectKind.view; } +/// SQL-editor toolbar label for the current transaction state. +String mysqlSqlToolbarTxLabel(bool? txOpen) { + if (txOpen == null) return 'Transaction: —'; + return txOpen ? 'Transaction: open' : 'Transaction: none'; +} + /// Confirms leaving the SQL tab (or opening a table) while a transaction is open. Future confirmLeaveOpenMysqlTransaction( material.BuildContext context, diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 67d1f51b..2807430a 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -17,6 +17,7 @@ import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/ui/querya_shell_status.dart'; +import 'package:querya_desktop/features/mysql/mysql_sql_tx_guard.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; @@ -630,12 +631,52 @@ class _MysqlSqlWorkspaceState extends material.State { } } - Future _runTxCommand(String sql) async { - _activeSession.controller.value = material.TextEditingValue( - text: sql, - selection: material.TextSelection.collapsed(offset: sql.length), - ); - await _execute(_activeSession); + Future _runTxCommand(String cmd) async { + final session = _activeSession; + setState(() { + session.running = true; + session.error = null; + }); + try { + await _ensureLease(); + final conn = _lease?.connection; + if (conn == null || !conn.isConnected) { + if (mounted) { + setState(() { + session.error = 'Could not connect to MySQL.'; + session.running = false; + }); + } + return; + } + final to = _statementTimeout(); + await conn.executeWithTimeout(cmd, timeout: to); + if (!mounted) return; + setState(() { + session.columns = []; + session.rows = []; + session.affectedRows = null; + session.statusLine = 'OK: $cmd'; + session.running = false; + }); + } on TimeoutException catch (e) { + unawaited(_lease?.connection.forceClose()); + if (mounted) { + setState(() { + session.error = e.toString(); + session.running = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + session.error = e.toString(); + session.running = false; + }); + } + } finally { + await _refreshTxStatus(); + } } @override @@ -797,12 +838,13 @@ class _MysqlSqlWorkspaceState extends material.State { ); } : null, + txOpen: _txOpen, onBegin: session.running ? null - : () => _runTxCommand('START TRANSACTION;'), - onCommit: session.running ? null : () => _runTxCommand('COMMIT;'), + : () => _runTxCommand('START TRANSACTION'), + onCommit: session.running ? null : () => _runTxCommand('COMMIT'), onRollback: - session.running ? null : () => _runTxCommand('ROLLBACK;'), + session.running ? null : () => _runTxCommand('ROLLBACK'), ), const Divider(height: 1), Expanded( @@ -863,6 +905,7 @@ class _MysqlSqlToolbar extends material.StatelessWidget { required this.onQueryTimeoutChanged, required this.onOpenPreferences, this.onOpenHistory, + required this.txOpen, required this.onBegin, required this.onCommit, required this.onRollback, @@ -874,6 +917,7 @@ class _MysqlSqlToolbar extends material.StatelessWidget { final void Function(int?) onQueryTimeoutChanged; final VoidCallback onOpenPreferences; final VoidCallback? onOpenHistory; + final bool? txOpen; final VoidCallback? onBegin; final VoidCallback? onCommit; final VoidCallback? onRollback; @@ -891,6 +935,8 @@ class _MysqlSqlToolbar extends material.StatelessWidget { material.Row( children: [ const Text('Query').semiBold().small(), + const Gap(12), + Text(mysqlSqlToolbarTxLabel(txOpen)).muted().small(), const Spacer(), OutlineButton( size: ButtonSize.small, diff --git a/test/features/mysql/mysql_sql_tx_guard_test.dart b/test/features/mysql/mysql_sql_tx_guard_test.dart index 615fff6c..7a7b0590 100644 --- a/test/features/mysql/mysql_sql_tx_guard_test.dart +++ b/test/features/mysql/mysql_sql_tx_guard_test.dart @@ -13,6 +13,12 @@ void main() { expect(mysqlObjectOpensTableBrowser(MysqlObjectKind.function), isFalse); }); + test('mysqlSqlToolbarTxLabel matches Postgres wording', () { + expect(mysqlSqlToolbarTxLabel(null), 'Transaction: —'); + expect(mysqlSqlToolbarTxLabel(true), 'Transaction: open'); + expect(mysqlSqlToolbarTxLabel(false), 'Transaction: none'); + }); + testWidgets('Stay keeps the caller on the SQL session', (tester) async { var left = false; await tester.pumpWidget( From 317d6a49cb4f26348d1ac3bd46a196a016492683 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 13:22:26 +0300 Subject: [PATCH 19/52] fix(sqlite): honor form and session read-only on Table Browser A connection marked Read only was still opened read-write for Save. Title-bar lock now reaches SqliteTableView so staging stays off. --- CHANGELOG.md | 1 + lib/core/database/sqlite_service.dart | 12 ++++- lib/features/main_screen/workspace_panel.dart | 1 + lib/features/sqlite/sqlite_table_view.dart | 31 +++++++++++++ .../workspace/table_view_staging.dart | 5 ++- .../core/database/sqlite_connection_test.dart | 45 +++++++++++++++++++ test/features/workspace/results_tab_test.dart | 24 ++++++++++ .../workspace/table_view_staging_test.dart | 19 ++++++++ 8 files changed, 136 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e83a485..8f1073a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **SQLite Table Browser read-only (#793)** — Connection-form Read only (`useSSL`) opens the file with `SQLITE_OPEN_READONLY` even for Save (`tableWrite`). Title-bar session lock is passed into `SqliteTableView`: staging / Save stay off and the grid does not acquire a writable handle. - **MySQL SQL tx toolbar (#809)** — Begin / Commit / Rollback run `START TRANSACTION` / `COMMIT` / `ROLLBACK` on the SQL socket without replacing the editor buffer. The toolbar shows Transaction open/none. Table Browser Save stays on the `tableWrite` socket from #802 (no nested `START TRANSACTION`); opening a table still warns while SQL has an open transaction. - **MySQL SQL-grid Save (#804)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK in the result (`getTableSchema`). JOIN, comma-`FROM`, and no-PK results stay read-only. DML uses the PK and `columnDataTypes`; applies still wrap `START TRANSACTION` / `COMMIT` / `ROLLBACK` (join an already-open SQL transaction). - **SQLite SQL-grid Save (#795)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK in the result (`getTableSchema`). JOIN, comma-`FROM`, and no-PK results stay read-only. DML uses the PK and `columnDataTypes`; applies still wrap `BEGIN TRANSACTION` / `COMMIT` / `ROLLBACK` (join an already-open `BEGIN`). diff --git a/lib/core/database/sqlite_service.dart b/lib/core/database/sqlite_service.dart index a43090c6..ad430701 100644 --- a/lib/core/database/sqlite_service.dart +++ b/lib/core/database/sqlite_service.dart @@ -15,12 +15,22 @@ Future _defaultCreateAndConnect( }) async { final conn = SqliteConnection.fromConnectionRow( row, - readOnly: mode.isReadOnlySession, + readOnly: sqliteOpenReadOnly(row: row, mode: mode), ); await conn.connect(); return conn; } +/// Whether this pool slot must open the file with `SQLITE_OPEN_READONLY`. +/// +/// Form **Read only** (`useSSL`) always opens `SQLITE_OPEN_READONLY`, even +/// for [SqliteSessionMode.tableWrite]. Browse already uses `readOnly`. +bool sqliteOpenReadOnly({ + required ConnectionRow row, + required SqliteSessionMode mode, +}) => + mode.isReadOnlySession || row.useSSL; + /// Global SQLite connection pool service (singleton). class SqliteService { SqliteService._() diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 4157fc87..e92ae37d 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -368,6 +368,7 @@ class _WorkspacePanelState extends State { tableName: sq.name, isView: sq.kind == SqliteObjectKind.view, onNavigateHome: widget.onNavigateHome, + isReadOnly: widget.isReadOnly, ), ); break; diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 1c22be05..3ec24df1 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -22,6 +22,7 @@ class SqliteTableView extends material.StatefulWidget { this.isView = false, this.limit = _defaultLimit, this.onNavigateHome, + this.isReadOnly = false, }); final ConnectionRow connectionRow; @@ -30,6 +31,9 @@ class SqliteTableView extends material.StatefulWidget { final int limit; final VoidCallback? onNavigateHome; + /// Title-bar session lock. Combined with connection-form Read only (`useSSL`). + final bool isReadOnly; + @override material.State createState() => _SqliteTableViewState(); } @@ -56,10 +60,14 @@ class _SqliteTableViewState extends material.State { bool get _isDirty => _stagingBuffer?.isDirty ?? false; + bool get _readOnly => + widget.isReadOnly || widget.connectionRow.useSSL; + bool get _editingEnabled => tableViewEditingEnabled( isView: widget.isView, customSqlActive: false, hasPrimaryKey: _primaryKeys.isNotEmpty, + readOnly: _readOnly, ); String _qualifiedFrom() { @@ -85,6 +93,9 @@ class _SqliteTableViewState extends material.State { _resetStaging(); _disconnectCurrent(); _connectAndLoad(); + } else if (oldWidget.isReadOnly != widget.isReadOnly || + oldWidget.connectionRow.useSSL != widget.connectionRow.useSSL) { + _syncStagingToReadOnly(); } } @@ -105,6 +116,21 @@ class _SqliteTableViewState extends material.State { _isSaving = false; } + void _syncStagingToReadOnly() { + if (_readOnly) { + _stagingBuffer?.dispose(); + _stagingBuffer = null; + } else if (_columnNames.isNotEmpty) { + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: _columnNames, + rows: _rows, + enabled: _editingEnabled, + ); + } + if (mounted) setState(() {}); + } + void _disconnectCurrent({bool interruptIfBusy = false}) { if (interruptIfBusy && _loading) { SqliteService.instance.interrupt( @@ -158,6 +184,9 @@ class _SqliteTableViewState extends material.State { Future _withTableWrite( Future Function(SqliteConnection conn) fn, ) async { + if (_readOnly) { + throw StateError('SQLite connection is read-only'); + } final lease = await SqliteService.instance.acquire( widget.connectionRow, mode: SqliteSessionMode.tableWrite, @@ -318,6 +347,7 @@ class _SqliteTableViewState extends material.State { } Future _applyStagedChanges() async { + if (_readOnly) return; final buffer = _stagingBuffer; if (buffer == null || !buffer.isDirty || _isSaving) return; setState(() => _isSaving = true); @@ -458,6 +488,7 @@ class _SqliteTableViewState extends material.State { customSqlActive: false, hasPrimaryKey: _primaryKeys.isNotEmpty, schemaLoaded: _schemaLoaded, + readOnly: _readOnly, ); final pag = _paginationLabel(); if (pag.isEmpty) return reason; diff --git a/lib/features/workspace/table_view_staging.dart b/lib/features/workspace/table_view_staging.dart index 8c60274f..f4441cb3 100644 --- a/lib/features/workspace/table_view_staging.dart +++ b/lib/features/workspace/table_view_staging.dart @@ -11,8 +11,9 @@ bool tableViewEditingEnabled({ bool isMaterializedView = false, required bool customSqlActive, required bool hasPrimaryKey, + bool readOnly = false, }) { - if (isView || isMaterializedView || customSqlActive) return false; + if (readOnly || isView || isMaterializedView || customSqlActive) return false; return hasPrimaryKey; } @@ -23,7 +24,9 @@ String? tableViewEditDisabledReason({ required bool customSqlActive, required bool hasPrimaryKey, required bool schemaLoaded, + bool readOnly = false, }) { + if (readOnly) return 'Read-only session'; if (isView || isMaterializedView) return 'Views are read-only'; if (customSqlActive) return 'Custom SQL results are read-only'; if (schemaLoaded && !hasPrimaryKey) { diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index 640b7253..4154774d 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/database/sqlite_connection.dart'; +import 'package:querya_desktop/core/database/sqlite_service.dart'; import 'package:querya_desktop/features/workspace/table_view_staging.dart'; void main() { @@ -43,6 +44,50 @@ void main() { }); }); + group('sqliteOpenReadOnly', () { + const rw = ConnectionRow( + id: 1, + type: 'sqlite', + name: 'rw', + host: '/tmp/rw.db', + createdAt: '', + ); + const formRo = ConnectionRow( + id: 2, + type: 'sqlite', + name: 'ro', + host: '/tmp/ro.db', + useSSL: true, + createdAt: '', + ); + + test('form read-only opens SQLITE_OPEN_READONLY even for tableWrite', () { + expect( + sqliteOpenReadOnly(row: formRo, mode: SqliteSessionMode.tableWrite), + isTrue, + ); + expect( + sqliteOpenReadOnly(row: formRo, mode: SqliteSessionMode.readWrite), + isTrue, + ); + }); + + test('writable form uses read-write only for tableWrite/readWrite', () { + expect( + sqliteOpenReadOnly(row: rw, mode: SqliteSessionMode.readOnly), + isTrue, + ); + expect( + sqliteOpenReadOnly(row: rw, mode: SqliteSessionMode.tableWrite), + isFalse, + ); + expect( + sqliteOpenReadOnly(row: rw, mode: SqliteSessionMode.readWrite), + isFalse, + ); + }); + }); + group('SqliteConnection operations (In-Memory)', () { late SqliteConnection conn; diff --git a/test/features/workspace/results_tab_test.dart b/test/features/workspace/results_tab_test.dart index 056eef8d..f7e868e0 100644 --- a/test/features/workspace/results_tab_test.dart +++ b/test/features/workspace/results_tab_test.dart @@ -947,6 +947,30 @@ void main() { expect(appliedChanges, 1); }); + testWidgets('hides Save Changes when staging is disabled (session lock)', + (tester) async { + await tester.pumpWidget( + resultsShell( + child: const material.Scaffold( + body: material.SizedBox( + width: 800, + height: 500, + child: ResultsTab( + columns: ['id', 'name'], + rows: [ + ['1', 'Alice'], + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DataGridStagingToolbar), findsNothing); + expect(find.text('Save Changes'), findsNothing); + }); + testWidgets('memoizes filtered rows across rebuilds when filterText and rows do not change', (tester) async { final rows = [ ['1', 'Alice', 'Engineering'], diff --git a/test/features/workspace/table_view_staging_test.dart b/test/features/workspace/table_view_staging_test.dart index 77fadabf..a17823ed 100644 --- a/test/features/workspace/table_view_staging_test.dart +++ b/test/features/workspace/table_view_staging_test.dart @@ -54,6 +54,15 @@ void main() { ), isFalse, ); + expect( + tableViewEditingEnabled( + isView: false, + customSqlActive: false, + hasPrimaryKey: true, + readOnly: true, + ), + isFalse, + ); }); }); @@ -105,6 +114,16 @@ void main() { ), isNull, ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: true, + schemaLoaded: true, + readOnly: true, + ), + 'Read-only session', + ); expect( tableViewEditDisabledReason( isView: false, From a6c50e5ab72293708eee28839c9a7ce5c578ab2a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 13:29:07 +0300 Subject: [PATCH 20/52] fix(redis): honor title-bar read-only in the key browser The session lock never reached Redis explorer, so DEL/SET/HSET/TTL still ran. Hide those actions and refuse writes on the explorer socket. --- CHANGELOG.md | 1 + lib/core/database/redis_connection.dart | 57 ++- lib/features/main_screen/workspace_panel.dart | 1 + lib/features/redis/redis_explorer_view.dart | 18 + lib/features/redis/redis_key_editor.dart | 364 ++++++++++-------- lib/features/redis/redis_keys_view.dart | 40 +- test/core/database/redis_connection_test.dart | 26 ++ .../features/redis/redis_key_editor_test.dart | 62 +++ test/features/redis/redis_keys_view_test.dart | 63 +++ 9 files changed, 445 insertions(+), 187 deletions(-) create mode 100644 test/features/redis/redis_key_editor_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1073a5..38c3199e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Redis title-bar read-only (#813)** — Session lock is passed into the key browser and editor. Save, DEL, HSET/HDEL, RPUSH, SADD/SREM, ZADD/ZREM, and TTL apply stay hidden; the explorer socket also refuses those writes and sends `READONLY` after AUTH when locked (ignored on standalone / older servers). - **SQLite Table Browser read-only (#793)** — Connection-form Read only (`useSSL`) opens the file with `SQLITE_OPEN_READONLY` even for Save (`tableWrite`). Title-bar session lock is passed into `SqliteTableView`: staging / Save stay off and the grid does not acquire a writable handle. - **MySQL SQL tx toolbar (#809)** — Begin / Commit / Rollback run `START TRANSACTION` / `COMMIT` / `ROLLBACK` on the SQL socket without replacing the editor buffer. The toolbar shows Transaction open/none. Table Browser Save stays on the `tableWrite` socket from #802 (no nested `START TRANSACTION`); opening a table still warns while SQL has an open transaction. - **MySQL SQL-grid Save (#804)** — Result-grid Save runs only for a simple single-table `SELECT` with a PK in the result (`getTableSchema`). JOIN, comma-`FROM`, and no-PK results stay read-only. DML uses the PK and `columnDataTypes`; applies still wrap `START TRANSACTION` / `COMMIT` / `ROLLBACK` (join an already-open SQL transaction). diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index 007cfa7d..cc2b7189 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -90,7 +90,8 @@ class RedisConnection { var effectiveConnectionString = _connectionString; if ((effectivePassword == null || effectivePassword.isEmpty) && - (effectiveConnectionString == null || effectiveConnectionString.isEmpty) && + (effectiveConnectionString == null || + effectiveConnectionString.isEmpty) && id > 0) { try { final secrets = await ConnectionSecretsStore.readForConnection(id); @@ -116,7 +117,8 @@ class RedisConnection { } if (effectivePassword != null && effectivePassword.isNotEmpty) { if (username != null && username!.trim().isNotEmpty) { - await _command!.send_object(['AUTH', username!.trim(), effectivePassword]); + await _command! + .send_object(['AUTH', username!.trim(), effectivePassword]); } else { await _command!.send_object(['AUTH', effectivePassword]); } @@ -131,6 +133,13 @@ class RedisConnection { throw RedisConnectionException('PING failed'); } _isConnected = true; + if (_clientReadOnly) { + try { + await _command!.send_object(['READONLY']); + } catch (_) { + // Standalone / older servers: READONLY is cluster-replica only. + } + } scrubCredentials(); } @@ -175,6 +184,28 @@ class RedisConnection { // ─── Data commands ───────────────────────────────────────────────────── + bool _clientReadOnly = false; + + /// Title-bar / session lock. Write helpers throw; [connect] may send + /// `READONLY` (Redis Cluster replica; ignored or missing on standalone). + bool get clientReadOnly => _clientReadOnly; + + /// Sets the local write lock and, when already connected, sends + /// `READONLY` / `READWRITE`. Errors from older servers are ignored. + Future applyClientReadOnly(bool readOnly) async { + _clientReadOnly = readOnly; + if (!isConnected) return; + try { + await sendCommand([readOnly ? 'READONLY' : 'READWRITE']); + } catch (_) {} + } + + void _assertWritable() { + if (_clientReadOnly) { + throw StateError('Redis connection is read-only'); + } + } + /// Raw command helper. Future sendCommand(List args) async { if (!isConnected || _command == null) { @@ -273,8 +304,7 @@ class RedisConnection { final types = await Future.wait(typeFutures); final ttls = await Future.wait(ttlFutures); return [ - for (var i = 0; i < keys.length; i++) - (type: types[i], ttl: ttls[i]), + for (var i = 0; i < keys.length; i++) (type: types[i], ttl: ttls[i]), ]; } finally { cmd?.pipe_end(); @@ -299,6 +329,7 @@ class RedisConnection { int? ttlSeconds, bool keepTtl = true, }) async { + _assertWritable(); if (ttlSeconds != null && ttlSeconds > 0) { await sendCommand(['SET', key, value, 'EX', ttlSeconds]); return; @@ -347,11 +378,13 @@ class RedisConnection { /// HSET key field value. Future hset(String key, String field, String value) async { + _assertWritable(); await sendCommand(['HSET', key, field, value]); } /// HDEL key field. Future hdel(String key, String field) async { + _assertWritable(); await sendCommand(['HDEL', key, field]); } @@ -372,6 +405,7 @@ class RedisConnection { /// RPUSH key value. Future rpush(String key, String value) async { + _assertWritable(); await sendCommand(['RPUSH', key, value]); } @@ -392,11 +426,13 @@ class RedisConnection { /// SADD key member. Future sadd(String key, String member) async { + _assertWritable(); await sendCommand(['SADD', key, member]); } /// SREM key member. Future srem(String key, String member) async { + _assertWritable(); await sendCommand(['SREM', key, member]); } @@ -424,32 +460,38 @@ class RedisConnection { /// ZADD key score member. Future zadd(String key, double score, String member) async { + _assertWritable(); await sendCommand(['ZADD', key, score, member]); } /// ZREM key member. Future zrem(String key, String member) async { + _assertWritable(); await sendCommand(['ZREM', key, member]); } /// DEL key. Future del(String key) async { + _assertWritable(); final result = await sendCommand(['DEL', key]); return result is int ? result : int.tryParse(result.toString()) ?? 0; } /// RENAME old new. Future rename(String oldKey, String newKey) async { + _assertWritable(); await sendCommand(['RENAME', oldKey, newKey]); } /// EXPIRE key seconds. Future expire(String key, int seconds) async { + _assertWritable(); await sendCommand(['EXPIRE', key, seconds]); } /// PERSIST key (remove TTL). Future persist(String key) async { + _assertWritable(); await sendCommand(['PERSIST', key]); } @@ -481,6 +523,7 @@ class RedisConnectionTestFake extends RedisConnection { this.firstScanKeys = const ['alpha', 'beta'], this.secondScanKeys = const [], this.dbSizeResult = 2, + this.getResult, }) : super( id: -1, name: 'test-fake', @@ -491,6 +534,7 @@ class RedisConnectionTestFake extends RedisConnection { final List firstScanKeys; final List secondScanKeys; final int dbSizeResult; + final String? getResult; bool _firstScanDone = false; @@ -543,6 +587,11 @@ class RedisConnectionTestFake extends RedisConnection { return 'string'; case 'TTL': return -1; + case 'GET': + return getResult; + case 'READONLY': + case 'READWRITE': + return 'OK'; default: return null; } diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index e92ae37d..d31ee98e 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -343,6 +343,7 @@ class _WorkspacePanelState extends State { key: ValueKey('redis_${activeConn.id}_db_$redisDb'), connectionRow: activeConn, database: redisDb, + isReadOnly: widget.isReadOnly, ), ); break; diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index 18f084bc..11cddcfe 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -32,10 +32,12 @@ class RedisExplorerView extends material.StatefulWidget { super.key, required this.connectionRow, required this.database, + this.isReadOnly = false, }); final ConnectionRow connectionRow; final int database; + final bool isReadOnly; @override material.State createState() => _RedisExplorerViewState(); @@ -67,6 +69,8 @@ class _RedisExplorerViewState extends material.State { oldWidget.database != widget.database) { _disconnectCurrent(); _connect(); + } else if (oldWidget.isReadOnly != widget.isReadOnly) { + unawaited(_connection?.applyClientReadOnly(widget.isReadOnly)); } } @@ -103,6 +107,7 @@ class _RedisExplorerViewState extends material.State { widget.connectionRow, role: RedisSessionRole.explorer, ); + await conn.applyClientReadOnly(widget.isReadOnly); if (!conn.isConnected) { await conn.connect(); } @@ -237,6 +242,7 @@ class _RedisExplorerViewState extends material.State { onCrumbTap: _onCrumbTap, onRefresh: () => setState(() => _refreshEpoch++), onStats: () => setState(() => _showStats = !_showStats), + isReadOnly: widget.isReadOnly, ), const Divider(height: 1), // Content with fluid cross-fade morph between keys and stats @@ -271,6 +277,7 @@ class _RedisExplorerViewState extends material.State { keyType: _selectedKeyType ?? 'string', onBack: _navigateToKeys, onKeyDeleted: _navigateToKeys, + isReadOnly: widget.isReadOnly, ); } @@ -280,6 +287,7 @@ class _RedisExplorerViewState extends material.State { connection: conn, database: widget.database, onKeyTap: _navigateToKey, + isReadOnly: widget.isReadOnly, ); } } @@ -292,12 +300,14 @@ class _BreadcrumbBar extends StatelessWidget { required this.onCrumbTap, required this.onRefresh, required this.onStats, + this.isReadOnly = false, }); final List<_Crumb> crumbs; final void Function(_Crumb) onCrumbTap; final VoidCallback onRefresh; final VoidCallback onStats; + final bool isReadOnly; @override material.Widget build(material.BuildContext context) { @@ -312,6 +322,14 @@ class _BreadcrumbBar extends StatelessWidget { children: [ material.Icon(material.Icons.memory_rounded, size: 18, color: cs.primary), + if (isReadOnly) ...[ + const Gap(6), + material.Icon( + material.Icons.lock_outline_rounded, + size: 14, + color: cs.mutedForeground, + ), + ], const Gap(10), material.Expanded( child: material.SingleChildScrollView( diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index 1fe4b8f7..d94ea83f 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -14,6 +14,7 @@ class RedisKeyEditor extends material.StatefulWidget { required this.keyType, this.onBack, this.onKeyDeleted, + this.isReadOnly = false, }); final RedisConnection connection; @@ -22,6 +23,7 @@ class RedisKeyEditor extends material.StatefulWidget { final String keyType; final VoidCallback? onBack; final VoidCallback? onKeyDeleted; + final bool isReadOnly; @override material.State createState() => _RedisKeyEditorState(); @@ -110,6 +112,7 @@ class _RedisKeyEditorState extends material.State { } Future _saveString() async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.set( @@ -127,6 +130,7 @@ class _RedisKeyEditorState extends material.State { } Future _deleteKey() async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.del(widget.keyName); @@ -138,6 +142,7 @@ class _RedisKeyEditorState extends material.State { } Future _setTtl(int seconds) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); if (seconds > 0) { @@ -159,6 +164,7 @@ class _RedisKeyEditorState extends material.State { // Hash operations Future _hashSet(String field, String value) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.hset(widget.keyName, field, value); @@ -170,6 +176,7 @@ class _RedisKeyEditorState extends material.State { } Future _hashDel(String field) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.hdel(widget.keyName, field); @@ -182,6 +189,7 @@ class _RedisKeyEditorState extends material.State { // List operations Future _listPush(String value) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.rpush(widget.keyName, value); @@ -194,6 +202,7 @@ class _RedisKeyEditorState extends material.State { // Set operations Future _setAdd(String member) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.sadd(widget.keyName, member); @@ -205,6 +214,7 @@ class _RedisKeyEditorState extends material.State { } Future _setRemove(String member) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.srem(widget.keyName, member); @@ -217,6 +227,7 @@ class _RedisKeyEditorState extends material.State { // ZSet operations Future _zsetAdd(String member, double score) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.zadd(widget.keyName, score, member); @@ -228,6 +239,7 @@ class _RedisKeyEditorState extends material.State { } Future _zsetRemove(String member) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.zrem(widget.keyName, member); @@ -406,19 +418,19 @@ class _RedisKeyEditorState extends material.State { ), ), const Gap(8), - // TTL button - material.Tooltip( - message: 'Set TTL', - child: material.InkWell( - onTap: () => _showTtlDialog(), - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon(material.Icons.timer_rounded, - size: 16, color: scs.mutedForeground), + if (!widget.isReadOnly) + material.Tooltip( + message: 'Set TTL', + child: material.InkWell( + onTap: () => _showTtlDialog(), + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon(material.Icons.timer_rounded, + size: 16, color: scs.mutedForeground), + ), ), ), - ), const Gap(4), // Refresh material.Tooltip( @@ -433,20 +445,21 @@ class _RedisKeyEditorState extends material.State { ), ), ), - const Gap(4), - // Delete - material.Tooltip( - message: 'Delete key', - child: material.InkWell( - onTap: _deleteKey, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon(material.Icons.delete_rounded, - size: 16, color: context.semanticPalette.destructive), + if (!widget.isReadOnly) ...[ + const Gap(4), + material.Tooltip( + message: 'Delete key', + child: material.InkWell( + onTap: _deleteKey, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon(material.Icons.delete_rounded, + size: 16, color: context.semanticPalette.destructive), + ), ), ), - ), + ], ], ), ); @@ -478,14 +491,16 @@ class _RedisKeyEditorState extends material.State { Row( children: [ const Text('Value').semiBold(), - const Spacer(), - PrimaryButton( - onPressed: _saveString, - size: ButtonSize.small, - leading: - const material.Icon(material.Icons.save_rounded, size: 14), - child: const Text('Save'), - ), + if (!widget.isReadOnly) ...[ + const Spacer(), + PrimaryButton( + onPressed: _saveString, + size: ButtonSize.small, + leading: + const material.Icon(material.Icons.save_rounded, size: 14), + child: const Text('Save'), + ), + ], ], ), const Gap(8), @@ -498,6 +513,7 @@ class _RedisKeyEditorState extends material.State { ), child: material.TextField( controller: _stringController, + readOnly: widget.isReadOnly, maxLines: null, style: const material.TextStyle( fontSize: 13, @@ -521,37 +537,39 @@ class _RedisKeyEditorState extends material.State { crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ Text('Hash fields (${entries.length})').semiBold(), - const Gap(8), - material.Row( - children: [ - material.Expanded( - child: TextField( - controller: _newFieldController, - placeholder: const Text('Field'), + if (!widget.isReadOnly) ...[ + const Gap(8), + material.Row( + children: [ + material.Expanded( + child: TextField( + controller: _newFieldController, + placeholder: const Text('Field'), + ), ), - ), - const Gap(8), - material.Expanded( - child: TextField( - controller: _newValueController, - placeholder: const Text('Value'), + const Gap(8), + material.Expanded( + child: TextField( + controller: _newValueController, + placeholder: const Text('Value'), + ), ), - ), - const Gap(8), - PrimaryButton( - onPressed: () { - final f = _newFieldController.text.trim(); - final v = _newValueController.text; - if (f.isEmpty) return; - _hashSet(f, v); - _newFieldController.clear(); - _newValueController.clear(); - }, - size: ButtonSize.small, - child: const Text('HSET'), - ), - ], - ), + const Gap(8), + PrimaryButton( + onPressed: () { + final f = _newFieldController.text.trim(); + final v = _newValueController.text; + if (f.isEmpty) return; + _hashSet(f, v); + _newFieldController.clear(); + _newValueController.clear(); + }, + size: ButtonSize.small, + child: const Text('HSET'), + ), + ], + ), + ], const Gap(12), material.Expanded( child: entries.isEmpty @@ -564,7 +582,8 @@ class _RedisKeyEditorState extends material.State { return _FieldRow( field: entry.key, value: entry.value, - onDelete: () => _hashDel(entry.key), + onDelete: + widget.isReadOnly ? null : () => _hashDel(entry.key), colorScheme: cs, shadcnCs: scs, ); @@ -582,29 +601,30 @@ class _RedisKeyEditorState extends material.State { crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ Text('List items (${_listValue.length})').semiBold(), - const Gap(8), - // Add item - material.Row( - children: [ - material.Expanded( - child: TextField( - controller: _newValueController, - placeholder: const Text('New item'), + if (!widget.isReadOnly) ...[ + const Gap(8), + material.Row( + children: [ + material.Expanded( + child: TextField( + controller: _newValueController, + placeholder: const Text('New item'), + ), ), - ), - const Gap(8), - PrimaryButton( - onPressed: () { - final v = _newValueController.text; - if (v.isEmpty) return; - _listPush(v); - _newValueController.clear(); - }, - size: ButtonSize.small, - child: const Text('RPUSH'), - ), - ], - ), + const Gap(8), + PrimaryButton( + onPressed: () { + final v = _newValueController.text; + if (v.isEmpty) return; + _listPush(v); + _newValueController.clear(); + }, + size: ButtonSize.small, + child: const Text('RPUSH'), + ), + ], + ), + ], const Gap(12), material.Expanded( child: _listValue.isEmpty @@ -631,29 +651,30 @@ class _RedisKeyEditorState extends material.State { crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ Text('Set members (${_setValue.length})').semiBold(), - const Gap(8), - // Add member - material.Row( - children: [ - material.Expanded( - child: TextField( - controller: _newValueController, - placeholder: const Text('New member'), + if (!widget.isReadOnly) ...[ + const Gap(8), + material.Row( + children: [ + material.Expanded( + child: TextField( + controller: _newValueController, + placeholder: const Text('New member'), + ), ), - ), - const Gap(8), - PrimaryButton( - onPressed: () { - final v = _newValueController.text.trim(); - if (v.isEmpty) return; - _setAdd(v); - _newValueController.clear(); - }, - size: ButtonSize.small, - child: const Text('SADD'), - ), - ], - ), + const Gap(8), + PrimaryButton( + onPressed: () { + final v = _newValueController.text.trim(); + if (v.isEmpty) return; + _setAdd(v); + _newValueController.clear(); + }, + size: ButtonSize.small, + child: const Text('SADD'), + ), + ], + ), + ], const Gap(12), material.Expanded( child: _setValue.isEmpty @@ -663,7 +684,9 @@ class _RedisKeyEditorState extends material.State { separatorBuilder: (_, __) => const Gap(4), itemBuilder: (context, index) => _MemberRow( member: _setValue[index], - onDelete: () => _setRemove(_setValue[index]), + onDelete: widget.isReadOnly + ? null + : () => _setRemove(_setValue[index]), colorScheme: cs, shadcnCs: scs, ), @@ -680,39 +703,40 @@ class _RedisKeyEditorState extends material.State { crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ Text('Sorted set (${_zsetValue.length})').semiBold(), - const Gap(8), - // Add member - material.Row( - children: [ - material.Expanded( - flex: 2, - child: TextField( - controller: _newValueController, - placeholder: const Text('Member'), + if (!widget.isReadOnly) ...[ + const Gap(8), + material.Row( + children: [ + material.Expanded( + flex: 2, + child: TextField( + controller: _newValueController, + placeholder: const Text('Member'), + ), ), - ), - const Gap(8), - material.Expanded( - child: TextField( - controller: _newFieldController, - placeholder: const Text('Score'), + const Gap(8), + material.Expanded( + child: TextField( + controller: _newFieldController, + placeholder: const Text('Score'), + ), ), - ), - const Gap(8), - PrimaryButton( - onPressed: () { - final m = _newValueController.text.trim(); - final s = double.tryParse(_newFieldController.text.trim()); - if (m.isEmpty || s == null) return; - _zsetAdd(m, s); - _newValueController.clear(); - _newFieldController.clear(); - }, - size: ButtonSize.small, - child: const Text('ZADD'), - ), - ], - ), + const Gap(8), + PrimaryButton( + onPressed: () { + final m = _newValueController.text.trim(); + final s = double.tryParse(_newFieldController.text.trim()); + if (m.isEmpty || s == null) return; + _zsetAdd(m, s); + _newValueController.clear(); + _newFieldController.clear(); + }, + size: ButtonSize.small, + child: const Text('ZADD'), + ), + ], + ), + ], const Gap(12), material.Expanded( child: _zsetValue.isEmpty @@ -725,7 +749,8 @@ class _RedisKeyEditorState extends material.State { return _ScoredMemberRow( member: member, score: score, - onDelete: () => _zsetRemove(member), + onDelete: + widget.isReadOnly ? null : () => _zsetRemove(member), colorScheme: cs, shadcnCs: scs, ); @@ -860,14 +885,14 @@ class _FieldRow extends StatelessWidget { const _FieldRow({ required this.field, required this.value, - required this.onDelete, + this.onDelete, required this.colorScheme, required this.shadcnCs, }); final String field; final String value; - final VoidCallback onDelete; + final VoidCallback? onDelete; final ColorScheme colorScheme; final shadcn.ColorScheme shadcnCs; @@ -908,15 +933,16 @@ class _FieldRow extends StatelessWidget { ), ), const Gap(8), - material.InkWell( - onTap: onDelete, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon(material.Icons.close_rounded, - size: 14, color: context.semanticPalette.destructive), + if (onDelete != null) + material.InkWell( + onTap: onDelete, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon(material.Icons.close_rounded, + size: 14, color: context.semanticPalette.destructive), + ), ), - ), ], ), ); @@ -979,13 +1005,13 @@ class _IndexedValueRow extends StatelessWidget { class _MemberRow extends StatelessWidget { const _MemberRow({ required this.member, - required this.onDelete, + this.onDelete, required this.colorScheme, required this.shadcnCs, }); final String member; - final VoidCallback onDelete; + final VoidCallback? onDelete; final ColorScheme colorScheme; final shadcn.ColorScheme shadcnCs; @@ -1012,15 +1038,16 @@ class _MemberRow extends StatelessWidget { ), ), const Gap(8), - material.InkWell( - onTap: onDelete, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon(material.Icons.close_rounded, - size: 14, color: context.semanticPalette.destructive), + if (onDelete != null) + material.InkWell( + onTap: onDelete, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon(material.Icons.close_rounded, + size: 14, color: context.semanticPalette.destructive), + ), ), - ), ], ), ); @@ -1031,14 +1058,14 @@ class _ScoredMemberRow extends StatelessWidget { const _ScoredMemberRow({ required this.member, required this.score, - required this.onDelete, + this.onDelete, required this.colorScheme, required this.shadcnCs, }); final String member; final double score; - final VoidCallback onDelete; + final VoidCallback? onDelete; final ColorScheme colorScheme; final shadcn.ColorScheme shadcnCs; @@ -1082,15 +1109,16 @@ class _ScoredMemberRow extends StatelessWidget { ), ), const Gap(8), - material.InkWell( - onTap: onDelete, - borderRadius: material.BorderRadius.circular(4), - child: material.Padding( - padding: const material.EdgeInsets.all(4), - child: material.Icon(material.Icons.close_rounded, - size: 14, color: context.semanticPalette.destructive), + if (onDelete != null) + material.InkWell( + onTap: onDelete, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon(material.Icons.close_rounded, + size: 14, color: context.semanticPalette.destructive), + ), ), - ), ], ), ); diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 364402ce..1e64d5b8 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -11,11 +11,13 @@ class RedisKeysView extends material.StatefulWidget { required this.connection, required this.database, this.onKeyTap, + this.isReadOnly = false, }); final RedisConnection connection; final int database; final void Function(String key, String type)? onKeyTap; + final bool isReadOnly; @override material.State createState() => _RedisKeysViewState(); @@ -135,6 +137,7 @@ class _RedisKeysViewState extends material.State { } Future _deleteKey(_KeyInfo keyInfo) async { + if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.del(keyInfo.name); @@ -214,7 +217,9 @@ class _RedisKeysViewState extends material.State { palette: context.semanticPalette, onTap: () => widget.onKeyTap?.call(_keys[i].name, _keys[i].type), - onDelete: () => _deleteKey(_keys[i]), + onDelete: widget.isReadOnly + ? null + : () => _deleteKey(_keys[i]), ), ); }, @@ -304,6 +309,10 @@ class _RedisKeysViewState extends material.State { Text('db${widget.database}').muted().small(), const Gap(16), Text('$_dbSize total keys').muted().small(), + if (widget.isReadOnly) ...[ + const Gap(16), + const Text('Read-only session').muted().small(), + ], const Spacer(), Text('${_keys.length} loaded').muted().small(), if (_hasMore) ...[ @@ -338,7 +347,7 @@ class _KeyTile extends material.StatelessWidget { required this.shadcnCs, required this.palette, required this.onTap, - required this.onDelete, + this.onDelete, }); final _KeyInfo keyInfo; @@ -346,7 +355,7 @@ class _KeyTile extends material.StatelessWidget { final shadcn.ColorScheme shadcnCs; final QueryaSemanticPalette palette; final material.VoidCallback onTap; - final material.VoidCallback onDelete; + final material.VoidCallback? onDelete; static Color _typeColor(String type, QueryaSemanticPalette palette) { switch (type) { @@ -460,19 +469,20 @@ class _KeyTile extends material.StatelessWidget { ), ), const Gap(4), - material.IconButton( - onPressed: onDelete, - icon: material.Icon( - material.Icons.delete_rounded, - size: 15, - color: palette.destructive, + if (onDelete != null) + material.IconButton( + onPressed: onDelete, + icon: material.Icon( + material.Icons.delete_rounded, + size: 15, + color: palette.destructive, + ), + padding: const material.EdgeInsets.all(4), + constraints: const material.BoxConstraints( + minWidth: 28, minHeight: 28), + splashRadius: 18, + tooltip: 'Delete key', ), - padding: const material.EdgeInsets.all(4), - constraints: - const material.BoxConstraints(minWidth: 28, minHeight: 28), - splashRadius: 18, - tooltip: 'Delete key', - ), material.Icon(material.Icons.chevron_right_rounded, size: 18, color: shadcnCs.mutedForeground), ], diff --git a/test/core/database/redis_connection_test.dart b/test/core/database/redis_connection_test.dart index c742260f..7ecea4db 100644 --- a/test/core/database/redis_connection_test.dart +++ b/test/core/database/redis_connection_test.dart @@ -136,4 +136,30 @@ void main() { expect(ex.toString(), 'something went wrong'); }); }); + + group('RedisConnection clientReadOnly', () { + test('SET and DEL throw while the session lock is on', () async { + final fake = RedisConnectionTestFake(); + await fake.connect(); + await fake.applyClientReadOnly(true); + + expect(fake.clientReadOnly, isTrue); + await expectLater( + fake.set('k', 'v'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Redis connection is read-only', + ), + ), + ); + await expectLater(fake.del('k'), throwsA(isA())); + + await fake.applyClientReadOnly(false); + expect(fake.clientReadOnly, isFalse); + expect(await fake.del('k'), 0); + await fake.disconnect(); + }); + }); } diff --git a/test/features/redis/redis_key_editor_test.dart b/test/features/redis/redis_key_editor_test.dart new file mode 100644 index 00000000..b8992add --- /dev/null +++ b/test/features/redis/redis_key_editor_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/features/redis/redis_key_editor.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future pumpEditor( + WidgetTester tester, { + required RedisConnectionTestFake fake, + required bool isReadOnly, + }) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: RedisKeyEditor( + connection: fake, + database: 0, + keyName: 'session:1', + keyType: 'string', + isReadOnly: isReadOnly, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('RedisKeyEditor hides Save, TTL, and delete when locked', + (tester) async { + final fake = RedisConnectionTestFake(getResult: 'hello'); + await fake.connect(); + + await pumpEditor(tester, fake: fake, isReadOnly: true); + + expect(find.text('hello'), findsOneWidget); + expect(find.text('Save'), findsNothing); + expect(find.byTooltip('Set TTL'), findsNothing); + expect(find.byTooltip('Delete key'), findsNothing); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor shows Save when the session is writable', + (tester) async { + final fake = RedisConnectionTestFake(getResult: 'hello'); + await fake.connect(); + + await pumpEditor(tester, fake: fake, isReadOnly: false); + + expect(find.text('Save'), findsOneWidget); + expect(find.byTooltip('Set TTL'), findsOneWidget); + expect(find.byTooltip('Delete key'), findsOneWidget); + await fake.disconnect(); + }); +} diff --git a/test/features/redis/redis_keys_view_test.dart b/test/features/redis/redis_keys_view_test.dart index b888f931..00035293 100644 --- a/test/features/redis/redis_keys_view_test.dart +++ b/test/features/redis/redis_keys_view_test.dart @@ -66,4 +66,67 @@ void main() { expect(find.text('No keys found'), findsOneWidget); await fake.disconnect(); }); + + testWidgets( + 'RedisKeysView hides delete and shows Read-only session when locked', + (tester) async { + final fake = RedisConnectionTestFake( + firstScanKeys: const ['key_a'], + dbSizeResult: 1, + ); + await fake.connect(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: RedisKeysView( + connection: fake, + database: 0, + isReadOnly: true, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('key_a'), findsOneWidget); + expect(find.text('Read-only session'), findsOneWidget); + expect(find.byTooltip('Delete key'), findsNothing); + await fake.disconnect(); + }); + + testWidgets('RedisKeysView shows delete when the session is writable', + (tester) async { + final fake = RedisConnectionTestFake( + firstScanKeys: const ['key_a'], + dbSizeResult: 1, + ); + await fake.connect(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: RedisKeysView( + connection: fake, + database: 0, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byTooltip('Delete key'), findsOneWidget); + expect(find.text('Read-only session'), findsNothing); + await fake.disconnect(); + }); } From 958ce8977b34283009a6fb9491014f3e783fc683 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 13:36:12 +0300 Subject: [PATCH 21/52] fix(mongo): confirm before drop database, collection, or document Drop and Del ran immediately with no dialog. Cancel now leaves the server untouched. --- CHANGELOG.md | 1 + .../database/destructive_sql_detector.dart | 46 ++++++--- .../mongodb/mongo_collections_view.dart | 11 +++ .../mongodb/mongo_databases_view.dart | 11 +++ .../mongodb/mongo_document_editor.dart | 12 +++ .../mongodb/mongo_documents_view.dart | 13 +++ .../workspace/destructive_query_dialog.dart | 56 ++++++++--- .../mongodb/mongo_document_editor_test.dart | 32 +++++++ .../destructive_query_dialog_test.dart | 93 ++++++++++++++++++- 9 files changed, 246 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c3199e..c5b8912d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Mongo drop/delete confirm (#781)** — Drop database, drop collection, document Del, and editor Delete open the same destructive-operation dialog as SQL. Cancel / Escape does not call `dropDatabase` / `dropCollection` / `deleteOne`. - **Redis title-bar read-only (#813)** — Session lock is passed into the key browser and editor. Save, DEL, HSET/HDEL, RPUSH, SADD/SREM, ZADD/ZREM, and TTL apply stay hidden; the explorer socket also refuses those writes and sends `READONLY` after AUTH when locked (ignored on standalone / older servers). - **SQLite Table Browser read-only (#793)** — Connection-form Read only (`useSSL`) opens the file with `SQLITE_OPEN_READONLY` even for Save (`tableWrite`). Title-bar session lock is passed into `SqliteTableView`: staging / Save stay off and the grid does not acquire a writable handle. - **MySQL SQL tx toolbar (#809)** — Begin / Commit / Rollback run `START TRANSACTION` / `COMMIT` / `ROLLBACK` on the SQL socket without replacing the editor buffer. The toolbar shows Transaction open/none. Table Browser Save stays on the `tableWrite` socket from #802 (no nested `START TRANSACTION`); opening a table still warns while SQL has an open transaction. diff --git a/lib/core/database/destructive_sql_detector.dart b/lib/core/database/destructive_sql_detector.dart index 25486934..9a3b43bd 100644 --- a/lib/core/database/destructive_sql_detector.dart +++ b/lib/core/database/destructive_sql_detector.dart @@ -1,4 +1,5 @@ -/// Categorization of destructive SQL operations that can alter or destroy schema/data. +/// Categorization of destructive SQL / Mongo operations that can alter or +/// destroy schema/data. enum DestructiveSqlType { dropDatabase, dropSchema, @@ -6,7 +7,9 @@ enum DestructiveSqlType { dropView, dropMaterializedView, truncateTable, - unconditionalDelete; + unconditionalDelete, + dropCollection, + deleteDocument; String get label => switch (this) { DestructiveSqlType.dropDatabase => 'DROP DATABASE', @@ -16,6 +19,8 @@ enum DestructiveSqlType { DestructiveSqlType.dropMaterializedView => 'DROP MATERIALIZED VIEW', DestructiveSqlType.truncateTable => 'TRUNCATE TABLE', DestructiveSqlType.unconditionalDelete => 'UNCONDITIONAL DELETE', + DestructiveSqlType.dropCollection => 'DROP COLLECTION', + DestructiveSqlType.deleteDocument => 'DELETE DOCUMENT', }; String get riskLevel => switch (this) { @@ -24,6 +29,8 @@ enum DestructiveSqlType { DestructiveSqlType.dropTable => 'HIGH', DestructiveSqlType.truncateTable => 'HIGH', DestructiveSqlType.unconditionalDelete => 'HIGH', + DestructiveSqlType.dropCollection => 'HIGH', + DestructiveSqlType.deleteDocument => 'HIGH', DestructiveSqlType.dropMaterializedView => 'MEDIUM', DestructiveSqlType.dropView => 'MEDIUM', }; @@ -48,14 +55,17 @@ class DestructiveSqlOperation { 'Permanently drops schema "$targetName" and all contained tables.', DestructiveSqlType.dropTable => 'Permanently drops table structure and all data in "$targetName".', - DestructiveSqlType.dropView => - 'Drops view "$targetName".', + DestructiveSqlType.dropView => 'Drops view "$targetName".', DestructiveSqlType.dropMaterializedView => 'Drops materialized view "$targetName".', DestructiveSqlType.truncateTable => 'Quickly deletes all rows from table "$targetName" without transaction rollbacks in some engines.', DestructiveSqlType.unconditionalDelete => 'Deletes all rows from table "$targetName" (no WHERE clause detected).', + DestructiveSqlType.dropCollection => + 'Permanently drops collection "$targetName" and all documents in it.', + DestructiveSqlType.deleteDocument => + 'Permanently deletes document "$targetName". This cannot be undone.', }; } @@ -72,7 +82,9 @@ class DestructiveSqlInspectionResult { /// Returns highest risk level present ('CRITICAL', 'HIGH', 'MEDIUM', or 'NONE'). String get maxRiskLevel { if (operations.isEmpty) return 'NONE'; - if (operations.any((o) => o.type.riskLevel == 'CRITICAL')) return 'CRITICAL'; + if (operations.any((o) => o.type.riskLevel == 'CRITICAL')) { + return 'CRITICAL'; + } if (operations.any((o) => o.type.riskLevel == 'HIGH')) return 'HIGH'; return 'MEDIUM'; } @@ -151,7 +163,8 @@ abstract final class DestructiveSqlDetector { // 3. Dollar quotes in PostgreSQL: $$ or $tag$ if (sql[i] == '\$') { - final match = RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); + final match = + RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); if (match != null) { final tag = match.group(0)!; i += tag.length; @@ -231,7 +244,8 @@ abstract final class DestructiveSqlDetector { // Dollar quotes if (sql[i] == '\$') { - final match = RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); + final match = + RegExp(r'^\$([a-zA-Z0-9_]*)\$').matchAsPrefix(sql.substring(i)); if (match != null) { final tag = match.group(0)!; current.write(tag); @@ -339,7 +353,8 @@ abstract final class DestructiveSqlDetector { if (dropMatViewMatch != null) { final schema = dropMatViewMatch.group(1); final view = dropMatViewMatch.group(2) ?? 'view'; - final target = (schema != null && schema.isNotEmpty) ? '$schema.$view' : view; + final target = + (schema != null && schema.isNotEmpty) ? '$schema.$view' : view; operations.add( DestructiveSqlOperation( type: DestructiveSqlType.dropMaterializedView, @@ -355,7 +370,8 @@ abstract final class DestructiveSqlDetector { if (dropViewMatch != null) { final schema = dropViewMatch.group(1); final view = dropViewMatch.group(2) ?? 'view'; - final target = (schema != null && schema.isNotEmpty) ? '$schema.$view' : view; + final target = + (schema != null && schema.isNotEmpty) ? '$schema.$view' : view; operations.add( DestructiveSqlOperation( type: DestructiveSqlType.dropView, @@ -371,7 +387,8 @@ abstract final class DestructiveSqlDetector { if (dropTableMatch != null) { final schema = dropTableMatch.group(1); final table = dropTableMatch.group(2) ?? 'table'; - final target = (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; + final target = + (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; operations.add( DestructiveSqlOperation( type: DestructiveSqlType.dropTable, @@ -387,7 +404,8 @@ abstract final class DestructiveSqlDetector { if (truncateMatch != null) { final schema = truncateMatch.group(1); final table = truncateMatch.group(2) ?? 'table'; - final target = (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; + final target = + (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; operations.add( DestructiveSqlOperation( type: DestructiveSqlType.truncateTable, @@ -401,11 +419,13 @@ abstract final class DestructiveSqlDetector { // 7. DELETE FROM table without WHERE final deleteMatch = _deleteRegex.firstMatch(sanitized); if (deleteMatch != null) { - final hasWhere = RegExp(r'\bWHERE\b', caseSensitive: false).hasMatch(sanitized); + final hasWhere = + RegExp(r'\bWHERE\b', caseSensitive: false).hasMatch(sanitized); if (!hasWhere) { final schema = deleteMatch.group(1); final table = deleteMatch.group(2) ?? 'table'; - final target = (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; + final target = + (schema != null && schema.isNotEmpty) ? '$schema.$table' : table; operations.add( DestructiveSqlOperation( type: DestructiveSqlType.unconditionalDelete, diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart index 3622039c..4d504e87 100644 --- a/lib/features/mongodb/mongo_collections_view.dart +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -3,8 +3,10 @@ import 'dart:math' show min; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/actions/querya_schema_object.dart'; import 'package:querya_desktop/core/actions/querya_schema_object_cache.dart'; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -181,6 +183,15 @@ class _MongoCollectionsViewState extends material.State { } Future _dropCollection(String name) async { + final confirmed = await confirmDestructiveMongoAction( + context: context, + type: DestructiveSqlType.dropCollection, + targetName: name, + commandPreview: 'db.$name.drop()', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; + try { await MongoService.instance.dropCollection( widget.connection, diff --git a/lib/features/mongodb/mongo_databases_view.dart b/lib/features/mongodb/mongo_databases_view.dart index 20e7686d..31aeaf14 100644 --- a/lib/features/mongodb/mongo_databases_view.dart +++ b/lib/features/mongodb/mongo_databases_view.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -176,6 +178,15 @@ class _MongoDatabasesViewState extends State { Future _dropDatabase(String name) async { if (_connection == null) return; + final confirmed = await confirmDestructiveMongoAction( + context: context, + type: DestructiveSqlType.dropDatabase, + targetName: name, + commandPreview: '{ dropDatabase: 1 }', + connectionName: widget.connectionRow.name, + ); + if (!mounted || !confirmed) return; + try { await MongoService.instance.executeCommand( _connection!, diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index 941e2335..9401cc83 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -179,6 +181,16 @@ class _MongoDocumentEditorState extends material.State { final id = widget.document['_id']; if (id == null) return; + final confirmed = await confirmDestructiveMongoAction( + context: context, + type: DestructiveSqlType.deleteDocument, + targetName: id.toString(), + commandPreview: + 'db.${widget.collection}.deleteOne({ _id: ${id.toString()} })', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; + setState(() { _deleting = true; _error = null; diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index 020475aa..efdcc438 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -2,9 +2,11 @@ import 'dart:async' show unawaited; import 'dart:convert'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/features/mongodb/mongo_field_codec.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/features/workspace/grid_cell_popover_inspector.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -191,6 +193,17 @@ class _MongoDocumentsViewState extends material.State { Future _deleteDocument(Map doc) async { final id = doc['_id']; if (id == null) return; + + final confirmed = await confirmDestructiveMongoAction( + context: context, + type: DestructiveSqlType.deleteDocument, + targetName: id.toString(), + commandPreview: + 'db.${widget.collection}.deleteOne({ _id: ${id.toString()} })', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; + try { await MongoService.instance.deleteDocument( widget.connection, diff --git a/lib/features/workspace/destructive_query_dialog.dart b/lib/features/workspace/destructive_query_dialog.dart index af5f74e5..7aaf87ae 100644 --- a/lib/features/workspace/destructive_query_dialog.dart +++ b/lib/features/workspace/destructive_query_dialog.dart @@ -23,6 +23,34 @@ Future showDestructiveQueryDialog({ ); } +/// Same dialog as SQL DROP/DELETE, with a Mongo command preview. +/// +/// Returns `true` only when the user checks the acknowledgment box and +/// confirms. Cancel / Escape returns `false`. +Future confirmDestructiveMongoAction({ + required material.BuildContext context, + required DestructiveSqlType type, + required String targetName, + required String commandPreview, + String? connectionName, +}) async { + final confirmed = await showDestructiveQueryDialog( + context: context, + result: DestructiveSqlInspectionResult( + operations: [ + DestructiveSqlOperation( + type: type, + targetName: targetName, + rawStatement: commandPreview, + ), + ], + ), + sql: commandPreview, + connectionName: connectionName, + ); + return confirmed == true; +} + class _DestructiveQueryDialog extends material.StatefulWidget { const _DestructiveQueryDialog({ required this.result, @@ -39,7 +67,8 @@ class _DestructiveQueryDialog extends material.StatefulWidget { _DestructiveQueryDialogState(); } -class _DestructiveQueryDialogState extends material.State<_DestructiveQueryDialog> { +class _DestructiveQueryDialogState + extends material.State<_DestructiveQueryDialog> { bool _acknowledged = false; bool _copied = false; @@ -67,14 +96,14 @@ class _DestructiveQueryDialogState extends material.State<_DestructiveQueryDialo maxHeight: 580, ), child: material.FocusTraversalGroup( - child: material.SizedBox( - height: 540, - child: material.Padding( - padding: const material.EdgeInsets.all(20), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ + child: material.SizedBox( + height: 540, + child: material.Padding( + padding: const material.EdgeInsets.all(20), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ // Header material.Row( children: [ @@ -245,12 +274,14 @@ class _DestructiveQueryDialogState extends material.State<_DestructiveQueryDialo children: [ material.Checkbox( value: _acknowledged, - onChanged: (v) => setState(() => _acknowledged = v ?? false), + onChanged: (v) => + setState(() => _acknowledged = v ?? false), ), const Gap(8), material.Expanded( child: material.GestureDetector( - onTap: () => setState(() => _acknowledged = !_acknowledged), + onTap: () => + setState(() => _acknowledged = !_acknowledged), child: const Text( 'I understand that this query cannot be undone and may result in permanent data loss.', ).small(), @@ -270,7 +301,8 @@ class _DestructiveQueryDialogState extends material.State<_DestructiveQueryDialo crossAxisAlignment: material.WrapCrossAlignment.center, children: [ GhostButton( - onPressed: () => material.Navigator.of(context).pop(false), + onPressed: () => + material.Navigator.of(context).pop(false), child: const Text('Cancel'), ), DestructiveButton( diff --git a/test/features/mongodb/mongo_document_editor_test.dart b/test/features/mongodb/mongo_document_editor_test.dart index 85f9ea7b..8f45e2ef 100644 --- a/test/features/mongodb/mongo_document_editor_test.dart +++ b/test/features/mongodb/mongo_document_editor_test.dart @@ -91,4 +91,36 @@ void main() { ); expect(container.color, bg); }); + + testWidgets('Delete Cancel does not call onDocumentDeleted', (tester) async { + await tester.binding.setSurfaceSize(const material.Size(800, 700)); + var deleted = false; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 800, + height: 700, + child: MongoDocumentEditor( + connection: connection, + database: 'db', + collection: 'items', + document: const {'_id': 'abc', 'a': 1}, + onDocumentDeleted: () => deleted = true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await pumpSyntaxHighlightDebounce(tester); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + expect(find.text('DELETE DOCUMENT'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(deleted, isFalse); + expect(find.text('Delete'), findsOneWidget); + }); } diff --git a/test/features/workspace/destructive_query_dialog_test.dart b/test/features/workspace/destructive_query_dialog_test.dart index 8e72fe0f..e2d2f02f 100644 --- a/test/features/workspace/destructive_query_dialog_test.dart +++ b/test/features/workspace/destructive_query_dialog_test.dart @@ -8,11 +8,14 @@ import '../../support/querya_theme_test_shell.dart'; void main() { group('DestructiveQueryDialog', () { - testWidgets('renders warning, detected operations, and disables confirm until acknowledged', (tester) async { + testWidgets( + 'renders warning, detected operations, and disables confirm until acknowledged', + (tester) async { await tester.binding.setSurfaceSize(const Size(800, 700)); bool? result; - final inspection = DestructiveSqlDetector.inspect('DROP TABLE legacy_users;'); + final inspection = + DestructiveSqlDetector.inspect('DROP TABLE legacy_users;'); await tester.pumpWidget( queryaThemeTestShell( @@ -36,7 +39,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Destructive Operation Detected'), findsOneWidget); - expect(find.text('Target connection: Production PostgreSQL'), findsOneWidget); + expect(find.text('Target connection: Production PostgreSQL'), + findsOneWidget); expect(find.text('DROP TABLE'), findsOneWidget); expect(find.text('DROP TABLE legacy_users;'), findsOneWidget); expect(find.text('Execute Destructive Statement'), findsOneWidget); @@ -60,7 +64,8 @@ void main() { testWidgets('shows Critical header for DROP DATABASE', (tester) async { await tester.binding.setSurfaceSize(const Size(800, 700)); - final inspection = DestructiveSqlDetector.inspect('DROP DATABASE customer_records;'); + final inspection = + DestructiveSqlDetector.inspect('DROP DATABASE customer_records;'); await tester.pumpWidget( queryaThemeTestShell( @@ -115,5 +120,85 @@ void main() { expect(result, isFalse); }); + + testWidgets( + 'Mongo drop database shows Critical and Cancel does not confirm', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + var confirmed = true; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + confirmed = await confirmDestructiveMongoAction( + context: context, + type: DestructiveSqlType.dropDatabase, + targetName: 'orders', + commandPreview: '{ dropDatabase: 1 }', + connectionName: 'prod-mongo', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Critical Destructive Operation'), findsOneWidget); + expect(find.text('DROP DATABASE'), findsOneWidget); + expect(find.text('{ dropDatabase: 1 }'), findsOneWidget); + expect(find.text('Target connection: prod-mongo'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(confirmed, isFalse); + }); + + testWidgets( + 'Mongo drop collection confirm stays disabled until acknowledged', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + var confirmed = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + confirmed = await confirmDestructiveMongoAction( + context: context, + type: DestructiveSqlType.dropCollection, + targetName: 'users', + commandPreview: 'db.users.drop()', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('DROP COLLECTION'), findsOneWidget); + expect(find.text('db.users.drop()'), findsOneWidget); + + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + expect(confirmed, isFalse); + expect(find.text('DROP COLLECTION'), findsOneWidget); + + await tester.tap(find.byType(material.Checkbox)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + expect(confirmed, isTrue); + }); }); } From 49e413468b48f2a2a401c372feb03425d7d4f3f2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 13:39:07 +0300 Subject: [PATCH 22/52] fix(redis): confirm before deleting a key or field A missclick sent DEL/HDEL/SREM/ZREM immediately. Cancel now leaves the key untouched. --- CHANGELOG.md | 1 + .../database/destructive_sql_detector.dart | 26 ++++++- lib/features/redis/redis_key_editor.dart | 34 +++++++++ lib/features/redis/redis_keys_view.dart | 10 +++ .../workspace/destructive_query_dialog.dart | 20 ++++- .../features/redis/redis_key_editor_test.dart | 34 ++++++++- test/features/redis/redis_keys_view_test.dart | 48 ++++++++++++ .../destructive_query_dialog_test.dart | 75 +++++++++++++++++++ 8 files changed, 241 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b8912d..116ed1d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Redis DEL confirm (#814)** — Deleting a key (browser or editor) and removing a hash field / set member / zset member opens the same destructive-operation dialog as SQL. Cancel / Escape does not send `DEL` / `HDEL` / `SREM` / `ZREM`. - **Mongo drop/delete confirm (#781)** — Drop database, drop collection, document Del, and editor Delete open the same destructive-operation dialog as SQL. Cancel / Escape does not call `dropDatabase` / `dropCollection` / `deleteOne`. - **Redis title-bar read-only (#813)** — Session lock is passed into the key browser and editor. Save, DEL, HSET/HDEL, RPUSH, SADD/SREM, ZADD/ZREM, and TTL apply stay hidden; the explorer socket also refuses those writes and sends `READONLY` after AUTH when locked (ignored on standalone / older servers). - **SQLite Table Browser read-only (#793)** — Connection-form Read only (`useSSL`) opens the file with `SQLITE_OPEN_READONLY` even for Save (`tableWrite`). Title-bar session lock is passed into `SqliteTableView`: staging / Save stay off and the grid does not acquire a writable handle. diff --git a/lib/core/database/destructive_sql_detector.dart b/lib/core/database/destructive_sql_detector.dart index 9a3b43bd..0ca1283a 100644 --- a/lib/core/database/destructive_sql_detector.dart +++ b/lib/core/database/destructive_sql_detector.dart @@ -1,5 +1,5 @@ -/// Categorization of destructive SQL / Mongo operations that can alter or -/// destroy schema/data. +/// Categorization of destructive SQL / Mongo / Redis operations that can +/// alter or destroy schema/data. enum DestructiveSqlType { dropDatabase, dropSchema, @@ -9,7 +9,11 @@ enum DestructiveSqlType { truncateTable, unconditionalDelete, dropCollection, - deleteDocument; + deleteDocument, + redisDel, + redisHdel, + redisSrem, + redisZrem; String get label => switch (this) { DestructiveSqlType.dropDatabase => 'DROP DATABASE', @@ -21,6 +25,10 @@ enum DestructiveSqlType { DestructiveSqlType.unconditionalDelete => 'UNCONDITIONAL DELETE', DestructiveSqlType.dropCollection => 'DROP COLLECTION', DestructiveSqlType.deleteDocument => 'DELETE DOCUMENT', + DestructiveSqlType.redisDel => 'DEL', + DestructiveSqlType.redisHdel => 'HDEL', + DestructiveSqlType.redisSrem => 'SREM', + DestructiveSqlType.redisZrem => 'ZREM', }; String get riskLevel => switch (this) { @@ -31,6 +39,10 @@ enum DestructiveSqlType { DestructiveSqlType.unconditionalDelete => 'HIGH', DestructiveSqlType.dropCollection => 'HIGH', DestructiveSqlType.deleteDocument => 'HIGH', + DestructiveSqlType.redisDel => 'HIGH', + DestructiveSqlType.redisHdel => 'HIGH', + DestructiveSqlType.redisSrem => 'HIGH', + DestructiveSqlType.redisZrem => 'HIGH', DestructiveSqlType.dropMaterializedView => 'MEDIUM', DestructiveSqlType.dropView => 'MEDIUM', }; @@ -66,6 +78,14 @@ class DestructiveSqlOperation { 'Permanently drops collection "$targetName" and all documents in it.', DestructiveSqlType.deleteDocument => 'Permanently deletes document "$targetName". This cannot be undone.', + DestructiveSqlType.redisDel => + 'Permanently deletes Redis key "$targetName". This cannot be undone.', + DestructiveSqlType.redisHdel => + 'Permanently removes hash field "$targetName".', + DestructiveSqlType.redisSrem => + 'Permanently removes set member "$targetName".', + DestructiveSqlType.redisZrem => + 'Permanently removes sorted-set member "$targetName".', }; } diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index d94ea83f..9a4cabad 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -131,6 +133,14 @@ class _RedisKeyEditorState extends material.State { Future _deleteKey() async { if (widget.isReadOnly) return; + final confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisDel, + targetName: '${widget.keyName} (${widget.keyType})', + commandPreview: 'DEL ${widget.keyName}', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.del(widget.keyName); @@ -177,6 +187,14 @@ class _RedisKeyEditorState extends material.State { Future _hashDel(String field) async { if (widget.isReadOnly) return; + final confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisHdel, + targetName: field, + commandPreview: 'HDEL ${widget.keyName} $field', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.hdel(widget.keyName, field); @@ -215,6 +233,14 @@ class _RedisKeyEditorState extends material.State { Future _setRemove(String member) async { if (widget.isReadOnly) return; + final confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisSrem, + targetName: member, + commandPreview: 'SREM ${widget.keyName} $member', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.srem(widget.keyName, member); @@ -240,6 +266,14 @@ class _RedisKeyEditorState extends material.State { Future _zsetRemove(String member) async { if (widget.isReadOnly) return; + final confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisZrem, + targetName: member, + commandPreview: 'ZREM ${widget.keyName} $member', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.zrem(widget.keyName, member); diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 1e64d5b8..1d7f9ac5 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/theme/querya_semantic_palette.dart'; +import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -138,6 +140,14 @@ class _RedisKeysViewState extends material.State { Future _deleteKey(_KeyInfo keyInfo) async { if (widget.isReadOnly) return; + final confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisDel, + targetName: '${keyInfo.name} (${keyInfo.type})', + commandPreview: 'DEL ${keyInfo.name}', + connectionName: widget.connection.name, + ); + if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.del(keyInfo.name); diff --git a/lib/features/workspace/destructive_query_dialog.dart b/lib/features/workspace/destructive_query_dialog.dart index 7aaf87ae..29a6cb2d 100644 --- a/lib/features/workspace/destructive_query_dialog.dart +++ b/lib/features/workspace/destructive_query_dialog.dart @@ -23,11 +23,11 @@ Future showDestructiveQueryDialog({ ); } -/// Same dialog as SQL DROP/DELETE, with a Mongo command preview. +/// Same dialog as SQL DROP/DELETE, with a command preview (Mongo / Redis). /// /// Returns `true` only when the user checks the acknowledgment box and /// confirms. Cancel / Escape returns `false`. -Future confirmDestructiveMongoAction({ +Future confirmDestructiveAction({ required material.BuildContext context, required DestructiveSqlType type, required String targetName, @@ -51,6 +51,22 @@ Future confirmDestructiveMongoAction({ return confirmed == true; } +/// Mongo explorer drop/delete. Same dialog as SQL. +Future confirmDestructiveMongoAction({ + required material.BuildContext context, + required DestructiveSqlType type, + required String targetName, + required String commandPreview, + String? connectionName, +}) => + confirmDestructiveAction( + context: context, + type: type, + targetName: targetName, + commandPreview: commandPreview, + connectionName: connectionName, + ); + class _DestructiveQueryDialog extends material.StatefulWidget { const _DestructiveQueryDialog({ required this.result, diff --git a/test/features/redis/redis_key_editor_test.dart b/test/features/redis/redis_key_editor_test.dart index b8992add..f0faa033 100644 --- a/test/features/redis/redis_key_editor_test.dart +++ b/test/features/redis/redis_key_editor_test.dart @@ -12,19 +12,22 @@ void main() { WidgetTester tester, { required RedisConnectionTestFake fake, required bool isReadOnly, + material.VoidCallback? onKeyDeleted, + material.Size size = const material.Size(800, 600), }) async { await tester.pumpWidget( queryaThemeTestShell( child: material.Scaffold( body: material.SizedBox( - width: 800, - height: 600, + width: size.width, + height: size.height, child: RedisKeyEditor( connection: fake, database: 0, keyName: 'session:1', keyType: 'string', isReadOnly: isReadOnly, + onKeyDeleted: onKeyDeleted, ), ), ), @@ -59,4 +62,31 @@ void main() { expect(find.byTooltip('Delete key'), findsOneWidget); await fake.disconnect(); }); + + testWidgets('RedisKeyEditor Delete Cancel does not call onKeyDeleted', + (tester) async { + await tester.binding.setSurfaceSize(const material.Size(800, 700)); + final fake = RedisConnectionTestFake(getResult: 'hello'); + await fake.connect(); + var deleted = false; + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + onKeyDeleted: () => deleted = true, + size: const material.Size(800, 700), + ); + + await tester.tap(find.byTooltip('Delete key')); + await tester.pumpAndSettle(); + + expect(find.text('DEL session:1'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(deleted, isFalse); + expect(find.byTooltip('Delete key'), findsOneWidget); + await fake.disconnect(); + }); } diff --git a/test/features/redis/redis_keys_view_test.dart b/test/features/redis/redis_keys_view_test.dart index 00035293..c0183ef1 100644 --- a/test/features/redis/redis_keys_view_test.dart +++ b/test/features/redis/redis_keys_view_test.dart @@ -129,4 +129,52 @@ void main() { expect(find.text('Read-only session'), findsNothing); await fake.disconnect(); }); + + testWidgets('RedisKeysView Delete Cancel does not send DEL', (tester) async { + await tester.binding.setSurfaceSize(const material.Size(800, 700)); + final fake = _DelTrackingFake( + firstScanKeys: const ['key_a'], + dbSizeResult: 1, + ); + await fake.connect(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 700, + child: RedisKeysView( + connection: fake, + database: 0, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Delete key')); + await tester.pumpAndSettle(); + + expect(find.text('DEL key_a'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(fake.deleted, isEmpty); + expect(find.text('key_a'), findsOneWidget); + await fake.disconnect(); + }); +} + +class _DelTrackingFake extends RedisConnectionTestFake { + _DelTrackingFake({super.firstScanKeys, super.dbSizeResult}); + + final deleted = []; + + @override + Future del(String key) async { + deleted.add(key); + return super.del(key); + } } diff --git a/test/features/workspace/destructive_query_dialog_test.dart b/test/features/workspace/destructive_query_dialog_test.dart index e2d2f02f..334533ef 100644 --- a/test/features/workspace/destructive_query_dialog_test.dart +++ b/test/features/workspace/destructive_query_dialog_test.dart @@ -200,5 +200,80 @@ void main() { await tester.pumpAndSettle(); expect(confirmed, isTrue); }); + + testWidgets('Redis DEL Cancel does not confirm', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + var confirmed = true; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisDel, + targetName: 'session:1 (string)', + commandPreview: 'DEL session:1', + connectionName: 'cache', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('DEL'), findsWidgets); + expect(find.text('DEL session:1'), findsOneWidget); + expect(find.text('Target connection: cache'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(confirmed, isFalse); + }); + + testWidgets('Redis HDEL confirm stays disabled until acknowledged', + (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 700)); + var confirmed = false; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + confirmed = await confirmDestructiveAction( + context: context, + type: DestructiveSqlType.redisHdel, + targetName: 'email', + commandPreview: 'HDEL user:1 email', + ); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('HDEL'), findsWidgets); + expect(find.text('HDEL user:1 email'), findsOneWidget); + + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + expect(confirmed, isFalse); + + await tester.tap(find.byType(material.Checkbox)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Execute Destructive Statement')); + await tester.pumpAndSettle(); + expect(confirmed, isTrue); + }); }); } From 8aeedee8a2ef2ead1b29feca2d89de8e9a24f4c8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 13:46:00 +0300 Subject: [PATCH 23/52] fix(redis): parse URI host and TLS for Test and sidebar URI-only saves left host/port null, so Test and the sidebar probe hit localhost:6379 without TLS. Both now go through fromConnectionRow; the probe keeps id -1. --- CHANGELOG.md | 1 + lib/core/database/redis_connection.dart | 13 +++-- .../connections/connections_panel_redis.dart | 11 ++-- lib/features/redis/redis_connection_form.dart | 32 ++++------- test/core/database/redis_connection_test.dart | 54 +++++++++++++++++++ 5 files changed, 78 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 116ed1d2..c70bdb2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Redis URI Test/sidebar (#815)** — Test Connection and the sidebar keyspace probe parse `redis://` / `rediss://` via `fromConnectionRow` (host, port, TLS, cert query params) instead of hitting localhost:6379. The probe keeps `id: -1` so it does not replace workspace sockets. - **Redis DEL confirm (#814)** — Deleting a key (browser or editor) and removing a hash field / set member / zset member opens the same destructive-operation dialog as SQL. Cancel / Escape does not send `DEL` / `HDEL` / `SREM` / `ZREM`. - **Mongo drop/delete confirm (#781)** — Drop database, drop collection, document Del, and editor Delete open the same destructive-operation dialog as SQL. Cancel / Escape does not call `dropDatabase` / `dropCollection` / `deleteOne`. - **Redis title-bar read-only (#813)** — Session lock is passed into the key browser and editor. Save, DEL, HSET/HDEL, RPUSH, SADD/SREM, ZADD/ZREM, and TTL apply stay hidden; the explorer socket also refuses those writes and sends `READONLY` after AUTH when locked (ignored on standalone / older servers). diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index cc2b7189..4bbeca9a 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -20,7 +20,14 @@ class RedisConnection { }) : _password = password, _connectionString = connectionString; - factory RedisConnection.fromConnectionRow(ConnectionRow row) { + /// Parses [ConnectionRow.connectionString] (`redis://` / `rediss://`) for + /// host, port, userinfo, and TLS. Pass [id] `-1` for a sidebar probe that + /// must not share the workspace pool id (and skips secrets lookup). + factory RedisConnection.fromConnectionRow( + ConnectionRow row, { + int? id, + }) { + final resolvedId = id ?? row.id ?? 0; final uriText = row.connectionString?.trim(); if (uriText != null && uriText.isNotEmpty) { final parsed = Uri.parse(uriText); @@ -37,7 +44,7 @@ class RedisConnection { } } return RedisConnection( - id: row.id ?? 0, + id: resolvedId, name: row.name, host: parsed.host.isEmpty ? (row.host ?? 'localhost') : parsed.host, port: parsed.hasPort ? parsed.port : (row.port ?? 6379), @@ -48,7 +55,7 @@ class RedisConnection { ); } return RedisConnection( - id: row.id ?? 0, + id: resolvedId, name: row.name, host: row.host ?? 'localhost', port: row.port ?? 6379, diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index c53787e3..9e65594d 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -73,15 +73,10 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { _error = null; }); try { - // Use a temporary connection so we don't kill the main view's connection. - final c = widget.connection; - final conn = RedisConnection( + // Temporary probe (id: -1) so we don't touch RedisService / workspace sockets. + final conn = RedisConnection.fromConnectionRow( + widget.connection, id: -1, - name: 'sidebar_probe', - host: c.host ?? 'localhost', - port: c.port ?? 6379, - username: c.username, - password: c.password, ); await conn.connect(); final raw = await conn.info(); diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index 4f98d433..592c31fa 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -184,22 +184,7 @@ class _RedisConnectionFormContentState _testResult = null; }); try { - final uri = _effectiveConnectionUri(); - final conn = RedisConnection( - id: 0, - name: _nameController.text.trim().isEmpty - ? 'test' - : _nameController.text.trim(), - host: uri.isNotEmpty ? 'localhost' : _hostController.text.trim(), - port: int.tryParse(_portController.text.trim()) ?? 6379, - username: _usernameController.text.trim().isEmpty - ? null - : _usernameController.text.trim(), - password: - _passwordController.text.isEmpty ? null : _passwordController.text, - useSSL: _useSSL || _hasSslCertificateFields(), - connectionString: uri.isEmpty ? null : uri, - ); + final conn = RedisConnection.fromConnectionRow(_draftRow()); final ok = await conn.testConnection(); if (mounted) _showTestResult(ok ? 'success' : 'failed'); } catch (e) { @@ -207,8 +192,7 @@ class _RedisConnectionFormContentState } } - void _save() { - if (!_formValidNotifier.value) return; + ConnectionRow _draftRow({int? id}) { _syncUriSslParams(); final name = _nameController.text.trim(); final host = _hostController.text.trim(); @@ -216,10 +200,10 @@ class _RedisConnectionFormContentState final uri = _effectiveConnectionUri(); final displayName = name.isNotEmpty ? name : 'Redis $host:$port'; final initial = widget.initial; - final row = ConnectionRow( - id: initial?.id, + return ConnectionRow( + id: id, type: initial?.type ?? 'redis', - name: displayName, + name: displayName.isEmpty ? 'test' : displayName, host: uri.isNotEmpty ? null : host, port: uri.isNotEmpty ? null : port, username: _usernameController.text.trim().isEmpty @@ -235,7 +219,11 @@ class _RedisConnectionFormContentState sortOrder: initial?.sortOrder ?? 0, createdAt: initial?.createdAt ?? DateTime.now().toUtc().toIso8601String(), ); - material.Navigator.of(context).pop(row); + } + + void _save() { + if (!_formValidNotifier.value) return; + material.Navigator.of(context).pop(_draftRow(id: widget.initial?.id)); } @override diff --git a/test/core/database/redis_connection_test.dart b/test/core/database/redis_connection_test.dart index 7ecea4db..cfd94ade 100644 --- a/test/core/database/redis_connection_test.dart +++ b/test/core/database/redis_connection_test.dart @@ -127,6 +127,60 @@ void main() { expect(conn.password, 'pass'); expect(conn.connectionString, contains('sslrootcert')); }); + + test('URI-only row (null host/port) still uses URI host, port, and TLS', + () { + final conn = RedisConnection.fromConnectionRow( + const ConnectionRow( + id: 8, + type: 'redis', + name: 'uri-only', + useSSL: false, + connectionString: + 'rediss://user:s3cret@cache.example.com:6380?sslrootcert=%2Fca.pem', + createdAt: '0', + ), + ); + expect(conn.host, 'cache.example.com'); + expect(conn.port, 6380); + expect(conn.useSSL, isTrue); + expect(conn.username, 'user'); + expect(conn.password, 's3cret'); + expect(conn.connectionString, contains('sslrootcert')); + }); + + test('sidebar probe id override does not use the saved connection id', () { + final conn = RedisConnection.fromConnectionRow( + const ConnectionRow( + id: 42, + type: 'redis', + name: 'prod', + connectionString: 'rediss://cache.example.com:6380', + createdAt: '0', + ), + id: -1, + ); + expect(conn.id, -1); + expect(conn.host, 'cache.example.com'); + expect(conn.port, 6380); + expect(conn.useSSL, isTrue); + }); + + test('host/port form without URI stays on those fields', () { + final conn = RedisConnection.fromConnectionRow( + const ConnectionRow( + type: 'redis', + name: 'local', + host: '127.0.0.1', + port: 6379, + useSSL: false, + createdAt: '0', + ), + ); + expect(conn.host, '127.0.0.1'); + expect(conn.port, 6379); + expect(conn.useSSL, isFalse); + }); }); group('RedisConnectionException', () { From c04d7ad3ccbe2f4e9b2eda6e2f9352743b373788 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 13:51:24 +0300 Subject: [PATCH 24/52] fix(redis): page hashes, lists, sets, and zsets in the key editor Opening a production collection used HGETALL / LRANGE 0 -1 / SMEMBERS / ZRANGE 0 -1 and could OOM. Load the first 200 members and offer Load more. --- CHANGELOG.md | 1 + lib/core/database/redis_connection.dart | 159 +++++++++++++++++- lib/features/redis/redis_key_editor.dart | 136 +++++++++++++-- test/core/database/redis_connection_test.dart | 35 ++++ .../features/redis/redis_key_editor_test.dart | 67 +++++++- 5 files changed, 384 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c70bdb2d..9367bbcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Redis collection paging (#816)** — Hash / list / set / zset editors load the first 200 members (`HSCAN` / `LRANGE` / `SSCAN` / `ZRANGE`) with Load more, instead of `HGETALL` / `LRANGE 0 -1` / `SMEMBERS` / `ZRANGE 0 -1`. Keys with 10k+ members show a large-key warning. - **Redis URI Test/sidebar (#815)** — Test Connection and the sidebar keyspace probe parse `redis://` / `rediss://` via `fromConnectionRow` (host, port, TLS, cert query params) instead of hitting localhost:6379. The probe keeps `id: -1` so it does not replace workspace sockets. - **Redis DEL confirm (#814)** — Deleting a key (browser or editor) and removing a hash field / set member / zset member opens the same destructive-operation dialog as SQL. Cancel / Escape does not send `DEL` / `HDEL` / `SREM` / `ZREM`. - **Mongo drop/delete confirm (#781)** — Drop database, drop collection, document Del, and editor Delete open the same destructive-operation dialog as SQL. Cancel / Escape does not call `dropDatabase` / `dropCollection` / `deleteOne`. diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index 4bbeca9a..936bd018 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -6,6 +6,12 @@ import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:redis/redis.dart' as redis; +/// First page / load-more size for hash, list, set, and zset editors. +const redisCollectionPageSize = 200; + +/// Warn in the key editor when HLEN / LLEN / SCARD / ZCARD is at least this. +const redisLargeCollectionWarnAt = 10000; + /// Redis connection using the Dart redis package (no Java/JRE). class RedisConnection { RedisConnection({ @@ -383,6 +389,33 @@ class RedisConnection { return map; } + /// HSCAN key cursor [COUNT count]. Returns (nextCursor, fields). + Future<(int, Map)> hscan( + String key, { + int cursor = 0, + int count = redisCollectionPageSize, + }) async { + final result = await sendCommand(['HSCAN', key, cursor, 'COUNT', count]); + if (result is List && result.length == 2) { + final nextCursor = int.tryParse(result[0].toString()) ?? 0; + final map = {}; + final pairs = result[1]; + if (pairs is List) { + for (var i = 0; i + 1 < pairs.length; i += 2) { + map[pairs[i].toString()] = pairs[i + 1].toString(); + } + } + return (nextCursor, map); + } + return (0, {}); + } + + /// HLEN key. + Future hlen(String key) async { + final result = await sendCommand(['HLEN', key]); + return result is int ? result : int.tryParse(result.toString()) ?? 0; + } + /// HSET key field value. Future hset(String key, String field, String value) async { _assertWritable(); @@ -425,6 +458,22 @@ class RedisConnection { return []; } + /// SSCAN key cursor [COUNT count]. Returns (nextCursor, members). + Future<(int, List)> sscan( + String key, { + int cursor = 0, + int count = redisCollectionPageSize, + }) async { + final result = await sendCommand(['SSCAN', key, cursor, 'COUNT', count]); + if (result is List && result.length == 2) { + final nextCursor = int.tryParse(result[0].toString()) ?? 0; + final members = + (result[1] as List?)?.map((e) => e.toString()).toList() ?? []; + return (nextCursor, members); + } + return (0, []); + } + /// SCARD key. Future scard(String key) async { final result = await sendCommand(['SCARD', key]); @@ -515,8 +564,7 @@ class RedisConnection { case 'zset': return zcard(key); case 'hash': - final r = await sendCommand(['HLEN', key]); - return r is int ? r : int.tryParse(r.toString()) ?? 0; + return hlen(key); default: return 0; } @@ -531,6 +579,16 @@ class RedisConnectionTestFake extends RedisConnection { this.secondScanKeys = const [], this.dbSizeResult = 2, this.getResult, + this.listItems = const [], + this.llenResult, + this.hashFirstPage = const {}, + this.hashSecondPage = const {}, + this.hlenResult, + this.setFirstPage = const [], + this.setSecondPage = const [], + this.scardResult, + this.zsetItems = const <(String, double)>[], + this.zcardResult, }) : super( id: -1, name: 'test-fake', @@ -542,8 +600,20 @@ class RedisConnectionTestFake extends RedisConnection { final List secondScanKeys; final int dbSizeResult; final String? getResult; + final List listItems; + final int? llenResult; + final Map hashFirstPage; + final Map hashSecondPage; + final int? hlenResult; + final List setFirstPage; + final List setSecondPage; + final int? scardResult; + final List<(String, double)> zsetItems; + final int? zcardResult; bool _firstScanDone = false; + bool _firstHscanDone = false; + bool _firstSscanDone = false; @override bool get isConnected => _isConnected; @@ -596,6 +666,52 @@ class RedisConnectionTestFake extends RedisConnection { return -1; case 'GET': return getResult; + case 'HGETALL': + case 'SMEMBERS': + throw StateError('$op is unbounded; use a paged command'); + case 'HLEN': + return hlenResult ?? hashFirstPage.length + hashSecondPage.length; + case 'LLEN': + return llenResult ?? listItems.length; + case 'SCARD': + return scardResult ?? setFirstPage.length + setSecondPage.length; + case 'ZCARD': + return zcardResult ?? zsetItems.length; + case 'LRANGE': + return _sliceList(listItems, args); + case 'ZRANGE': + final stop = int.tryParse(args[3].toString()) ?? -1; + if (stop < 0) { + throw StateError('unbounded ZRANGE'); + } + final start = int.tryParse(args[2].toString()) ?? 0; + if (start >= zsetItems.length || start > stop) return []; + final end = (stop + 1).clamp(0, zsetItems.length); + final out = []; + for (final (member, score) in zsetItems.sublist(start, end)) { + out.add(member); + out.add(score); + } + return out; + case 'HSCAN': + return _pagedPairs( + cursor: int.tryParse(args[2].toString()) ?? 0, + first: hashFirstPage, + second: hashSecondPage, + firstDone: _firstHscanDone, + markFirst: () => _firstHscanDone = true, + ); + case 'SSCAN': + final cursor = int.tryParse(args[2].toString()) ?? 0; + if (cursor == 0 && !_firstSscanDone) { + _firstSscanDone = true; + final next = setSecondPage.isNotEmpty ? 1 : 0; + return [next, setFirstPage]; + } + if (cursor == 1 && setSecondPage.isNotEmpty) { + return [0, setSecondPage]; + } + return [0, []]; case 'READONLY': case 'READWRITE': return 'OK'; @@ -603,6 +719,45 @@ class RedisConnectionTestFake extends RedisConnection { return null; } } + + List _sliceList(List items, List args) { + if (items.isEmpty) return const []; + final start = int.tryParse(args[2].toString()) ?? 0; + var stop = int.tryParse(args[3].toString()) ?? -1; + if (stop < 0) { + throw StateError('unbounded LRANGE'); + } + if (start >= items.length || start > stop) return const []; + final end = (stop + 1).clamp(0, items.length); + return items.sublist(start, end); + } + + List _pagedPairs({ + required int cursor, + required Map first, + required Map second, + required bool firstDone, + required void Function() markFirst, + }) { + List flatten(Map map) { + final out = []; + map.forEach((k, v) { + out.add(k); + out.add(v); + }); + return out; + } + + if (cursor == 0 && !firstDone) { + markFirst(); + final next = second.isNotEmpty ? 1 : 0; + return [next, flatten(first)]; + } + if (cursor == 1 && second.isNotEmpty) { + return [0, flatten(second)]; + } + return [0, []]; + } } bool _isRedisNil(Object? result) => diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index 9a4cabad..807cc4f2 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -53,6 +53,11 @@ class _RedisKeyEditorState extends material.State { // Sorted set value List<(String, double)> _zsetValue = []; + int _collectionTotal = 0; + int _scanCursor = 0; + bool _hasMore = false; + bool _loadingMore = false; + // For adding new items final _newFieldController = material.TextEditingController(); final _newValueController = material.TextEditingController(); @@ -87,15 +92,20 @@ class _RedisKeyEditorState extends material.State { _stringValue = await widget.connection.get(widget.keyName); _stringController.text = _stringValue ?? ''; case 'hash': - _hashValue = await widget.connection.hgetall(widget.keyName); case 'list': - _listValue = await widget.connection.lrange(widget.keyName, 0, -1); case 'set': - _setValue = await widget.connection.smembers(widget.keyName); - _setValue.sort(); case 'zset': - _zsetValue = - await widget.connection.zrangeWithScores(widget.keyName, 0, -1); + _collectionTotal = await widget.connection.keySize( + widget.keyName, + widget.keyType, + ); + _scanCursor = 0; + _hashValue = {}; + _listValue = []; + _setValue = []; + _zsetValue = []; + _hasMore = false; + await _loadMore(reset: true); default: _stringValue = await widget.connection.get(widget.keyName); _stringController.text = _stringValue ?? ''; @@ -113,6 +123,100 @@ class _RedisKeyEditorState extends material.State { } } + int get _loadedCount => switch (widget.keyType) { + 'hash' => _hashValue.length, + 'list' => _listValue.length, + 'set' => _setValue.length, + 'zset' => _zsetValue.length, + _ => 0, + }; + + String _collectionHeading(String noun) { + if (_collectionTotal > 0) { + return '$noun ($_loadedCount / $_collectionTotal)'; + } + return '$noun ($_loadedCount)'; + } + + Widget _loadMoreTile() { + return material.Padding( + padding: const material.EdgeInsets.only(top: 12), + child: material.Center( + child: OutlineButton( + onPressed: _loadingMore ? null : () => _loadMore(), + size: ButtonSize.small, + child: _loadingMore + ? const Text('Loading...') + : Text('Load more ($_loadedCount / $_collectionTotal)'), + ), + ), + ); + } + + Future _loadMore({bool reset = false}) async { + if (_loadingMore) return; + if (!reset && mounted) setState(() => _loadingMore = true); + try { + await widget.connection.selectDatabase(widget.database); + switch (widget.keyType) { + case 'hash': + if (reset) { + _scanCursor = 0; + _hashValue = {}; + } + final (next, page) = await widget.connection.hscan( + widget.keyName, + cursor: _scanCursor, + count: redisCollectionPageSize, + ); + _hashValue.addAll(page); + _scanCursor = next; + _hasMore = next != 0; + case 'list': + if (reset) _listValue = []; + final start = _listValue.length; + final chunk = await widget.connection.lrange( + widget.keyName, + start, + start + redisCollectionPageSize - 1, + ); + _listValue.addAll(chunk); + _hasMore = _listValue.length < _collectionTotal; + case 'set': + if (reset) { + _scanCursor = 0; + _setValue = []; + } + final (next, members) = await widget.connection.sscan( + widget.keyName, + cursor: _scanCursor, + count: redisCollectionPageSize, + ); + _setValue.addAll(members); + _scanCursor = next; + _hasMore = next != 0; + case 'zset': + if (reset) _zsetValue = []; + final start = _zsetValue.length; + final chunk = await widget.connection.zrangeWithScores( + widget.keyName, + start, + start + redisCollectionPageSize - 1, + ); + _zsetValue.addAll(chunk); + _hasMore = _zsetValue.length < _collectionTotal; + } + if (!mounted) return; + setState(() => _loadingMore = false); + } catch (e) { + if (!mounted) return; + setState(() { + _loadingMore = false; + _error = 'Load failed: $e'; + }); + } + } + Future _saveString() async { if (widget.isReadOnly) return; try { @@ -374,6 +478,14 @@ class _RedisKeyEditorState extends material.State { ], ), ), + if (_collectionTotal >= redisLargeCollectionWarnAt) + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + child: Text( + 'Large key ($_collectionTotal members). Loading in pages of $redisCollectionPageSize.', + ).muted().small(), + ), // Header _buildHeader(cs, shadcnCs), const Divider(height: 1), @@ -570,7 +682,7 @@ class _RedisKeyEditorState extends material.State { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - Text('Hash fields (${entries.length})').semiBold(), + Text(_collectionHeading('Hash fields')).semiBold(), if (!widget.isReadOnly) ...[ const Gap(8), material.Row( @@ -624,6 +736,7 @@ class _RedisKeyEditorState extends material.State { }, ), ), + if (_hasMore) _loadMoreTile(), ], ); } @@ -634,7 +747,7 @@ class _RedisKeyEditorState extends material.State { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - Text('List items (${_listValue.length})').semiBold(), + Text(_collectionHeading('List items')).semiBold(), if (!widget.isReadOnly) ...[ const Gap(8), material.Row( @@ -674,6 +787,7 @@ class _RedisKeyEditorState extends material.State { ), ), ), + if (_hasMore) _loadMoreTile(), ], ); } @@ -684,7 +798,7 @@ class _RedisKeyEditorState extends material.State { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - Text('Set members (${_setValue.length})').semiBold(), + Text(_collectionHeading('Set members')).semiBold(), if (!widget.isReadOnly) ...[ const Gap(8), material.Row( @@ -726,6 +840,7 @@ class _RedisKeyEditorState extends material.State { ), ), ), + if (_hasMore) _loadMoreTile(), ], ); } @@ -736,7 +851,7 @@ class _RedisKeyEditorState extends material.State { return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ - Text('Sorted set (${_zsetValue.length})').semiBold(), + Text(_collectionHeading('Sorted set')).semiBold(), if (!widget.isReadOnly) ...[ const Gap(8), material.Row( @@ -791,6 +906,7 @@ class _RedisKeyEditorState extends material.State { }, ), ), + if (_hasMore) _loadMoreTile(), ], ); } diff --git a/test/core/database/redis_connection_test.dart b/test/core/database/redis_connection_test.dart index cfd94ade..36cf88b6 100644 --- a/test/core/database/redis_connection_test.dart +++ b/test/core/database/redis_connection_test.dart @@ -216,4 +216,39 @@ void main() { await fake.disconnect(); }); }); + + group('RedisConnection collection paging', () { + test('LRANGE first page is capped and HGETALL is refused by the fake', + () async { + final fake = RedisConnectionTestFake( + listItems: List.generate(250, (i) => 'item_$i'), + ); + await fake.connect(); + + final page = await fake.lrange('k', 0, redisCollectionPageSize - 1); + expect(page, hasLength(redisCollectionPageSize)); + expect(page.first, 'item_0'); + expect(page.last, 'item_199'); + expect(await fake.llen('k'), 250); + await expectLater(fake.hgetall('k'), throwsStateError); + await fake.disconnect(); + }); + + test('HSCAN returns pages instead of HGETALL', () async { + final fake = RedisConnectionTestFake( + hashFirstPage: const {'a': '1', 'b': '2'}, + hashSecondPage: const {'c': '3'}, + ); + await fake.connect(); + + final (c1, p1) = await fake.hscan('h'); + expect(c1, 1); + expect(p1, {'a': '1', 'b': '2'}); + final (c2, p2) = await fake.hscan('h', cursor: c1); + expect(c2, 0); + expect(p2, {'c': '3'}); + expect(await fake.hlen('h'), 3); + await fake.disconnect(); + }); + }); } diff --git a/test/features/redis/redis_key_editor_test.dart b/test/features/redis/redis_key_editor_test.dart index f0faa033..46fb4010 100644 --- a/test/features/redis/redis_key_editor_test.dart +++ b/test/features/redis/redis_key_editor_test.dart @@ -14,6 +14,8 @@ void main() { required bool isReadOnly, material.VoidCallback? onKeyDeleted, material.Size size = const material.Size(800, 600), + String keyType = 'string', + String keyName = 'session:1', }) async { await tester.pumpWidget( queryaThemeTestShell( @@ -24,8 +26,8 @@ void main() { child: RedisKeyEditor( connection: fake, database: 0, - keyName: 'session:1', - keyType: 'string', + keyName: keyName, + keyType: keyType, isReadOnly: isReadOnly, onKeyDeleted: onKeyDeleted, ), @@ -89,4 +91,65 @@ void main() { expect(find.byTooltip('Delete key'), findsOneWidget); await fake.disconnect(); }); + + testWidgets('RedisKeyEditor list loads a page instead of LRANGE 0 -1', + (tester) async { + final fake = RedisConnectionTestFake( + listItems: List.generate(250, (i) => 'item_$i'), + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: true, + keyType: 'list', + keyName: 'jobs', + size: const material.Size(800, 700), + ); + + expect(find.text('List items (200 / 250)'), findsOneWidget); + expect(find.text('Load more (200 / 250)'), findsOneWidget); + expect(find.text('item_0'), findsOneWidget); + expect(find.text('item_249'), findsNothing); + + await tester.tap(find.text('Load more (200 / 250)')); + await tester.pumpAndSettle(); + + expect(find.text('List items (250 / 250)'), findsOneWidget); + expect(find.text('Load more (200 / 250)'), findsNothing); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor hash uses HSCAN and warns when huge', + (tester) async { + final fake = RedisConnectionTestFake( + hashFirstPage: const {'email': 'a@b.c'}, + hashSecondPage: const {'name': 'Ada'}, + hlenResult: 15000, + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: true, + keyType: 'hash', + keyName: 'user:1', + size: const material.Size(800, 700), + ); + + expect( + find.textContaining('Large key (15000 members)'), + findsOneWidget, + ); + expect(find.text('Hash fields (1 / 15000)'), findsOneWidget); + expect(find.text('email'), findsOneWidget); + expect(find.text('name'), findsNothing); + + await tester.tap(find.text('Load more (1 / 15000)')); + await tester.pumpAndSettle(); + expect(find.text('name'), findsOneWidget); + await fake.disconnect(); + }); } From 5696eae778d46cafe6527fc2995ff7c5c8b2e65e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 14:00:12 +0300 Subject: [PATCH 25/52] fix(ui): wrap SQL toolbar so Execute does not overflow at 700px CI widget tests treat RenderFlex overflow as failure after the MySQL transaction label landed on the same row as History and Execute. --- CHANGELOG.md | 1 + lib/features/mysql/mysql_sql_workspace.dart | 8 ++++---- lib/features/postgresql/postgres_sql_workspace.dart | 9 ++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9367bbcc..a799a792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **SQL toolbar overflow** — MySQL / Postgres Query + History + Execute wrap instead of overflowing at ~700px (widget tests treat RenderFlex overflow as failure). - **Redis collection paging (#816)** — Hash / list / set / zset editors load the first 200 members (`HSCAN` / `LRANGE` / `SSCAN` / `ZRANGE`) with Load more, instead of `HGETALL` / `LRANGE 0 -1` / `SMEMBERS` / `ZRANGE 0 -1`. Keys with 10k+ members show a large-key warning. - **Redis URI Test/sidebar (#815)** — Test Connection and the sidebar keyspace probe parse `redis://` / `rediss://` via `fromConnectionRow` (host, port, TLS, cert query params) instead of hitting localhost:6379. The probe keeps `id: -1` so it does not replace workspace sockets. - **Redis DEL confirm (#814)** — Deleting a key (browser or editor) and removing a hash field / set member / zset member opens the same destructive-operation dialog as SQL. Cancel / Escape does not send `DEL` / `HDEL` / `SREM` / `ZREM`. diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 2807430a..bb2cb4fe 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -932,12 +932,13 @@ class _MysqlSqlToolbar extends material.StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: material.MainAxisSize.min, children: [ - material.Row( + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, children: [ const Text('Query').semiBold().small(), - const Gap(12), Text(mysqlSqlToolbarTxLabel(txOpen)).muted().small(), - const Spacer(), OutlineButton( size: ButtonSize.small, onPressed: onOpenHistory, @@ -948,7 +949,6 @@ class _MysqlSqlToolbar extends material.StatelessWidget { ), child: const Text('History'), ), - const Gap(8), OutlineButton( onPressed: onExecute, leading: running diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index cddbe194..46f95e60 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -1012,14 +1012,14 @@ class _SqlToolbar extends material.StatelessWidget { crossAxisAlignment: material.CrossAxisAlignment.stretch, mainAxisSize: material.MainAxisSize.min, children: [ - material.Row( + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, children: [ const Text('Query').semiBold().small(), - const Gap(12), Text('DB: $sessionDatabase').muted().small(), - const Gap(12), Text(_txLabel()).muted().small(), - const Spacer(), OutlineButton( size: ButtonSize.small, onPressed: onOpenHistory, @@ -1030,7 +1030,6 @@ class _SqlToolbar extends material.StatelessWidget { ), child: const Text('History'), ), - const Gap(8), OutlineButton( onPressed: onExecute, leading: running From 21ecdf0f4aeece1f6ad3dbbc2ece990b5d821213 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 14:12:12 +0300 Subject: [PATCH 26/52] fix(redis): do not GET or SET stream and unknown keys as strings Stream, module, and unknown types stay read-only until TYPE is string, so SET cannot replace the original Redis type. --- CHANGELOG.md | 1 + lib/core/database/redis_connection.dart | 21 ++++- lib/features/redis/redis_explorer_view.dart | 2 +- lib/features/redis/redis_key_editor.dart | 72 +++++++++++--- .../features/redis/redis_key_editor_test.dart | 93 +++++++++++++++++++ 5 files changed, 173 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a799a792..6d8fbbcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Redis stream/unknown GET-SET (#817)** — Stream, module, and `unknown` keys are not opened with `GET` or saved with `SET`. Save stays on `string` only; `unknown` retries `TYPE` before treating the value as a string. - **SQL toolbar overflow** — MySQL / Postgres Query + History + Execute wrap instead of overflowing at ~700px (widget tests treat RenderFlex overflow as failure). - **Redis collection paging (#816)** — Hash / list / set / zset editors load the first 200 members (`HSCAN` / `LRANGE` / `SSCAN` / `ZRANGE`) with Load more, instead of `HGETALL` / `LRANGE 0 -1` / `SMEMBERS` / `ZRANGE 0 -1`. Keys with 10k+ members show a large-key warning. - **Redis URI Test/sidebar (#815)** — Test Connection and the sidebar keyspace probe parse `redis://` / `rediss://` via `fromConnectionRow` (host, port, TLS, cert query params) instead of hitting localhost:6379. The probe keeps `id: -1` so it does not replace workspace sockets. diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index 936bd018..55d80725 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -589,6 +589,8 @@ class RedisConnectionTestFake extends RedisConnection { this.scardResult, this.zsetItems = const <(String, double)>[], this.zcardResult, + this.typeResult = 'string', + this.failType = false, }) : super( id: -1, name: 'test-fake', @@ -610,6 +612,9 @@ class RedisConnectionTestFake extends RedisConnection { final int? scardResult; final List<(String, double)> zsetItems; final int? zcardResult; + final String typeResult; + final bool failType; + final List sentCommands = []; bool _firstScanDone = false; bool _firstHscanDone = false; @@ -644,6 +649,7 @@ class RedisConnectionTestFake extends RedisConnection { throw StateError('Not connected to Redis'); } final op = args.first.toString().toUpperCase(); + sentCommands.add(op); switch (op) { case 'SELECT': return 'OK'; @@ -661,11 +667,24 @@ class RedisConnectionTestFake extends RedisConnection { } return [0, []]; case 'TYPE': - return 'string'; + if (failType) { + throw StateError('TYPE failed'); + } + return typeResult; case 'TTL': return -1; case 'GET': + if (typeResult != 'string') { + throw StateError( + 'WRONGTYPE Operation against a key holding the wrong kind of value', + ); + } return getResult; + case 'SET': + if (typeResult != 'string') { + throw StateError('SET refused: key is $typeResult'); + } + return 'OK'; case 'HGETALL': case 'SMEMBERS': throw StateError('$op is unbounded; use a paged command'); diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index 11cddcfe..6dcfd4c2 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -274,7 +274,7 @@ class _RedisExplorerViewState extends material.State { connection: conn, database: widget.database, keyName: _selectedKey!, - keyType: _selectedKeyType ?? 'string', + keyType: _selectedKeyType ?? 'unknown', onBack: _navigateToKeys, onKeyDeleted: _navigateToKeys, isReadOnly: widget.isReadOnly, diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index 807cc4f2..ed745f0e 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -6,7 +6,7 @@ import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; /// Viewer / editor for a single Redis key. Type-aware: string, hash, -/// list, set, zset. +/// list, set, zset. Stream / module / unknown types are not GET/SET. class RedisKeyEditor extends material.StatefulWidget { const RedisKeyEditor({ super.key, @@ -57,6 +57,7 @@ class _RedisKeyEditorState extends material.State { int _scanCursor = 0; bool _hasMore = false; bool _loadingMore = false; + late String _effectiveType; // For adding new items final _newFieldController = material.TextEditingController(); @@ -65,6 +66,7 @@ class _RedisKeyEditorState extends material.State { @override void initState() { super.initState(); + _effectiveType = _normalizedType(widget.keyType); _load(); } @@ -76,6 +78,26 @@ class _RedisKeyEditorState extends material.State { super.dispose(); } + String _normalizedType(String type) { + final t = type.trim().toLowerCase(); + if (t.isEmpty) return 'unknown'; + return t; + } + + Future _resolveType() async { + final incoming = _normalizedType(widget.keyType); + if (incoming != 'unknown') return incoming; + try { + final resolved = _normalizedType( + await widget.connection.keyType(widget.keyName), + ); + if (resolved == 'none' || resolved == 'unknown') return 'unknown'; + return resolved; + } catch (_) { + return 'unknown'; + } + } + Future _load() async { if (!mounted) return; setState(() { @@ -86,8 +108,9 @@ class _RedisKeyEditorState extends material.State { try { await widget.connection.selectDatabase(widget.database); _ttl = await widget.connection.ttl(widget.keyName); + _effectiveType = await _resolveType(); - switch (widget.keyType) { + switch (_effectiveType) { case 'string': _stringValue = await widget.connection.get(widget.keyName); _stringController.text = _stringValue ?? ''; @@ -97,7 +120,7 @@ class _RedisKeyEditorState extends material.State { case 'zset': _collectionTotal = await widget.connection.keySize( widget.keyName, - widget.keyType, + _effectiveType, ); _scanCursor = 0; _hashValue = {}; @@ -107,8 +130,8 @@ class _RedisKeyEditorState extends material.State { _hasMore = false; await _loadMore(reset: true); default: - _stringValue = await widget.connection.get(widget.keyName); - _stringController.text = _stringValue ?? ''; + _stringValue = null; + _stringController.text = ''; } if (!mounted) return; @@ -123,7 +146,7 @@ class _RedisKeyEditorState extends material.State { } } - int get _loadedCount => switch (widget.keyType) { + int get _loadedCount => switch (_effectiveType) { 'hash' => _hashValue.length, 'list' => _listValue.length, 'set' => _setValue.length, @@ -158,7 +181,7 @@ class _RedisKeyEditorState extends material.State { if (!reset && mounted) setState(() => _loadingMore = true); try { await widget.connection.selectDatabase(widget.database); - switch (widget.keyType) { + switch (_effectiveType) { case 'hash': if (reset) { _scanCursor = 0; @@ -219,6 +242,7 @@ class _RedisKeyEditorState extends material.State { Future _saveString() async { if (widget.isReadOnly) return; + if (_effectiveType != 'string') return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.set( @@ -240,7 +264,7 @@ class _RedisKeyEditorState extends material.State { final confirmed = await confirmDestructiveAction( context: context, type: DestructiveSqlType.redisDel, - targetName: '${widget.keyName} (${widget.keyType})', + targetName: '${widget.keyName} ($_effectiveType)', commandPreview: 'DEL ${widget.keyName}', connectionName: widget.connection.name, ); @@ -493,7 +517,7 @@ class _RedisKeyEditorState extends material.State { material.Expanded( child: material.Padding( padding: const material.EdgeInsets.all(16), - child: widget.keyType == 'string' + child: _effectiveType == 'string' ? material.SingleChildScrollView( child: _buildContent(cs, shadcnCs), ) @@ -505,7 +529,7 @@ class _RedisKeyEditorState extends material.State { } Widget _buildHeader(ColorScheme cs, shadcn.ColorScheme scs) { - final typeCol = _typeColor(widget.keyType); + final typeCol = _typeColor(_effectiveType); return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 10), @@ -514,7 +538,7 @@ class _RedisKeyEditorState extends material.State { ), child: material.Row( children: [ - material.Icon(_typeIcon(widget.keyType), size: 18, color: typeCol), + material.Icon(_typeIcon(_effectiveType), size: 18, color: typeCol), const Gap(8), material.Container( padding: @@ -524,7 +548,7 @@ class _RedisKeyEditorState extends material.State { borderRadius: material.BorderRadius.circular(4), ), child: Text( - widget.keyType.toUpperCase(), + _effectiveType.toUpperCase(), style: material.TextStyle( fontSize: 10, fontWeight: material.FontWeight.w600, @@ -612,7 +636,7 @@ class _RedisKeyEditorState extends material.State { } Widget _buildContent(ColorScheme cs, shadcn.ColorScheme scs) { - switch (widget.keyType) { + switch (_effectiveType) { case 'string': return _buildStringEditor(cs, scs); case 'hash': @@ -624,10 +648,28 @@ class _RedisKeyEditorState extends material.State { case 'zset': return _buildZsetEditor(cs, scs); default: - return _buildStringEditor(cs, scs); + return _buildUnsupportedViewer(); } } + Widget _buildUnsupportedViewer() { + final unknown = _effectiveType == 'unknown'; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text(unknown ? 'Type unknown' : 'Unsupported type').semiBold(), + const Gap(8), + Text( + unknown + ? 'This key is not opened as a string until TYPE succeeds. ' + 'GET and SET are disabled so the original type is not overwritten.' + : '$_effectiveType keys cannot be opened with GET/SET. ' + 'Save is disabled so the original type is not replaced with a string.', + ).muted().small(), + ], + ); + } + // ─── String ───────────────────────────────────────────────────────────── Widget _buildStringEditor(ColorScheme cs, shadcn.ColorScheme scs) { @@ -955,6 +997,8 @@ class _RedisKeyEditorState extends material.State { return material.Icons.scatter_plot_rounded; case 'zset': return material.Icons.sort_rounded; + case 'stream': + return material.Icons.view_stream_rounded; default: return material.Icons.help_outline_rounded; } diff --git a/test/features/redis/redis_key_editor_test.dart b/test/features/redis/redis_key_editor_test.dart index 46fb4010..b1484143 100644 --- a/test/features/redis/redis_key_editor_test.dart +++ b/test/features/redis/redis_key_editor_test.dart @@ -152,4 +152,97 @@ void main() { expect(find.text('name'), findsOneWidget); await fake.disconnect(); }); + + testWidgets('RedisKeyEditor stream does not GET or show Save', + (tester) async { + final fake = RedisConnectionTestFake( + getResult: 'should-not-load', + typeResult: 'stream', + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + keyType: 'stream', + keyName: 'querya:stream:events', + ); + + expect(find.text('Unsupported type'), findsOneWidget); + expect(find.text('Save'), findsNothing); + expect(find.text('should-not-load'), findsNothing); + expect(fake.sentCommands.contains('GET'), isFalse); + expect(fake.sentCommands.contains('SET'), isFalse); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor unknown does not GET until TYPE succeeds', + (tester) async { + final fake = RedisConnectionTestFake( + getResult: 'should-not-load', + failType: true, + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + keyType: 'unknown', + keyName: 'maybe-module', + ); + + expect(find.text('Type unknown'), findsOneWidget); + expect(find.text('Save'), findsNothing); + expect(find.text('should-not-load'), findsNothing); + expect(fake.sentCommands.contains('GET'), isFalse); + expect(fake.sentCommands.contains('SET'), isFalse); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor unknown TYPE stream does not GET', + (tester) async { + final fake = RedisConnectionTestFake( + getResult: 'should-not-load', + typeResult: 'stream', + ); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + keyType: 'unknown', + keyName: 'querya:stream:events', + ); + + expect(find.text('Unsupported type'), findsOneWidget); + expect(find.text('Save'), findsNothing); + expect(find.textContaining('STREAM'), findsWidgets); + expect(fake.sentCommands.contains('TYPE'), isTrue); + expect(fake.sentCommands.contains('GET'), isFalse); + await fake.disconnect(); + }); + + testWidgets('RedisKeyEditor unknown TYPE string then GET and Save', + (tester) async { + final fake = + RedisConnectionTestFake(getResult: 'hello', typeResult: 'string'); + await fake.connect(); + + await pumpEditor( + tester, + fake: fake, + isReadOnly: false, + keyType: 'unknown', + keyName: 'session:1', + ); + + expect(find.text('hello'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + expect(fake.sentCommands.contains('TYPE'), isTrue); + expect(fake.sentCommands.contains('GET'), isTrue); + await fake.disconnect(); + }); } From f5570a38ad015f364db18962926f2dfeeb690d8c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 14:54:51 +0300 Subject: [PATCH 27/52] fix(redis): keep binary bulk values as bytes, not UTF-8 toString Invalid UTF-8 GET/SCAN replies show hex/base64 and cannot be saved as text, so SET does not write replacement characters. --- CHANGELOG.md | 1 + lib/core/database/redis_bulk.dart | 119 ++++++++++ lib/core/database/redis_connection.dart | 214 ++++++++++-------- lib/features/redis/redis_explorer_view.dart | 13 +- lib/features/redis/redis_key_editor.dart | 127 +++++++---- lib/features/redis/redis_keys_view.dart | 11 +- test/core/database/redis_bulk_test.dart | 41 ++++ test/core/database/redis_connection_test.dart | 14 +- .../database/redis_types_and_ttls_test.dart | 7 +- .../features/redis/redis_key_editor_test.dart | 16 ++ test/features/redis/redis_keys_view_test.dart | 38 +++- 11 files changed, 445 insertions(+), 156 deletions(-) create mode 100644 lib/core/database/redis_bulk.dart create mode 100644 test/core/database/redis_bulk_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8fbbcd..9c93ade7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Redis binary bulk (#818)** — GET / SCAN / hash / list / set / zset decode bulk replies as bytes (`RedisParserBulkBinary`). Invalid UTF-8 is shown as hex / base64; Save as text is off so SET cannot write replacement characters. - **Redis stream/unknown GET-SET (#817)** — Stream, module, and `unknown` keys are not opened with `GET` or saved with `SET`. Save stays on `string` only; `unknown` retries `TYPE` before treating the value as a string. - **SQL toolbar overflow** — MySQL / Postgres Query + History + Execute wrap instead of overflowing at ~700px (widget tests treat RenderFlex overflow as failure). - **Redis collection paging (#816)** — Hash / list / set / zset editors load the first 200 members (`HSCAN` / `LRANGE` / `SSCAN` / `ZRANGE`) with Load more, instead of `HGETALL` / `LRANGE 0 -1` / `SMEMBERS` / `ZRANGE 0 -1`. Keys with 10k+ members show a large-key warning. diff --git a/lib/core/database/redis_bulk.dart b/lib/core/database/redis_bulk.dart new file mode 100644 index 00000000..ec602d0c --- /dev/null +++ b/lib/core/database/redis_bulk.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:redis/redis.dart' as redis; + +/// Bulk string from Redis: UTF-8 text when valid, otherwise raw bytes. +class RedisBulkValue { + RedisBulkValue._({required this.bytes, required this.text}); + + factory RedisBulkValue.utf8(String value) { + return RedisBulkValue._( + bytes: Uint8List.fromList(utf8.encode(value)), + text: value, + ); + } + + factory RedisBulkValue.fromReply(Object? reply) { + if (reply == null) { + return RedisBulkValue._(bytes: Uint8List(0), text: ''); + } + if (reply is String) { + return RedisBulkValue.utf8(reply); + } + if (reply is int) { + return RedisBulkValue.utf8(reply.toString()); + } + if (reply is double) { + return RedisBulkValue.utf8(reply.toString()); + } + final bytes = _asBytes(reply); + if (bytes != null) { + return RedisBulkValue._(bytes: bytes, text: _tryUtf8(bytes)); + } + return RedisBulkValue.utf8(reply.toString()); + } + + final Uint8List bytes; + + /// Null when [bytes] are not valid UTF-8. + final String? text; + + bool get isUtf8 => text != null; + + bool get isEmpty => bytes.isEmpty; + + /// Argument for `send_object`: a Dart [String] when UTF-8, else [redis.RedisBulk]. + Object get commandArg => isUtf8 ? text! : redis.RedisBulk(bytes); + + /// UI label: the UTF-8 string, or a hex preview that is not `List.toString()`. + String get label { + if (text != null) return text!; + if (bytes.isEmpty) return '(empty)'; + final hex = toHex(); + if (hex.length <= 48) return '0x$hex'; + return '0x${hex.substring(0, 48)}… (${bytes.length} B)'; + } + + String toHex() { + final out = StringBuffer(); + for (final b in bytes) { + out.write(b.toRadixString(16).padLeft(2, '0')); + } + return out.toString(); + } + + String toBase64() => base64Encode(bytes); + + @override + bool operator ==(Object other) { + if (other is! RedisBulkValue || other.bytes.length != bytes.length) { + return false; + } + for (var i = 0; i < bytes.length; i++) { + if (other.bytes[i] != bytes[i]) return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll(bytes); + + @override + String toString() => label; +} + +int redisReplyInt(Object? reply, [int fallback = 0]) { + if (reply is int) return reply; + final text = RedisBulkValue.fromReply(reply).text; + return int.tryParse(text ?? '') ?? fallback; +} + +double redisReplyDouble(Object? reply, [double fallback = 0]) { + if (reply is num) return reply.toDouble(); + final text = RedisBulkValue.fromReply(reply).text; + return double.tryParse(text ?? '') ?? fallback; +} + +Object redisCommandArg(Object value) { + if (value is RedisBulkValue) return value.commandArg; + return value; +} + +Uint8List? _asBytes(Object reply) { + if (reply is Uint8List) return reply; + if (reply is List) return Uint8List.fromList(reply); + if (reply is List && reply.isNotEmpty && reply.every((e) => e is int)) { + return Uint8List.fromList(List.from(reply)); + } + if (reply is List && reply.isEmpty) return Uint8List(0); + return null; +} + +String? _tryUtf8(Uint8List bytes) { + try { + return utf8.decode(bytes); + } on FormatException { + return null; + } +} diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index 55d80725..3684e119 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:redis/redis.dart' as redis; @@ -128,6 +129,7 @@ class RedisConnection { } else { _command = await _conn!.connect(host, port); } + _command!.setParser(redis.RedisParserBulkBinary()); if (effectivePassword != null && effectivePassword.isNotEmpty) { if (username != null && username!.trim().isNotEmpty) { await _command! @@ -180,7 +182,7 @@ class RedisConnection { throw StateError('Not connected to Redis'); } final result = await _command!.send_object(['INFO']); - return result?.toString() ?? ''; + return RedisBulkValue.fromReply(result).text ?? ''; } Future testConnection() async { @@ -235,7 +237,7 @@ class RedisConnection { /// DBSIZE — number of keys in the currently selected database. Future dbSize() async { final result = await sendCommand(['DBSIZE']); - return result is int ? result : int.tryParse(result.toString()) ?? 0; + return redisReplyInt(result); } /// CONFIG GET databases — max number of databases. @@ -243,7 +245,7 @@ class RedisConnection { try { final result = await sendCommand(['CONFIG', 'GET', 'databases']); if (result is List && result.length >= 2) { - return int.tryParse(result[1].toString()) ?? 16; + return redisReplyInt(result[1], 16); } } catch (e) { debugPrint('RedisConnection.getMaxDatabases: $e'); @@ -254,7 +256,7 @@ class RedisConnection { /// SCAN cursor [MATCH pattern] [COUNT count]. /// Returns (nextCursor, keys). - Future<(int, List)> scan({ + Future<(int, List)> scan({ int cursor = 0, String? match, int count = 100, @@ -266,24 +268,24 @@ class RedisConnection { args.addAll(['COUNT', count]); final result = await sendCommand(args); if (result is List && result.length == 2) { - final nextCursor = int.tryParse(result[0].toString()) ?? 0; + final nextCursor = redisReplyInt(result[0]); final keys = - (result[1] as List?)?.map((e) => e.toString()).toList() ?? []; + (result[1] as List?)?.map(RedisBulkValue.fromReply).toList() ?? []; return (nextCursor, keys); } - return (0, []); + return (0, []); } /// TYPE key. - Future keyType(String key) async { - final result = await sendCommand(['TYPE', key]); - return result?.toString() ?? 'none'; + Future keyType(Object key) async { + final result = await sendCommand(['TYPE', redisCommandArg(key)]); + return RedisBulkValue.fromReply(result).text ?? 'none'; } /// TTL key (returns -1 if no expiry, -2 if missing). - Future ttl(String key) async { - final result = await sendCommand(['TTL', key]); - return result is int ? result : int.tryParse(result.toString()) ?? -1; + Future ttl(Object key) async { + final result = await sendCommand(['TTL', redisCommandArg(key)]); + return redisReplyInt(result, -1); } /// Pipelined TYPE + TTL for a SCAN batch. @@ -291,7 +293,8 @@ class RedisConnection { /// Writes all commands before awaiting replies (redis-dart FIFO parse /// queue + optional Nagle via [Command.pipe_start]), so a batch of N keys /// costs ~1 RTT instead of ~2N sequential round-trips. - Future> typesAndTtls(List keys) async { + Future> typesAndTtls( + List keys) async { if (keys.isEmpty) return const []; if (!isConnected) { throw StateError('Not connected to Redis'); @@ -302,15 +305,15 @@ class RedisConnection { try { final typeFutures = >[ for (final key in keys) - sendCommand(['TYPE', key]).then( - (v) => v?.toString() ?? 'none', + sendCommand(['TYPE', key.commandArg]).then( + (v) => RedisBulkValue.fromReply(v).text ?? 'none', onError: (_) => 'unknown', ), ]; final ttlFutures = >[ for (final key in keys) - sendCommand(['TTL', key]).then( - (v) => v is int ? v : int.tryParse(v.toString()) ?? -1, + sendCommand(['TTL', key.commandArg]).then( + (v) => redisReplyInt(v, -1), onError: (_) => -1, ), ]; @@ -324,10 +327,11 @@ class RedisConnection { } } - /// GET (string). - Future get(String key) async { - final result = await sendCommand(['GET', key]); - return result?.toString(); + /// GET. Returns null for a missing key. Non-UTF-8 values keep raw bytes. + Future get(Object key) async { + final result = await sendCommand(['GET', redisCommandArg(key)]); + if (result == null) return null; + return RedisBulkValue.fromReply(result); } /// SET key value [EX seconds]. @@ -337,24 +341,25 @@ class RedisConnection { /// `SET … EX` on older servers. Does not recreate a key that is already gone /// (TTL `-2` / `XX` miss). Future set( - String key, + Object key, String value, { int? ttlSeconds, bool keepTtl = true, }) async { _assertWritable(); + final k = redisCommandArg(key); if (ttlSeconds != null && ttlSeconds > 0) { - await sendCommand(['SET', key, value, 'EX', ttlSeconds]); + await sendCommand(['SET', k, value, 'EX', ttlSeconds]); return; } if (!keepTtl) { - await sendCommand(['SET', key, value]); + await sendCommand(['SET', k, value]); return; } - await _setPreservingTtl(key, value); + await _setPreservingTtl(k, value); } - Future _setPreservingTtl(String key, String value) async { + Future _setPreservingTtl(Object key, String value) async { try { final result = await sendCommand(['SET', key, value, 'KEEPTTL', 'XX']); if (_isRedisNil(result)) { @@ -377,160 +382,166 @@ class RedisConnection { } } - /// HGETALL key. Returns a `Map`. - Future> hgetall(String key) async { - final result = await sendCommand(['HGETALL', key]); - final map = {}; + /// HGETALL key. + Future> hgetall(Object key) async { + final result = await sendCommand(['HGETALL', redisCommandArg(key)]); + final map = {}; if (result is List) { for (var i = 0; i + 1 < result.length; i += 2) { - map[result[i].toString()] = result[i + 1].toString(); + map[RedisBulkValue.fromReply(result[i])] = + RedisBulkValue.fromReply(result[i + 1]); } } return map; } /// HSCAN key cursor [COUNT count]. Returns (nextCursor, fields). - Future<(int, Map)> hscan( - String key, { + Future<(int, Map)> hscan( + Object key, { int cursor = 0, int count = redisCollectionPageSize, }) async { - final result = await sendCommand(['HSCAN', key, cursor, 'COUNT', count]); + final result = await sendCommand( + ['HSCAN', redisCommandArg(key), cursor, 'COUNT', count]); if (result is List && result.length == 2) { - final nextCursor = int.tryParse(result[0].toString()) ?? 0; - final map = {}; + final nextCursor = redisReplyInt(result[0]); + final map = {}; final pairs = result[1]; if (pairs is List) { for (var i = 0; i + 1 < pairs.length; i += 2) { - map[pairs[i].toString()] = pairs[i + 1].toString(); + map[RedisBulkValue.fromReply(pairs[i])] = + RedisBulkValue.fromReply(pairs[i + 1]); } } return (nextCursor, map); } - return (0, {}); + return (0, {}); } /// HLEN key. - Future hlen(String key) async { - final result = await sendCommand(['HLEN', key]); - return result is int ? result : int.tryParse(result.toString()) ?? 0; + Future hlen(Object key) async { + final result = await sendCommand(['HLEN', redisCommandArg(key)]); + return redisReplyInt(result); } /// HSET key field value. - Future hset(String key, String field, String value) async { + Future hset(Object key, String field, String value) async { _assertWritable(); - await sendCommand(['HSET', key, field, value]); + await sendCommand(['HSET', redisCommandArg(key), field, value]); } /// HDEL key field. - Future hdel(String key, String field) async { + Future hdel(Object key, Object field) async { _assertWritable(); - await sendCommand(['HDEL', key, field]); + await sendCommand(['HDEL', redisCommandArg(key), redisCommandArg(field)]); } /// LRANGE key start stop. - Future> lrange(String key, int start, int stop) async { - final result = await sendCommand(['LRANGE', key, start, stop]); + Future> lrange(Object key, int start, int stop) async { + final result = + await sendCommand(['LRANGE', redisCommandArg(key), start, stop]); if (result is List) { - return result.map((e) => e.toString()).toList(); + return result.map(RedisBulkValue.fromReply).toList(); } return []; } /// LLEN key. - Future llen(String key) async { - final result = await sendCommand(['LLEN', key]); - return result is int ? result : int.tryParse(result.toString()) ?? 0; + Future llen(Object key) async { + final result = await sendCommand(['LLEN', redisCommandArg(key)]); + return redisReplyInt(result); } /// RPUSH key value. - Future rpush(String key, String value) async { + Future rpush(Object key, String value) async { _assertWritable(); - await sendCommand(['RPUSH', key, value]); + await sendCommand(['RPUSH', redisCommandArg(key), value]); } /// SMEMBERS key. - Future> smembers(String key) async { - final result = await sendCommand(['SMEMBERS', key]); + Future> smembers(Object key) async { + final result = await sendCommand(['SMEMBERS', redisCommandArg(key)]); if (result is List) { - return result.map((e) => e.toString()).toList(); + return result.map(RedisBulkValue.fromReply).toList(); } return []; } /// SSCAN key cursor [COUNT count]. Returns (nextCursor, members). - Future<(int, List)> sscan( - String key, { + Future<(int, List)> sscan( + Object key, { int cursor = 0, int count = redisCollectionPageSize, }) async { - final result = await sendCommand(['SSCAN', key, cursor, 'COUNT', count]); + final result = await sendCommand( + ['SSCAN', redisCommandArg(key), cursor, 'COUNT', count]); if (result is List && result.length == 2) { - final nextCursor = int.tryParse(result[0].toString()) ?? 0; + final nextCursor = redisReplyInt(result[0]); final members = - (result[1] as List?)?.map((e) => e.toString()).toList() ?? []; + (result[1] as List?)?.map(RedisBulkValue.fromReply).toList() ?? []; return (nextCursor, members); } - return (0, []); + return (0, []); } /// SCARD key. - Future scard(String key) async { - final result = await sendCommand(['SCARD', key]); - return result is int ? result : int.tryParse(result.toString()) ?? 0; + Future scard(Object key) async { + final result = await sendCommand(['SCARD', redisCommandArg(key)]); + return redisReplyInt(result); } /// SADD key member. - Future sadd(String key, String member) async { + Future sadd(Object key, String member) async { _assertWritable(); - await sendCommand(['SADD', key, member]); + await sendCommand(['SADD', redisCommandArg(key), member]); } /// SREM key member. - Future srem(String key, String member) async { + Future srem(Object key, Object member) async { _assertWritable(); - await sendCommand(['SREM', key, member]); + await sendCommand(['SREM', redisCommandArg(key), redisCommandArg(member)]); } /// ZRANGE key start stop WITHSCORES → list of (member, score). - Future> zrangeWithScores( - String key, int start, int stop) async { - final result = - await sendCommand(['ZRANGE', key, start, stop, 'WITHSCORES']); - final list = <(String, double)>[]; + Future> zrangeWithScores( + Object key, int start, int stop) async { + final result = await sendCommand( + ['ZRANGE', redisCommandArg(key), start, stop, 'WITHSCORES']); + final list = <(RedisBulkValue, double)>[]; if (result is List) { for (var i = 0; i + 1 < result.length; i += 2) { - final member = result[i].toString(); - final score = double.tryParse(result[i + 1].toString()) ?? 0; - list.add((member, score)); + list.add(( + RedisBulkValue.fromReply(result[i]), + redisReplyDouble(result[i + 1]), + )); } } return list; } /// ZCARD key. - Future zcard(String key) async { - final result = await sendCommand(['ZCARD', key]); - return result is int ? result : int.tryParse(result.toString()) ?? 0; + Future zcard(Object key) async { + final result = await sendCommand(['ZCARD', redisCommandArg(key)]); + return redisReplyInt(result); } /// ZADD key score member. - Future zadd(String key, double score, String member) async { + Future zadd(Object key, double score, String member) async { _assertWritable(); - await sendCommand(['ZADD', key, score, member]); + await sendCommand(['ZADD', redisCommandArg(key), score, member]); } /// ZREM key member. - Future zrem(String key, String member) async { + Future zrem(Object key, Object member) async { _assertWritable(); - await sendCommand(['ZREM', key, member]); + await sendCommand(['ZREM', redisCommandArg(key), redisCommandArg(member)]); } /// DEL key. - Future del(String key) async { + Future del(Object key) async { _assertWritable(); - final result = await sendCommand(['DEL', key]); - return result is int ? result : int.tryParse(result.toString()) ?? 0; + final result = await sendCommand(['DEL', redisCommandArg(key)]); + return redisReplyInt(result); } /// RENAME old new. @@ -540,23 +551,23 @@ class RedisConnection { } /// EXPIRE key seconds. - Future expire(String key, int seconds) async { + Future expire(Object key, int seconds) async { _assertWritable(); - await sendCommand(['EXPIRE', key, seconds]); + await sendCommand(['EXPIRE', redisCommandArg(key), seconds]); } /// PERSIST key (remove TTL). - Future persist(String key) async { + Future persist(Object key) async { _assertWritable(); - await sendCommand(['PERSIST', key]); + await sendCommand(['PERSIST', redisCommandArg(key)]); } /// STRLEN / LLEN / SCARD / ZCARD / HLEN — get size for any type. - Future keySize(String key, String type) async { + Future keySize(Object key, String type) async { switch (type) { case 'string': - final r = await sendCommand(['STRLEN', key]); - return r is int ? r : int.tryParse(r.toString()) ?? 0; + final r = await sendCommand(['STRLEN', redisCommandArg(key)]); + return redisReplyInt(r); case 'list': return llen(key); case 'set': @@ -591,6 +602,8 @@ class RedisConnectionTestFake extends RedisConnection { this.zcardResult, this.typeResult = 'string', this.failType = false, + this.getBytesResult, + this.binaryScanKeys = const >[], }) : super( id: -1, name: 'test-fake', @@ -614,6 +627,8 @@ class RedisConnectionTestFake extends RedisConnection { final int? zcardResult; final String typeResult; final bool failType; + final List? getBytesResult; + final List> binaryScanKeys; final List sentCommands = []; bool _firstScanDone = false; @@ -657,15 +672,17 @@ class RedisConnectionTestFake extends RedisConnection { return dbSizeResult; case 'SCAN': final cursor = int.tryParse(args[1].toString()) ?? 0; + List keysFor(List named) => + [...named, ...binaryScanKeys]; if (cursor == 0 && !_firstScanDone) { _firstScanDone = true; final next = secondScanKeys.isNotEmpty ? 1 : 0; - return [next, firstScanKeys]; + return [next, keysFor(firstScanKeys)]; } if (cursor == 1 && secondScanKeys.isNotEmpty) { - return [0, secondScanKeys]; + return [0, keysFor(secondScanKeys)]; } - return [0, []]; + return [0, []]; case 'TYPE': if (failType) { throw StateError('TYPE failed'); @@ -674,6 +691,7 @@ class RedisConnectionTestFake extends RedisConnection { case 'TTL': return -1; case 'GET': + if (getBytesResult != null) return getBytesResult; if (typeResult != 'string') { throw StateError( 'WRONGTYPE Operation against a key holding the wrong kind of value', diff --git a/lib/features/redis/redis_explorer_view.dart b/lib/features/redis/redis_explorer_view.dart index 6dcfd4c2..af409d67 100644 --- a/lib/features/redis/redis_explorer_view.dart +++ b/lib/features/redis/redis_explorer_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; @@ -53,7 +54,7 @@ class _RedisExplorerViewState extends material.State { int _refreshEpoch = 0; // Navigation state - String? _selectedKey; + RedisBulkValue? _selectedKey; String? _selectedKeyType; @override @@ -131,7 +132,7 @@ class _RedisExplorerViewState extends material.State { // ─── Navigation helpers ───────────────────────────────────────────────── - void _navigateToKey(String key, String type) { + void _navigateToKey(RedisBulkValue key, String type) { setState(() { _selectedKey = key; _selectedKeyType = type; @@ -153,7 +154,7 @@ class _RedisExplorerViewState extends material.State { '${widget.connectionRow.name} › db${widget.database}', _Level.keys), ]; if (_selectedKey != null) { - list.add(_Crumb(_selectedKey!, _Level.key)); + list.add(_Crumb(_selectedKey!.label, _Level.key)); } if (_showStats) { list.add(const _Crumb('Statistics', _Level.stats)); @@ -270,10 +271,12 @@ class _RedisExplorerViewState extends material.State { // Key editor if (_selectedKey != null) { return RedisKeyEditor( - key: ValueKey('key_${widget.database}_${_selectedKey}_$_refreshEpoch'), + key: ValueKey( + 'key_${widget.database}_${_selectedKey!.label}_$_refreshEpoch'), connection: conn, database: widget.database, - keyName: _selectedKey!, + keyName: _selectedKey!.label, + keyArg: _selectedKey!.commandArg, keyType: _selectedKeyType ?? 'unknown', onBack: _navigateToKeys, onKeyDeleted: _navigateToKeys, diff --git a/lib/features/redis/redis_key_editor.dart b/lib/features/redis/redis_key_editor.dart index ed745f0e..db6a51cd 100644 --- a/lib/features/redis/redis_key_editor.dart +++ b/lib/features/redis/redis_key_editor.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -14,6 +15,7 @@ class RedisKeyEditor extends material.StatefulWidget { required this.database, required this.keyName, required this.keyType, + this.keyArg, this.onBack, this.onKeyDeleted, this.isReadOnly = false, @@ -23,6 +25,9 @@ class RedisKeyEditor extends material.StatefulWidget { final int database; final String keyName; final String keyType; + + /// Wire key for GET/SET/DEL when [keyName] is only a UTF-8/hex label. + final Object? keyArg; final VoidCallback? onBack; final VoidCallback? onKeyDeleted; final bool isReadOnly; @@ -38,20 +43,20 @@ class _RedisKeyEditorState extends material.State { int _ttl = -1; // String value - String? _stringValue; + RedisBulkValue? _stringValue; final _stringController = material.TextEditingController(); // Hash value - Map _hashValue = {}; + Map _hashValue = {}; // List value - List _listValue = []; + List _listValue = []; // Set value - List _setValue = []; + List _setValue = []; // Sorted set value - List<(String, double)> _zsetValue = []; + List<(RedisBulkValue, double)> _zsetValue = []; int _collectionTotal = 0; int _scanCursor = 0; @@ -78,6 +83,13 @@ class _RedisKeyEditorState extends material.State { super.dispose(); } + Object get _cmdKey => widget.keyArg ?? widget.keyName; + + bool get _stringIsBinary => + _effectiveType == 'string' && + _stringValue != null && + !_stringValue!.isUtf8; + String _normalizedType(String type) { final t = type.trim().toLowerCase(); if (t.isEmpty) return 'unknown'; @@ -89,7 +101,7 @@ class _RedisKeyEditorState extends material.State { if (incoming != 'unknown') return incoming; try { final resolved = _normalizedType( - await widget.connection.keyType(widget.keyName), + await widget.connection.keyType(_cmdKey), ); if (resolved == 'none' || resolved == 'unknown') return 'unknown'; return resolved; @@ -107,19 +119,19 @@ class _RedisKeyEditorState extends material.State { }); try { await widget.connection.selectDatabase(widget.database); - _ttl = await widget.connection.ttl(widget.keyName); + _ttl = await widget.connection.ttl(_cmdKey); _effectiveType = await _resolveType(); switch (_effectiveType) { case 'string': - _stringValue = await widget.connection.get(widget.keyName); - _stringController.text = _stringValue ?? ''; + _stringValue = await widget.connection.get(_cmdKey); + _stringController.text = _stringValue?.text ?? ''; case 'hash': case 'list': case 'set': case 'zset': _collectionTotal = await widget.connection.keySize( - widget.keyName, + _cmdKey, _effectiveType, ); _scanCursor = 0; @@ -188,7 +200,7 @@ class _RedisKeyEditorState extends material.State { _hashValue = {}; } final (next, page) = await widget.connection.hscan( - widget.keyName, + _cmdKey, cursor: _scanCursor, count: redisCollectionPageSize, ); @@ -199,7 +211,7 @@ class _RedisKeyEditorState extends material.State { if (reset) _listValue = []; final start = _listValue.length; final chunk = await widget.connection.lrange( - widget.keyName, + _cmdKey, start, start + redisCollectionPageSize - 1, ); @@ -211,7 +223,7 @@ class _RedisKeyEditorState extends material.State { _setValue = []; } final (next, members) = await widget.connection.sscan( - widget.keyName, + _cmdKey, cursor: _scanCursor, count: redisCollectionPageSize, ); @@ -222,7 +234,7 @@ class _RedisKeyEditorState extends material.State { if (reset) _zsetValue = []; final start = _zsetValue.length; final chunk = await widget.connection.zrangeWithScores( - widget.keyName, + _cmdKey, start, start + redisCollectionPageSize - 1, ); @@ -242,11 +254,11 @@ class _RedisKeyEditorState extends material.State { Future _saveString() async { if (widget.isReadOnly) return; - if (_effectiveType != 'string') return; + if (_effectiveType != 'string' || _stringIsBinary) return; try { await widget.connection.selectDatabase(widget.database); await widget.connection.set( - widget.keyName, + _cmdKey, _stringController.text, keepTtl: true, ); @@ -271,7 +283,7 @@ class _RedisKeyEditorState extends material.State { if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.del(widget.keyName); + await widget.connection.del(_cmdKey); widget.onKeyDeleted?.call(); } catch (e) { if (!mounted) return; @@ -284,11 +296,11 @@ class _RedisKeyEditorState extends material.State { try { await widget.connection.selectDatabase(widget.database); if (seconds > 0) { - await widget.connection.expire(widget.keyName, seconds); + await widget.connection.expire(_cmdKey, seconds); } else { - await widget.connection.persist(widget.keyName); + await widget.connection.persist(_cmdKey); } - _ttl = await widget.connection.ttl(widget.keyName); + _ttl = await widget.connection.ttl(_cmdKey); if (!mounted) return; setState(() { _success = seconds > 0 ? 'TTL set to $seconds seconds' : 'TTL removed'; @@ -305,7 +317,7 @@ class _RedisKeyEditorState extends material.State { if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.hset(widget.keyName, field, value); + await widget.connection.hset(_cmdKey, field, value); await _load(); } catch (e) { if (!mounted) return; @@ -313,19 +325,19 @@ class _RedisKeyEditorState extends material.State { } } - Future _hashDel(String field) async { + Future _hashDel(RedisBulkValue field) async { if (widget.isReadOnly) return; final confirmed = await confirmDestructiveAction( context: context, type: DestructiveSqlType.redisHdel, - targetName: field, - commandPreview: 'HDEL ${widget.keyName} $field', + targetName: field.label, + commandPreview: 'HDEL ${widget.keyName} ${field.label}', connectionName: widget.connection.name, ); if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.hdel(widget.keyName, field); + await widget.connection.hdel(_cmdKey, field); await _load(); } catch (e) { if (!mounted) return; @@ -338,7 +350,7 @@ class _RedisKeyEditorState extends material.State { if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.rpush(widget.keyName, value); + await widget.connection.rpush(_cmdKey, value); await _load(); } catch (e) { if (!mounted) return; @@ -351,7 +363,7 @@ class _RedisKeyEditorState extends material.State { if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.sadd(widget.keyName, member); + await widget.connection.sadd(_cmdKey, member); await _load(); } catch (e) { if (!mounted) return; @@ -359,19 +371,19 @@ class _RedisKeyEditorState extends material.State { } } - Future _setRemove(String member) async { + Future _setRemove(RedisBulkValue member) async { if (widget.isReadOnly) return; final confirmed = await confirmDestructiveAction( context: context, type: DestructiveSqlType.redisSrem, - targetName: member, - commandPreview: 'SREM ${widget.keyName} $member', + targetName: member.label, + commandPreview: 'SREM ${widget.keyName} ${member.label}', connectionName: widget.connection.name, ); if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.srem(widget.keyName, member); + await widget.connection.srem(_cmdKey, member); await _load(); } catch (e) { if (!mounted) return; @@ -384,7 +396,7 @@ class _RedisKeyEditorState extends material.State { if (widget.isReadOnly) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.zadd(widget.keyName, score, member); + await widget.connection.zadd(_cmdKey, score, member); await _load(); } catch (e) { if (!mounted) return; @@ -392,19 +404,19 @@ class _RedisKeyEditorState extends material.State { } } - Future _zsetRemove(String member) async { + Future _zsetRemove(RedisBulkValue member) async { if (widget.isReadOnly) return; final confirmed = await confirmDestructiveAction( context: context, type: DestructiveSqlType.redisZrem, - targetName: member, - commandPreview: 'ZREM ${widget.keyName} $member', + targetName: member.label, + commandPreview: 'ZREM ${widget.keyName} ${member.label}', connectionName: widget.connection.name, ); if (!mounted || !confirmed) return; try { await widget.connection.selectDatabase(widget.database); - await widget.connection.zrem(widget.keyName, member); + await widget.connection.zrem(_cmdKey, member); await _load(); } catch (e) { if (!mounted) return; @@ -673,6 +685,39 @@ class _RedisKeyEditorState extends material.State { // ─── String ───────────────────────────────────────────────────────────── Widget _buildStringEditor(ColorScheme cs, shadcn.ColorScheme scs) { + if (_stringIsBinary) { + final bulk = _stringValue!; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text('Binary value (${bulk.bytes.length} bytes)').semiBold(), + const Gap(8), + const Text( + 'Not valid UTF-8. Save as text is disabled so the original bytes are not overwritten.', + ).muted().small(), + const Gap(12), + const Text('Hex').semiBold().small(), + const Gap(4), + material.SelectableText( + bulk.toHex(), + style: const material.TextStyle( + fontSize: 13, + fontFamily: 'monospace', + ), + ), + const Gap(12), + const Text('Base64').semiBold().small(), + const Gap(4), + material.SelectableText( + bulk.toBase64(), + style: const material.TextStyle( + fontSize: 13, + fontFamily: 'monospace', + ), + ), + ], + ); + } return material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, children: [ @@ -768,8 +813,8 @@ class _RedisKeyEditorState extends material.State { itemBuilder: (context, index) { final entry = entries[index]; return _FieldRow( - field: entry.key, - value: entry.value, + field: entry.key.label, + value: entry.value.label, onDelete: widget.isReadOnly ? null : () => _hashDel(entry.key), colorScheme: cs, @@ -823,7 +868,7 @@ class _RedisKeyEditorState extends material.State { separatorBuilder: (_, __) => const Gap(4), itemBuilder: (context, i) => _IndexedValueRow( index: i, - value: _listValue[i], + value: _listValue[i].label, colorScheme: cs, shadcnCs: scs, ), @@ -873,7 +918,7 @@ class _RedisKeyEditorState extends material.State { itemCount: _setValue.length, separatorBuilder: (_, __) => const Gap(4), itemBuilder: (context, index) => _MemberRow( - member: _setValue[index], + member: _setValue[index].label, onDelete: widget.isReadOnly ? null : () => _setRemove(_setValue[index]), @@ -938,7 +983,7 @@ class _RedisKeyEditorState extends material.State { itemBuilder: (context, index) { final (member, score) = _zsetValue[index]; return _ScoredMemberRow( - member: member, + member: member.label, score: score, onDelete: widget.isReadOnly ? null : () => _zsetRemove(member), diff --git a/lib/features/redis/redis_keys_view.dart b/lib/features/redis/redis_keys_view.dart index 1d7f9ac5..b311e367 100644 --- a/lib/features/redis/redis_keys_view.dart +++ b/lib/features/redis/redis_keys_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/theme/querya_semantic_palette.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; @@ -18,7 +19,7 @@ class RedisKeysView extends material.StatefulWidget { final RedisConnection connection; final int database; - final void Function(String key, String type)? onKeyTap; + final void Function(RedisBulkValue key, String type)? onKeyTap; final bool isReadOnly; @override @@ -143,8 +144,8 @@ class _RedisKeysViewState extends material.State { final confirmed = await confirmDestructiveAction( context: context, type: DestructiveSqlType.redisDel, - targetName: '${keyInfo.name} (${keyInfo.type})', - commandPreview: 'DEL ${keyInfo.name}', + targetName: '${keyInfo.name.label} (${keyInfo.type})', + commandPreview: 'DEL ${keyInfo.name.label}', connectionName: widget.connection.name, ); if (!mounted || !confirmed) return; @@ -343,7 +344,7 @@ class _KeyInfo { required this.type, required this.ttl, }); - final String name; + final RedisBulkValue name; final String type; final int ttl; // -1 = no expiry, -2 = key doesn't exist } @@ -451,7 +452,7 @@ class _KeyTile extends material.StatelessWidget { const Gap(10), material.Expanded( child: material.Text( - ki.name, + ki.name.label, overflow: material.TextOverflow.ellipsis, maxLines: 1, style: material.TextStyle( diff --git a/test/core/database/redis_bulk_test.dart b/test/core/database/redis_bulk_test.dart new file mode 100644 index 00000000..54216604 --- /dev/null +++ b/test/core/database/redis_bulk_test.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; +import 'package:redis/redis.dart' as redis; + +void main() { + test('UTF-8 string reply stays text, not List.toString', () { + final v = RedisBulkValue.fromReply('hello'); + expect(v.isUtf8, isTrue); + expect(v.text, 'hello'); + expect(v.label, 'hello'); + expect(v.commandArg, 'hello'); + }); + + test('UTF-8 bulk bytes decode to text', () { + final v = RedisBulkValue.fromReply(utf8.encode('café')); + expect(v.isUtf8, isTrue); + expect(v.text, 'café'); + expect(v.label, 'café'); + }); + + test('non-UTF-8 bulk is hex/base64, not replacement characters', () { + final raw = Uint8List.fromList(const [0xff, 0xfe, 0x01, 0x1f, 0x8b]); + final v = RedisBulkValue.fromReply(raw); + expect(v.isUtf8, isFalse); + expect(v.text, isNull); + expect(v.label, '0xfffe011f8b'); + expect(v.toHex(), 'fffe011f8b'); + expect(v.toBase64(), base64Encode(raw)); + expect(v.label.contains('['), isFalse); + expect(v.commandArg, isA()); + }); + + test('SCAN cursor bulk digits parse as int, not List.toString', () { + expect(redisReplyInt(utf8.encode('42')), 42); + expect(redisReplyInt(42), 42); + expect(redisReplyInt('7'), 7); + }); +} diff --git a/test/core/database/redis_connection_test.dart b/test/core/database/redis_connection_test.dart index 36cf88b6..8362a7fc 100644 --- a/test/core/database/redis_connection_test.dart +++ b/test/core/database/redis_connection_test.dart @@ -227,8 +227,8 @@ void main() { final page = await fake.lrange('k', 0, redisCollectionPageSize - 1); expect(page, hasLength(redisCollectionPageSize)); - expect(page.first, 'item_0'); - expect(page.last, 'item_199'); + expect(page.first.text, 'item_0'); + expect(page.last.text, 'item_199'); expect(await fake.llen('k'), 250); await expectLater(fake.hgetall('k'), throwsStateError); await fake.disconnect(); @@ -243,10 +243,16 @@ void main() { final (c1, p1) = await fake.hscan('h'); expect(c1, 1); - expect(p1, {'a': '1', 'b': '2'}); + expect( + {for (final e in p1.entries) e.key.text: e.value.text}, + {'a': '1', 'b': '2'}, + ); final (c2, p2) = await fake.hscan('h', cursor: c1); expect(c2, 0); - expect(p2, {'c': '3'}); + expect( + {for (final e in p2.entries) e.key.text: e.value.text}, + {'c': '3'}, + ); expect(await fake.hlen('h'), 3); await fake.disconnect(); }); diff --git a/test/core/database/redis_types_and_ttls_test.dart b/test/core/database/redis_types_and_ttls_test.dart index b7067209..b472e31e 100644 --- a/test/core/database/redis_types_and_ttls_test.dart +++ b/test/core/database/redis_types_and_ttls_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; /// Counts outbound commands to prove [typesAndTtls] fires TYPE+TTL without @@ -22,7 +23,11 @@ void main() { final fake = _CountingRedisFake(); await fake.connect(); - final metas = await fake.typesAndTtls(['a', 'b', 'c']); + final metas = await fake.typesAndTtls([ + RedisBulkValue.utf8('a'), + RedisBulkValue.utf8('b'), + RedisBulkValue.utf8('c'), + ]); expect(metas, hasLength(3)); expect(metas.map((m) => m.type), everyElement('string')); diff --git a/test/features/redis/redis_key_editor_test.dart b/test/features/redis/redis_key_editor_test.dart index b1484143..9bc57880 100644 --- a/test/features/redis/redis_key_editor_test.dart +++ b/test/features/redis/redis_key_editor_test.dart @@ -245,4 +245,20 @@ void main() { expect(fake.sentCommands.contains('GET'), isTrue); await fake.disconnect(); }); + + testWidgets('RedisKeyEditor binary GET shows hex and hides Save', + (tester) async { + final fake = RedisConnectionTestFake( + getBytesResult: const [0xff, 0xfe, 0x01], + ); + await fake.connect(); + + await pumpEditor(tester, fake: fake, isReadOnly: false); + + expect(find.textContaining('Binary value'), findsOneWidget); + expect(find.text('fffe01'), findsOneWidget); + expect(find.text('Save'), findsNothing); + expect(find.text('[255, 254, 1]'), findsNothing); + await fake.disconnect(); + }); } diff --git a/test/features/redis/redis_keys_view_test.dart b/test/features/redis/redis_keys_view_test.dart index c0183ef1..146da411 100644 --- a/test/features/redis/redis_keys_view_test.dart +++ b/test/features/redis/redis_keys_view_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/redis_bulk.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/features/redis/redis_keys_view.dart'; @@ -165,6 +166,39 @@ void main() { expect(find.text('key_a'), findsOneWidget); await fake.disconnect(); }); + + testWidgets('RedisKeysView shows binary SCAN keys as hex, not List.toString', + (tester) async { + final fake = RedisConnectionTestFake( + firstScanKeys: const ['ascii'], + binaryScanKeys: const [ + [0xff, 0xfe], + ], + dbSizeResult: 2, + ); + await fake.connect(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + height: 600, + child: RedisKeysView( + connection: fake, + database: 0, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('ascii'), findsOneWidget); + expect(find.text('0xfffe'), findsOneWidget); + expect(find.text('[255, 254]'), findsNothing); + await fake.disconnect(); + }); } class _DelTrackingFake extends RedisConnectionTestFake { @@ -173,8 +207,8 @@ class _DelTrackingFake extends RedisConnectionTestFake { final deleted = []; @override - Future del(String key) async { - deleted.add(key); + Future del(Object key) async { + deleted.add(key is RedisBulkValue ? key.label : key.toString()); return super.del(key); } } From c13a88039907f256452bedd0b6e20c3c23cdca87 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 14:56:31 +0300 Subject: [PATCH 28/52] fix(redis): keep pasted redis/rediss URIs on connectionString sslrootcert/sslcert/sslkey live on the URI; dropping it made RedisConnection.connect ignore the CA after URL paste. --- CHANGELOG.md | 1 + .../connections/connection_url_parser.dart | 5 +++- .../connection_url_parser_test.dart | 24 ++++++++++++++++--- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c93ade7..d886fb21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Redis URL paste (#819)** — Paste `redis://` / `rediss://` stores the raw URI on `connectionString` (like Postgres/MySQL/Mongo), so `sslrootcert` / `sslcert` / `sslkey` reach `RedisConnection.connect`. - **Redis binary bulk (#818)** — GET / SCAN / hash / list / set / zset decode bulk replies as bytes (`RedisParserBulkBinary`). Invalid UTF-8 is shown as hex / base64; Save as text is off so SET cannot write replacement characters. - **Redis stream/unknown GET-SET (#817)** — Stream, module, and `unknown` keys are not opened with `GET` or saved with `SET`. Save stays on `string` only; `unknown` retries `TYPE` before treating the value as a string. - **SQL toolbar overflow** — MySQL / Postgres Query + History + Execute wrap instead of overflowing at ~700px (widget tests treat RenderFlex overflow as failure). diff --git a/lib/features/connections/connection_url_parser.dart b/lib/features/connections/connection_url_parser.dart index 6ad7ed61..8b43a12c 100644 --- a/lib/features/connections/connection_url_parser.dart +++ b/lib/features/connections/connection_url_parser.dart @@ -165,7 +165,10 @@ ConnectionRow? _buildConnectionRow( authSource = uri.queryParameters['authSource'] ?? uri.queryParameters['authsource']; - if (type == 'postgresql' || type == 'mysql' || type == 'mongodb') { + if (type == 'postgresql' || + type == 'mysql' || + type == 'mongodb' || + type == 'redis') { connectionString = url; } } diff --git a/test/features/connections/connection_url_parser_test.dart b/test/features/connections/connection_url_parser_test.dart index 49c9905f..c9d69d72 100644 --- a/test/features/connections/connection_url_parser_test.dart +++ b/test/features/connections/connection_url_parser_test.dart @@ -34,7 +34,8 @@ void main() { expect(row.username, 'alice'); expect(row.password, 'secret'); expect(row.databaseName, 'myapp'); - expect(row.connectionString, 'postgresql://alice:secret@db.example.com:5432/myapp'); + expect(row.connectionString, + 'postgresql://alice:secret@db.example.com:5432/myapp'); expect(row.useSSL, false); }); @@ -149,13 +150,17 @@ void main() { }); test('parses redis URL', () { - final result = parseConnectionUrlInput('redis://:password@localhost:6379'); + final result = + parseConnectionUrlInput('redis://:password@localhost:6379'); expect(result.error, isNull); final row = result.row!; expect(row.type, 'redis'); expect(row.name, 'Redis: localhost:6379'); expect(row.password, 'password'); - expect(row.connectionString, isNull); + expect( + row.connectionString, + 'redis://:password@localhost:6379', + ); }); test('uses default driver port when URI omits port', () { @@ -171,6 +176,19 @@ void main() { expect(result.row!.type, 'redis'); expect(result.row!.useSSL, true); expect(result.row!.port, 6379); + expect(result.row!.connectionString, 'rediss://localhost'); + }); + + test('keeps Redis TLS cert query params on the stored URI', () { + const url = 'rediss://cache.example.com:6380?sslrootcert=/ca.pem'; + final result = parseConnectionUrlInput(url); + expect(result.error, isNull); + final row = result.row!; + expect(row.type, 'redis'); + expect(row.useSSL, true); + expect(row.host, 'cache.example.com'); + expect(row.port, 6380); + expect(row.connectionString, url); }); test('password with colon is preserved', () { From 0048ec6d98ba3ff9a843ed813aa98965a8e9821a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 15:00:57 +0300 Subject: [PATCH 29/52] fix(mysql): ORDER BY PK on Table Browser and skip COUNT(*) InnoDB LIMIT/OFFSET without a key order skips/duplicates rows. First paint uses information_schema TABLE_ROWS instead of a full COUNT(*). --- CHANGELOG.md | 1 + lib/core/database/mysql_connection.dart | 22 +++++++++ lib/features/mysql/mysql_table_utils.dart | 16 +++++++ lib/features/mysql/mysql_table_view.dart | 45 ++++++++++--------- .../mysql/mysql_table_utils_test.dart | 38 ++++++++++++++++ 5 files changed, 102 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d886fb21..a8e57732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MySQL Table Browser paging (#807)** — Browse `SELECT` uses `ORDER BY` primary-key columns when a PK exists. Row totals come from `information_schema.TABLES.TABLE_ROWS` instead of a blocking `COUNT(*)` before first paint (stale estimates that are below the current page are ignored so Next still works). - **Redis URL paste (#819)** — Paste `redis://` / `rediss://` stores the raw URI on `connectionString` (like Postgres/MySQL/Mongo), so `sslrootcert` / `sslcert` / `sslkey` reach `RedisConnection.connect`. - **Redis binary bulk (#818)** — GET / SCAN / hash / list / set / zset decode bulk replies as bytes (`RedisParserBulkBinary`). Invalid UTF-8 is shown as hex / base64; Save as text is off so SET cannot write replacement characters. - **Redis stream/unknown GET-SET (#817)** — Stream, module, and `unknown` keys are not opened with `GET` or saved with `SET`. Save stays on `string` only; `unknown` retries `TYPE` before treating the value as a string. diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index c3dc10e0..6b25f990 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -493,6 +493,28 @@ class MysqlConnection { ); } + /// InnoDB `TABLE_ROWS` estimate (not a blocking `COUNT(*)`). + Future estimateTableRows({ + required String database, + required String table, + }) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to MySQL'); + } + final rs = await execute( + 'SELECT TABLE_ROWS FROM information_schema.TABLES ' + 'WHERE TABLE_SCHEMA = :database AND TABLE_NAME = :table', + { + 'database': database, + 'table': table, + }, + ); + if (rs.rows.isEmpty) return null; + final v = rs.rows.first.colAt(0); + if (v == null || v.isEmpty) return null; + return int.tryParse(v); + } + /// Returns primary key column names for [table] in [database]. Future> getPrimaryKeys({ required String database, diff --git a/lib/features/mysql/mysql_table_utils.dart b/lib/features/mysql/mysql_table_utils.dart index 045db086..59db41d6 100644 --- a/lib/features/mysql/mysql_table_utils.dart +++ b/lib/features/mysql/mysql_table_utils.dart @@ -1,3 +1,19 @@ +import 'package:querya_desktop/core/database/mysql_connection.dart'; + +/// Browse SELECT for Table Browser. PK columns get `ORDER BY` so LIMIT/OFFSET +/// is stable on InnoDB. Empty [primaryKeys] keeps unordered scan (views / no PK). +String mysqlBrowseDataSql({ + required String qualifiedFrom, + required List primaryKeys, + required int limit, + required int offset, +}) { + final order = primaryKeys.isEmpty + ? '' + : ' ORDER BY ${primaryKeys.map(MysqlConnection.quoteIdentifier).join(', ')}'; + return 'SELECT * FROM $qualifiedFrom$order LIMIT $limit OFFSET $offset'; +} + /// Whether [sql] is allowed for the table browser "custom SQL" path (read-only SELECT). bool isAllowedMysqlSelectQuery(String sql) { final t = sql.trim(); diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index 9d7779c2..63af9a82 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -79,7 +79,12 @@ class _MysqlTableViewState extends material.State { } String _browseDataSql() { - return 'SELECT * FROM ${_qualifiedFrom()} LIMIT ${widget.limit} OFFSET $_offset'; + return mysqlBrowseDataSql( + qualifiedFrom: _qualifiedFrom(), + primaryKeys: _primaryKeys, + limit: widget.limit, + offset: _offset, + ); } @override @@ -176,11 +181,6 @@ class _MysqlTableViewState extends material.State { } } - 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(); } @@ -280,33 +280,38 @@ class _MysqlTableViewState extends material.State { _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!; + await _ensureSchema(conn); + if (!mounted) return; + + int? totalRows = _totalRowCount; + if (refreshCount || totalRows == null) { + try { + totalRows = await conn.estimateTableRows( + database: widget.database, + table: widget.tableName, + ); + } catch (_) { + totalRows = null; + } } + final dataSql = _browseDataSql(); final result = await conn.execute(dataSql); if (!mounted) return; - await _ensureSchema(conn); - if (!mounted) return; - final colNames = _resultColumns(result); final stringRows = await _resultRowsAsync(result); if (!mounted) return; + final shown = stringRows.length; + if (totalRows != null && shown > 0 && totalRows < _offset + shown) { + totalRows = null; + } setState(() { _columnNames = colNames; _rows = stringRows; - _rowsOnPage = stringRows.length; + _rowsOnPage = shown; if (refreshCount || _totalRowCount == null) { _totalRowCount = totalRows; } diff --git a/test/features/mysql/mysql_table_utils_test.dart b/test/features/mysql/mysql_table_utils_test.dart index ab36baa3..b80f5442 100644 --- a/test/features/mysql/mysql_table_utils_test.dart +++ b/test/features/mysql/mysql_table_utils_test.dart @@ -84,4 +84,42 @@ void main() { expect(isAllowedMysqlSelectQuery('DELETE FROM t'), isFalse); }); }); + + group('mysqlBrowseDataSql', () { + test('orders by quoted PK columns', () { + expect( + mysqlBrowseDataSql( + qualifiedFrom: '`shop`.`orders`', + primaryKeys: const ['id'], + limit: 200, + offset: 400, + ), + 'SELECT * FROM `shop`.`orders` ORDER BY `id` LIMIT 200 OFFSET 400', + ); + }); + + test('composite PK lists all columns', () { + expect( + mysqlBrowseDataSql( + qualifiedFrom: '`db`.`t`', + primaryKeys: const ['a', 'b'], + limit: 200, + offset: 0, + ), + 'SELECT * FROM `db`.`t` ORDER BY `a`, `b` LIMIT 200 OFFSET 0', + ); + }); + + test('omits ORDER BY when there is no PK', () { + expect( + mysqlBrowseDataSql( + qualifiedFrom: '`db`.`v`', + primaryKeys: const [], + limit: 200, + offset: 0, + ), + 'SELECT * FROM `db`.`v` LIMIT 200 OFFSET 0', + ); + }); + }); } From 2a2d6c4e608da37f7d1ae18c0a3bc48b504ea6e7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 15:10:15 +0300 Subject: [PATCH 30/52] fix(mysql): round-trip BOOLEAN, BIT, BLOB, and JSON in the grid --- CHANGELOG.md | 1 + lib/core/database/mysql_connection.dart | 8 +- lib/core/database/mysql_result_cells.dart | 106 ++++++++++++++ lib/core/database/table_mutation_engine.dart | 6 + lib/features/mysql/mysql_sql_workspace.dart | 10 +- lib/features/mysql/mysql_table_view.dart | 11 +- .../workspace/grid_data_type_validator.dart | 31 ++-- test/core/database/mysql_grid_types_test.dart | 138 ++++++++++++++++++ .../database/table_mutation_engine_test.dart | 22 ++- .../grid_data_type_validator_test.dart | 9 ++ .../lib/mysql_protocol_extension.dart | 11 +- .../lib/src/mysql_client/connection.dart | 27 +++- .../src/mysql_protocol/mysql_column_type.dart | 36 ++++- .../lib/src/mysql_protocol/mysql_packet.dart | 6 +- .../packet/packet_binary_result_set_row.dart | 1 + .../packet/packet_result_set_row.dart | 14 +- .../mysql_client/test/mysql_packet_test.dart | 6 + 17 files changed, 411 insertions(+), 32 deletions(-) create mode 100644 lib/core/database/mysql_result_cells.dart create mode 100644 test/core/database/mysql_grid_types_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e57732..cdc90c1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MySQL BOOLEAN / BIT / BLOB / JSON grid (#806)** — Schema uses `COLUMN_TYPE` so BOOLEAN is `tinyint(1)` (TRUE/FALSE on Save). BIT/BLOB/BINARY cells display as `0x` hex and persist as `X'…'`; JSON stays quoted text. The MySQL driver decodes charset-63 payloads as latin1 so invalid UTF-8 no longer throws. - **MySQL Table Browser paging (#807)** — Browse `SELECT` uses `ORDER BY` primary-key columns when a PK exists. Row totals come from `information_schema.TABLES.TABLE_ROWS` instead of a blocking `COUNT(*)` before first paint (stale estimates that are below the current page are ignored so Next still works). - **Redis URL paste (#819)** — Paste `redis://` / `rediss://` stores the raw URI on `connectionString` (like Postgres/MySQL/Mongo), so `sslrootcert` / `sslcert` / `sslkey` reach `RedisConnection.connect`. - **Redis binary bulk (#818)** — GET / SCAN / hash / list / set / zset decode bulk replies as bytes (`RedisParserBulkBinary`). Invalid UTF-8 is shown as hex / base64; Save as text is off so SET cannot write replacement characters. diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 6b25f990..458f0c5b 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mysql_client/mysql_client.dart'; +import 'package:querya_desktop/core/database/mysql_result_cells.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; @@ -432,7 +433,7 @@ class MysqlConnection { throw StateError('Not connected to MySQL'); } final colsRs = await execute( - 'SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA ' + 'SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA ' 'FROM information_schema.COLUMNS ' 'WHERE TABLE_SCHEMA = :database AND TABLE_NAME = :table ' 'ORDER BY ORDINAL_POSITION', @@ -458,7 +459,10 @@ class MysqlConnection { for (final r in colsRs.rows) { final name = r.colByName('COLUMN_NAME') ?? ''; - final dataType = r.colByName('DATA_TYPE') ?? ''; + final dataType = mysqlColumnSchemaType( + dataType: r.colByName('DATA_TYPE') ?? '', + columnType: r.colByName('COLUMN_TYPE') ?? '', + ); final isNullable = (r.colByName('IS_NULLABLE') ?? 'YES').toUpperCase() == 'YES'; final isPk = primaryKeys.contains(name); diff --git a/lib/core/database/mysql_result_cells.dart b/lib/core/database/mysql_result_cells.dart new file mode 100644 index 00000000..76733e68 --- /dev/null +++ b/lib/core/database/mysql_result_cells.dart @@ -0,0 +1,106 @@ +import 'dart:typed_data'; + +import 'package:mysql_client/mysql_client.dart'; +import 'package:querya_desktop/core/database/result_row_string_convert.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; + +/// Prefer `COLUMN_TYPE` (`tinyint(1)`, `bit(8)`) over `DATA_TYPE` (`tinyint`). +String mysqlColumnSchemaType({ + required String dataType, + required String columnType, +}) { + final ct = columnType.trim(); + if (ct.isNotEmpty) return ct; + return dataType; +} + +/// Grid/SQL display for a MySQL cell: BOOLEAN as true/false, BIT/BLOB as `0x` hex. +String mysqlResultCellToDisplayString( + Object? value, { + ResultSetColumn? column, + String? schemaDataType, + StringInternPool? pool, +}) { + if (value == null) { + return pool?.intern('NULL') ?? 'NULL'; + } + final typeName = schemaDataType ?? ''; + final binary = (column?.isBinaryPayload ?? false) || + (typeName.isNotEmpty && TableMutationEngine.isBinaryType(typeName)); + if (binary) { + final hex = binaryCellToHexDisplay(value); + return pool?.intern(hex) ?? hex; + } + final boolean = (column?.isBooleanTiny ?? false) || + (typeName.isNotEmpty && TableMutationEngine.isBoolType(typeName)); + if (boolean) { + final shown = _boolDisplay(value.toString()); + return pool?.intern(shown) ?? shown; + } + return resultCellToDisplayString(value, pool); +} + +/// BIT/BLOB/BINARY cell → `0xdeadbeef` (or `NULL`). +String binaryCellToHexDisplay(Object value) { + if (value is Uint8List) { + return _hexPrefix(value); + } + if (value is List) { + return _hexPrefix(Uint8List.fromList(value)); + } + final raw = value.toString(); + final trimmed = raw.trim(); + if (trimmed == 'NULL' || trimmed == 'null') { + return 'NULL'; + } + if (_looksLikeHexLiteral(trimmed)) { + var hex = trimmed; + if (hex.startsWith(r'\x') || + hex.startsWith(r'\X') || + hex.startsWith('0x') || + hex.startsWith('0X')) { + hex = hex.substring(2); + } else if ((hex.startsWith("x'") || hex.startsWith("X'")) && + hex.endsWith("'")) { + hex = hex.substring(2, hex.length - 1); + } + final clean = hex.replaceAll(RegExp(r'[^0-9a-fA-F]'), '').toLowerCase(); + return '0x$clean'; + } + return _hexPrefix( + Uint8List.fromList(raw.codeUnits.map((u) => u & 0xFF).toList()), + ); +} + +bool _looksLikeHexLiteral(String trimmed) { + if (trimmed.startsWith('0x') || + trimmed.startsWith('0X') || + trimmed.startsWith(r'\x') || + trimmed.startsWith(r'\X')) { + return true; + } + if ((trimmed.startsWith("x'") || trimmed.startsWith("X'")) && + trimmed.endsWith("'")) { + return true; + } + return false; +} + +String _hexPrefix(Uint8List bytes) { + final out = StringBuffer('0x'); + for (final b in bytes) { + out.write(b.toRadixString(16).padLeft(2, '0')); + } + return out.toString(); +} + +String _boolDisplay(String raw) { + final trimmed = raw.trim().toLowerCase(); + if (trimmed == '1' || trimmed == 'true' || trimmed == 't') { + return 'true'; + } + if (trimmed == '0' || trimmed == 'false' || trimmed == 'f') { + return 'false'; + } + return raw; +} diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart index 2742f25c..b414ea6b 100644 --- a/lib/core/database/table_mutation_engine.dart +++ b/lib/core/database/table_mutation_engine.dart @@ -142,6 +142,12 @@ abstract final class TableMutationEngine { lower.startsWith('bit'); } + /// Public alias of [_isBoolType] for grid display / validators. + static bool isBoolType(String dataTypeName) => _isBoolType(dataTypeName); + + /// Public alias of [_isBinaryType] for grid display / validators. + static bool isBinaryType(String dataTypeName) => _isBinaryType(dataTypeName); + static const String kNullSentinel = '\u0000__QUERYA_NULL__\u0000'; /// Formats a cell string value safely as an SQL literal or `NULL`. diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index bb2cb4fe..6699a8e3 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -7,6 +7,7 @@ import 'package:file_selector/file_selector.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; +import 'package:querya_desktop/core/database/mysql_result_cells.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/sql_limit.dart'; @@ -341,11 +342,18 @@ class _MysqlSqlWorkspaceState extends material.State { } }, ); + final colList = rs.cols.toList(); final outRows = [ for (final row in taken.items) List.generate( row.numOfColumns, - (i) => resultCellToDisplayString(row.colAt(i)), + (i) { + final col = i < colList.length ? colList[i] : null; + return mysqlResultCellToDisplayString( + row.colAt(i), + column: col, + ); + }, ), ]; final truncated = diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index 63af9a82..06669677 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:mysql_client/mysql_client.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/database/mysql_result_cells.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/sql_limit.dart'; @@ -186,13 +187,21 @@ class _MysqlTableViewState extends material.State { } Future>> _resultRowsAsync(IResultSet rs) async { + final colList = rs.cols.toList(); final out = >[]; var n = 0; for (final row in rs.rows) { out.add( List.generate( row.numOfColumns, - (i) => resultCellToDisplayString(row.colAt(i)), + (i) { + final col = i < colList.length ? colList[i] : null; + return mysqlResultCellToDisplayString( + row.colAt(i), + column: col, + schemaDataType: col == null ? null : _columnDataTypes[col.name], + ); + }, ), ); n++; diff --git a/lib/features/workspace/grid_data_type_validator.dart b/lib/features/workspace/grid_data_type_validator.dart index aa3a0d35..4eb2f69f 100644 --- a/lib/features/workspace/grid_data_type_validator.dart +++ b/lib/features/workspace/grid_data_type_validator.dart @@ -24,6 +24,20 @@ abstract final class GridDataTypeValidator { final type = dataTypeName.toLowerCase().trim(); + // Boolean types (MySQL BOOLEAN is TINYINT(1) — check before generic int) + if (type == 'bool' || type == 'boolean' || type.startsWith('tinyint(1)')) { + final lower = value.toLowerCase().trim(); + if (lower != 'true' && + lower != 'false' && + lower != '1' && + lower != '0' && + lower != 't' && + lower != 'f') { + return 'Expected boolean (true/false/1/0)'; + } + return null; + } + // Integer types if (type.contains('int') || type == 'serial' || type == 'bigserial') { if (!_intRegex.hasMatch(value.trim())) { @@ -44,20 +58,6 @@ abstract final class GridDataTypeValidator { return null; } - // Boolean types - if (type == 'bool' || type == 'boolean') { - final lower = value.toLowerCase().trim(); - if (lower != 'true' && - lower != 'false' && - lower != '1' && - lower != '0' && - lower != 't' && - lower != 'f') { - return 'Expected boolean (true/false/1/0)'; - } - return null; - } - // UUID if (type == 'uuid') { if (!_uuidRegex.hasMatch(value.trim())) { @@ -99,11 +99,12 @@ abstract final class GridDataTypeValidator { return null; } - // Binary / BLOB / Bytea + // Binary / BLOB / Bytea / BIT if (type.contains('blob') || type.contains('bytea') || type.contains('binary') || type.contains('varbinary') || + type.startsWith('bit') || type == 'raw' || type == 'image') { var hex = value.trim(); diff --git a/test/core/database/mysql_grid_types_test.dart b/test/core/database/mysql_grid_types_test.dart new file mode 100644 index 00000000..6f21c737 --- /dev/null +++ b/test/core/database/mysql_grid_types_test.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mysql_client/mysql_client.dart'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:querya_desktop/core/database/mysql_result_cells.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; + +void main() { + group('mysqlColumnSchemaType', () { + test('prefers COLUMN_TYPE so BOOLEAN is tinyint(1)', () { + expect( + mysqlColumnSchemaType(dataType: 'tinyint', columnType: 'tinyint(1)'), + 'tinyint(1)', + ); + expect( + mysqlColumnSchemaType(dataType: 'bit', columnType: 'bit(8)'), + 'bit(8)', + ); + expect( + mysqlColumnSchemaType(dataType: 'json', columnType: 'json'), + 'json', + ); + expect( + mysqlColumnSchemaType(dataType: 'blob', columnType: ''), + 'blob', + ); + }); + }); + + group('all_mysql_types display + persist', () { + test('BOOLEAN tinyint(1) displays true/false and persists TRUE/FALSE', () { + expect( + mysqlResultCellToDisplayString('1', schemaDataType: 'tinyint(1)'), + 'true', + ); + expect( + mysqlResultCellToDisplayString('0', schemaDataType: 'tinyint(1)'), + 'false', + ); + expect( + TableMutationEngine.formatLiteral( + 'true', + SqlDialect.mysql, + dataTypeName: 'tinyint(1)', + ), + 'TRUE', + ); + expect( + TableMutationEngine.formatLiteral( + '0', + SqlDialect.mysql, + dataTypeName: 'tinyint(1)', + ), + 'FALSE', + ); + expect(TableMutationEngine.isBoolType('tinyint'), isFalse); + expect(TableMutationEngine.isBoolType('tinyint(1)'), isTrue); + }); + + test('BIT(8) latin1 byte displays as hex and persists X\'..\'', () { + final bitCol = ResultSetColumn( + name: 'col_bit', + type: MySQLColumnType.bitType, + length: 8, + charset: 63, + ); + final raw = latin1.decode(const [0xaa]); + expect( + mysqlResultCellToDisplayString(raw, column: bitCol), + '0xaa', + ); + expect( + TableMutationEngine.formatLiteral( + '0xaa', + SqlDialect.mysql, + dataTypeName: 'bit(8)', + ), + "X'aa'", + ); + }); + + test('BLOB / BINARY display as hex and persist the same', () { + const blob = 'Binary BLOB payload'; + expect( + mysqlResultCellToDisplayString(blob, schemaDataType: 'blob'), + binaryCellToHexDisplay(blob), + ); + expect( + binaryCellToHexDisplay(Uint8List.fromList([0xde, 0xad, 0xbe, 0xef])), + '0xdeadbeef', + ); + expect( + TableMutationEngine.formatLiteral( + '0xdeadbeef', + SqlDialect.mysql, + dataTypeName: 'binary(16)', + ), + "X'deadbeef'", + ); + expect( + TableMutationEngine.formatLiteral( + r'\xcafebabe', + SqlDialect.mysql, + dataTypeName: 'varbinary(64)', + ), + "X'cafebabe'", + ); + }); + + test('JSON stays quoted text', () { + const json = '{"name":"Querya MySQL","version":"8.4"}'; + expect( + mysqlResultCellToDisplayString(json, schemaDataType: 'json'), + json, + ); + expect( + TableMutationEngine.formatLiteral( + json, + SqlDialect.mysql, + dataTypeName: 'json', + ), + "'$json'", + ); + }); + + test('TINYINT(1) driver column displays as boolean without schema', () { + final col = ResultSetColumn( + name: 'col_boolean', + type: MySQLColumnType.tinyType, + length: 1, + ); + expect(mysqlResultCellToDisplayString('1', column: col), 'true'); + expect(mysqlResultCellToDisplayString('0', column: col), 'false'); + }); + }); +} diff --git a/test/core/database/table_mutation_engine_test.dart b/test/core/database/table_mutation_engine_test.dart index 7f494404..1779d149 100644 --- a/test/core/database/table_mutation_engine_test.dart +++ b/test/core/database/table_mutation_engine_test.dart @@ -63,7 +63,9 @@ void main() { ['2', 'bob', '25', 'false'], ]; - test('generates UPDATE statement for modified cells with single PK in Postgres dialect', () { + test( + 'generates UPDATE statement for modified cells with single PK in Postgres dialect', + () { final plan = TableMutationEngine.generatePlan( dialect: SqlDialect.postgres, tableName: 'users', @@ -216,7 +218,9 @@ void main() { ); }); - test('preserves leading zeros and boolean strings in string columns with columnDataTypes', () { + test( + 'preserves leading zeros and boolean strings in string columns with columnDataTypes', + () { const stringCols = ['id', 'zip_code', 'is_flag_str']; const stringRows = [ ['1', '01234', 'true'], @@ -253,7 +257,9 @@ void main() { ); }); - test('preserves leading zeros in fallback heuristic without columnDataTypes', () { + test( + 'preserves leading zeros in fallback heuristic without columnDataTypes', + () { expect( TableMutationEngine.formatLiteral('01234', SqlDialect.postgres), '\'01234\'', @@ -331,6 +337,16 @@ void main() { "X'CAFE'", ); + // MySQL BOOLEAN stored as tinyint(1) + expect( + TableMutationEngine.formatLiteral( + '1', + SqlDialect.mysql, + dataTypeName: 'tinyint(1)', + ), + 'TRUE', + ); + // NULL for binary expect( TableMutationEngine.formatLiteral( diff --git a/test/features/workspace/grid_data_type_validator_test.dart b/test/features/workspace/grid_data_type_validator_test.dart index 9e5f051c..303e67bb 100644 --- a/test/features/workspace/grid_data_type_validator_test.dart +++ b/test/features/workspace/grid_data_type_validator_test.dart @@ -24,6 +24,14 @@ void main() { expect(GridDataTypeValidator.validate('1', dataTypeName: 'bool'), isNull); expect(GridDataTypeValidator.validate('0', dataTypeName: 'bool'), isNull); expect(GridDataTypeValidator.validate('yes', dataTypeName: 'bool'), isNotNull); + expect( + GridDataTypeValidator.validate('true', dataTypeName: 'tinyint(1)'), + isNull, + ); + expect( + GridDataTypeValidator.validate('yes', dataTypeName: 'tinyint(1)'), + isNotNull, + ); }); test('validates UUID types', () { @@ -80,6 +88,7 @@ void main() { expect(GridDataTypeValidator.validate('0x12AB', dataTypeName: 'blob'), isNull); expect(GridDataTypeValidator.validate("X'CAFE'", dataTypeName: 'binary'), isNull); expect(GridDataTypeValidator.validate('DEADBEEF', dataTypeName: 'varbinary'), isNull); + expect(GridDataTypeValidator.validate('0xaa', dataTypeName: 'bit(8)'), isNull); expect(GridDataTypeValidator.validate('not_hex', dataTypeName: 'blob'), isNotNull); expect(GridDataTypeValidator.validate('123', dataTypeName: 'bytea'), isNotNull); // odd length hex }); diff --git a/third_party/mysql_client/lib/mysql_protocol_extension.dart b/third_party/mysql_client/lib/mysql_protocol_extension.dart index e8ed8f57..80e91a00 100644 --- a/third_party/mysql_client/lib/mysql_protocol_extension.dart +++ b/third_party/mysql_client/lib/mysql_protocol_extension.dart @@ -18,6 +18,14 @@ extension MySQLUint8ListExtension on Uint8List { } Tuple2 getUtf8LengthEncodedString(int startOffset) { + return getLengthEncodedString(startOffset); + } + + /// Length-encoded string. [latin1Bytes] maps each byte 1:1 (BIT/BLOB/BINARY). + Tuple2 getLengthEncodedString( + int startOffset, { + bool latin1Bytes = false, + }) { final tmp = Uint8List.sublistView(this, startOffset); final bd = ByteData.sublistView(tmp); @@ -29,7 +37,8 @@ extension MySQLUint8ListExtension on Uint8List { strLength.item2 + strLength.item1.toInt(), ); - return Tuple2(utf8.decode(tmp2), strLength.item2 + strLength.item1.toInt()); + final decoded = latin1Bytes ? latin1.decode(tmp2) : utf8.decode(tmp2); + return Tuple2(decoded, strLength.item2 + strLength.item1.toInt()); } } diff --git a/third_party/mysql_client/lib/src/mysql_client/connection.dart b/third_party/mysql_client/lib/src/mysql_client/connection.dart index 821b0f4c..c3b4b6d4 100644 --- a/third_party/mysql_client/lib/src/mysql_client/connection.dart +++ b/third_party/mysql_client/lib/src/mysql_client/connection.dart @@ -550,7 +550,11 @@ class MySQLConnection { return; } - packet = MySQLPacket.decodeResultSetRowPacket(data, colsCount); + packet = MySQLPacket.decodeResultSetRowPacket( + data, + colsCount, + colDefs, + ); final values = (packet.payload as MySQLResultSetRowPacket).values; sink!.add(ResultSetRow._(colDefs: colDefs, values: values)); packet = null; @@ -593,7 +597,11 @@ class MySQLConnection { } } - packet = MySQLPacket.decodeResultSetRowPacket(data, colsCount); + packet = MySQLPacket.decodeResultSetRowPacket( + data, + colsCount, + colDefs, + ); break; } } @@ -1258,6 +1266,7 @@ class ResultSet extends IResultSet { name: e.name, type: e.type, length: e.columnLength, + charset: e.charset, ), ); } @@ -1318,6 +1327,7 @@ class IterableResultSet with IterableMixin implements IResultSet { name: e.name, type: e.type, length: e.columnLength, + charset: e.charset, ), ); } @@ -1365,6 +1375,7 @@ class PreparedStmtResultSet extends IResultSet { name: e.name, type: e.type, length: e.columnLength, + charset: e.charset, ), ); } @@ -1412,6 +1423,7 @@ class IterablePreparedStmtResultSet extends IResultSet { name: e.name, type: e.type, length: e.columnLength, + charset: e.charset, ), ); } @@ -1587,12 +1599,23 @@ class ResultSetColumn { String name; MySQLColumnType type; int length; + int charset; ResultSetColumn({ required this.name, required this.type, required this.length, + this.charset = 0, }); + + /// BIT / BLOB / BINARY payload (charset 63 or blob/bit type). + bool get isBinaryPayload => mysqlColumnHoldsRawBytes( + columnType: type.intVal, + charset: charset, + ); + + /// `TINYINT(1)` / BOOLEAN. + bool get isBooleanTiny => type.intVal == mysqlColumnTypeTiny && length == 1; } /// Prepared statement class diff --git a/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart b/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart index 6b07e1d5..9fcae8c9 100644 --- a/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart +++ b/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart @@ -34,6 +34,29 @@ const mysqlColumnTypeVarString = 0xfd; const mysqlColumnTypeString = 0xfe; const mysqlColumnTypeGeometry = 0xff; +/// `character_set` id 63 (`binary`) — BIT / BLOB / BINARY / VARBINARY. +const mysqlCharsetBinary = 63; + +/// Whether the wire value is raw bytes (latin1 1:1), not UTF-8 text. +bool mysqlColumnHoldsRawBytes({ + required int columnType, + required int charset, +}) { + if (charset == mysqlCharsetBinary) { + return true; + } + switch (columnType) { + case mysqlColumnTypeBit: + case mysqlColumnTypeTinyBlob: + case mysqlColumnTypeMediumBlob: + case mysqlColumnTypeLongBlob: + case mysqlColumnTypeBlob: + return true; + default: + return false; + } +} + class MySQLColumnType { final int _value; @@ -188,8 +211,9 @@ Tuple2 parseBinaryColumnData( int columnType, ByteData data, Uint8List buffer, - int startOffset, -) { + int startOffset, [ + int charset = 0, +]) { switch (columnType) { case mysqlColumnTypeTiny: final value = data.getInt8(startOffset); @@ -333,7 +357,13 @@ Tuple2 parseBinaryColumnData( case mysqlColumnTypeBit: case mysqlColumnTypeDecimal: case mysqlColumnTypeNewDecimal: - return buffer.getUtf8LengthEncodedString(startOffset); + return buffer.getLengthEncodedString( + startOffset, + latin1Bytes: mysqlColumnHoldsRawBytes( + columnType: columnType, + charset: charset, + ), + ); } throw MySQLProtocolException( diff --git a/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart b/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart index c24c6e9c..e5651f62 100644 --- a/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart +++ b/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart @@ -238,8 +238,9 @@ class MySQLPacket { factory MySQLPacket.decodeResultSetRowPacket( Uint8List buffer, - int numOfCols, - ) { + int numOfCols, [ + List? colDefs, + ]) { int offset = 0; final header = MySQLPacket.decodePacketHeader(buffer); @@ -250,6 +251,7 @@ class MySQLPacket { final payload = MySQLResultSetRowPacket.decode( Uint8List.sublistView(buffer, offset), numOfCols, + colDefs, ); return MySQLPacket( diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart index c696dce6..b16e1bad 100644 --- a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart @@ -56,6 +56,7 @@ class MySQLBinaryResultSetRowPacket extends MySQLPacketPayload { byteData, buffer, offset, + colDefs[x].charset, ); offset += parseResult.item2; values.add(parseResult.item1); diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart index 9c45cc67..f9289b47 100644 --- a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart @@ -10,7 +10,11 @@ class MySQLResultSetRowPacket extends MySQLPacketPayload { required this.values, }); - factory MySQLResultSetRowPacket.decode(Uint8List buffer, int numOfCols) { + factory MySQLResultSetRowPacket.decode( + Uint8List buffer, + int numOfCols, [ + List? colDefs, + ]) { final byteData = ByteData.sublistView(buffer); int offset = 0; @@ -24,7 +28,13 @@ class MySQLResultSetRowPacket extends MySQLPacketPayload { values.add(null); offset += 1; } else { - value = buffer.getUtf8LengthEncodedString(offset); + final binary = colDefs != null && + x < colDefs.length && + mysqlColumnHoldsRawBytes( + columnType: colDefs[x].type.intVal, + charset: colDefs[x].charset, + ); + value = buffer.getLengthEncodedString(offset, latin1Bytes: binary); values.add(value.item1); offset += value.item2; } diff --git a/third_party/mysql_client/test/mysql_packet_test.dart b/third_party/mysql_client/test/mysql_packet_test.dart index 4e8d98ca..a09159ad 100644 --- a/third_party/mysql_client/test/mysql_packet_test.dart +++ b/third_party/mysql_client/test/mysql_packet_test.dart @@ -241,6 +241,12 @@ void main() { expect(actual.item1, "def"); expect(actual.item2, 4); }); + test("testing getLengthEncodedString latin1 binary bytes", () { + final buffer = Uint8List.fromList([0x02, 0xaa, 0xde]); + final actual = buffer.getLengthEncodedString(0, latin1Bytes: true); + expect(actual.item1.codeUnits, [0xaa, 0xde]); + expect(actual.item2, 3); + }); test("testing getLengthEncodedString for long string", () { final buffer = Uint8List.fromList([ 0xfc, From 37d7f8eb012671cc3570c060a80a0c486a0c4f41 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 15:14:08 +0300 Subject: [PATCH 31/52] fix(mysql): treat ssl-mode=prefer as TLS and fail closed on verify_* --- CHANGELOG.md | 1 + lib/core/database/mysql_connection.dart | 104 ++++++++++++++--- lib/features/mysql/mysql_connection_form.dart | 6 +- test/core/database/mysql_connection_test.dart | 105 ++++++++++++++++++ .../lib/src/mysql_client/connection.dart | 14 ++- 5 files changed, 209 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc90c1d..93900a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MySQL ssl-mode (#805)** — `ssl-mode=prefer` enables TLS (same as `require`) instead of turning it off. `verify_ca` / `verify_identity` fail closed without `sslrootcert` and ask the driver to check the CA (and hostname for identity). Form Use SSL stays encrypt-only. - **MySQL BOOLEAN / BIT / BLOB / JSON grid (#806)** — Schema uses `COLUMN_TYPE` so BOOLEAN is `tinyint(1)` (TRUE/FALSE on Save). BIT/BLOB/BINARY cells display as `0x` hex and persist as `X'…'`; JSON stays quoted text. The MySQL driver decodes charset-63 payloads as latin1 so invalid UTF-8 no longer throws. - **MySQL Table Browser paging (#807)** — Browse `SELECT` uses `ORDER BY` primary-key columns when a PK exists. Row totals come from `information_schema.TABLES.TABLE_ROWS` instead of a blocking `COUNT(*)` before first paint (stale estimates that are below the current page are ignored so Next still works). - **Redis URL paste (#819)** — Paste `redis://` / `rediss://` stores the raw URI on `connectionString` (like Postgres/MySQL/Mongo), so `sslrootcert` / `sslcert` / `sslkey` reach `RedisConnection.connect`. diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 458f0c5b..eeb71943 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -8,6 +8,61 @@ import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +/// TLS policy from `ssl-mode` / `sslmode` / `ssl` on a MySQL URI. +enum MysqlSslMode { + disable, + encrypt, + verifyCa, + verifyIdentity, +} + +extension MysqlSslModeX on MysqlSslMode { + bool get secure => this != MysqlSslMode.disable; + bool get verifyCertificates => + this == MysqlSslMode.verifyCa || this == MysqlSslMode.verifyIdentity; + bool get verifyIdentity => this == MysqlSslMode.verifyIdentity; +} + +/// Parses Connector/J-style `ssl-mode` (`prefer` is TLS on, not disable). +MysqlSslMode parseMysqlSslMode(String? raw, {required bool fallbackSsl}) { + if (raw == null || raw.trim().isEmpty) { + return fallbackSsl ? MysqlSslMode.encrypt : MysqlSslMode.disable; + } + switch (raw.toLowerCase().replaceAll('-', '_')) { + case 'false': + case '0': + case 'disable': + case 'disabled': + return MysqlSslMode.disable; + case 'prefer': + case 'preferred': + case 'require': + case 'required': + case 'true': + case '1': + case 'enabled': + return MysqlSslMode.encrypt; + case 'verify_ca': + return MysqlSslMode.verifyCa; + case 'verify_identity': + case 'verify_full': + return MysqlSslMode.verifyIdentity; + default: + return fallbackSsl ? MysqlSslMode.encrypt : MysqlSslMode.disable; + } +} + +/// `verify_ca` / `verify_identity` fail closed without `sslrootcert`. +void validateMysqlSslMode(MysqlSslMode mode, SslCertificatePaths paths) { + if (!mode.verifyCertificates) return; + final ca = paths.rootCert?.trim() ?? ''; + if (ca.isEmpty) { + final label = + mode == MysqlSslMode.verifyIdentity ? 'verify_identity' : 'verify_ca'; + throw ArgumentError('ssl-mode=$label requires sslrootcert'); + } +} + /// Replaces the database in a `mysql://` / `mariadb://` URI (path or `database=`). String replaceDatabaseInMysqlConnectionString( String connectionString, @@ -127,15 +182,23 @@ class MysqlConnection { : effectiveConnectionString!.trim(); final parsed = _parseMysqlUri(uriStr, fallbackSsl: useSSL); final sslPaths = extractSslCertificatePathsFromString(uriStr); + var sslMode = parsed.sslMode; + if (sslPaths.hasAny && sslMode == MysqlSslMode.disable) { + sslMode = MysqlSslMode.encrypt; + } + validateMysqlSslMode(sslMode, sslPaths); final securityContext = buildSecurityContext(sslPaths); + final host = parsed.host; _conn = await MySQLConnection.createConnection( - host: parsed.host, + host: host, port: parsed.port, userName: parsed.userName, password: parsed.password, - secure: parsed.secure || sslPaths.hasAny, + secure: sslMode.secure, databaseName: parsed.databaseName, securityContext: securityContext, + sslVerifyCertificates: sslMode.verifyCertificates, + sslServerName: sslMode.verifyIdentity && host is String ? host : null, ); await _conn!.connect(timeoutMs: connectTimeoutMs); } else { @@ -169,7 +232,7 @@ class MysqlConnection { String userName, String password, String? databaseName, - bool secure, + MysqlSslMode sslMode, }) _parseMysqlUri(String raw, {required bool fallbackSsl}) { final uri = Uri.parse(raw); if (uri.scheme != 'mysql' && uri.scheme != 'mariadb') { @@ -198,22 +261,16 @@ class MysqlConnection { 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; - } + var sslMode = parseMysqlSslMode( + q['ssl-mode'] ?? q['sslmode'] ?? q['ssl'], + fallbackSsl: fallbackSsl, + ); if (q.containsKey(kSslRootCertParam) || q.containsKey(kSslCertParam) || q.containsKey(kSslKeyParam)) { - secure = true; + if (sslMode == MysqlSslMode.disable) { + sslMode = MysqlSslMode.encrypt; + } } return ( @@ -222,17 +279,28 @@ class MysqlConnection { userName: userName, password: password, databaseName: db, - secure: secure, + sslMode: sslMode, ); } + /// Parsed `ssl-mode` for [connectionString] (tests). + @visibleForTesting + static MysqlSslMode sslModeFromConnectionString( + String connectionString, { + bool fallbackSsl = true, + }) { + return _parseMysqlUri(connectionString, fallbackSsl: fallbackSsl).sslMode; + } + /// Whether [connectionString] implies a TLS session (including cert query params). @visibleForTesting static bool connectionStringRequiresSsl( String connectionString, { bool fallbackSsl = true, }) { - return _parseMysqlUri(connectionString, fallbackSsl: fallbackSsl).secure; + return _parseMysqlUri(connectionString, fallbackSsl: fallbackSsl) + .sslMode + .secure; } Future disconnect() async { diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 8cb7cfd2..7406447e 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -368,8 +368,10 @@ class _MysqlConnectionFormContentState ).muted().small(), const Gap(4), const Text( - 'Query params: ssl-mode (disable, require), database, ' - 'sslrootcert, sslcert, sslkey.', + 'Query params: ssl-mode (disable, prefer, require, ' + 'verify_ca, verify_identity), database, ' + 'sslrootcert, sslcert, sslkey. ' + 'prefer/require encrypt; verify_* needs sslrootcert.', ).muted().small(), const Gap(8), TextField( diff --git a/test/core/database/mysql_connection_test.dart b/test/core/database/mysql_connection_test.dart index d3a4f6bf..094ee800 100644 --- a/test/core/database/mysql_connection_test.dart +++ b/test/core/database/mysql_connection_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/security/ssl_certificate_support.dart'; void main() { group('MysqlConnection SSL URI parsing', () { @@ -21,6 +22,110 @@ void main() { isTrue, ); }); + + test('ssl-mode=prefer enables TLS even when fallback is off', () { + expect( + MysqlConnection.connectionStringRequiresSsl( + 'mysql://localhost/db?ssl-mode=prefer', + fallbackSsl: false, + ), + isTrue, + ); + expect( + MysqlConnection.sslModeFromConnectionString( + 'mysql://localhost/db?ssl-mode=prefer', + fallbackSsl: false, + ), + MysqlSslMode.encrypt, + ); + }); + + test('ssl-mode=require enables TLS', () { + expect( + MysqlConnection.connectionStringRequiresSsl( + 'mysql://localhost/db?ssl-mode=require', + fallbackSsl: false, + ), + isTrue, + ); + expect( + MysqlConnection.sslModeFromConnectionString( + 'mysql://localhost/db?ssl-mode=require', + ), + MysqlSslMode.encrypt, + ); + }); + + test('ssl-mode=disable stays off without cert params', () { + expect( + MysqlConnection.connectionStringRequiresSsl( + 'mysql://localhost/db?ssl-mode=disable', + fallbackSsl: true, + ), + isFalse, + ); + }); + + test('ssl-mode=verify_identity verifies CA and hostname', () { + expect( + MysqlConnection.sslModeFromConnectionString( + 'mysql://db.example.com/db?ssl-mode=verify_identity&sslrootcert=%2Fca.pem', + ), + MysqlSslMode.verifyIdentity, + ); + expect( + MysqlConnection.sslModeFromConnectionString( + 'mysql://db.example.com/db?ssl-mode=verify-full&sslrootcert=%2Fca.pem', + ), + MysqlSslMode.verifyIdentity, + ); + }); + + test('ssl-mode=verify_ca requires sslrootcert', () { + expect( + MysqlConnection.sslModeFromConnectionString( + 'mysql://localhost/db?ssl-mode=verify_ca&sslrootcert=%2Fca.pem', + ), + MysqlSslMode.verifyCa, + ); + expect( + () => validateMysqlSslMode( + MysqlSslMode.verifyCa, + const SslCertificatePaths(), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('sslrootcert'), + ), + ), + ); + expect( + () => validateMysqlSslMode( + MysqlSslMode.verifyIdentity, + const SslCertificatePaths(), + ), + throwsA(isA()), + ); + expect( + () => validateMysqlSslMode( + MysqlSslMode.verifyCa, + const SslCertificatePaths(rootCert: '/ca.pem'), + ), + returnsNormally, + ); + }); + + test('connect fails closed on verify_ca without sslrootcert', () async { + final conn = MysqlConnection( + id: 0, + name: 't', + host: 'localhost', + connectionString: 'mysql://u:p@example.com/db?ssl-mode=verify_ca', + ); + await expectLater(conn.connect(), throwsA(isA())); + }); }); group('replaceDatabaseInMysqlConnectionString', () { diff --git a/third_party/mysql_client/lib/src/mysql_client/connection.dart b/third_party/mysql_client/lib/src/mysql_client/connection.dart index c3b4b6d4..4f04d583 100644 --- a/third_party/mysql_client/lib/src/mysql_client/connection.dart +++ b/third_party/mysql_client/lib/src/mysql_client/connection.dart @@ -33,6 +33,8 @@ class MySQLConnection { bool _inTransaction = false; final bool _secure; final SecurityContext? _securityContext; + final bool _sslVerifyCertificates; + final String? _sslServerName; final List _incompleteBufferData = []; Object? _lastError; int _serverCapabilities = 0; @@ -47,12 +49,16 @@ class MySQLConnection { bool secure = true, String? databaseName, SecurityContext? securityContext, + bool sslVerifyCertificates = false, + String? sslServerName, }) : _socket = socket, _username = username, _password = password, _databaseName = databaseName, _secure = secure, _securityContext = securityContext, + _sslVerifyCertificates = sslVerifyCertificates, + _sslServerName = sslServerName, _collation = collation; /// Creates connection with provided options. @@ -82,6 +88,8 @@ class MySQLConnection { String? databaseName, String collation = 'utf8mb4_general_ci', SecurityContext? securityContext, + bool sslVerifyCertificates = false, + String? sslServerName, }) async { final Socket socket = await Socket.connect(host, port); @@ -97,6 +105,8 @@ class MySQLConnection { databaseName: databaseName, secure: secure, securityContext: securityContext, + sslVerifyCertificates: sslVerifyCertificates, + sslServerName: sslServerName, collation: collation, ); @@ -364,8 +374,10 @@ class MySQLConnection { final secureSocket = await SecureSocket.secure( _socket, + host: _sslServerName, context: _securityContext, - onBadCertificate: (certificate) => true, + onBadCertificate: + _sslVerifyCertificates ? null : (certificate) => true, ); // switch socket From f72411e6f62d590eb33cbe2940eed2fc9684d6be Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 15:18:16 +0300 Subject: [PATCH 32/52] fix(mysql): honor title-bar lock on Table Browser browse/save --- CHANGELOG.md | 1 + lib/core/database/mysql_connection.dart | 23 +++++++++++----- lib/features/main_screen/workspace_panel.dart | 1 + lib/features/mysql/mysql_table_view.dart | 27 +++++++++++++++++++ test/core/database/mysql_connection_test.dart | 15 +++++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93900a0b..d2b46c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **MySQL Table Browser session lock (#803)** — Browse stays on `MysqlSessionMode.readOnly`. Title-bar lock is passed into `MysqlTableView`: staging / Save stay off and Save does not acquire `tableWrite`. `SET SESSION TRANSACTION READ ONLY` is documented as a next-transaction hint (weaker than a read-only user; MariaDB vs MySQL 8). - **MySQL ssl-mode (#805)** — `ssl-mode=prefer` enables TLS (same as `require`) instead of turning it off. `verify_ca` / `verify_identity` fail closed without `sslrootcert` and ask the driver to check the CA (and hostname for identity). Form Use SSL stays encrypt-only. - **MySQL BOOLEAN / BIT / BLOB / JSON grid (#806)** — Schema uses `COLUMN_TYPE` so BOOLEAN is `tinyint(1)` (TRUE/FALSE on Save). BIT/BLOB/BINARY cells display as `0x` hex and persist as `X'…'`; JSON stays quoted text. The MySQL driver decodes charset-63 payloads as latin1 so invalid UTF-8 no longer throws. - **MySQL Table Browser paging (#807)** — Browse `SELECT` uses `ORDER BY` primary-key columns when a PK exists. Row totals come from `information_schema.TABLES.TABLE_ROWS` instead of a blocking `COUNT(*)` before first paint (stale estimates that are below the current page are ignored so Next still works). diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index eeb71943..16c905e4 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -334,16 +334,27 @@ class MysqlConnection { } } - /// Session hint for read-only browsing (MySQL 8+ / MariaDB — semantics differ from PostgreSQL). + /// Session hint for Table Browser / tree (`MysqlSessionMode.readOnly`). + /// + /// Issues `SET SESSION TRANSACTION READ ONLY` (or `READ WRITE`). This is the + /// **next-transaction** default, not PostgreSQL `default_transaction_read_only`. + /// + /// On MySQL 8 with autocommit, each statement is its own transaction, so the + /// hint applies — but it is weaker than a dedicated read-only user and does + /// not block `SET SESSION TRANSACTION READ WRITE` later on the same socket. + /// MariaDB accepts the same syntax; enforcement still depends on account + /// privileges. Browse stays on a read-only pool slot; Save uses `tableWrite`. 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'); - } + await execute(sessionTransactionAccessModeSql(readOnly)); } + /// `SET SESSION TRANSACTION READ ONLY` / `READ WRITE`. + @visibleForTesting + static String sessionTransactionAccessModeSql(bool readOnly) => readOnly + ? 'SET SESSION TRANSACTION READ ONLY' + : 'SET SESSION TRANSACTION READ WRITE'; + Future testConnection() async { try { await connect(); diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index d31ee98e..6cf76d76 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -291,6 +291,7 @@ class _WorkspacePanelState extends State { tableName: my.name, isView: my.kind == MysqlObjectKind.view, onNavigateHome: widget.onNavigateHome, + isReadOnly: widget.isReadOnly, ); } } diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index 06669677..c9a14399 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -28,6 +28,7 @@ class MysqlTableView extends material.StatefulWidget { this.isView = false, this.limit = _defaultLimit, this.onNavigateHome, + this.isReadOnly = false, }); final ConnectionRow connectionRow; @@ -37,6 +38,9 @@ class MysqlTableView extends material.StatefulWidget { final int limit; final VoidCallback? onNavigateHome; + /// Title-bar session lock: no staging / Save / `tableWrite` acquire. + final bool isReadOnly; + @override material.State createState() => _MysqlTableViewState(); } @@ -71,6 +75,7 @@ class _MysqlTableViewState extends material.State { isView: widget.isView, customSqlActive: _customSqlActive, hasPrimaryKey: _primaryKeys.isNotEmpty, + readOnly: widget.isReadOnly, ); String _qualifiedFrom() { @@ -106,6 +111,8 @@ class _MysqlTableViewState extends material.State { _resetStaging(); _disconnectCurrent(interruptIfBusy: true); _connectAndLoad(); + } else if (oldWidget.isReadOnly != widget.isReadOnly) { + _syncStagingToReadOnly(); } } @@ -126,6 +133,21 @@ class _MysqlTableViewState extends material.State { _isSaving = false; } + void _syncStagingToReadOnly() { + if (widget.isReadOnly) { + _stagingBuffer?.dispose(); + _stagingBuffer = null; + } else if (_columnNames.isNotEmpty) { + _stagingBuffer = replaceTableViewStagingBuffer( + previous: _stagingBuffer, + columns: _columnNames, + rows: _rows, + enabled: _editingEnabled, + ); + } + if (mounted) setState(() {}); + } + void _disconnectCurrent({bool interruptIfBusy = false}) { if (interruptIfBusy && _loading) { MysqlService.instance.interrupt( @@ -215,6 +237,9 @@ class _MysqlTableViewState extends material.State { Future _withTableWrite( Future Function(MysqlConnection conn) fn, ) async { + if (widget.isReadOnly) { + throw StateError('MySQL session is read-only'); + } final lease = await MysqlService.instance.acquire( widget.connectionRow, database: widget.database, @@ -483,6 +508,7 @@ class _MysqlTableViewState extends material.State { customSqlActive: _customSqlActive, hasPrimaryKey: _primaryKeys.isNotEmpty, schemaLoaded: _schemaLoaded, + readOnly: widget.isReadOnly, ); final pag = _paginationLabel(); if (reason != null) return '$pag · $reason'; @@ -508,6 +534,7 @@ class _MysqlTableViewState extends material.State { } Future _applyStagedChanges() async { + if (widget.isReadOnly) return; final buffer = _stagingBuffer; if (buffer == null || !buffer.isDirty || _isSaving) return; setState(() => _isSaving = true); diff --git a/test/core/database/mysql_connection_test.dart b/test/core/database/mysql_connection_test.dart index 094ee800..9c895712 100644 --- a/test/core/database/mysql_connection_test.dart +++ b/test/core/database/mysql_connection_test.dart @@ -157,6 +157,21 @@ void main() { }); }); + group('MysqlConnection.sessionTransactionAccessModeSql', () { + test( + 'documents SET SESSION TRANSACTION (not default_transaction_read_only)', + () { + expect( + MysqlConnection.sessionTransactionAccessModeSql(true), + 'SET SESSION TRANSACTION READ ONLY', + ); + expect( + MysqlConnection.sessionTransactionAccessModeSql(false), + 'SET SESSION TRANSACTION READ WRITE', + ); + }); + }); + group('MysqlConnection.quoteIdentifier', () { test('escapes backticks', () { expect( From cd498e05b7423e62c6c67b32624d95e153d3fbd3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 15:23:16 +0300 Subject: [PATCH 33/52] fix(sqlite): show BLOB as hex and keep TEXT affinity on Save --- CHANGELOG.md | 1 + lib/features/sqlite/sqlite_result_utils.dart | 69 ++++++++++++++-- lib/features/sqlite/sqlite_sql_workspace.dart | 5 +- lib/features/sqlite/sqlite_table_view.dart | 7 +- .../result_conversion_perf_test.dart | 17 +++- .../sqlite/sqlite_grid_types_test.dart | 82 +++++++++++++++++++ 6 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 test/features/sqlite/sqlite_grid_types_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index d2b46c89..42612c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **SQLite BLOB / TEXT grid (#800)** — Table Browser and SQL results show BLOB as `X'hex'` (not `Uint8List.toString()`) and persist the same. Declared TEXT stays a quoted string even when it looks numeric (`00123`). - **MySQL Table Browser session lock (#803)** — Browse stays on `MysqlSessionMode.readOnly`. Title-bar lock is passed into `MysqlTableView`: staging / Save stay off and Save does not acquire `tableWrite`. `SET SESSION TRANSACTION READ ONLY` is documented as a next-transaction hint (weaker than a read-only user; MariaDB vs MySQL 8). - **MySQL ssl-mode (#805)** — `ssl-mode=prefer` enables TLS (same as `require`) instead of turning it off. `verify_ca` / `verify_identity` fail closed without `sslrootcert` and ask the driver to check the CA (and hostname for identity). Form Use SSL stays encrypt-only. - **MySQL BOOLEAN / BIT / BLOB / JSON grid (#806)** — Schema uses `COLUMN_TYPE` so BOOLEAN is `tinyint(1)` (TRUE/FALSE on Save). BIT/BLOB/BINARY cells display as `0x` hex and persist as `X'…'`; JSON stays quoted text. The MySQL driver decodes charset-63 payloads as latin1 so invalid UTF-8 no longer throws. diff --git a/lib/features/sqlite/sqlite_result_utils.dart b/lib/features/sqlite/sqlite_result_utils.dart index 0f616c68..7cbe4cca 100644 --- a/lib/features/sqlite/sqlite_result_utils.dart +++ b/lib/features/sqlite/sqlite_result_utils.dart @@ -1,3 +1,7 @@ +import 'dart:typed_data'; + +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; + /// Serializable row batch for [convertSqliteResultRowsToStrings] in a worker isolate. class SqliteResultConvertJob { const SqliteResultConvertJob({ @@ -8,12 +12,61 @@ class SqliteResultConvertJob { } /// Converts SQLite result cell values to display strings off the UI thread. -List> convertSqliteResultRowsToStrings(SqliteResultConvertJob job) { - return job.rowValues - .map( - (row) => row - .map((value) => value == null ? 'NULL' : value.toString()) - .toList(), - ) - .toList(); +List> convertSqliteResultRowsToStrings( + SqliteResultConvertJob job) { + return [ + for (final row in job.rowValues) + [for (final value in row) sqliteResultCellToDisplayString(value)], + ]; +} + +/// BLOB as `X'deadbeef'`; other values via [Object.toString]. Null → `NULL`. +String sqliteResultCellToDisplayString( + Object? value, { + String? dataTypeName, +}) { + if (value == null) return 'NULL'; + if (value is Uint8List) { + return sqliteBlobHexLiteral(value); + } + if (value is List) { + return sqliteBlobHexLiteral(Uint8List.fromList(value)); + } + if (dataTypeName != null && + dataTypeName.isNotEmpty && + TableMutationEngine.isBinaryType(dataTypeName)) { + return sqliteBlobHexLiteralFromCell(value); + } + return value.toString(); +} + +/// SQLite blob literal `X'aabbcc'`. +String sqliteBlobHexLiteral(Uint8List bytes) { + final out = StringBuffer("X'"); + for (final b in bytes) { + out.write(b.toRadixString(16).padLeft(2, '0')); + } + out.write("'"); + return out.toString(); +} + +String sqliteBlobHexLiteralFromCell(Object value) { + final raw = value.toString().trim(); + if (raw == 'NULL' || raw == 'null') return 'NULL'; + var hex = raw; + if (hex.startsWith(r'\x') || + hex.startsWith(r'\X') || + hex.startsWith('0x') || + hex.startsWith('0X')) { + hex = hex.substring(2); + } else if ((hex.startsWith("x'") || hex.startsWith("X'")) && + hex.endsWith("'")) { + hex = hex.substring(2, hex.length - 1); + } else { + return sqliteBlobHexLiteral( + Uint8List.fromList(raw.codeUnits.map((u) => u & 0xFF).toList()), + ); + } + final clean = hex.replaceAll(RegExp(r'[^0-9a-fA-F]'), '').toLowerCase(); + return "X'$clean'"; } diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 61df5f13..df33d27f 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -15,6 +15,7 @@ import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/ui/querya_shell_status.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -309,7 +310,9 @@ class _SqliteSqlWorkspaceState extends material.State { final injectedLimit = sql != userSql; final rawRows = results.take(limitCount).map((row) { - return cols.map((col) => row[col]).toList(); + return cols + .map((col) => sqliteResultCellToDisplayString(row[col])) + .toList(); }).toList(); final outRows = await convertResultRowsToStringsAdaptive(rawRows); diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 3ec24df1..b24eae3a 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -9,6 +9,7 @@ import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -282,8 +283,10 @@ class _SqliteTableViewState extends material.State { final outRows = rs.map((row) { return cols.map((col) { - final val = row[col]; - return val == null ? 'NULL' : val.toString(); + return sqliteResultCellToDisplayString( + row[col], + dataTypeName: _columnDataTypes[col], + ); }).toList(); }).toList(); diff --git a/test/features/sql_workspaces/result_conversion_perf_test.dart b/test/features/sql_workspaces/result_conversion_perf_test.dart index 902d9a8b..7e85c48e 100644 --- a/test/features/sql_workspaces/result_conversion_perf_test.dart +++ b/test/features/sql_workspaces/result_conversion_perf_test.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/features/mysql/mysql_result_utils.dart'; import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart'; @@ -18,7 +20,9 @@ void main() { expect(out[1], ['2', 'world', 'abc', 'NULL', 'false']); }); - test('convertPostgresResultRowsToStrings maps nulls and primitives correctly', () { + test( + 'convertPostgresResultRowsToStrings maps nulls and primitives correctly', + () { final rawRows = [ [100, null, 'pg_test'], [null, 999, 'foo'], @@ -31,17 +35,24 @@ void main() { expect(out[1], ['NULL', '999', 'foo']); }); - test('convertSqliteResultRowsToStrings maps nulls and primitives correctly', () { + test('convertSqliteResultRowsToStrings maps nulls, primitives, and blobs', + () { final rawRows = [ ['sqlite', null, 42], [null, null, null], + [ + Uint8List.fromList(const [0xff]), + 'x', + 1 + ], ]; final job = SqliteResultConvertJob(rowValues: rawRows); final out = convertSqliteResultRowsToStrings(job); - expect(out.length, 2); + expect(out.length, 3); expect(out[0], ['sqlite', 'NULL', '42']); expect(out[1], ['NULL', 'NULL', 'NULL']); + expect(out[2], ["X'ff'", 'x', '1']); }); test('All convert jobs handle large batches efficiently', () { diff --git a/test/features/sqlite/sqlite_grid_types_test.dart b/test/features/sqlite/sqlite_grid_types_test.dart new file mode 100644 index 00000000..673390fe --- /dev/null +++ b/test/features/sqlite/sqlite_grid_types_test.dart @@ -0,0 +1,82 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart'; + +void main() { + group('sqliteResultCellToDisplayString', () { + test('BLOB bytes display as X\'hex\'', () { + expect( + sqliteResultCellToDisplayString( + Uint8List.fromList(const [0xde, 0xad, 0xbe, 0xef]), + ), + "X'deadbeef'", + ); + expect( + sqliteResultCellToDisplayString( + const [0x00, 0x00, 0x00, 0x00], + dataTypeName: 'BLOB', + ), + "X'00000000'", + ); + }); + + test('TEXT stays the string, including leading zeros', () { + expect( + sqliteResultCellToDisplayString('00123', dataTypeName: 'TEXT'), + '00123', + ); + expect(sqliteResultCellToDisplayString(null), 'NULL'); + }); + }); + + group('all_sqlite_types persist', () { + test('BLOB hex persists as X\'..\'', () { + expect( + TableMutationEngine.formatLiteral( + "X'deadbeefcafe0102030405'", + SqlDialect.sqlite, + dataTypeName: 'BLOB', + ), + "X'deadbeefcafe0102030405'", + ); + }); + + test('TEXT 00123 stays a quoted string, not an integer', () { + expect( + TableMutationEngine.formatLiteral( + '00123', + SqlDialect.sqlite, + dataTypeName: 'TEXT', + ), + "'00123'", + ); + expect( + TableMutationEngine.formatLiteral( + '123', + SqlDialect.sqlite, + dataTypeName: 'text', + ), + "'123'", + ); + }); + }); + + group('convertSqliteResultRowsToStrings', () { + test('maps blob bytes to hex, not List.toString', () { + final out = convertSqliteResultRowsToStrings( + SqliteResultConvertJob( + rowValues: [ + [ + 'ok', + Uint8List.fromList(const [0xca, 0xfe]), + null, + ], + ], + ), + ); + expect(out.single, ['ok', "X'cafe'", 'NULL']); + }); + }); +} From c9115facf3b8e6cd0f7231f93aef78f2908cfbbb Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 15:23:32 +0300 Subject: [PATCH 34/52] chore: keep postgres convert-job test name on one line --- test/features/sql_workspaces/result_conversion_perf_test.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/features/sql_workspaces/result_conversion_perf_test.dart b/test/features/sql_workspaces/result_conversion_perf_test.dart index 7e85c48e..b82c87a3 100644 --- a/test/features/sql_workspaces/result_conversion_perf_test.dart +++ b/test/features/sql_workspaces/result_conversion_perf_test.dart @@ -20,9 +20,7 @@ void main() { expect(out[1], ['2', 'world', 'abc', 'NULL', 'false']); }); - test( - 'convertPostgresResultRowsToStrings maps nulls and primitives correctly', - () { + test('convertPostgresResultRowsToStrings maps nulls and primitives correctly', () { final rawRows = [ [100, null, 'pg_test'], [null, 999, 'foo'], From df73654a16488c54f35e95bbc5c1d359878d9fd0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 15:32:20 +0300 Subject: [PATCH 35/52] fix(sqlite): ORDER BY PK/rowid on Table Browser and skip COUNT(*) Unordered LIMIT/OFFSET skipped or duplicated pages, and COUNT(*) blocked the FFI isolate before first paint. SQL workspace gets an optional statement timeout matching Postgres/MySQL. --- CHANGELOG.md | 1 + lib/core/storage/app_settings.dart | 24 ++++ lib/features/settings/preferences_dialog.dart | 23 +++- .../sql_statement_timeout_dropdown.dart | 2 +- lib/features/sqlite/sqlite_sql_workspace.dart | 119 ++++++++++++------ lib/features/sqlite/sqlite_table_utils.dart | 32 +++++ lib/features/sqlite/sqlite_table_view.dart | 35 +++--- test/core/storage/app_settings_test.dart | 22 ++++ .../sqlite/sqlite_table_utils_test.dart | 90 +++++++++++++ 9 files changed, 289 insertions(+), 59 deletions(-) create mode 100644 lib/features/sqlite/sqlite_table_utils.dart create mode 100644 test/features/sqlite/sqlite_table_utils_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 42612c43..76137a33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **SQLite Table Browser paging (#799)** — Browse `SELECT` uses `ORDER BY` primary-key columns, or `rowid` when the table has implicit rowid. First paint skips blocking `COUNT(*)` (Next stays available when the page is full). SQL workspace has an optional statement timeout matching Postgres/MySQL. - **SQLite BLOB / TEXT grid (#800)** — Table Browser and SQL results show BLOB as `X'hex'` (not `Uint8List.toString()`) and persist the same. Declared TEXT stays a quoted string even when it looks numeric (`00123`). - **MySQL Table Browser session lock (#803)** — Browse stays on `MysqlSessionMode.readOnly`. Title-bar lock is passed into `MysqlTableView`: staging / Save stay off and Save does not acquire `tableWrite`. `SET SESSION TRANSACTION READ ONLY` is documented as a next-transaction hint (weaker than a read-only user; MariaDB vs MySQL 8). - **MySQL ssl-mode (#805)** — `ssl-mode=prefer` enables TLS (same as `require`) instead of turning it off. `verify_ca` / `verify_identity` fail closed without `sslrootcert` and ask the driver to check the CA (and hostname for identity). Form Use SSL stays encrypt-only. diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 053241e7..15178249 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -107,6 +107,7 @@ abstract final class AppSettingsKeys { static const postgresSqlStmtTimeoutSeconds = 'postgres_sql_stmt_timeout_seconds'; static const mysqlSqlStmtTimeoutSeconds = 'mysql_sql_stmt_timeout_seconds'; + static const sqliteSqlStmtTimeoutSeconds = 'sqlite_sql_stmt_timeout_seconds'; static const sqlResultMaxRows = 'sql_result_max_rows'; static const sqlEditorFontSizePoints = 'sql_editor_font_size_points'; static const sqlHistoryMaxEntries = 'sql_history_max_entries'; @@ -197,6 +198,29 @@ class AppSettings { SqlWorkspaceSettingsRevision.bump(); } + /// `null` = no application-level timeout (`executeWithTimeout` is unbounded). + Future getSqliteSqlStmtTimeoutSeconds() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.sqliteSqlStmtTimeoutSeconds, + ); + if (v == null || v.isEmpty) return null; + return int.tryParse(v); + } + + Future setSqliteSqlStmtTimeoutSeconds(int? seconds) async { + if (seconds == null) { + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.sqliteSqlStmtTimeoutSeconds, + ); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.sqliteSqlStmtTimeoutSeconds, + seconds.toString(), + ); + } + SqlWorkspaceSettingsRevision.bump(); + } + /// Max rows loaded into the result grid for PostgreSQL / MySQL workspaces. Future getSqlResultMaxRows() async { final v = await LocalDb.instance.getAppSetting( diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 3840641d..4e46de23 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -57,6 +57,7 @@ class PreferencesDialogContentState bool _confirmDestructive = true; int? _pgTimeout; int? _mysqlTimeout; + int? _sqliteTimeout; int _maxRows = kDefaultSqlResultMaxRows; int _historyMax = kDefaultSqlHistoryMaxEntries; double _fontSize = kDefaultSqlEditorFontSize; @@ -95,6 +96,8 @@ class PreferencesDialogContentState await AppSettings.instance.getConfirmDestructiveOperations(); final pg = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); final my = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); + final sqlite = + await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(); final rows = await AppSettings.instance.getSqlResultMaxRows(); final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); final font = await AppSettings.instance.getSqlEditorFontSize(); @@ -105,6 +108,7 @@ class PreferencesDialogContentState _confirmDestructive = destructive; _pgTimeout = pg; _mysqlTimeout = my; + _sqliteTimeout = sqlite; _maxRows = rows; _historyMax = hist; _fontSize = font; @@ -137,6 +141,11 @@ class PreferencesDialogContentState await AppSettings.instance.setMysqlSqlStmtTimeoutSeconds(v); } + Future _setSqlite(int? v) async { + setState(() => _sqliteTimeout = v); + await AppSettings.instance.setSqliteSqlStmtTimeoutSeconds(v); + } + Future _setMaxRows(int v) async { setState(() => _maxRows = v); await AppSettings.instance.setSqlResultMaxRows(v); @@ -171,7 +180,7 @@ class PreferencesDialogContentState count++; } case PreferencesCategory.sql: - if ('font size postgresql mysql statement timeout history destructive drop truncate delete' + if ('font size postgresql mysql sqlite statement timeout history destructive drop truncate delete' .contains(q)) { count++; } @@ -620,6 +629,18 @@ class PreferencesDialogContentState ), ), + const material.SizedBox(height: 16), + PreferencesFieldRow( + label: 'SQLite timeout', + hint: + 'Application-level statement timeout for queries on SQLite connections.', + control: SqlStatementTimeoutDropdown( + value: _sqliteTimeout, + expandToParent: true, + onChanged: (v) => unawaited(_setSqlite(v)), + ), + ), + const material.SizedBox(height: 16), PreferencesFieldRow( label: 'Query history limit', diff --git a/lib/features/settings/sql_statement_timeout_dropdown.dart b/lib/features/settings/sql_statement_timeout_dropdown.dart index 129bb956..d119d7b0 100644 --- a/lib/features/settings/sql_statement_timeout_dropdown.dart +++ b/lib/features/settings/sql_statement_timeout_dropdown.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; -/// Shared dropdown values for SQL statement timeouts (PostgreSQL / MySQL). +/// Shared dropdown values for SQL statement timeouts (PostgreSQL / MySQL / SQLite). const List> kSqlStatementTimeoutMenuItems = [ QueryaDropdownItem(value: null, label: 'No limit'), QueryaDropdownItem(value: 10, label: '10 s'), diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index df33d27f..dae351af 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -17,6 +17,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/ui/querya_shell_status.dart'; import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; +import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -50,6 +51,8 @@ class _SqliteSqlWorkspaceState extends material.State { SqliteLease? _lease; bool? _txOpen; + int? _queryTimeoutSeconds; + int _resultMaxRows = kDefaultSqlResultMaxRows; int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; double _editorFontSize = kDefaultSqlEditorFontSize; @@ -180,17 +183,24 @@ class _SqliteSqlWorkspaceState extends material.State { } Future _loadWorkspaceSettings() async { + final t = await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(); final rows = await AppSettings.instance.getSqlResultMaxRows(); final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); final font = await AppSettings.instance.getSqlEditorFontSize(); if (!mounted) return; setState(() { + _queryTimeoutSeconds = t; _resultMaxRows = rows; _historyMaxEntries = hist; _editorFontSize = font; }); } + void _onStmtTimeoutChanged(int? v) { + setState(() => _queryTimeoutSeconds = v); + unawaited(AppSettings.instance.setSqliteSqlStmtTimeoutSeconds(v)); + } + Future _ensureLease() async { if (_lease != null && _lease!.connection.isConnected) return; _lease?.release(); @@ -224,6 +234,10 @@ class _SqliteSqlWorkspaceState extends material.State { _notifyTransactionOpen(); } + Duration? _statementTimeout() => _queryTimeoutSeconds == null + ? null + : Duration(seconds: _queryTimeoutSeconds!); + @override void dispose() { SqlEditorCommandBridge.instance @@ -296,7 +310,8 @@ class _SqliteSqlWorkspaceState extends material.State { // Client-side take() remains as defense for PRAGMA/EXPLAIN and author LIMIT. final cap = _resultMaxRows; final sql = injectSqlLimit(userSql, cap); - final results = await conn.execute(sql); + final results = + await conn.executeWithTimeout(sql, timeout: _statementTimeout()); if (!mounted) return; @@ -723,6 +738,8 @@ class _SqliteSqlWorkspaceState extends material.State { _SqliteSqlToolbar( onExecute: session.running ? null : () => _execute(session), running: session.running, + queryTimeoutSeconds: _queryTimeoutSeconds, + onQueryTimeoutChanged: _onStmtTimeoutChanged, onOpenPreferences: () => showPreferencesDialog(context), onOpenHistory: widget.connectionRow.id != null && !session.running ? () { @@ -790,12 +807,16 @@ class _SqliteSqlToolbar extends material.StatelessWidget { const _SqliteSqlToolbar({ required this.onExecute, required this.running, + required this.queryTimeoutSeconds, + required this.onQueryTimeoutChanged, required this.onOpenPreferences, this.onOpenHistory, }); final Future Function()? onExecute; final bool running; + final int? queryTimeoutSeconds; + final void Function(int?) onQueryTimeoutChanged; final VoidCallback onOpenPreferences; final VoidCallback? onOpenHistory; @@ -805,47 +826,67 @@ class _SqliteSqlToolbar extends material.StatelessWidget { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: SqlEditorChrome.sqlToolbarDecoration(context), - child: material.Row( + child: material.Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, children: [ - const Text('Query').semiBold().small(), - const Spacer(), - OutlineButton( - size: ButtonSize.small, - onPressed: onOpenHistory, - leading: material.Icon( - material.Icons.history_rounded, - size: 16, - color: accent, - ), - child: const Text('History'), - ), - const Gap(8), - IconButton.ghost( - onPressed: running ? null : onOpenPreferences, - icon: material.Icon( - material.Icons.settings_rounded, - size: 20, - color: accent, - ), + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + const Text('Query').semiBold().small(), + OutlineButton( + size: ButtonSize.small, + onPressed: onOpenHistory, + leading: material.Icon( + material.Icons.history_rounded, + size: 16, + color: accent, + ), + child: const Text('History'), + ), + OutlineButton( + onPressed: onExecute, + leading: running + ? material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: accent, + ), + ) + : material.Icon( + material.Icons.play_arrow_rounded, + size: 18, + color: accent, + ), + child: const Text('Execute (F5)'), + ), + ], ), const Gap(8), - OutlineButton( - onPressed: onExecute, - leading: running - ? material.SizedBox( - width: 16, - height: 16, - child: material.CircularProgressIndicator( - strokeWidth: 2, - color: accent, - ), - ) - : material.Icon( - material.Icons.play_arrow_rounded, - size: 18, - color: accent, - ), - child: const Text('Execute (F5)'), + material.Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: material.WrapCrossAlignment.center, + children: [ + const Text('Stmt timeout').small(), + SqlStatementTimeoutDropdown( + value: queryTimeoutSeconds, + onChanged: onQueryTimeoutChanged, + enabled: !running, + ), + IconButton.ghost( + onPressed: running ? null : onOpenPreferences, + icon: material.Icon( + material.Icons.settings_rounded, + size: 20, + color: accent, + ), + ), + ], ), ], ), diff --git a/lib/features/sqlite/sqlite_table_utils.dart b/lib/features/sqlite/sqlite_table_utils.dart new file mode 100644 index 00000000..67ebcce2 --- /dev/null +++ b/lib/features/sqlite/sqlite_table_utils.dart @@ -0,0 +1,32 @@ +import 'package:querya_desktop/core/database/sqlite_connection.dart'; + +/// Columns for Table Browser `ORDER BY`. +/// +/// Declared PK first. Ordinary tables with no PK use implicit `rowid`. +/// Views omit `ORDER BY` (no PK, no `rowid`). +List sqliteBrowseOrderColumns({ + required List primaryKeys, + required bool isView, +}) { + if (primaryKeys.isNotEmpty) return List.from(primaryKeys); + if (!isView) return const ['rowid']; + return const []; +} + +/// Browse SELECT for Table Browser. PK / `rowid` keep LIMIT/OFFSET stable. +String sqliteBrowseDataSql({ + required String qualifiedFrom, + required List primaryKeys, + required bool isView, + required int limit, + required int offset, +}) { + final orderCols = sqliteBrowseOrderColumns( + primaryKeys: primaryKeys, + isView: isView, + ); + final order = orderCols.isEmpty + ? '' + : ' ORDER BY ${orderCols.map(SqliteConnection.quoteIdentifier).join(', ')}'; + return 'SELECT * FROM $qualifiedFrom$order LIMIT $limit OFFSET $offset'; +} diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index b24eae3a..d9756711 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_table_utils.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -76,7 +77,13 @@ class _SqliteTableViewState extends material.State { } String _browseDataSql() { - return 'SELECT * FROM ${_qualifiedFrom()} LIMIT ${widget.limit} OFFSET $_offset'; + return sqliteBrowseDataSql( + qualifiedFrom: _qualifiedFrom(), + primaryKeys: _primaryKeys, + isView: widget.isView, + limit: widget.limit, + offset: _offset, + ); } @override @@ -172,7 +179,7 @@ class _SqliteTableViewState extends material.State { return; } _lease = lease; - await _fetch(refreshCount: true); + await _fetch(); } catch (e) { if (!mounted) return; setState(() { @@ -238,7 +245,7 @@ class _SqliteTableViewState extends material.State { ); } - Future _fetch({bool refreshCount = false}) async { + Future _fetch() async { final conn = _connection; if (conn == null || !conn.isConnected) { if (mounted && _loading) { @@ -254,26 +261,18 @@ class _SqliteTableViewState extends material.State { _error = null; }); try { - if (refreshCount) { - try { - final countRs = - await conn.execute('SELECT COUNT(*) FROM ${_qualifiedFrom()}'); - if (countRs.isNotEmpty) { - _totalRowCount = countRs.first.values.first as int?; - } - } catch (_) { - _totalRowCount = null; - } - } + await _ensureSchema(conn); + if (!mounted) return; + + // Skip COUNT(*) — it blocks the FFI isolate on large files. Next is + // enabled when the current page is full (_canGoNext). + _totalRowCount = null; final browseSql = _browseDataSql(); final rs = await conn.execute(browseSql); if (!mounted) return; - await _ensureSchema(conn); - if (!mounted) return; - final cols = []; if (rs.isNotEmpty) { cols.addAll(rs.first.keys); @@ -338,7 +337,7 @@ class _SqliteTableViewState extends material.State { Future _onRefresh() async { if (!await _confirmDiscardIfNeeded()) return; if (!mounted) return; - await _fetch(refreshCount: true); + await _fetch(); } Future _onNavigateHome() async { diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 7cf70e6c..b095f0f4 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -125,6 +125,28 @@ void main() { await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(), isNull); }); + test('getSqliteSqlStmtTimeoutSeconds roundtrip', () async { + expect( + await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(), isNull); + + await AppSettings.instance.setSqliteSqlStmtTimeoutSeconds(60); + expect(await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(), 60); + + await AppSettings.instance.setSqliteSqlStmtTimeoutSeconds(null); + expect( + await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(), isNull); + }); + + test('getSqliteSqlStmtTimeoutSeconds returns null for invalid stored value', + () async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.sqliteSqlStmtTimeoutSeconds, + 'not-a-number', + ); + expect( + await AppSettings.instance.getSqliteSqlStmtTimeoutSeconds(), isNull); + }); + test('getSqlResultMaxRows defaults and normalizes to preset', () async { expect(await AppSettings.instance.getSqlResultMaxRows(), kDefaultSqlResultMaxRows); diff --git a/test/features/sqlite/sqlite_table_utils_test.dart b/test/features/sqlite/sqlite_table_utils_test.dart new file mode 100644 index 00000000..3f6e25f8 --- /dev/null +++ b/test/features/sqlite/sqlite_table_utils_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_table_utils.dart'; + +void main() { + group('sqliteBrowseOrderColumns', () { + test('uses declared PK columns', () { + expect( + sqliteBrowseOrderColumns( + primaryKeys: const ['id'], + isView: false, + ), + ['id'], + ); + }); + + test('uses rowid when a table has no PK', () { + expect( + sqliteBrowseOrderColumns( + primaryKeys: const [], + isView: false, + ), + ['rowid'], + ); + }); + + test('omits order columns for views', () { + expect( + sqliteBrowseOrderColumns( + primaryKeys: const [], + isView: true, + ), + isEmpty, + ); + }); + }); + + group('sqliteBrowseDataSql', () { + test('orders by quoted PK columns', () { + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"orders"', + primaryKeys: const ['id'], + isView: false, + limit: 200, + offset: 400, + ), + 'SELECT * FROM "orders" ORDER BY "id" LIMIT 200 OFFSET 400', + ); + }); + + test('composite PK lists all columns', () { + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"t"', + primaryKeys: const ['a', 'b'], + isView: false, + limit: 200, + offset: 0, + ), + 'SELECT * FROM "t" ORDER BY "a", "b" LIMIT 200 OFFSET 0', + ); + }); + + test('orders by rowid when a table has no PK', () { + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"t"', + primaryKeys: const [], + isView: false, + limit: 200, + offset: 0, + ), + 'SELECT * FROM "t" ORDER BY "rowid" LIMIT 200 OFFSET 0', + ); + }); + + test('omits ORDER BY for views', () { + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"v"', + primaryKeys: const [], + isView: true, + limit: 200, + offset: 0, + ), + 'SELECT * FROM "v" LIMIT 200 OFFSET 0', + ); + }); + }); +} From 0b40bc4fe4b7cb1a1440a8f162b27fa796c4d384 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 15:41:11 +0300 Subject: [PATCH 36/52] fix(sqlite): do not create an empty database when the file is missing Test Connection and opening a saved connection no longer create a .db on a typo path. New-connection Save still creates the file if needed. --- CHANGELOG.md | 1 + lib/core/database/sqlite_connection.dart | 97 +++++++++++++- .../connections/sqlite_connection_form.dart | 21 ++- .../core/database/sqlite_connection_test.dart | 121 +++++++++++++++++- 4 files changed, 228 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76137a33..d9bf9b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **SQLite missing file (#798)** — Test Connection and opening a saved connection no longer create an empty `.db` when the path is a typo. Errors distinguish file not found, permission, and corrupt. New-connection Save still creates the file if it does not exist. - **SQLite Table Browser paging (#799)** — Browse `SELECT` uses `ORDER BY` primary-key columns, or `rowid` when the table has implicit rowid. First paint skips blocking `COUNT(*)` (Next stays available when the page is full). SQL workspace has an optional statement timeout matching Postgres/MySQL. - **SQLite BLOB / TEXT grid (#800)** — Table Browser and SQL results show BLOB as `X'hex'` (not `Uint8List.toString()`) and persist the same. Declared TEXT stays a quoted string even when it looks numeric (`00123`). - **MySQL Table Browser session lock (#803)** — Browse stays on `MysqlSessionMode.readOnly`. Title-bar lock is passed into `MysqlTableView`: staging / Save stay off and Save does not acquire `tableWrite`. `SET SESSION TRANSACTION READ ONLY` is documented as a next-transaction hint (weaker than a read-only user; MariaDB vs MySQL 8). diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index 054a0f27..95449d3e 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -1,10 +1,51 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +/// In-memory paths (`:memory:`) skip the missing-file check. +bool sqlitePathIsInMemory(String path) => + path == inMemoryDatabasePath || path == ':memory:'; + +/// Maps driver / OS errors to a user-facing [SqliteConnectionException]. +SqliteConnectionException sqliteMapOpenError(Object error, String path) { + final msg = error.toString().toLowerCase(); + if (msg.contains('not found') || + msg.contains('no such file') || + msg.contains('errno = 2') || + msg.contains('errno =2')) { + return SqliteConnectionException( + 'SQLite file not found: $path', + cause: error, + ); + } + if (msg.contains('permission') || + msg.contains('access denied') || + msg.contains('errno = 13') || + msg.contains('errno =13')) { + return SqliteConnectionException( + 'Permission denied opening SQLite file: $path', + cause: error, + ); + } + if (msg.contains('not a database') || + msg.contains('malformed') || + msg.contains('corrupt') || + msg.contains('disk image')) { + return SqliteConnectionException( + 'SQLite database is corrupt or not a database: $path', + cause: error, + ); + } + return SqliteConnectionException( + 'Failed to open SQLite database: $error', + cause: error, + ); +} + /// SQLite database connection using sqflite_common_ffi. class SqliteConnection { SqliteConnection({ @@ -12,6 +53,7 @@ class SqliteConnection { required this.name, required this.path, this.readOnly = false, + this.createIfMissing = false, }); factory SqliteConnection.fromConnectionRow( @@ -32,6 +74,9 @@ class SqliteConnection { final String path; final bool readOnly; + /// When true, `openDatabase` may create the file (new-connection Save only). + final bool createIfMissing; + Database? _db; bool _isConnected = false; bool _inTransaction = false; @@ -42,12 +87,14 @@ class SqliteConnection { if (_isConnected && _db != null) return; try { await LocalDb.initFfi(); + _ensureFileReady(); _db = await databaseFactoryFfi.openDatabase( path, options: OpenDatabaseOptions( readOnly: readOnly, onOpen: (db) async { await db.execute('PRAGMA busy_timeout = 5000'); + await db.rawQuery('SELECT 1'); if (!readOnly) { await db.execute('PRAGMA foreign_keys = ON'); try { @@ -60,10 +107,46 @@ class SqliteConnection { ), ); _isConnected = true; - } catch (e) { + } on SqliteConnectionException { _isConnected = false; _db = null; rethrow; + } catch (e) { + _isConnected = false; + _db = null; + throw sqliteMapOpenError(e, path); + } + } + + void _ensureFileReady() { + if (sqlitePathIsInMemory(path)) return; + final file = File(path); + if (file.existsSync()) { + if (file.statSync().type == FileSystemEntityType.directory) { + throw SqliteConnectionException('SQLite path is a directory: $path'); + } + return; + } + // sqflite FFI uses OpenMode.readWriteCreate and even mkdir's parents. + if (!createIfMissing || readOnly) { + throw SqliteConnectionException('SQLite file not found: $path'); + } + } + + /// Creates an empty SQLite file if [path] is missing (new-connection Save). + static Future createFileIfMissing(String path) async { + if (sqlitePathIsInMemory(path)) return; + if (File(path).existsSync()) return; + final conn = SqliteConnection( + id: 0, + name: 'create', + path: path, + createIfMissing: true, + ); + try { + await conn.connect(); + } finally { + await conn.disconnect(); } } @@ -81,17 +164,19 @@ class SqliteConnection { Future forceClose() => disconnect(); - Future testConnection() async { + /// Tests connectivity without leaving a session open. + Future<({bool ok, String? error})> testConnection() async { try { await connect(); if (_db != null) { await _db!.rawQuery('SELECT 1'); - return true; + return (ok: true, error: null); } - return false; + return (ok: false, error: 'Connection could not be established.'); + } on SqliteConnectionException catch (e) { + return (ok: false, error: e.message); } catch (e) { - debugPrint('SqliteConnection.testConnection: $e'); - return false; + return (ok: false, error: sqliteMapOpenError(e, path).message); } finally { await disconnect(); } diff --git a/lib/features/connections/sqlite_connection_form.dart b/lib/features/connections/sqlite_connection_form.dart index d15c7dc5..08c6769f 100644 --- a/lib/features/connections/sqlite_connection_form.dart +++ b/lib/features/connections/sqlite_connection_form.dart @@ -149,12 +149,14 @@ class _SqliteConnectionFormContentState path: _pathController.text.trim(), readOnly: _readOnly, ); - final ok = await conn.testConnection(); + final result = await conn.testConnection(); if (!mounted) return; - if (ok) { + if (result.ok) { _showTestResult('success'); } else { - _showTestResult('error:Failed to open SQLite database.'); + _showTestResult( + 'error:${result.error ?? 'Failed to open SQLite database.'}', + ); } } catch (e) { if (!mounted) return; @@ -162,8 +164,19 @@ class _SqliteConnectionFormContentState } } - void _save() { + Future _save() async { if (!_formValidNotifier.value) return; + final path = _pathController.text.trim(); + if (!_isEditing) { + try { + await SqliteConnection.createFileIfMissing(path); + } catch (e) { + if (!mounted) return; + _showTestResult('error:$e'); + return; + } + } + if (!mounted) return; final initial = widget.initial; final row = ConnectionRow( id: initial?.id, diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index 4154774d..7ca9167f 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -131,6 +131,7 @@ void main() { id: 9, name: 'wal_file', path: path, + createIfMissing: true, ); addTearDown(() async { await fileConn.disconnect(); @@ -145,8 +146,8 @@ void main() { }); test('testConnection connects, queries and cleans up', () async { - final ok = await conn.testConnection(); - expect(ok, true); + final result = await conn.testConnection(); + expect(result.ok, isTrue); expect(conn.isConnected, false); // should be disconnected afterwards }); @@ -282,4 +283,120 @@ void main() { '"table""with""quotes"'); }); }); + + group('SqliteConnection missing file (#798)', () { + test('connect on a missing path does not create the file', () async { + final dir = await Directory.systemTemp.createTemp('querya_sqlite_miss_'); + final path = '${dir.path}/oops.db'; + final conn = SqliteConnection( + id: 1, + name: 'missing', + path: path, + ); + addTearDown(() async { + await conn.disconnect(); + await dir.delete(recursive: true); + }); + + await expectLater( + conn.connect(), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('not found'), + ), + ), + ); + expect(File(path).existsSync(), isFalse); + expect(conn.isConnected, isFalse); + }); + + test('testConnection on a missing path does not create the file', () async { + final dir = await Directory.systemTemp.createTemp('querya_sqlite_test_'); + final path = '${dir.path}/oops.db'; + final conn = SqliteConnection( + id: 1, + name: 'missing', + path: path, + ); + addTearDown(() async { + await conn.disconnect(); + await dir.delete(recursive: true); + }); + + final result = await conn.testConnection(); + expect(result.ok, isFalse); + expect(result.error, contains('not found')); + expect(File(path).existsSync(), isFalse); + }); + + test('createIfMissing creates an empty database', () async { + final dir = await Directory.systemTemp.createTemp('querya_sqlite_new_'); + final path = '${dir.path}/new.db'; + addTearDown(() async { + await dir.delete(recursive: true); + }); + + await SqliteConnection.createFileIfMissing(path); + expect(File(path).existsSync(), isTrue); + + final conn = SqliteConnection( + id: 1, + name: 'created', + path: path, + ); + addTearDown(conn.disconnect); + await conn.connect(); + expect(conn.isConnected, isTrue); + }); + + test('corrupt file is reported as not a database', () async { + final dir = await Directory.systemTemp.createTemp('querya_sqlite_bad_'); + final path = '${dir.path}/junk.db'; + await File(path).writeAsString('this is not sqlite'); + final conn = SqliteConnection( + id: 1, + name: 'junk', + path: path, + ); + addTearDown(() async { + await conn.disconnect(); + await dir.delete(recursive: true); + }); + + await expectLater( + conn.connect(), + throwsA( + isA().having( + (e) => e.message.toLowerCase(), + 'message', + anyOf(contains('corrupt'), contains('not a database')), + ), + ), + ); + expect(conn.isConnected, isFalse); + }); + + test('sqliteMapOpenError distinguishes missing, permission, corrupt', () { + expect( + sqliteMapOpenError(StateError('file /x not found'), '/x').message, + 'SQLite file not found: /x', + ); + expect( + sqliteMapOpenError( + Exception('OS Error: Permission denied, errno = 13'), + '/x', + ).message, + 'Permission denied opening SQLite file: /x', + ); + expect( + sqliteMapOpenError( + Exception('file is not a database'), + '/x', + ).message, + 'SQLite database is corrupt or not a database: /x', + ); + }); + }); } From e64bb161318745cd2e3f9e5e95e441f465439f4b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 15:45:00 +0300 Subject: [PATCH 37/52] fix(sqlite): classify WITH / PRAGMA by first statement, not prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WITH … INSERT is a write (read-only guard + execute, not rawQuery). Assignment PRAGMA is a write; query PRAGMA stays allowed. SQL workspace injects LIMIT only for read-only statements. --- CHANGELOG.md | 1 + lib/core/database/sqlite_connection.dart | 15 +- lib/core/database/sqlite_sql.dart | 225 ++++++++++++++++++ lib/features/sqlite/sqlite_sql_workspace.dart | 9 +- .../core/database/sqlite_connection_test.dart | 44 ++++ test/core/database/sqlite_sql_test.dart | 73 ++++++ 6 files changed, 352 insertions(+), 15 deletions(-) create mode 100644 lib/core/database/sqlite_sql.dart create mode 100644 test/core/database/sqlite_sql_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index d9bf9b87..9be25534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **SQLite WITH / PRAGMA (#797)** — `execute` classifies the first statement after comments: `WITH … INSERT` is a write (Dart read-only guard + `execute` instead of `rawQuery`). Assignment `PRAGMA name=value` is a write; `PRAGMA busy_timeout` stays a query. SQL workspace injects `LIMIT` only for read-only statements. - **SQLite missing file (#798)** — Test Connection and opening a saved connection no longer create an empty `.db` when the path is a typo. Errors distinguish file not found, permission, and corrupt. New-connection Save still creates the file if it does not exist. - **SQLite Table Browser paging (#799)** — Browse `SELECT` uses `ORDER BY` primary-key columns, or `rowid` when the table has implicit rowid. First paint skips blocking `COUNT(*)` (Next stays available when the page is full). SQL workspace has an optional statement timeout matching Postgres/MySQL. - **SQLite BLOB / TEXT grid (#800)** — Table Browser and SQL results show BLOB as `X'hex'` (not `Uint8List.toString()`) and persist the same. Declared TEXT stays a quoted string even when it looks numeric (`00123`). diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index 95449d3e..9bac4700 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:querya_desktop/core/database/sqlite_sql.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -190,18 +191,8 @@ class SqliteConnection { if (!isConnected || _db == null) { throw StateError('Not connected to SQLite'); } - final sqlLower = sql - .replaceAll(RegExp(r'--.*$', multiLine: true), '') - .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), '') - .trim() - .toLowerCase(); - - // SQLite can execute PRAGMA, SELECT, EXPLAIN statements, which return data - final isReadOnlyQuery = sqlLower.startsWith('select') || - sqlLower.startsWith('pragma') || - sqlLower.startsWith('explain') || - sqlLower.startsWith('with') || - sqlLower.startsWith('values'); + final sqlLower = sqliteStripSqlComments(sql).trim().toLowerCase(); + final isReadOnlyQuery = sqliteSqlIsReadOnlyQuery(sql); final hasReturning = RegExp(r'\breturning\b').hasMatch(sqlLower); diff --git a/lib/core/database/sqlite_sql.dart b/lib/core/database/sqlite_sql.dart new file mode 100644 index 00000000..5f820c49 --- /dev/null +++ b/lib/core/database/sqlite_sql.dart @@ -0,0 +1,225 @@ +/// Comment-strip + first-keyword classification for SQLite (not a full parser). + +/// Strips `--` line comments and `/* */` block comments. +String sqliteStripSqlComments(String sql) { + return sql + .replaceAll(RegExp(r'--.*$', multiLine: true), '') + .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), ''); +} + +/// Whether [sql] should use `rawQuery` and is allowed on a read-only connection. +/// +/// `WITH` is read-only only when the statement after the CTEs is `SELECT` / +/// `VALUES` / `EXPLAIN`. `PRAGMA name = value` is a write. +bool sqliteSqlIsReadOnlyQuery(String sql) { + final stmt = sqliteStripSqlComments(sql).trim().toLowerCase(); + if (stmt.isEmpty) return true; + + final scan = _SqliteSqlScan(stmt); + scan.skipWsAndSemis(); + final kw = scan.peekKeyword(); + if (kw == null) return false; + + switch (kw) { + case 'select': + case 'values': + case 'explain': + return true; + case 'pragma': + return !_pragmaAssigns(scan); + case 'with': + if (!_skipWithCtes(scan)) return false; + final inner = scan.peekKeyword(); + return inner == 'select' || inner == 'values' || inner == 'explain'; + default: + return false; + } +} + +bool _pragmaAssigns(_SqliteSqlScan scan) { + scan.eatKeyword('pragma'); + var depth = 0; + while (scan.i < scan.s.length) { + final c = scan.s[scan.i]; + if (c == "'" || c == '"' || c == '`') { + scan.skipQuoted(c); + continue; + } + if (c == '[') { + scan.skipUntil(']'); + continue; + } + if (c == '(') { + depth++; + scan.i++; + continue; + } + if (c == ')') { + if (depth > 0) depth--; + scan.i++; + continue; + } + if (c == ';' && depth == 0) break; + if (c == '=' && depth == 0) return true; + scan.i++; + } + return false; +} + +bool _skipWithCtes(_SqliteSqlScan scan) { + if (!scan.eatKeyword('with')) return false; + scan.eatKeyword('recursive'); + while (true) { + if (!scan.skipIdent()) return false; + scan.skipWs(); + if (scan.peek('(')) { + if (!scan.skipBalancedParen()) return false; + } + if (!scan.eatKeyword('as')) return false; + if (scan.eatKeyword('not')) { + if (!scan.eatKeyword('materialized')) return false; + } else { + scan.eatKeyword('materialized'); + } + if (!scan.skipBalancedParen()) return false; + scan.skipWs(); + if (scan.peek(',')) { + scan.i++; + continue; + } + return true; + } +} + +class _SqliteSqlScan { + _SqliteSqlScan(this.s); + + final String s; + int i = 0; + + void skipWs() { + while (i < s.length) { + final c = s.codeUnitAt(i); + if (c == 0x20 || c == 0x09 || c == 0x0a || c == 0x0d) { + i++; + continue; + } + break; + } + } + + void skipWsAndSemis() { + skipWs(); + while (i < s.length && s[i] == ';') { + i++; + skipWs(); + } + } + + bool peek(String ch) { + skipWs(); + return i < s.length && s[i] == ch; + } + + String? peekKeyword() { + skipWs(); + if (i >= s.length) return null; + final c = s.codeUnitAt(i); + if (!_isIdentStart(c)) return null; + var j = i + 1; + while (j < s.length && _isIdentPart(s.codeUnitAt(j))) { + j++; + } + return s.substring(i, j); + } + + bool eatKeyword(String kw) { + skipWs(); + if (i + kw.length > s.length) return false; + if (s.substring(i, i + kw.length) != kw) return false; + final end = i + kw.length; + if (end < s.length && _isIdentPart(s.codeUnitAt(end))) return false; + i = end; + return true; + } + + bool skipIdent() { + skipWs(); + if (i >= s.length) return false; + final c = s[i]; + if (c == '"' || c == '`') { + skipQuoted(c); + return true; + } + if (c == '[') { + skipUntil(']'); + return true; + } + if (!_isIdentStart(s.codeUnitAt(i))) return false; + i++; + while (i < s.length && _isIdentPart(s.codeUnitAt(i))) { + i++; + } + return true; + } + + bool skipBalancedParen() { + skipWs(); + if (i >= s.length || s[i] != '(') return false; + var depth = 0; + while (i < s.length) { + final c = s[i]; + if (c == "'" || c == '"' || c == '`') { + skipQuoted(c); + continue; + } + if (c == '[') { + skipUntil(']'); + continue; + } + if (c == '(') { + depth++; + i++; + continue; + } + if (c == ')') { + depth--; + i++; + if (depth == 0) return true; + continue; + } + i++; + } + return false; + } + + void skipQuoted(String quote) { + i++; + while (i < s.length) { + if (s[i] == quote) { + i++; + if (i < s.length && s[i] == quote) { + i++; + continue; + } + return; + } + i++; + } + } + + void skipUntil(String end) { + i++; + while (i < s.length) { + if (s[i] == end) { + i++; + return; + } + i++; + } + } +} + +bool _isIdentStart(int c) => (c >= 0x61 && c <= 0x7a) || c == 0x5f; + +bool _isIdentPart(int c) => _isIdentStart(c) || (c >= 0x30 && c <= 0x39); diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index dae351af..3d542191 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; import 'package:querya_desktop/core/database/sql_limit.dart'; +import 'package:querya_desktop/core/database/sqlite_sql.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; @@ -306,10 +307,12 @@ class _SqliteSqlWorkspaceState extends material.State { return; } - // Bound SELECT/WITH/VALUES at the engine before materializing rows. - // Client-side take() remains as defense for PRAGMA/EXPLAIN and author LIMIT. + // Bound SELECT/WITH-SELECT/VALUES at the engine before materializing rows. + // WITH … INSERT and assignment PRAGMA skip LIMIT (they are writes). final cap = _resultMaxRows; - final sql = injectSqlLimit(userSql, cap); + final sql = sqliteSqlIsReadOnlyQuery(userSql) + ? injectSqlLimit(userSql, cap) + : userSql; final results = await conn.executeWithTimeout(sql, timeout: _statementTimeout()); diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index 7ca9167f..0395c0ab 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -220,6 +220,50 @@ void main() { await roConn.disconnect(); }); + test('read-only rejects WITH INSERT; PRAGMA busy_timeout still allowed', + () async { + final roConn = SqliteConnection( + id: 3, + name: 'in_memory_ro_with', + path: inMemoryDatabasePath, + readOnly: true, + ); + await roConn.connect(); + addTearDown(roConn.disconnect); + + expect( + await roConn.execute('PRAGMA busy_timeout'), + isNotEmpty, + ); + expect( + await roConn.execute('WITH x AS (SELECT 1) SELECT * FROM x'), + isNotEmpty, + ); + expect( + () => roConn.execute( + 'WITH x AS (SELECT 1) INSERT INTO t SELECT * FROM x', + ), + throwsA(isA()), + ); + expect( + () => roConn.execute('PRAGMA journal_mode=WAL'), + throwsA(isA()), + ); + }); + + test('WITH INSERT on a writable connection applies DML', () async { + await conn.connect(); + await conn.execute( + 'CREATE TABLE w (id INTEGER PRIMARY KEY, n INTEGER)', + ); + await conn.execute( + 'WITH x AS (SELECT 7 AS n) INSERT INTO w (n) SELECT n FROM x', + ); + final rows = await conn.execute('SELECT n FROM w'); + expect(rows, hasLength(1)); + expect(rows.first['n'], 7); + }); + test('tracks BEGIN/COMMIT and refuses nested BEGIN via runInTransaction', () async { await conn.connect(); diff --git a/test/core/database/sqlite_sql_test.dart b/test/core/database/sqlite_sql_test.dart new file mode 100644 index 00000000..f2fc4043 --- /dev/null +++ b/test/core/database/sqlite_sql_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/sqlite_sql.dart'; + +void main() { + group('sqliteSqlIsReadOnlyQuery', () { + test('SELECT / VALUES / EXPLAIN are read-only', () { + expect(sqliteSqlIsReadOnlyQuery('SELECT 1'), isTrue); + expect(sqliteSqlIsReadOnlyQuery(' values (1)'), isTrue); + expect(sqliteSqlIsReadOnlyQuery('EXPLAIN SELECT 1'), isTrue); + }); + + test('strips leading comments before the first keyword', () { + expect(sqliteSqlIsReadOnlyQuery('-- c\nSELECT 1'), isTrue); + expect(sqliteSqlIsReadOnlyQuery('/* c */ SELECT 1'), isTrue); + expect( + sqliteSqlIsReadOnlyQuery( + '-- c\nWITH x AS (SELECT 1) INSERT INTO t SELECT * FROM x'), + isFalse, + ); + }); + + test('WITH SELECT / VALUES is read-only; WITH INSERT is a write', () { + expect( + sqliteSqlIsReadOnlyQuery('WITH x AS (SELECT 1) SELECT * FROM x'), + isTrue, + ); + expect( + sqliteSqlIsReadOnlyQuery( + 'WITH x AS (SELECT 1), y AS (SELECT 2) SELECT * FROM x', + ), + isTrue, + ); + expect( + sqliteSqlIsReadOnlyQuery( + 'WITH RECURSIVE x(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM x WHERE n<3) SELECT * FROM x', + ), + isTrue, + ); + expect( + sqliteSqlIsReadOnlyQuery( + 'WITH x AS NOT MATERIALIZED (SELECT 1) SELECT * FROM x', + ), + isTrue, + ); + expect( + sqliteSqlIsReadOnlyQuery( + 'WITH x AS (SELECT 1) INSERT INTO t SELECT * FROM x', + ), + isFalse, + ); + expect( + sqliteSqlIsReadOnlyQuery( + 'WITH x AS (SELECT 1) UPDATE t SET a = 1', + ), + isFalse, + ); + }); + + test('PRAGMA query is read-only; assignment is a write', () { + expect(sqliteSqlIsReadOnlyQuery('PRAGMA busy_timeout'), isTrue); + expect(sqliteSqlIsReadOnlyQuery('PRAGMA table_info(users)'), isTrue); + expect(sqliteSqlIsReadOnlyQuery('PRAGMA busy_timeout=5000'), isFalse); + expect(sqliteSqlIsReadOnlyQuery('PRAGMA journal_mode=WAL'), isFalse); + expect(sqliteSqlIsReadOnlyQuery('PRAGMA journal_mode = WAL'), isFalse); + expect(sqliteSqlIsReadOnlyQuery('PRAGMA foreign_keys=ON'), isFalse); + }); + + test('INSERT / CREATE are writes', () { + expect(sqliteSqlIsReadOnlyQuery('INSERT INTO t VALUES (1)'), isFalse); + expect(sqliteSqlIsReadOnlyQuery('CREATE TABLE t (id INT)'), isFalse); + }); + }); +} From bb45769b3908fb839e46fd6603812baafa2c1e6e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 15:46:44 +0300 Subject: [PATCH 38/52] fix(sqlite): bind getObjectDdl with ? not :name plus a List sqflite positional parameters are ?. The named placeholder was unbound, so the DDL dialog showed No definition found for tables that exist. --- CHANGELOG.md | 1 + lib/core/database/sqlite_connection.dart | 2 +- test/core/database/sqlite_connection_test.dart | 12 ++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9be25534..ebb763dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **SQLite getObjectDdl bind (#796)** — DDL lookup uses `WHERE name = ?` with a positional list. `:name` plus `[objectName]` did not bind, so the dialog showed **No definition found** for tables that exist. - **SQLite WITH / PRAGMA (#797)** — `execute` classifies the first statement after comments: `WITH … INSERT` is a write (Dart read-only guard + `execute` instead of `rawQuery`). Assignment `PRAGMA name=value` is a write; `PRAGMA busy_timeout` stays a query. SQL workspace injects `LIMIT` only for read-only statements. - **SQLite missing file (#798)** — Test Connection and opening a saved connection no longer create an empty `.db` when the path is a typo. Errors distinguish file not found, permission, and corrupt. New-connection Save still creates the file if it does not exist. - **SQLite Table Browser paging (#799)** — Browse `SELECT` uses `ORDER BY` primary-key columns, or `rowid` when the table has implicit rowid. First paint skips blocking `COUNT(*)` (Next stays available when the page is full). SQL workspace has an optional statement timeout matching Postgres/MySQL. diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index 9bac4700..e60ee133 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -358,7 +358,7 @@ class SqliteConnection { /// Returns the DDL (`sql`) of a table or view from sqlite_master. Future getObjectDdl(String objectName) async { final rows = await execute( - "SELECT sql FROM sqlite_master WHERE name = :name", + 'SELECT sql FROM sqlite_master WHERE name = ?', [objectName], ); if (rows.isEmpty) return '-- No definition found for $objectName'; diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index 0395c0ab..557096be 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -168,6 +168,18 @@ void main() { expect(columns, containsAll(['id', 'name'])); }); + test('getObjectDdl returns CREATE SQL for a table that exists', () async { + await conn.connect(); + await conn.execute( + 'CREATE TABLE foo (id INTEGER PRIMARY KEY, name TEXT)', + ); + + final ddl = await conn.getObjectDdl('foo'); + expect(ddl, isNot(contains('No definition found'))); + expect(ddl.toUpperCase(), contains('CREATE TABLE')); + expect(ddl.toLowerCase(), contains('foo')); + }); + test('executes INSERT, UPDATE, DELETE with RETURNING clause correctly', () async { await conn.connect(); From 67bd5ff5ee934ac6ea23ee7d63f541a67c5a10cd Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 15:50:24 +0300 Subject: [PATCH 39/52] fix(postgres): classify Table Browser SQL by first statement, not contains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substring matching blocked inserted_at / dropped_at and allowed a second statement after SELECT. WITH … INSERT is a write; TABLE / VALUES / (SELECT) stay allowed. --- CHANGELOG.md | 1 + .../postgres_sql_editor_dialog.dart | 28 +- .../postgresql/postgres_table_utils.dart | 275 ++++++++++++++++++ .../postgresql/postgres_table_utils_test.dart | 58 ++++ 4 files changed, 335 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb763dc..ea2c0b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Postgres custom-SQL allowlist (#791)** — Table Browser SQL dialog classifies the first statement after comments instead of substring `contains('insert ')`. `SELECT inserted_at` is allowed; `SELECT 1; DELETE FROM t` is rejected. `WITH … INSERT` is a write; `TABLE` / `VALUES` / `(SELECT …)` stay allowed. - **SQLite getObjectDdl bind (#796)** — DDL lookup uses `WHERE name = ?` with a positional list. `:name` plus `[objectName]` did not bind, so the dialog showed **No definition found** for tables that exist. - **SQLite WITH / PRAGMA (#797)** — `execute` classifies the first statement after comments: `WITH … INSERT` is a write (Dart read-only guard + `execute` instead of `rawQuery`). Assignment `PRAGMA name=value` is a write; `PRAGMA busy_timeout` stays a query. SQL workspace injects `LIMIT` only for read-only statements. - **SQLite missing file (#798)** — Test Connection and opening a saved connection no longer create an empty `.db` when the path is a typo. Errors distinguish file not found, permission, and corrupt. New-connection Save still creates the file if it does not exist. diff --git a/lib/features/postgresql/postgres_sql_editor_dialog.dart b/lib/features/postgresql/postgres_sql_editor_dialog.dart index d8a51601..aa2f0455 100644 --- a/lib/features/postgresql/postgres_sql_editor_dialog.dart +++ b/lib/features/postgresql/postgres_sql_editor_dialog.dart @@ -2,36 +2,10 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -/// Returns true if [sql] is allowed to run (read-only: SELECT / WITH). -bool isAllowedPostgresSelectQuery(String sql) { - final t = sql.trim(); - if (t.isEmpty) return false; - final lower = t.toLowerCase(); - const blocked = [ - 'insert ', - 'update ', - 'delete ', - 'drop ', - 'truncate ', - 'alter ', - 'create ', - 'grant ', - 'revoke ', - 'call ', - 'execute ', - 'copy ', - ]; - for (final b in blocked) { - if (lower.contains(b)) return false; - } - return lower.startsWith('select') || - lower.startsWith('with') || - lower.startsWith('('); -} - /// Dialog to view/edit SQL and run it. /// [initialSql] — text shown when opening; [browseSql] — "Reset" restores table browse query. Future showPostgresSqlEditorDialog({ diff --git a/lib/features/postgresql/postgres_table_utils.dart b/lib/features/postgresql/postgres_table_utils.dart index 19355bb4..e4bfb336 100644 --- a/lib/features/postgresql/postgres_table_utils.dart +++ b/lib/features/postgresql/postgres_table_utils.dart @@ -1,6 +1,8 @@ /// Utilities for PostgreSQL table data view (quoting, row conversion). library; +import 'package:querya_desktop/core/database/sql_limit.dart'; + /// Default page size for table browse and the SQL template filled from the tree. const kPostgresBrowseDefaultRowLimit = 200; @@ -30,3 +32,276 @@ List> convertResultRowsToStrings(List> rawRows) { }).toList(); }).toList(); } + +/// Whether [sql] is allowed for the Table Browser custom-SQL dialog. +/// +/// Classifies the **first** statement after comments (not substring `contains`). +/// Allows `SELECT` / `WITH … SELECT` / `TABLE` / `VALUES` / `(SELECT …)` only. +/// Multi-statement scripts are rejected. +bool isAllowedPostgresSelectQuery(String sql) { + final t = sql.trim(); + if (t.isEmpty) return false; + + final statements = _splitPostgresStatements(t) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty && !_postgresFragmentIsOnlyComments(s)) + .toList(); + if (statements.length != 1) return false; + + return _postgresStatementIsReadOnlySelect(statements.first); +} + +List _splitPostgresStatements(String sql) { + final masked = _maskPostgresComments(maskSqlLiteralsForLimitScan(sql)); + final statements = []; + var start = 0; + for (var i = 0; i < masked.length; i++) { + if (masked[i] == ';') { + statements.add(sql.substring(start, i)); + start = i + 1; + } + } + statements.add(sql.substring(start)); + return statements; +} + +String _maskPostgresComments(String sql) { + final out = StringBuffer(); + var i = 0; + while (i < sql.length) { + if (i + 1 < sql.length && sql[i] == '-' && sql[i + 1] == '-') { + while (i < sql.length && sql[i] != '\n') { + out.write(' '); + i++; + } + continue; + } + if (i + 1 < sql.length && sql[i] == '/' && sql[i + 1] == '*') { + out.write(' '); + i += 2; + while (i < sql.length) { + if (i + 1 < sql.length && sql[i] == '*' && sql[i + 1] == '/') { + out.write(' '); + i += 2; + break; + } + out.write(' '); + i++; + } + continue; + } + out.write(sql[i]); + i++; + } + return out.toString(); +} + +bool _postgresFragmentIsOnlyComments(String sql) { + var s = sql.trim(); + while (s.isNotEmpty) { + s = stripLeadingWhitespaceAndLineComments(s).trimLeft(); + if (s.startsWith('/*')) { + final end = s.indexOf('*/'); + if (end == -1) return true; + s = s.substring(end + 2).trim(); + continue; + } + if (s.isEmpty || s == ';') return true; + return false; + } + return true; +} + +bool _postgresStatementIsReadOnlySelect(String sql) { + final code = _postgresLeadingCode(sql); + if (code.isEmpty) return false; + final u = code.toUpperCase(); + if (_startsWithKeyword(u, 'SELECT')) return true; + if (_startsWithKeyword(u, 'VALUES')) return true; + if (_startsWithKeyword(u, 'TABLE')) return true; + if (_startsWithKeyword(u, 'WITH')) { + return _postgresWithIsSelect(_stripPostgresComments(code)); + } + return false; +} + +String _postgresLeadingCode(String sql) { + var s = sql.trim(); + while (s.isNotEmpty) { + s = stripLeadingWhitespaceAndLineComments(s).trimLeft(); + if (s.startsWith('/*')) { + final end = s.indexOf('*/'); + if (end == -1) return ''; + s = s.substring(end + 2).trimLeft(); + continue; + } + if (s.startsWith('(')) { + s = s.substring(1).trimLeft(); + continue; + } + return s; + } + return ''; +} + +bool _startsWithKeyword(String upper, String kw) { + if (!upper.startsWith(kw)) return false; + if (upper.length == kw.length) return true; + final next = upper.codeUnitAt(kw.length); + final isIdent = (next >= 0x41 && next <= 0x5a) || + next == 0x5f || + (next >= 0x30 && next <= 0x39); + return !isIdent; +} + +String _stripPostgresComments(String sql) { + return sql + .replaceAll(RegExp(r'--.*$', multiLine: true), '') + .replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), ''); +} + +/// `WITH` is read-only only when the statement after the CTEs is SELECT/VALUES/TABLE. +bool _postgresWithIsSelect(String sql) { + final scan = _PgSqlScan(sql.toLowerCase()); + if (!_skipPostgresWithCtes(scan)) return false; + final inner = scan.peekKeyword(); + return inner == 'select' || inner == 'values' || inner == 'table'; +} + +bool _skipPostgresWithCtes(_PgSqlScan scan) { + if (!scan.eatKeyword('with')) return false; + scan.eatKeyword('recursive'); + while (true) { + if (!scan.skipIdent()) return false; + scan.skipWs(); + if (scan.peek('(')) { + if (!scan.skipBalancedParen()) return false; + } + if (!scan.eatKeyword('as')) return false; + if (scan.eatKeyword('not')) { + if (!scan.eatKeyword('materialized')) return false; + } else { + scan.eatKeyword('materialized'); + } + if (!scan.skipBalancedParen()) return false; + scan.skipWs(); + if (scan.peek(',')) { + scan.i++; + continue; + } + return true; + } +} + +class _PgSqlScan { + _PgSqlScan(this.s); + + final String s; + int i = 0; + + void skipWs() { + while (i < s.length) { + final c = s.codeUnitAt(i); + if (c == 0x20 || c == 0x09 || c == 0x0a || c == 0x0d) { + i++; + continue; + } + break; + } + } + + bool peek(String ch) { + skipWs(); + return i < s.length && s[i] == ch; + } + + String? peekKeyword() { + skipWs(); + if (i >= s.length) return null; + final c = s.codeUnitAt(i); + if (!_pgIdentStart(c)) return null; + var j = i + 1; + while (j < s.length && _pgIdentPart(s.codeUnitAt(j))) { + j++; + } + return s.substring(i, j); + } + + bool eatKeyword(String kw) { + skipWs(); + if (i + kw.length > s.length) return false; + if (s.substring(i, i + kw.length) != kw) return false; + final end = i + kw.length; + if (end < s.length && _pgIdentPart(s.codeUnitAt(end))) return false; + i = end; + return true; + } + + bool skipIdent() { + skipWs(); + if (i >= s.length) return false; + if (s[i] == '"') { + i++; + while (i < s.length) { + if (s[i] == '"') { + i++; + if (i < s.length && s[i] == '"') { + i++; + continue; + } + return true; + } + i++; + } + return true; + } + if (!_pgIdentStart(s.codeUnitAt(i))) return false; + i++; + while (i < s.length && _pgIdentPart(s.codeUnitAt(i))) { + i++; + } + return true; + } + + bool skipBalancedParen() { + skipWs(); + if (i >= s.length || s[i] != '(') return false; + var depth = 0; + while (i < s.length) { + final c = s[i]; + if (c == "'" || c == '"') { + final q = c; + i++; + while (i < s.length) { + if (s[i] == q) { + i++; + if (i < s.length && s[i] == q) { + i++; + continue; + } + break; + } + i++; + } + continue; + } + if (c == '(') { + depth++; + i++; + continue; + } + if (c == ')') { + depth--; + i++; + if (depth == 0) return true; + continue; + } + i++; + } + return false; + } +} + +bool _pgIdentStart(int c) => (c >= 0x61 && c <= 0x7a) || c == 0x5f; + +bool _pgIdentPart(int c) => _pgIdentStart(c) || (c >= 0x30 && c <= 0x39); diff --git a/test/features/postgresql/postgres_table_utils_test.dart b/test/features/postgresql/postgres_table_utils_test.dart index 8b59e7e8..1db0508d 100644 --- a/test/features/postgresql/postgres_table_utils_test.dart +++ b/test/features/postgresql/postgres_table_utils_test.dart @@ -23,6 +23,64 @@ void main() { }); }); + group('isAllowedPostgresSelectQuery', () { + test('allows SELECT inserted_at (not substring-blocked)', () { + expect( + isAllowedPostgresSelectQuery('SELECT inserted_at FROM t'), + isTrue, + ); + expect( + isAllowedPostgresSelectQuery('SELECT dropped_at, updated_at FROM t'), + isTrue, + ); + }); + + test('rejects multi-statement even when the first is SELECT', () { + expect(isAllowedPostgresSelectQuery('SELECT 1; DELETE FROM t'), isFalse); + expect(isAllowedPostgresSelectQuery('SELECT 1; SELECT 2'), isFalse); + }); + + test('allows trailing semicolon and trailing comment', () { + expect(isAllowedPostgresSelectQuery('SELECT * FROM t;'), isTrue); + expect(isAllowedPostgresSelectQuery('SELECT * FROM t; -- done'), isTrue); + }); + + test('allows WITH SELECT, TABLE, VALUES, and parenthesized SELECT', () { + expect( + isAllowedPostgresSelectQuery('WITH x AS (SELECT 1) SELECT * FROM x'), + isTrue, + ); + expect(isAllowedPostgresSelectQuery('TABLE t'), isTrue); + expect(isAllowedPostgresSelectQuery('VALUES (1)'), isTrue); + expect( + isAllowedPostgresSelectQuery('(SELECT inserted_at FROM t)'), isTrue); + }); + + test('rejects WITH INSERT and other writes', () { + expect( + isAllowedPostgresSelectQuery( + 'WITH x AS (SELECT 1) INSERT INTO t SELECT * FROM x', + ), + isFalse, + ); + expect(isAllowedPostgresSelectQuery('DELETE FROM t'), isFalse); + expect(isAllowedPostgresSelectQuery('INSERT INTO t VALUES (1)'), isFalse); + }); + + test('allows semicolon inside a string literal', () { + expect( + isAllowedPostgresSelectQuery( + "SELECT * FROM logs WHERE message = 'error; system halted'", + ), + isTrue, + ); + }); + + test('rejects empty', () { + expect(isAllowedPostgresSelectQuery(''), isFalse); + }); + }); + group('postgresBrowseSelectSql', () { test('quotes identifiers and uses default limit', () { expect( From 4e46e7cdabf485377f1e6337cf91a6f937126e68 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 15:55:27 +0300 Subject: [PATCH 40/52] fix(sqlite): drop dangling library doc comment on sqlite_sql.dart CI treats analyzer info as failure (dangling_library_doc_comments). --- lib/core/database/sqlite_sql.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/core/database/sqlite_sql.dart b/lib/core/database/sqlite_sql.dart index 5f820c49..ca535468 100644 --- a/lib/core/database/sqlite_sql.dart +++ b/lib/core/database/sqlite_sql.dart @@ -1,4 +1,4 @@ -/// Comment-strip + first-keyword classification for SQLite (not a full parser). +// Comment-strip + first-keyword classification for SQLite (not a full parser). /// Strips `--` line comments and `/* */` block comments. String sqliteStripSqlComments(String sql) { From 00e6c17e48d246da59a13deb20de0ab1c8afa74e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 15:59:14 +0300 Subject: [PATCH 41/52] fix(postgres): host/port SSL uses verify-full when a Root CA is set Without a CA, Use SSL/TLS stays sslmode=require (encrypt, MITM possible). URI sslmode= still wins. The form documents the tradeoff. --- CHANGELOG.md | 1 + lib/core/database/postgres_connection.dart | 33 +++++++++++-- .../postgresql_connection_form.dart | 15 +++++- ...postgres_connection_string_parse_test.dart | 47 +++++++++++++++++++ .../postgresql_connection_form_test.dart | 1 + 5 files changed, 91 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea2c0b26..7199247c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Postgres host/port SSL (#790)** — Host/port Use SSL/TLS stays `sslmode=require` (encrypt, no CA check) unless a Root CA is set, then `verify-full`. A URI `sslmode=` value still wins. The form documents the MITM tradeoff and writes `sslmode=verify-full` when saving a Root CA. - **Postgres custom-SQL allowlist (#791)** — Table Browser SQL dialog classifies the first statement after comments instead of substring `contains('insert ')`. `SELECT inserted_at` is allowed; `SELECT 1; DELETE FROM t` is rejected. `WITH … INSERT` is a write; `TABLE` / `VALUES` / `(SELECT …)` stay allowed. - **SQLite getObjectDdl bind (#796)** — DDL lookup uses `WHERE name = ?` with a positional list. `:name` plus `[objectName]` did not bind, so the dialog showed **No definition found** for tables that exist. - **SQLite WITH / PRAGMA (#797)** — `execute` classifies the first statement after comments: `WITH … INSERT` is a write (Dart read-only guard + `execute` instead of `rawQuery`). Assignment `PRAGMA name=value` is a write; `PRAGMA busy_timeout` stays a query. SQL workspace injects `LIMIT` only for read-only statements. diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index dfb45cee..dffbfb74 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -37,6 +37,22 @@ String replaceDatabaseInConnectionString( return uri.replace(queryParameters: params).toString(); } +/// Host/port TLS mode when the URI has no `sslmode`. +/// +/// [uriSslMode] wins (URI is source of truth). Otherwise encrypt-only +/// (`require`) unless a Root CA is set, then `verifyFull`. `require` does not +/// check the CA — MITM is possible. +SslMode postgresResolveSslMode({ + SslMode? uriSslMode, + required bool encrypt, + required bool hasRootCert, +}) { + if (uriSslMode != null) return uriSslMode; + if (!encrypt && !hasRootCert) return SslMode.disable; + if (hasRootCert) return SslMode.verifyFull; + return SslMode.require; +} + /// PostgreSQL connection using the pure-Dart `postgres` package. class PostgresConnection { PostgresConnection({ @@ -145,9 +161,10 @@ class PostgresConnection { } } return ConnectionSettings( - sslMode: (useSSL || securityContext != null) - ? SslMode.require - : SslMode.disable, + sslMode: postgresResolveSslMode( + encrypt: useSSL || securityContext != null, + hasRootCert: sslRootCert != null && sslRootCert!.trim().isNotEmpty, + ), connectTimeout: const Duration(seconds: 10), queryTimeout: const Duration(seconds: 30), securityContext: securityContext, @@ -184,8 +201,14 @@ class PostgresConnection { dbName, ); final parsed = parseConnectionString(uriForOpen); - final sslMode = - parsed.sslMode ?? (useSSL ? SslMode.require : SslMode.disable); + final sslMode = postgresResolveSslMode( + uriSslMode: parsed.sslMode, + encrypt: useSSL || + (sslRootCert != null && sslRootCert!.trim().isNotEmpty) || + (sslCert != null && sslCert!.trim().isNotEmpty) || + (sslKey != null && sslKey!.trim().isNotEmpty), + hasRootCert: sslRootCert != null && sslRootCert!.trim().isNotEmpty, + ); _conn = await Connection.open( parsed.endpoints.first, settings: ConnectionSettings( diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index 2d35191c..c105a8e1 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -144,6 +144,10 @@ class _PostgresConnectionFormContentState _setOrRemoveSslParam(params, 'sslrootcert', _sslRootCertController); _setOrRemoveSslParam(params, 'sslcert', _sslCertController); _setOrRemoveSslParam(params, 'sslkey', _sslKeyController); + if (!params.containsKey('sslmode') && + _sslRootCertController.text.trim().isNotEmpty) { + params['sslmode'] = 'verify-full'; + } final newUri = Uri( scheme: parsed.scheme, userInfo: parsed.userInfo.isEmpty ? null : parsed.userInfo, @@ -187,8 +191,10 @@ class _PostgresConnectionFormContentState Uri.encodeComponent(password), ]; final queryParams = { - if (sslRootCert != null && sslRootCert.isNotEmpty) + if (sslRootCert != null && sslRootCert.isNotEmpty) ...{ 'sslrootcert': sslRootCert, + 'sslmode': 'verify-full', + }, if (sslCert != null && sslCert.isNotEmpty) 'sslcert': sslCert, if (sslKey != null && sslKey.isNotEmpty) 'sslkey': sslKey, }; @@ -623,6 +629,13 @@ class _PostgresConnectionFormContentState ], ), if (_useSSL) ...[ + const Gap(8), + const Text( + 'Without a Root CA this is sslmode=require: traffic is ' + 'encrypted but the server certificate is not checked ' + '(MITM is possible). A Root CA enables verify-full. ' + 'A URI sslmode= value always wins.', + ).muted().small(), const Gap(16), material.FocusTraversalGroup( policy: material.WidgetOrderTraversalPolicy(), diff --git a/test/core/database/postgres_connection_string_parse_test.dart b/test/core/database/postgres_connection_string_parse_test.dart index bb4845d7..8d120fc3 100644 --- a/test/core/database/postgres_connection_string_parse_test.dart +++ b/test/core/database/postgres_connection_string_parse_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:postgres/postgres.dart' show SslMode; // ignore: implementation_imports import 'package:postgres/src/connection_string.dart' show parseConnectionString; +import 'package:querya_desktop/core/database/postgres_connection.dart'; /// Ensures libpq-style URI params match what we document for [PostgresConnection.connect]. void main() { @@ -62,4 +63,50 @@ void main() { ); }); }); + + group('postgresResolveSslMode', () { + test('URI sslmode is source of truth', () { + expect( + postgresResolveSslMode( + uriSslMode: SslMode.require, + encrypt: true, + hasRootCert: true, + ), + SslMode.require, + ); + expect( + postgresResolveSslMode( + uriSslMode: SslMode.verifyFull, + encrypt: false, + hasRootCert: false, + ), + SslMode.verifyFull, + ); + }); + + test('host/port SSL without CA is require (encrypt only)', () { + expect( + postgresResolveSslMode(encrypt: true, hasRootCert: false), + SslMode.require, + ); + }); + + test('host/port with Root CA is verifyFull', () { + expect( + postgresResolveSslMode(encrypt: true, hasRootCert: true), + SslMode.verifyFull, + ); + expect( + postgresResolveSslMode(encrypt: false, hasRootCert: true), + SslMode.verifyFull, + ); + }); + + test('no SSL and no CA is disable', () { + expect( + postgresResolveSslMode(encrypt: false, hasRootCert: false), + SslMode.disable, + ); + }); + }); } diff --git a/test/features/postgresql/postgresql_connection_form_test.dart b/test/features/postgresql/postgresql_connection_form_test.dart index a3461d88..48a81b7a 100644 --- a/test/features/postgresql/postgresql_connection_form_test.dart +++ b/test/features/postgresql/postgresql_connection_form_test.dart @@ -300,6 +300,7 @@ void main() { expect(result!.connectionString, contains('pg.example.com')); expect(result!.connectionString, contains('sslrootcert')); expect(result!.connectionString, contains('root.pem')); + expect(result!.connectionString, contains('sslmode=verify-full')); expect(result!.connectionString, contains('admin')); expect(result!.connectionString, contains('secret')); expect(result!.useSSL, true); From d311cac5eef1e84753f4fde9079467088eece647 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 16:06:15 +0300 Subject: [PATCH 42/52] fix(postgres): round-trip timestamptz / bytea / jsonb / arrays in the grid Schema uses udt_name so ARRAY / USER-DEFINED reach formatLiteral. Display uses ISO timestamps, \x hex, JSON text, and PG array literals. --- CHANGELOG.md | 1 + lib/core/database/postgres_connection.dart | 18 +- lib/core/database/postgres_result_cells.dart | 154 +++++++++++++++ lib/core/database/table_mutation_engine.dart | 14 ++ .../postgresql/postgres_result_utils.dart | 33 +++- .../postgresql/postgres_sql_workspace.dart | 11 +- .../postgresql/postgres_table_utils.dart | 12 +- .../postgresql/postgres_table_view.dart | 38 ++-- .../workspace/grid_data_type_validator.dart | 5 + .../database/postgres_grid_types_test.dart | 186 ++++++++++++++++++ .../grid_data_type_validator_test.dart | 11 ++ 11 files changed, 446 insertions(+), 37 deletions(-) create mode 100644 lib/core/database/postgres_result_cells.dart create mode 100644 test/core/database/postgres_grid_types_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7199247c..e2fdb4dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Postgres timestamptz / bytea / jsonb / arrays (#789)** — Table Browser and SQL results encode cells as PG literals (ISO timestamps, `\x` hex for bytea, JSON text for jsonb, `{1,2,3}` for arrays) instead of `Object.toString`. Schema uses `udt_name` so `ARRAY` / `USER-DEFINED` round-trip through `formatLiteral`. - **Postgres host/port SSL (#790)** — Host/port Use SSL/TLS stays `sslmode=require` (encrypt, no CA check) unless a Root CA is set, then `verify-full`. A URI `sslmode=` value still wins. The form documents the MITM tradeoff and writes `sslmode=verify-full` when saving a Root CA. - **Postgres custom-SQL allowlist (#791)** — Table Browser SQL dialog classifies the first statement after comments instead of substring `contains('insert ')`. `SELECT inserted_at` is allowed; `SELECT 1; DELETE FROM t` is rejected. `WITH … INSERT` is a write; `TABLE` / `VALUES` / `(SELECT …)` stay allowed. - **SQLite getObjectDdl bind (#796)** — DDL lookup uses `WHERE name = ?` with a positional list. `:name` plus `[objectName]` did not bind, so the dialog showed **No definition found** for tables that exist. diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index dffbfb74..e6190e34 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:postgres/src/connection_string.dart' show parseConnectionString; import 'postgres_metadata.dart'; +import 'postgres_result_cells.dart'; import 'table_schema_meta.dart'; /// Replaces the database in a `postgresql://` / `postgres://` URI (path or @@ -421,7 +422,7 @@ class PostgresConnection { } final colsRs = await _conn!.execute( Sql.named( - 'SELECT column_name, data_type, is_nullable, column_default, ' + 'SELECT column_name, data_type, udt_name, is_nullable, column_default, ' 'is_generated, is_identity, identity_generation ' 'FROM information_schema.columns ' 'WHERE table_schema = @schema AND table_name = @table ' @@ -450,15 +451,18 @@ class PostgresConnection { for (final r in colsRs) { final name = r[0] as String? ?? ''; - final dataType = r[1] as String? ?? ''; - final isNullable = (r[2] as String? ?? 'YES').toUpperCase() == 'YES'; + final dataType = postgresColumnSchemaType( + dataType: r[1] as String? ?? '', + udtName: r[2] as String? ?? '', + ); + final isNullable = (r[3] as String? ?? 'YES').toUpperCase() == 'YES'; final isPk = primaryKeys.contains(name); final pkPos = isPk ? primaryKeys.indexOf(name) + 1 : null; - final dflt = r[3]?.toString(); + final dflt = r[4]?.toString(); final isGenerated = - (r[4] as String? ?? 'NEVER').toUpperCase() == 'ALWAYS'; - final isIdentity = (r[5] as String? ?? 'NO').toUpperCase() == 'YES'; - final identityGeneration = (r[6] as String? ?? '').toUpperCase(); + (r[5] as String? ?? 'NEVER').toUpperCase() == 'ALWAYS'; + final isIdentity = (r[6] as String? ?? 'NO').toUpperCase() == 'YES'; + final identityGeneration = (r[7] as String? ?? '').toUpperCase(); final omitOnInsert = isGenerated || (isIdentity && identityGeneration == 'ALWAYS'); final hasServerDefault = (dflt != null && dflt.isNotEmpty) || diff --git a/lib/core/database/postgres_result_cells.dart b/lib/core/database/postgres_result_cells.dart new file mode 100644 index 00000000..307daa47 --- /dev/null +++ b/lib/core/database/postgres_result_cells.dart @@ -0,0 +1,154 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:postgres/postgres.dart'; + +/// Schema type for the grid codec: prefer `udt_name` over `data_type`. +/// +/// `information_schema.columns.data_type` is `ARRAY` / `USER-DEFINED` for +/// arrays, enums, and domains — too coarse for `formatLiteral`. +String postgresColumnSchemaType({ + required String dataType, + required String udtName, +}) { + final dt = dataType.trim(); + final udt = udtName.trim(); + final dtLower = dt.toLowerCase(); + if (dtLower == 'array') { + if (udt.startsWith('_') && udt.length > 1) { + return '${udt.substring(1)}[]'; + } + return udt.isNotEmpty ? '$udt[]' : dt; + } + if (dtLower == 'user-defined') { + return udt.isNotEmpty ? udt : dt; + } + if (udt.isNotEmpty) return udt; + return dt; +} + +/// PG-literal-friendly cell text: ISO timestamps, `\x` hex for bytea, JSON +/// for jsonb, `{1,2,3}` for arrays. Null → `NULL`. +String postgresResultCellToDisplayString( + Object? value, { + int? typeOid, + String? dataTypeName, +}) { + if (value == null) return 'NULL'; + if (value is UndecodedBytes) return value.asString; + + if (value is Uint8List) { + return postgresByteaHexDisplay(value); + } + if (_isByteaType(typeOid, dataTypeName)) { + if (value is List) { + return postgresByteaHexDisplay(Uint8List.fromList(value)); + } + return postgresByteaHexDisplayFromCell(value); + } + + if (value is DateTime) { + if (_isDateOnly(typeOid, dataTypeName)) { + return value.toIso8601String().split('T').first; + } + return value.toIso8601String(); + } + + if (value is bool) return value ? 'true' : 'false'; + + if (_isJsonType(typeOid, dataTypeName) || value is Map) { + if (value is String) return value; + try { + return json.encode(value); + } catch (_) { + return value.toString(); + } + } + + if (value is List) { + return postgresArrayLiteral(value); + } + + return value.toString(); +} + +/// PostgreSQL bytea text `\xdeadbeef`. +String postgresByteaHexDisplay(Uint8List bytes) { + final out = StringBuffer(r'\x'); + for (final b in bytes) { + out.write(b.toRadixString(16).padLeft(2, '0')); + } + return out.toString(); +} + +String postgresByteaHexDisplayFromCell(Object value) { + final raw = value.toString().trim(); + if (raw == 'NULL' || raw == 'null') return 'NULL'; + var hex = raw; + if (hex.startsWith(r'\x') || + hex.startsWith(r'\X') || + hex.startsWith('0x') || + hex.startsWith('0X')) { + hex = hex.substring(2); + } else if ((hex.startsWith("x'") || hex.startsWith("X'")) && + hex.endsWith("'")) { + hex = hex.substring(2, hex.length - 1); + } else { + return postgresByteaHexDisplay( + Uint8List.fromList(raw.codeUnits.map((u) => u & 0xFF).toList()), + ); + } + final clean = hex.replaceAll(RegExp(r'[^0-9a-fA-F]'), '').toLowerCase(); + return '\\x$clean'; +} + +/// PostgreSQL array text `{1,2,3}` / `{Alpha,"a b"}` (not Dart `[…]`). +String postgresArrayLiteral(List items) { + final inner = items.map(_postgresArrayElement).join(','); + return '{$inner}'; +} + +String _postgresArrayElement(Object? e) { + if (e == null) return 'NULL'; + if (e is Uint8List) return postgresByteaHexDisplay(e); + if (e is List) return postgresArrayLiteral(e); + if (e is Map) { + return _postgresArrayQuote(json.encode(e)); + } + if (e is DateTime) return e.toIso8601String(); + if (e is bool) return e ? 't' : 'f'; + if (e is String) return _postgresArrayQuote(e); + return e.toString(); +} + +String _postgresArrayQuote(String s) { + return '"${s.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}"'; +} + +bool _isByteaType(int? typeOid, String? dataTypeName) { + if (typeOid != null && typeOid == Type.byteArray.oid) return true; + final t = dataTypeName?.toLowerCase() ?? ''; + if (_isPgArrayTypeName(t)) return false; + return t.contains('bytea'); +} + +bool _isJsonType(int? typeOid, String? dataTypeName) { + if (typeOid != null && + (typeOid == Type.json.oid || typeOid == Type.jsonb.oid)) { + return true; + } + final t = dataTypeName?.toLowerCase() ?? ''; + if (_isPgArrayTypeName(t)) return false; + return t.contains('json'); +} + +bool _isDateOnly(int? typeOid, String? dataTypeName) { + if (typeOid != null && typeOid == Type.date.oid) return true; + final t = dataTypeName?.toLowerCase().trim() ?? ''; + return t == 'date'; +} + +bool _isPgArrayTypeName(String lower) { + return lower.contains('[]') || + (lower.startsWith('_') && !lower.contains(' ')); +} diff --git a/lib/core/database/table_mutation_engine.dart b/lib/core/database/table_mutation_engine.dart index b414ea6b..e8790fc1 100644 --- a/lib/core/database/table_mutation_engine.dart +++ b/lib/core/database/table_mutation_engine.dart @@ -142,6 +142,13 @@ abstract final class TableMutationEngine { lower.startsWith('bit'); } + static bool _isArrayType(String dataTypeName) { + final lower = dataTypeName.toLowerCase().trim(); + if (lower.contains('[]')) return true; + if (lower == 'array') return true; + return lower.startsWith('_') && !lower.contains(' '); + } + /// Public alias of [_isBoolType] for grid display / validators. static bool isBoolType(String dataTypeName) => _isBoolType(dataTypeName); @@ -165,6 +172,13 @@ abstract final class TableMutationEngine { final trimmed = value.trim(); if (dataTypeName != null && dataTypeName.isNotEmpty) { + if (_isArrayType(dataTypeName)) { + if (trimmed == 'NULL' || trimmed == 'null') { + return 'NULL'; + } + return _formatStringLiteral(value, dialect); + } + if (_isBinaryType(dataTypeName)) { if (trimmed == 'NULL' || trimmed == 'null') { return 'NULL'; diff --git a/lib/features/postgresql/postgres_result_utils.dart b/lib/features/postgresql/postgres_result_utils.dart index a6aaf18d..da0e494b 100644 --- a/lib/features/postgresql/postgres_result_utils.dart +++ b/lib/features/postgresql/postgres_result_utils.dart @@ -1,19 +1,32 @@ -/// Serializable row batch for [convertPostgresResultRowsToStrings] in a worker isolate. +import 'package:querya_desktop/core/database/postgres_result_cells.dart'; + +/// Serializable row batch for [convertPostgresResultRowsToStrings]. class PostgresResultConvertJob { const PostgresResultConvertJob({ required this.rowValues, + this.columnTypeOids, + this.columnDataTypes, }); final List> rowValues; + final List? columnTypeOids; + final List? columnDataTypes; } -/// Converts PostgreSQL result cell values to display strings off the UI thread. -List> convertPostgresResultRowsToStrings(PostgresResultConvertJob job) { - return job.rowValues - .map( - (row) => row - .map((value) => value == null ? 'NULL' : value.toString()) - .toList(), - ) - .toList(); +/// Converts PostgreSQL result cells to PG-literal-friendly display strings. +List> convertPostgresResultRowsToStrings( + PostgresResultConvertJob job) { + final oids = job.columnTypeOids; + final types = job.columnDataTypes; + return [ + for (final row in job.rowValues) + [ + for (var i = 0; i < row.length; i++) + postgresResultCellToDisplayString( + row[i], + typeOid: oids != null && i < oids.length ? oids[i] : null, + dataTypeName: types != null && i < types.length ? types[i] : null, + ), + ], + ]; } diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 46f95e60..8574f965 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -18,6 +18,7 @@ import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/ui/querya_shell_status.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; +import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; @@ -475,7 +476,15 @@ class _PostgresSqlWorkspaceState extends material.State { n++; } - final outRows = await convertResultRowsToStringsAdaptive(rawRows); + final converted = convertPostgresResultRowsToStrings( + PostgresResultConvertJob( + rowValues: rawRows, + columnTypeOids: [ + for (final c in schema.columns) c.typeOid, + ], + ), + ); + final outRows = await convertResultRowsToStringsAdaptive(converted); final target = SqlTableTargetExtractor.extract(userSql); var pks = const []; diff --git a/lib/features/postgresql/postgres_table_utils.dart b/lib/features/postgresql/postgres_table_utils.dart index e4bfb336..029c635c 100644 --- a/lib/features/postgresql/postgres_table_utils.dart +++ b/lib/features/postgresql/postgres_table_utils.dart @@ -1,6 +1,7 @@ /// Utilities for PostgreSQL table data view (quoting, row conversion). library; +import 'package:querya_desktop/core/database/postgres_result_cells.dart'; import 'package:querya_desktop/core/database/sql_limit.dart'; /// Default page size for table browse and the SQL template filled from the tree. @@ -24,13 +25,10 @@ String quotePostgresIdentifier(String name) { /// Converts raw result rows (list of dynamic values per row) to list of string rows. List> convertResultRowsToStrings(List> rawRows) { - return rawRows.map((row) { - return row.map((value) { - if (value == null) return 'NULL'; - if (value is DateTime) return value.toIso8601String(); - return value.toString(); - }).toList(); - }).toList(); + return [ + for (final row in rawRows) + [for (final value in row) postgresResultCellToDisplayString(value)], + ]; } /// Whether [sql] is allowed for the Table Browser custom-SQL dialog. diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index 48fe547b..e06fdbbd 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -2,6 +2,7 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:postgres/postgres.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; @@ -9,6 +10,7 @@ import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart'; import 'package:querya_desktop/features/postgresql/postgres_sql_editor_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_privileges_dialog.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_toolbar.dart'; @@ -251,6 +253,28 @@ class _PostgresTableViewState extends material.State { ); } + Future>> _postgresRowsToDisplayStrings( + Result result, + List colNames, + ) async { + final rawRows = >[ + for (final row in result) + List.generate(row.length, (i) => row[i]), + ]; + final converted = convertPostgresResultRowsToStrings( + PostgresResultConvertJob( + rowValues: rawRows, + columnTypeOids: [ + for (final c in result.schema.columns) c.typeOid, + ], + columnDataTypes: [ + for (final n in colNames) _columnDataTypes[n], + ], + ), + ); + return convertResultRowsToStringsAdaptive(converted); + } + /// [refreshCount] runs `COUNT(*)` (e.g. first load or Refresh). Pagination only runs SELECT. Future _fetch({bool refreshCount = false}) async { final conn = _connection; @@ -296,12 +320,7 @@ class _PostgresTableViewState extends material.State { (i) => result.schema.columns[i].columnName ?? 'col_$i', ); - final rawRows = >[ - for (final row in result) - List.generate(row.length, (i) => row[i]), - ]; - - final stringRows = await convertResultRowsToStringsAdaptive(rawRows); + final stringRows = await _postgresRowsToDisplayStrings(result, colNames); if (!mounted) return; setState(() { @@ -351,12 +370,7 @@ class _PostgresTableViewState extends material.State { (i) => result.schema.columns[i].columnName ?? 'col_$i', ); - final rawRows = >[ - for (final row in result) - List.generate(row.length, (i) => row[i]), - ]; - - final stringRows = await convertResultRowsToStringsAdaptive(rawRows); + final stringRows = await _postgresRowsToDisplayStrings(result, colNames); if (!mounted) return; setState(() { diff --git a/lib/features/workspace/grid_data_type_validator.dart b/lib/features/workspace/grid_data_type_validator.dart index 4eb2f69f..0b1c1172 100644 --- a/lib/features/workspace/grid_data_type_validator.dart +++ b/lib/features/workspace/grid_data_type_validator.dart @@ -24,6 +24,11 @@ abstract final class GridDataTypeValidator { final type = dataTypeName.toLowerCase().trim(); + // PostgreSQL arrays (`integer[]`, `_int4`) are not scalar ints/json. + if (type.contains('[]') || (type.startsWith('_') && !type.contains(' '))) { + return null; + } + // Boolean types (MySQL BOOLEAN is TINYINT(1) — check before generic int) if (type == 'bool' || type == 'boolean' || type.startsWith('tinyint(1)')) { final lower = value.toLowerCase().trim(); diff --git a/test/core/database/postgres_grid_types_test.dart b/test/core/database/postgres_grid_types_test.dart new file mode 100644 index 00000000..eeca3dd7 --- /dev/null +++ b/test/core/database/postgres_grid_types_test.dart @@ -0,0 +1,186 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:postgres/postgres.dart'; +import 'package:querya_desktop/core/database/postgres_result_cells.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart'; +import 'package:querya_desktop/features/workspace/grid_data_type_validator.dart'; + +void main() { + group('postgresColumnSchemaType', () { + test('ARRAY uses udt_name element plus []', () { + expect( + postgresColumnSchemaType(dataType: 'ARRAY', udtName: '_int4'), + 'int4[]', + ); + expect( + postgresColumnSchemaType(dataType: 'ARRAY', udtName: '_jsonb'), + 'jsonb[]', + ); + }); + + test('USER-DEFINED uses udt_name (enum / domain)', () { + expect( + postgresColumnSchemaType( + dataType: 'USER-DEFINED', + udtName: 'user_role_enum', + ), + 'user_role_enum', + ); + }); + + test('prefers udt_name over data_type', () { + expect( + postgresColumnSchemaType( + dataType: 'timestamp with time zone', + udtName: 'timestamptz', + ), + 'timestamptz', + ); + expect( + postgresColumnSchemaType(dataType: 'bytea', udtName: 'bytea'), + 'bytea', + ); + expect( + postgresColumnSchemaType(dataType: 'jsonb', udtName: 'jsonb'), + 'jsonb', + ); + }); + }); + + group('all_types display + persist', () { + test('timestamptz DateTime displays ISO and persists quoted', () { + final dt = DateTime.utc(2026, 8, 26, 10, 30); + expect( + postgresResultCellToDisplayString( + dt, + typeOid: Type.timestampTz.oid, + dataTypeName: 'timestamptz', + ), + dt.toIso8601String(), + ); + expect( + TableMutationEngine.formatLiteral( + dt.toIso8601String(), + SqlDialect.postgres, + dataTypeName: 'timestamptz', + ), + "'${dt.toIso8601String()}'", + ); + }); + + test('bytea displays \\x hex and persists as bytea literal', () { + expect( + postgresResultCellToDisplayString( + Uint8List.fromList(const [0xde, 0xad, 0xbe, 0xef]), + dataTypeName: 'bytea', + ), + r'\xdeadbeef', + ); + expect( + TableMutationEngine.formatLiteral( + r'\xdeadbeef', + SqlDialect.postgres, + dataTypeName: 'bytea', + ), + r"'\xdeadbeef'::bytea", + ); + }); + + test('jsonb Map displays JSON text and persists quoted', () { + const decoded = { + 'id': 42, + 'user': {'email': 'alice@querya.dev'}, + }; + final shown = postgresResultCellToDisplayString( + decoded, + typeOid: Type.jsonb.oid, + dataTypeName: 'jsonb', + ); + expect(json.decode(shown), decoded); + expect( + TableMutationEngine.formatLiteral( + shown, + SqlDialect.postgres, + dataTypeName: 'jsonb', + ), + "'$shown'", + ); + }); + + test('enum stays a quoted label', () { + expect( + postgresResultCellToDisplayString( + 'admin', + dataTypeName: 'user_role_enum', + ), + 'admin', + ); + expect( + TableMutationEngine.formatLiteral( + 'admin', + SqlDialect.postgres, + dataTypeName: 'user_role_enum', + ), + "'admin'", + ); + }); + + test('int[] displays PG array text and persists quoted', () { + expect( + postgresResultCellToDisplayString( + [1, 2, 3, 42, 999], + dataTypeName: 'int4[]', + ), + '{1,2,3,42,999}', + ); + expect( + TableMutationEngine.formatLiteral( + '{1,2,3,42,999}', + SqlDialect.postgres, + dataTypeName: 'int4[]', + ), + "'{1,2,3,42,999}'", + ); + expect( + GridDataTypeValidator.validate('{1,2,3}', dataTypeName: 'int4[]'), + isNull, + ); + }); + }); + + group('convertPostgresResultRowsToStrings', () { + test('maps timestamptz, bytea, jsonb, array — not Object.toString', () { + final dt = DateTime.utc(2026, 1, 1, 0, 0, 0); + final out = convertPostgresResultRowsToStrings( + PostgresResultConvertJob( + rowValues: [ + [ + dt, + Uint8List.fromList(const [0xca, 0xfe]), + {'a': true}, + [10, 20], + null, + ], + ], + columnDataTypes: const [ + 'timestamptz', + 'bytea', + 'jsonb', + 'int4[]', + 'text', + ], + ), + ); + expect(out.single, [ + dt.toIso8601String(), + r'\xcafe', + '{"a":true}', + '{10,20}', + 'NULL', + ]); + }); + }); +} diff --git a/test/features/workspace/grid_data_type_validator_test.dart b/test/features/workspace/grid_data_type_validator_test.dart index 303e67bb..03004152 100644 --- a/test/features/workspace/grid_data_type_validator_test.dart +++ b/test/features/workspace/grid_data_type_validator_test.dart @@ -93,6 +93,17 @@ void main() { expect(GridDataTypeValidator.validate('123', dataTypeName: 'bytea'), isNotNull); // odd length hex }); + test('skips scalar checks for PostgreSQL array types', () { + expect( + GridDataTypeValidator.validate('{1,2,3}', dataTypeName: 'integer[]'), + isNull, + ); + expect( + GridDataTypeValidator.validate('{1,2,3}', dataTypeName: '_int4'), + isNull, + ); + }); + test('allows empty and NULL values regardless of type', () { expect(GridDataTypeValidator.validate('', dataTypeName: 'int'), isNull); expect(GridDataTypeValidator.validate('NULL', dataTypeName: 'uuid'), isNull); From 0b7c86fb694f6c34b6173d17511498ea3ce2c0b2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 16:23:34 +0300 Subject: [PATCH 43/52] fix(postgres): ORDER BY PK on Table Browser and skip COUNT(*) Browse stays read-only. Row totals use reltuples so a sequential COUNT cannot forceClose the pooled socket. Stale estimates below the page leave Next enabled. --- CHANGELOG.md | 1 + lib/core/database/postgres_connection.dart | 31 +++++++++++ .../database/postgres_connection_pool.dart | 2 +- .../postgresql/postgres_table_utils.dart | 14 +++++ .../postgresql/postgres_table_view.dart | 52 ++++++++++--------- ...postgres_connection_string_parse_test.dart | 10 ++++ .../postgresql/postgres_table_utils_test.dart | 38 ++++++++++++++ 7 files changed, 122 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2fdb4dc..d17a2ecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Postgres Table Browser paging (#788)** — Browse stays on `PgSessionMode.readOnly`. `SELECT` uses `ORDER BY` primary-key columns when a PK exists. Row totals come from `pg_class.reltuples` instead of a blocking `COUNT(*)` before first paint (stale estimates below the current page are ignored so Next still works). Save / REFRESH stay on `tableWrite`. - **Postgres timestamptz / bytea / jsonb / arrays (#789)** — Table Browser and SQL results encode cells as PG literals (ISO timestamps, `\x` hex for bytea, JSON text for jsonb, `{1,2,3}` for arrays) instead of `Object.toString`. Schema uses `udt_name` so `ARRAY` / `USER-DEFINED` round-trip through `formatLiteral`. - **Postgres host/port SSL (#790)** — Host/port Use SSL/TLS stays `sslmode=require` (encrypt, no CA check) unless a Root CA is set, then `verify-full`. A URI `sslmode=` value still wins. The form documents the MITM tradeoff and writes `sslmode=verify-full` when saving a Root CA. - **Postgres custom-SQL allowlist (#791)** — Table Browser SQL dialog classifies the first statement after comments instead of substring `contains('insert ')`. `SELECT inserted_at` is allowed; `SELECT 1; DELETE FROM t` is rejected. `WITH … INSERT` is a write; `TABLE` / `VALUES` / `(SELECT …)` stay allowed. diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index e6190e34..09670961 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -54,6 +54,14 @@ SslMode postgresResolveSslMode({ return SslMode.require; } +/// `pg_class.reltuples` → row estimate. Negative / unanalyzed → `null`. +int? postgresReltuplesEstimate(Object? value) { + if (value == null) return null; + final n = value is num ? value.toDouble() : double.tryParse(value.toString()); + if (n == null || n < 0) return null; + return n.round(); +} + /// PostgreSQL connection using the pure-Dart `postgres` package. class PostgresConnection { PostgresConnection({ @@ -490,6 +498,29 @@ class PostgresConnection { ); } + /// Planner row estimate (`pg_class.reltuples`), not a blocking `COUNT(*)`. + /// Unanalyzed relations (`-1`) and missing catalog rows return `null`. + Future estimateTableRows({ + String schema = 'public', + required String table, + }) async { + if (!isConnected || _conn == null) { + throw StateError('Not connected to PostgreSQL'); + } + final result = await _conn!.execute( + Sql.named( + 'SELECT c.reltuples FROM pg_catalog.pg_class c ' + 'JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace ' + 'WHERE n.nspname = @schema AND c.relname = @table ' + "AND c.relkind IN ('r', 'p', 'm', 'f') " + 'LIMIT 1', + ), + parameters: {'schema': schema, 'table': table}, + ); + if (result.isEmpty) return null; + return postgresReltuplesEstimate(result.first[0]); + } + /// Returns primary key column names for [table] in [schema]. Future> getPrimaryKeys({ String schema = 'public', diff --git a/lib/core/database/postgres_connection_pool.dart b/lib/core/database/postgres_connection_pool.dart index df5469f5..575fbf53 100644 --- a/lib/core/database/postgres_connection_pool.dart +++ b/lib/core/database/postgres_connection_pool.dart @@ -7,7 +7,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; /// Session policy for pooled connections: browse-only vs ad-hoc SQL (writes). enum PgSessionMode { /// `SET default_transaction_read_only = ON` after connect. - /// Tree catalog, stats, and Table Browser SELECT/COUNT. + /// Tree catalog, stats, and Table Browser SELECT. readOnly, /// SQL editor read-write session. Must not be shared with Table Browser. diff --git a/lib/features/postgresql/postgres_table_utils.dart b/lib/features/postgresql/postgres_table_utils.dart index 029c635c..a91d252e 100644 --- a/lib/features/postgresql/postgres_table_utils.dart +++ b/lib/features/postgresql/postgres_table_utils.dart @@ -7,6 +7,20 @@ import 'package:querya_desktop/core/database/sql_limit.dart'; /// Default page size for table browse and the SQL template filled from the tree. const kPostgresBrowseDefaultRowLimit = 200; +/// Browse SELECT for Table Browser. PK columns get `ORDER BY` so LIMIT/OFFSET +/// is stable. Empty [primaryKeys] keeps unordered scan (views / no PK). +String postgresBrowseDataSql({ + required String qualifiedFrom, + required List primaryKeys, + required int limit, + required int offset, +}) { + final order = primaryKeys.isEmpty + ? '' + : ' ORDER BY ${primaryKeys.map(quotePostgresIdentifier).join(', ')}'; + return 'SELECT * FROM $qualifiedFrom$order LIMIT $limit OFFSET $offset'; +} + /// `SELECT *` template matching [PostgresTableView] browse (same limit/offset). String postgresBrowseSelectSql({ required String schema, diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index e06fdbbd..eed61a36 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -59,7 +59,7 @@ class _PostgresTableViewState extends material.State { /// Rows on the current page (same as _rows.length when not loading). int _rowsOnPage = 0; - /// Total rows in table/view (from COUNT(*)). + /// Planner estimate (`reltuples`); `null` when unknown or stale. int? _totalRowCount; /// Zero-based offset for LIMIT/OFFSET pagination. @@ -182,18 +182,15 @@ class _PostgresTableViewState extends material.State { } } - static int _asInt(dynamic v) { - if (v == null) return 0; - if (v is int) return v; - if (v is BigInt) return v.toInt(); - if (v is num) return v.toInt(); - return int.tryParse(v.toString()) ?? 0; - } - String _browseDataSql() { final schemaQ = quotePostgresIdentifier(widget.schema); final tableQ = quotePostgresIdentifier(widget.tableName); - return 'SELECT * FROM $schemaQ.$tableQ LIMIT ${widget.limit} OFFSET $_offset'; + return postgresBrowseDataSql( + qualifiedFrom: '$schemaQ.$tableQ', + primaryKeys: _primaryKeys, + limit: widget.limit, + offset: _offset, + ); } Future _withTableWrite( @@ -275,7 +272,7 @@ class _PostgresTableViewState extends material.State { return convertResultRowsToStringsAdaptive(converted); } - /// [refreshCount] runs `COUNT(*)` (e.g. first load or Refresh). Pagination only runs SELECT. + /// [refreshCount] re-reads `reltuples` (e.g. first load or Refresh). Pagination only runs SELECT. Future _fetch({bool refreshCount = false}) async { final conn = _connection; if (conn == null || !conn.isConnected) { @@ -296,25 +293,26 @@ class _PostgresTableViewState extends material.State { _loading = true; _error = null; }); - final schemaQ = quotePostgresIdentifier(widget.schema); - final tableQ = quotePostgresIdentifier(widget.tableName); - final countSql = 'SELECT COUNT(*) AS c FROM $schemaQ.$tableQ'; - final dataSql = _browseDataSql(); try { - int totalRows; - if (refreshCount || _totalRowCount == null) { - final countResult = await conn.execute(countSql); - totalRows = countResult.isEmpty ? 0 : _asInt(countResult.first[0]); - } else { - totalRows = _totalRowCount!; + await _ensureSchema(conn); + if (!mounted) return; + + int? totalRows = _totalRowCount; + if (refreshCount || totalRows == null) { + try { + totalRows = await conn.estimateTableRows( + schema: widget.schema, + table: widget.tableName, + ); + } catch (_) { + totalRows = null; + } } + final dataSql = _browseDataSql(); final result = await conn.execute(dataSql); if (!mounted) return; - await _ensureSchema(conn); - if (!mounted) return; - final colNames = List.generate( result.schema.columns.length, (i) => result.schema.columns[i].columnName ?? 'col_$i', @@ -323,10 +321,14 @@ class _PostgresTableViewState extends material.State { final stringRows = await _postgresRowsToDisplayStrings(result, colNames); if (!mounted) return; + final shown = stringRows.length; + if (totalRows != null && shown > 0 && totalRows < _offset + shown) { + totalRows = null; + } setState(() { _columnNames = colNames; _rows = stringRows; - _rowsOnPage = stringRows.length; + _rowsOnPage = shown; if (refreshCount || _totalRowCount == null) { _totalRowCount = totalRows; } diff --git a/test/core/database/postgres_connection_string_parse_test.dart b/test/core/database/postgres_connection_string_parse_test.dart index 8d120fc3..5f1f95f5 100644 --- a/test/core/database/postgres_connection_string_parse_test.dart +++ b/test/core/database/postgres_connection_string_parse_test.dart @@ -109,4 +109,14 @@ void main() { ); }); }); + + group('postgresReltuplesEstimate', () { + test('rounds planner floats and treats unanalyzed as unknown', () { + expect(postgresReltuplesEstimate(200.4), 200); + expect(postgresReltuplesEstimate(199.6), 200); + expect(postgresReltuplesEstimate(0), 0); + expect(postgresReltuplesEstimate(-1), isNull); + expect(postgresReltuplesEstimate(null), isNull); + }); + }); } diff --git a/test/features/postgresql/postgres_table_utils_test.dart b/test/features/postgresql/postgres_table_utils_test.dart index 1db0508d..db70c9b9 100644 --- a/test/features/postgresql/postgres_table_utils_test.dart +++ b/test/features/postgresql/postgres_table_utils_test.dart @@ -90,6 +90,44 @@ void main() { }); }); + group('postgresBrowseDataSql', () { + test('orders by quoted PK columns', () { + expect( + postgresBrowseDataSql( + qualifiedFrom: '"public"."orders"', + primaryKeys: const ['id'], + limit: 200, + offset: 400, + ), + 'SELECT * FROM "public"."orders" ORDER BY "id" LIMIT 200 OFFSET 400', + ); + }); + + test('composite PK lists all columns', () { + expect( + postgresBrowseDataSql( + qualifiedFrom: '"public"."t"', + primaryKeys: const ['a', 'b'], + limit: 200, + offset: 0, + ), + 'SELECT * FROM "public"."t" ORDER BY "a", "b" LIMIT 200 OFFSET 0', + ); + }); + + test('omits ORDER BY when there is no PK', () { + expect( + postgresBrowseDataSql( + qualifiedFrom: '"public"."v"', + primaryKeys: const [], + limit: 200, + offset: 0, + ), + 'SELECT * FROM "public"."v" LIMIT 200 OFFSET 0', + ); + }); + }); + group('convertResultRowsToStrings', () { test('converts null to "NULL"', () { final result = convertResultRowsToStrings([ From 8a6525239532e8da1d91ae5922e05350fc2cf688 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 16:26:24 +0300 Subject: [PATCH 44/52] fix(postgres): detect BEGIN without an assigned XID Track BEGIN/COMMIT/ROLLBACK on the session so SELECT-only transactions keep the SQL-tab badge on. Fall back to xact_start (PG 9+) instead of pg_current_xact_id_if_assigned. --- CHANGELOG.md | 1 + lib/core/database/postgres_connection.dart | 30 +++++++---- lib/core/database/postgres_sql.dart | 41 ++++++++++++++ .../postgresql/postgres_sql_workspace.dart | 1 + test/core/database/postgres_sql_test.dart | 54 +++++++++++++++++++ 5 files changed, 118 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d17a2ecc..7ee59abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Postgres open-transaction probe (#787)** — `BEGIN` + `SELECT` does not assign an XID, so `pg_current_xact_id_if_assigned` stayed NULL (SQL-tab badge off; autocommit-off prepended a second `BEGIN`). The session tracks BEGIN/COMMIT/ROLLBACK and otherwise probes `pg_stat_activity.xact_start` (PG 9+). Implicit BEGIN when autocommit is off stays a separate `execute`. - **Postgres Table Browser paging (#788)** — Browse stays on `PgSessionMode.readOnly`. `SELECT` uses `ORDER BY` primary-key columns when a PK exists. Row totals come from `pg_class.reltuples` instead of a blocking `COUNT(*)` before first paint (stale estimates below the current page are ignored so Next still works). Save / REFRESH stay on `tableWrite`. - **Postgres timestamptz / bytea / jsonb / arrays (#789)** — Table Browser and SQL results encode cells as PG literals (ISO timestamps, `\x` hex for bytea, JSON text for jsonb, `{1,2,3}` for arrays) instead of `Object.toString`. Schema uses `udt_name` so `ARRAY` / `USER-DEFINED` round-trip through `formatLiteral`. - **Postgres host/port SSL (#790)** — Host/port Use SSL/TLS stays `sslmode=require` (encrypt, no CA check) unless a Root CA is set, then `verify-full`. A URI `sslmode=` value still wins. The form documents the MITM tradeoff and writes `sslmode=verify-full` when saving a Root CA. diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index 09670961..8507de0d 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -11,6 +11,7 @@ import 'package:postgres/src/connection_string.dart' show parseConnectionString; import 'postgres_metadata.dart'; import 'postgres_result_cells.dart'; +import 'postgres_sql.dart'; import 'table_schema_meta.dart'; /// Replaces the database in a `postgresql://` / `postgres://` URI (path or @@ -131,6 +132,7 @@ class PostgresConnection { Connection? _conn; bool _isConnected = false; + bool _inTransaction = false; bool get isConnected => _isConnected && _conn != null; @@ -238,6 +240,7 @@ class PostgresConnection { ); } _isConnected = true; + _inTransaction = false; scrubCredentials(); } catch (e, st) { _isConnected = false; @@ -255,6 +258,7 @@ class PostgresConnection { Future disconnect() async { _isConnected = false; + _inTransaction = false; final c = _conn; _conn = null; try { @@ -268,6 +272,7 @@ class PostgresConnection { /// cancelling a long query or [PostgresService.interrupt]. Future forceClose() async { _isConnected = false; + _inTransaction = false; final c = _conn; _conn = null; try { @@ -313,7 +318,9 @@ class PostgresConnection { throw StateError('Not connected to PostgreSQL'); } try { - return await _conn!.execute(sql, timeout: timeout); + final result = await _conn!.execute(sql, timeout: timeout); + _inTransaction = applyPostgresTransactionSql(_inTransaction, sql); + return result; } on TimeoutException { unawaited(forceClose()); rethrow; @@ -335,19 +342,24 @@ class PostgresConnection { } } - /// Whether the session has an open transaction (PostgreSQL 13+). - /// Returns `null` if the server does not support the probe or an error occurs. + /// Whether the session has an open transaction. + /// + /// `BEGIN` + `SELECT` does not assign an XID, so `pg_current_xact_id_if_assigned` + /// stays NULL. We track BEGIN/COMMIT/ROLLBACK on [execute], and otherwise + /// probe `pg_stat_activity.xact_start` for this backend (PG 9+; no PG 13 + /// requirement). Returns `null` only when disconnected. Future inOpenTransaction() async { if (!isConnected || _conn == null) return null; + if (_inTransaction) return true; try { - final r = await _conn!.execute( - 'SELECT pg_current_xact_id_if_assigned() IS NOT NULL', - ); - if (r.isEmpty) return null; - return r.first[0] as bool; + final r = await _conn!.execute(kPostgresOpenTransactionProbeSql); + if (r.isEmpty) return false; + final open = r.first[0] == true; + _inTransaction = open; + return open; } catch (e) { debugPrint('PostgresConnection.inOpenTransaction: $e'); - return null; + return _inTransaction; } } diff --git a/lib/core/database/postgres_sql.dart b/lib/core/database/postgres_sql.dart index d5655063..fd746b07 100644 --- a/lib/core/database/postgres_sql.dart +++ b/lib/core/database/postgres_sql.dart @@ -27,6 +27,47 @@ bool shouldSkipImplicitBegin(String sql) { return false; } +/// Own-backend `xact_start` (set at `BEGIN`, even when no XID is assigned). +/// +/// Replaces `pg_current_xact_id_if_assigned()` (PG 13+), which stays NULL after +/// `BEGIN` + `SELECT`. `pg_stat_activity.xact_start` exists on PG 9+. +const kPostgresOpenTransactionProbeSql = + 'SELECT xact_start IS NOT NULL ' + 'FROM pg_catalog.pg_stat_activity ' + 'WHERE pid = pg_backend_pid()'; + +/// Updates session tx state from a successfully executed statement. +/// +/// `BEGIN` / `START TRANSACTION` open; `COMMIT` / `END` / `ROLLBACK` close. +/// `ROLLBACK TO` (savepoint) leaves the transaction open. +bool applyPostgresTransactionSql(bool currentlyOpen, String sql) { + final s = stripLeadingWhitespaceAndLineComments(sql); + if (s.isEmpty) return currentlyOpen; + final u = s.toUpperCase(); + + if (u.startsWith('START TRANSACTION') || _pgTxStartsWithKeyword(u, 'BEGIN')) { + return true; + } + if (_pgTxStartsWithKeyword(u, 'COMMIT') || _pgTxStartsWithKeyword(u, 'END')) { + return false; + } + if (_pgTxStartsWithKeyword(u, 'ROLLBACK')) { + if (RegExp(r'^ROLLBACK\s+TO\b').hasMatch(u)) return currentlyOpen; + return false; + } + return currentlyOpen; +} + +bool _pgTxStartsWithKeyword(String upper, String kw) { + if (!upper.startsWith(kw)) return false; + if (upper.length == kw.length) return true; + final next = upper.codeUnitAt(kw.length); + final isIdent = (next >= 0x41 && next <= 0x5a) || + next == 0x5f || + (next >= 0x30 && next <= 0x39); + return !isIdent; +} + /// Runs [statements] as separate extended-protocol executes inside BEGIN/COMMIT. /// /// PostgreSQL Parse cannot contain multiple commands, so diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 8574f965..b6a06fdc 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -450,6 +450,7 @@ class _PostgresSqlWorkspaceState extends material.State { if (!_autocommit) { final inTx = await conn.inOpenTransaction() ?? false; if (!inTx && !shouldSkipImplicitBegin(sql)) { + // Separate execute: Parse cannot take `BEGIN;` + the next statement. await conn.execute('BEGIN', timeout: to); } } diff --git a/test/core/database/postgres_sql_test.dart b/test/core/database/postgres_sql_test.dart index fd69b469..efec0764 100644 --- a/test/core/database/postgres_sql_test.dart +++ b/test/core/database/postgres_sql_test.dart @@ -80,6 +80,60 @@ void main() { }); }); + group('applyPostgresTransactionSql', () { + test('BEGIN + SELECT stays open; COMMIT closes', () { + var open = false; + open = applyPostgresTransactionSql(open, 'BEGIN'); + expect(open, isTrue); + open = applyPostgresTransactionSql(open, 'SELECT 1'); + expect(open, isTrue); + open = applyPostgresTransactionSql(open, 'COMMIT'); + expect(open, isFalse); + }); + + test('BEGIN WORK opens; BEGINNING does not', () { + expect(applyPostgresTransactionSql(false, 'BEGIN WORK'), isTrue); + expect(applyPostgresTransactionSql(false, 'BEGINNING'), isFalse); + }); + + test('START TRANSACTION and END', () { + var open = applyPostgresTransactionSql(false, 'START TRANSACTION'); + expect(open, isTrue); + open = applyPostgresTransactionSql(open, 'END'); + expect(open, isFalse); + }); + + test('ROLLBACK closes; ROLLBACK TO savepoint does not', () { + expect(applyPostgresTransactionSql(true, 'ROLLBACK'), isFalse); + expect( + applyPostgresTransactionSql(true, 'ROLLBACK TO sp1'), + isTrue, + ); + expect( + applyPostgresTransactionSql(true, 'ROLLBACK TO SAVEPOINT sp1'), + isTrue, + ); + }); + + test('strips leading comments before BEGIN', () { + expect( + applyPostgresTransactionSql(false, '-- note\nBEGIN'), + isTrue, + ); + }); + }); + + group('kPostgresOpenTransactionProbeSql', () { + test('uses xact_start, not xid-if-assigned', () { + expect(kPostgresOpenTransactionProbeSql, contains('xact_start')); + expect(kPostgresOpenTransactionProbeSql, contains('pg_backend_pid()')); + expect( + kPostgresOpenTransactionProbeSql, + isNot(contains('pg_current_xact_id')), + ); + }); + }); + group('runPostgresStatementsInTransaction', () { test('executes BEGIN, each statement, COMMIT as separate calls', () async { final calls = []; From 8b0c227c5bd1d98dd8851616c4e0b5d3f9bdfc6b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 16:29:16 +0300 Subject: [PATCH 45/52] fix(mongo): parse Extended JSON and hex _id in the document filter json.decode left _id as a string, so find never matched BSON ObjectId. Accept $oid / $date and wrap a 24-character hex _id. Hint when a string _id still matches nothing. --- CHANGELOG.md | 1 + .../mongodb/mongo_documents_view.dart | 19 ++++++-- lib/features/mongodb/mongo_ejson.dart | 31 +++++++++++++ test/features/mongodb/mongo_ejson_test.dart | 43 +++++++++++++++++++ 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ee59abd..3c293830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Mongo JSON filter ObjectId / DateTime (#783)** — Document list filter parses Extended JSON (`$oid`, `$date`) and wraps a 24-character hex `_id` as `ObjectId`. A leftover string `_id` with zero matches shows how to write `{ "_id": { "$oid": "…" } }`. - **Postgres open-transaction probe (#787)** — `BEGIN` + `SELECT` does not assign an XID, so `pg_current_xact_id_if_assigned` stayed NULL (SQL-tab badge off; autocommit-off prepended a second `BEGIN`). The session tracks BEGIN/COMMIT/ROLLBACK and otherwise probes `pg_stat_activity.xact_start` (PG 9+). Implicit BEGIN when autocommit is off stays a separate `execute`. - **Postgres Table Browser paging (#788)** — Browse stays on `PgSessionMode.readOnly`. `SELECT` uses `ORDER BY` primary-key columns when a PK exists. Row totals come from `pg_class.reltuples` instead of a blocking `COUNT(*)` before first paint (stale estimates below the current page are ignored so Next still works). Save / REFRESH stay on `tableWrite`. - **Postgres timestamptz / bytea / jsonb / arrays (#789)** — Table Browser and SQL results encode cells as PG literals (ISO timestamps, `\x` hex for bytea, JSON text for jsonb, `{1,2,3}` for arrays) instead of `Object.toString`. Schema uses `udt_name` so `ARRAY` / `USER-DEFINED` round-trip through `formatLiteral`. diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index efdcc438..98700e7c 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -1,10 +1,10 @@ import 'dart:async' show unawaited; -import 'dart:convert'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/features/mongodb/mongo_field_codec.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/features/workspace/grid_cell_popover_inspector.dart'; @@ -47,6 +47,7 @@ class _MongoDocumentsViewState extends material.State { final _filterController = material.TextEditingController(); Map? _activeFilter; + String? _emptyFilterHint; @override void initState() { @@ -94,6 +95,11 @@ class _MongoDocumentsViewState extends material.State { _totalCount = count; _documents = docs; _loading = false; + _emptyFilterHint = docs.isEmpty && + _activeFilter != null && + mongoFilterNeedsObjectIdHint(_activeFilter!) + ? kMongoFilterIdStringHint + : null; }); } catch (e) { if (mounted) { @@ -111,7 +117,7 @@ class _MongoDocumentsViewState extends material.State { _activeFilter = null; } else { try { - _activeFilter = json.decode(text) as Map; + _activeFilter = mongoFilterFromJson(text); } catch (e) { setState(() { _error = 'Invalid JSON filter: $e'; @@ -119,6 +125,7 @@ class _MongoDocumentsViewState extends material.State { return; } } + _emptyFilterHint = null; _skip = 0; _load(); } @@ -126,6 +133,7 @@ class _MongoDocumentsViewState extends material.State { void _clearFilter() { _filterController.clear(); _activeFilter = null; + _emptyFilterHint = null; _skip = 0; _load(); } @@ -258,7 +266,8 @@ class _MongoDocumentsViewState extends material.State { ? material.Center( child: material.Padding( padding: const material.EdgeInsets.all(48), - child: const Text('No documents found').muted(), + child: Text(_emptyFilterHint ?? 'No documents found') + .muted(), ), ) : material.ListView.separated( @@ -303,7 +312,9 @@ class _MongoDocumentsViewState extends material.State { material.Expanded( child: TextField( controller: _filterController, - placeholder: const Text('Filter (JSON) e.g. {"name": "John"}'), + placeholder: const Text( + r'Filter (JSON / EJSON) e.g. {"_id": {"$oid": "…"}}', + ), onSubmitted: (_) => _applyFilter(), ), ), diff --git a/lib/features/mongodb/mongo_ejson.dart b/lib/features/mongodb/mongo_ejson.dart index 9a825299..f20f0022 100644 --- a/lib/features/mongodb/mongo_ejson.dart +++ b/lib/features/mongodb/mongo_ejson.dart @@ -22,3 +22,34 @@ Map mongoDocumentFromEjson(String text) { } return EJsonCodec.eJson2Doc(Map.from(decoded)); } + +/// Hint when a find filter's `_id` is still a JSON string (not ObjectId). +const kMongoFilterIdStringHint = + 'No documents matched. If `_id` is an ObjectId, use ' + r'{"_id": {"$oid": "…"}} or a 24-character hex `_id`.'; + +/// Parses a find filter: Extended JSON (`$oid`, `$date`, …) plus a 24-char +/// hex `_id` string wrapped as [ObjectId]. +Map mongoFilterFromJson(String text) { + final decoded = json.decode(text); + if (decoded is! Map) { + throw const FormatException('Filter JSON must be an object'); + } + final raw = Map.from(decoded); + Map doc; + try { + doc = EJsonCodec.eJson2Doc(Map.from(raw)); + } catch (_) { + doc = raw; + } + final id = doc['_id']; + if (id is String && ObjectId.isValidHexId(id)) { + doc['_id'] = ObjectId.fromHexString(id); + } + return doc; +} + +/// True when `_id` is still a JSON string (hex wrap did not apply). +bool mongoFilterNeedsObjectIdHint(Map filter) { + return filter['_id'] is String; +} diff --git a/test/features/mongodb/mongo_ejson_test.dart b/test/features/mongodb/mongo_ejson_test.dart index bb65f987..0b74c834 100644 --- a/test/features/mongodb/mongo_ejson_test.dart +++ b/test/features/mongodb/mongo_ejson_test.dart @@ -55,4 +55,47 @@ void main() { ); }); }); + + group('mongoFilterFromJson', () { + test('24-char hex _id becomes ObjectId, not a String', () { + const hex = '507f1f77bcf86cd799439011'; + final filter = mongoFilterFromJson('{"_id": "$hex"}'); + expect(filter['_id'], isA()); + expect(filter['_id'], isNot(isA())); + expect((filter['_id'] as ObjectId).oid, hex); + expect(mongoFilterNeedsObjectIdHint(filter), isFalse); + }); + + test('Extended JSON \$oid _id is ObjectId', () { + const hex = '507f191e810c19729de860ea'; + final filter = mongoFilterFromJson( + '{"_id": {"\$oid": "$hex"}}', + ); + expect(filter['_id'], isA()); + expect((filter['_id'] as ObjectId).oid, hex); + }); + + test('Extended JSON \$date is DateTime', () { + final filter = mongoFilterFromJson( + '{"created": {"\$date": "2024-01-15T12:30:00.000Z"}}', + ); + expect(filter['created'], isA()); + expect( + (filter['created'] as DateTime).toUtc(), + DateTime.utc(2024, 1, 15, 12, 30), + ); + }); + + test('non-hex _id string stays a string and needs the ObjectId hint', () { + final filter = mongoFilterFromJson('{"_id": "not-an-objectid"}'); + expect(filter['_id'], 'not-an-objectid'); + expect(mongoFilterNeedsObjectIdHint(filter), isTrue); + }); + + test('query operators like \$gt are kept', () { + final filter = mongoFilterFromJson('{"age": {"\$gt": 5}}'); + expect(filter['age'], isA()); + expect((filter['age'] as Map)[r'$gt'], 5); + }); + }); } From d7066619a4e6d71602addda7b63950f4476ba019 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 16:34:30 +0300 Subject: [PATCH 46/52] fix(mongo): confirm before discarding a dirty document editor --- CHANGELOG.md | 1 + lib/core/unsaved_work_guard.dart | 11 ++-- lib/core/unsaved_work_registry.dart | 2 +- .../mongodb/mongo_document_editor.dart | 14 +++++- lib/features/mongodb/mongo_explorer_view.dart | 14 +++++- lib/features/updater/update_dialog.dart | 4 +- .../mongodb/mongo_document_editor_test.dart | 50 +++++++++++++++++++ 7 files changed, 85 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c293830..bf42d0ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Mongo document editor Back (#782)** — Dirty JSON in `MongoDocumentEditor` is registered with `UnsavedWorkRegistry`. Breadcrumb Back, Home, Close, and tree navigation confirm before discarding; Cancel keeps the editor. - **Mongo JSON filter ObjectId / DateTime (#783)** — Document list filter parses Extended JSON (`$oid`, `$date`) and wraps a 24-character hex `_id` as `ObjectId`. A leftover string `_id` with zero matches shows how to write `{ "_id": { "$oid": "…" } }`. - **Postgres open-transaction probe (#787)** — `BEGIN` + `SELECT` does not assign an XID, so `pg_current_xact_id_if_assigned` stayed NULL (SQL-tab badge off; autocommit-off prepended a second `BEGIN`). The session tracks BEGIN/COMMIT/ROLLBACK and otherwise probes `pg_stat_activity.xact_start` (PG 9+). Implicit BEGIN when autocommit is off stays a separate `execute`. - **Postgres Table Browser paging (#788)** — Browse stays on `PgSessionMode.readOnly`. `SELECT` uses `ORDER BY` primary-key columns when a PK exists. Row totals come from `pg_class.reltuples` instead of a blocking `COUNT(*)` before first paint (stale estimates below the current page are ignored so Next still works). Save / REFRESH stay on `tableWrite`. diff --git a/lib/core/unsaved_work_guard.dart b/lib/core/unsaved_work_guard.dart index f8d63029..7f745245 100644 --- a/lib/core/unsaved_work_guard.dart +++ b/lib/core/unsaved_work_guard.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -/// Confirms before Home / Close / tree navigation discards SQL or staged grid -/// edits. Returns true when it is safe to tear down the current view. +/// Confirms before Home / Close / tree navigation discards SQL, staged grid +/// edits, or a dirty document editor. Returns true when it is safe to tear +/// down the current view. Future confirmDiscardUnsavedWorkIfNeeded( material.BuildContext context, ) async { @@ -12,7 +13,7 @@ Future confirmDiscardUnsavedWorkIfNeeded( return confirmed == true; } -/// Prompt when leaving a workspace that has unsaved SQL or table edits. +/// Prompt when leaving a workspace that has unsaved SQL, table, or document edits. Future showDiscardUnsavedWorkDialog(material.BuildContext context) { return showAppDialog( context: context, @@ -28,8 +29,8 @@ Future showDiscardUnsavedWorkDialog(material.BuildContext context) { const Text('Unsaved changes').semiBold().large(), const Gap(8), const Text( - 'You have unsaved SQL or staged table edits. ' - 'Continuing will discard them.', + 'You have unsaved SQL, staged table edits, or document ' + 'changes. Continuing will discard them.', ).muted().small(), const Gap(20), material.Align( diff --git a/lib/core/unsaved_work_registry.dart b/lib/core/unsaved_work_registry.dart index 8511a9df..7f02855a 100644 --- a/lib/core/unsaved_work_registry.dart +++ b/lib/core/unsaved_work_registry.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -/// Process-wide probes for unsaved SQL / staged grid edits. +/// Process-wide probes for unsaved SQL, staged grid edits, and document editors. /// /// Used by the in-app updater (and similar quit paths) to warn before /// discarding work. Owners register a probe and must unregister on dispose. diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index 9401cc83..6e6e52c6 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -1,9 +1,13 @@ +import 'dart:async' show unawaited; + import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/querya_code_language.dart'; +import 'package:querya_desktop/core/unsaved_work_guard.dart'; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -55,6 +59,7 @@ class _MongoDocumentEditorState extends material.State { text: mongoDocumentToEjson(widget.document), ); _controller.addListener(_onTextChanged); + UnsavedWorkRegistry.instance.register(this, () => _dirty || _saving); } @override @@ -102,11 +107,18 @@ class _MongoDocumentEditorState extends material.State { @override void dispose() { + UnsavedWorkRegistry.instance.unregister(this); _controller.removeListener(_onTextChanged); _controller.dispose(); super.dispose(); } + Future _onBack() async { + if (!await confirmDiscardUnsavedWorkIfNeeded(context)) return; + if (!mounted) return; + widget.onBack?.call(); + } + void _onTextChanged() { if (!_dirty) { setState(() => _dirty = true); @@ -237,7 +249,7 @@ class _MongoDocumentEditorState extends material.State { child: Row( children: [ material.InkWell( - onTap: widget.onBack, + onTap: () => unawaited(_onBack()), borderRadius: material.BorderRadius.circular(6), child: material.Padding( padding: const material.EdgeInsets.all(4), diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index ddb1580a..ef7938e8 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -1,8 +1,11 @@ +import 'dart:async' show unawaited; + import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/unsaved_work_guard.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -192,7 +195,14 @@ class _MongoExplorerViewState extends material.State { return list; } - void _onCrumbTap(_Crumb crumb) { + Future _onCrumbTap(_Crumb crumb) async { + final leavesDocument = _selectedDocument != null && + crumb.level != _Level.document && + crumb.level != _Level.stats; + if (leavesDocument) { + if (!await confirmDiscardUnsavedWorkIfNeeded(context)) return; + if (!mounted) return; + } if (_showStats && crumb.level != _Level.stats) { setState(() => _showStats = false); } @@ -275,7 +285,7 @@ class _MongoExplorerViewState extends material.State { // Breadcrumb bar _BreadcrumbBar( crumbs: _crumbs, - onCrumbTap: _onCrumbTap, + onCrumbTap: (crumb) => unawaited(_onCrumbTap(crumb)), onRefresh: () => setState(() => _refreshToken++), onStats: () => setState(() => _showStats = !_showStats), ), diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index 758a03db..be638f41 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -53,8 +53,8 @@ Future showUnsavedUpdateRestartDialog(material.BuildContext context) { const Text('Unsaved changes').semiBold().large(), const Gap(8), const Text( - 'You have unsaved SQL or staged table changes. ' - 'Restarting to install the update will discard them.', + 'You have unsaved SQL, staged table edits, or document ' + 'changes. Restarting to install the update will discard them.', ).muted().small(), const Gap(20), material.Align( diff --git a/test/features/mongodb/mongo_document_editor_test.dart b/test/features/mongodb/mongo_document_editor_test.dart index 8f45e2ef..1cb72c26 100644 --- a/test/features/mongodb/mongo_document_editor_test.dart +++ b/test/features/mongodb/mongo_document_editor_test.dart @@ -4,6 +4,7 @@ import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/editor/querya_code_editor.dart'; import 'package:querya_desktop/core/editor/syntax_highlight_service.dart'; import 'package:querya_desktop/core/theme/querya_theme.dart'; +import 'package:querya_desktop/core/unsaved_work_registry.dart'; import 'package:querya_desktop/features/mongodb/mongo_document_editor.dart'; import '../../support/pump_syntax_highlight.dart'; @@ -15,12 +16,15 @@ void main() { await SyntaxHighlightService.ensureInitialized(); }); + tearDown(UnsavedWorkRegistry.instance.resetForTest); + final connection = MongoConnection(id: 1, name: 'test', host: 'localhost'); Future pumpEditor( WidgetTester tester, { QueryaTheme? theme, Map document = const {'_id': 'abc', 'a': 1}, + material.VoidCallback? onBack, }) async { await tester.pumpWidget( queryaThemeTestShell( @@ -33,6 +37,7 @@ void main() { database: 'db', collection: 'items', document: document, + onBack: onBack, ), ), ), @@ -123,4 +128,49 @@ void main() { expect(deleted, isFalse); expect(find.text('Delete'), findsOneWidget); }); + + testWidgets('clean Back leaves the editor without a confirm dialog', + (tester) async { + var wentBack = false; + await pumpEditor(tester, onBack: () => wentBack = true); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isFalse); + + await tester.tap(find.byIcon(material.Icons.arrow_back_rounded)); + await tester.pumpAndSettle(); + + expect(wentBack, isTrue); + expect(find.text('Unsaved changes'), findsNothing); + }); + + testWidgets('dirty Back Cancel keeps edits; Discard calls onBack', + (tester) async { + await tester.binding.setSurfaceSize(const material.Size(800, 700)); + var wentBack = false; + await pumpEditor(tester, onBack: () => wentBack = true); + + await tester.enterText(find.byType(material.EditableText), '{"a":2}'); + await tester.pump(); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isTrue); + + await tester.tap(find.byIcon(material.Icons.arrow_back_rounded)); + await tester.pumpAndSettle(); + expect(find.text('Unsaved changes'), findsOneWidget); + expect(wentBack, isFalse); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(wentBack, isFalse); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isTrue); + + await tester.tap(find.byIcon(material.Icons.arrow_back_rounded)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + expect(wentBack, isTrue); + + await tester.pumpWidget( + queryaThemeTestShell(child: const material.SizedBox()), + ); + expect(UnsavedWorkRegistry.instance.hasUnsaved, isFalse); + }); } From b7dbe959cb587be765f3e5e7bdcbd5018a076257 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 16:37:48 +0300 Subject: [PATCH 47/52] fix(mongo): replace full-document Save instead of $set merge --- CHANGELOG.md | 1 + lib/core/database/mongodb_service.dart | 16 ++++++++- .../mongodb/mongo_document_editor.dart | 14 ++++---- lib/features/mongodb/mongo_ejson.dart | 14 ++++++++ test/features/mongodb/mongo_ejson_test.dart | 35 +++++++++++++++++++ 5 files changed, 73 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf42d0ff..75c45c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Mongo full-document Save (#778)** — JSON editor Save uses `replaceOne` (whole document, `_id` locked) instead of `$set`, so fields deleted in JSON — including nested keys — are removed on the server. - **Mongo document editor Back (#782)** — Dirty JSON in `MongoDocumentEditor` is registered with `UnsavedWorkRegistry`. Breadcrumb Back, Home, Close, and tree navigation confirm before discarding; Cancel keeps the editor. - **Mongo JSON filter ObjectId / DateTime (#783)** — Document list filter parses Extended JSON (`$oid`, `$date`) and wraps a 24-character hex `_id` as `ObjectId`. A leftover string `_id` with zero matches shows how to write `{ "_id": { "$oid": "…" } }`. - **Postgres open-transaction probe (#787)** — `BEGIN` + `SELECT` does not assign an XID, so `pg_current_xact_id_if_assigned` stayed NULL (SQL-tab badge off; autocommit-off prepended a second `BEGIN`). The session tracks BEGIN/COMMIT/ROLLBACK and otherwise probes `pg_stat_activity.xact_start` (PG 9+). Implicit BEGIN when autocommit is off stays a separate `execute`. diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 4b09bab6..7d4c59f3 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -231,7 +231,7 @@ class MongoService { }); } - /// Updates a single document matched by [filter]. + /// Updates a single document matched by [filter] (`$set` / `$unset` / …). Future updateDocument( MongoConnection connection, String database, @@ -245,6 +245,20 @@ class MongoService { }); } + /// Replaces a single document matched by [filter] (full-document Save). + Future replaceDocument( + MongoConnection connection, + String database, + String collection, + Map filter, + Map replacement, + ) async { + return _withDb(connection, database, (db) async { + final coll = db.collection(collection); + await coll.replaceOne(filter, replacement); + }); + } + /// Deletes a single document matched by [filter]. Future deleteDocument( MongoConnection connection, diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index 6e6e52c6..f1834853 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -150,10 +150,12 @@ class _MongoDocumentEditorState extends material.State { return; } - // Remove _id from the update payload (can't change _id). Filter uses the - // original BSON _id, not a stringified copy from the editor. - final updateDoc = Map.from(parsed); - updateDoc.remove('_id'); + // replaceOne drops keys the user deleted. `_id` stays the original BSON + // value even if the editor JSON changed it. + final replacement = mongoFullDocumentReplacement( + parsed: parsed, + originalId: id, + ); setState(() { _saving = true; @@ -162,12 +164,12 @@ class _MongoDocumentEditorState extends material.State { }); try { - await MongoService.instance.updateDocument( + await MongoService.instance.replaceDocument( widget.connection, widget.database, widget.collection, {'_id': id}, - {r'$set': updateDoc}, + replacement, ); if (!mounted) return; setState(() { diff --git a/lib/features/mongodb/mongo_ejson.dart b/lib/features/mongodb/mongo_ejson.dart index f20f0022..d8afb8be 100644 --- a/lib/features/mongodb/mongo_ejson.dart +++ b/lib/features/mongodb/mongo_ejson.dart @@ -53,3 +53,17 @@ Map mongoFilterFromJson(String text) { bool mongoFilterNeedsObjectIdHint(Map filter) { return filter['_id'] is String; } + +/// Full-document Save payload for [DbCollection.replaceOne]. +/// +/// Parsed JSON minus any `_id` the user typed, then `_id` locked to +/// [originalId]. Keys absent from [parsed] (including nested ones) are not in +/// the replacement, so they are dropped — unlike `$set`, which only merges. +Map mongoFullDocumentReplacement({ + required Map parsed, + required Object originalId, +}) { + final out = Map.from(parsed); + out['_id'] = originalId; + return out; +} diff --git a/test/features/mongodb/mongo_ejson_test.dart b/test/features/mongodb/mongo_ejson_test.dart index 0b74c834..2bca49bc 100644 --- a/test/features/mongodb/mongo_ejson_test.dart +++ b/test/features/mongodb/mongo_ejson_test.dart @@ -98,4 +98,39 @@ void main() { expect((filter['age'] as Map)[r'$gt'], 5); }); }); + + group('mongoFullDocumentReplacement', () { + test('start {a:1,b:2}, save {a:1} drops b and keeps original _id', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + final replacement = mongoFullDocumentReplacement( + parsed: {'a': 1}, + originalId: id, + ); + expect(replacement.containsKey('b'), isFalse); + expect(replacement['a'], 1); + expect(replacement['_id'], same(id)); + }); + + test('nested keys deleted in JSON are absent from the replacement', () { + final id = ObjectId.fromHexString('507f191e810c19729de860ea'); + final replacement = mongoFullDocumentReplacement( + parsed: { + 'nested': {'x': 1}, + }, + originalId: id, + ); + expect(replacement['nested'], {'x': 1}); + expect((replacement['nested'] as Map).containsKey('y'), isFalse); + }); + + test('edited _id in JSON is overwritten with the original BSON id', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + final replacement = mongoFullDocumentReplacement( + parsed: {'_id': 'tampered', 'a': 1}, + originalId: id, + ); + expect(replacement['_id'], same(id)); + expect(replacement['a'], 1); + }); + }); } From 47e53b384b089f9ea1738a364b850914d11448ff Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 16:41:21 +0300 Subject: [PATCH 48/52] fix(mongo): fail update/delete when 0 documents match --- CHANGELOG.md | 1 + lib/core/database/mongodb_service.dart | 46 ++++++++++++++++++++-- test/core/database/mongo_service_test.dart | 25 ++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75c45c16..3e44376d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Mongo 0-match write (#776)** — `updateOne` / `replaceOne` / `deleteOne` throw when `nMatched` / `nRemoved` is 0 (wrong `_id` type, deleted doc). Inspector and JSON editor surface Save Failed instead of a success toast. An identical `$set` (`nModified == 0`) still counts as a match. - **Mongo full-document Save (#778)** — JSON editor Save uses `replaceOne` (whole document, `_id` locked) instead of `$set`, so fields deleted in JSON — including nested keys — are removed on the server. - **Mongo document editor Back (#782)** — Dirty JSON in `MongoDocumentEditor` is registered with `UnsavedWorkRegistry`. Breadcrumb Back, Home, Close, and tree navigation confirm before discarding; Cancel keeps the editor. - **Mongo JSON filter ObjectId / DateTime (#783)** — Document list filter parses Extended JSON (`$oid`, `$date`) and wraps a 24-character hex `_id` as `ObjectId`. A leftover string `_id` with zero matches shows how to write `{ "_id": { "$oid": "…" } }`. diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 7d4c59f3..85f89a45 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -3,6 +3,31 @@ import 'package:mongo_dart/mongo_dart.dart'; import '../storage/local_db.dart'; import 'mongodb_connection.dart'; +/// Throws if a single-document write matched nothing (wrong `_id` type, +/// concurrent delete). Same class of lie as 0-row SQL DML. +/// +/// [nModified] is ignored: an identical `$set` still matched the document. +void expectMongoDocumentMatched(int matched, {required String operation}) { + if (matched >= 1) return; + throw StateError( + '$operation failed: matched 0 documents. ' + 'The document may have been deleted or the _id type does not match.', + ); +} + +void _throwIfMongoWriteFailed( + WriteResult result, { + required String operation, + required int matched, +}) { + if (result.hasWriteErrors) { + throw StateError( + '$operation failed: ${result.writeError?.errmsg ?? 'write error'}', + ); + } + expectMongoDocumentMatched(matched, operation: operation); +} + /// Service for managing MongoDB connections. class MongoService { MongoService._(); @@ -241,7 +266,12 @@ class MongoService { ) async { return _withDb(connection, database, (db) async { final coll = db.collection(collection); - await coll.updateOne(filter, update); + final result = await coll.updateOne(filter, update); + _throwIfMongoWriteFailed( + result, + operation: 'updateOne', + matched: result.nMatched, + ); }); } @@ -255,7 +285,12 @@ class MongoService { ) async { return _withDb(connection, database, (db) async { final coll = db.collection(collection); - await coll.replaceOne(filter, replacement); + final result = await coll.replaceOne(filter, replacement); + _throwIfMongoWriteFailed( + result, + operation: 'replaceOne', + matched: result.nMatched, + ); }); } @@ -268,7 +303,12 @@ class MongoService { ) async { return _withDb(connection, database, (db) async { final coll = db.collection(collection); - await coll.deleteOne(filter); + final result = await coll.deleteOne(filter); + _throwIfMongoWriteFailed( + result, + operation: 'deleteOne', + matched: result.nRemoved, + ); }); } diff --git a/test/core/database/mongo_service_test.dart b/test/core/database/mongo_service_test.dart index 71bcd4e4..84feb5ca 100644 --- a/test/core/database/mongo_service_test.dart +++ b/test/core/database/mongo_service_test.dart @@ -3,6 +3,31 @@ import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; void main() { + group('expectMongoDocumentMatched', () { + test('allows 1+ matched documents', () { + expectMongoDocumentMatched(1, operation: 'updateOne'); + expectMongoDocumentMatched(2, operation: 'replaceOne'); + }); + + test('throws on 0 matches so Save is a failure', () { + expect( + () => expectMongoDocumentMatched(0, operation: 'updateOne'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('matched 0 documents'), + ), + ), + ); + }); + + test('nModified 0 is not a failure when the filter matched', () { + // Identical $set: nMatched=1, nModified=0 — still a successful save. + expectMongoDocumentMatched(1, operation: 'updateOne'); + }); + }); + group('MongoService.createConnection', () { test('creates MongoConnection from ConnectionRow', () { const row = ConnectionRow( From 334ef367c51eaeeb94a545da0d0daf559022d6e4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 16:46:17 +0300 Subject: [PATCH 49/52] fix(sqlite): edit Table Browser rows via implicit rowid --- CHANGELOG.md | 1 + lib/features/sqlite/sqlite_table_utils.dart | 53 +++++++++- lib/features/sqlite/sqlite_table_view.dart | 14 ++- .../core/database/sqlite_connection_test.dart | 100 ++++++++++++++++++ .../sqlite/sqlite_table_utils_test.dart | 49 ++++++++- 5 files changed, 212 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e44376d..cbef70cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **SQLite implicit rowid Table Browser (#774)** — Tables with no declared PRIMARY KEY (`CREATE TABLE t (name TEXT)`) use implicit `rowid` as the DML key. Browse `SELECT` projects `"rowid", *` so Save can `UPDATE … WHERE rowid`. Status is no longer “no primary key”. `WITHOUT ROWID` tables keep their declared PK. - **Mongo 0-match write (#776)** — `updateOne` / `replaceOne` / `deleteOne` throw when `nMatched` / `nRemoved` is 0 (wrong `_id` type, deleted doc). Inspector and JSON editor surface Save Failed instead of a success toast. An identical `$set` (`nModified == 0`) still counts as a match. - **Mongo full-document Save (#778)** — JSON editor Save uses `replaceOne` (whole document, `_id` locked) instead of `$set`, so fields deleted in JSON — including nested keys — are removed on the server. - **Mongo document editor Back (#782)** — Dirty JSON in `MongoDocumentEditor` is registered with `UnsavedWorkRegistry`. Breadcrumb Back, Home, Close, and tree navigation confirm before discarding; Cancel keeps the editor. diff --git a/lib/features/sqlite/sqlite_table_utils.dart b/lib/features/sqlite/sqlite_table_utils.dart index 67ebcce2..2cbb149d 100644 --- a/lib/features/sqlite/sqlite_table_utils.dart +++ b/lib/features/sqlite/sqlite_table_utils.dart @@ -1,4 +1,44 @@ import 'package:querya_desktop/core/database/sqlite_connection.dart'; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; + +/// Implicit SQLite `rowid` used as the Table Browser PK when none is declared. +const kSqliteImplicitRowid = 'rowid'; + +/// Synthetic column so INSERT omits `rowid` and DML types it as INTEGER. +const sqliteImplicitRowidColumn = TableColumnMeta( + name: kSqliteImplicitRowid, + dataType: 'INTEGER', + isNullable: false, + isPrimaryKey: true, + primaryKeyPosition: 1, + omitOnInsert: true, + hasServerDefault: true, +); + +/// PK columns for Table Browser DML. +/// +/// Declared PRIMARY KEY wins. Ordinary tables with no PK use implicit `rowid`. +/// `WITHOUT ROWID` tables always declare a PK, so they never hit this fallback. +/// Views have no `rowid` and stay read-only. +List sqliteTableBrowserPrimaryKeys({ + required List declaredPrimaryKeys, + required bool isView, +}) { + if (isView) return const []; + if (declaredPrimaryKeys.isNotEmpty) { + return List.from(declaredPrimaryKeys); + } + return const [kSqliteImplicitRowid]; +} + +/// True when browse SELECT must project `rowid` (it is not in `SELECT *`). +bool sqliteBrowseNeedsRowidColumn({ + required List primaryKeys, + required bool isView, +}) { + if (isView) return false; + return primaryKeys.length == 1 && primaryKeys.first == kSqliteImplicitRowid; +} /// Columns for Table Browser `ORDER BY`. /// @@ -9,11 +49,13 @@ List sqliteBrowseOrderColumns({ required bool isView, }) { if (primaryKeys.isNotEmpty) return List.from(primaryKeys); - if (!isView) return const ['rowid']; + if (!isView) return const [kSqliteImplicitRowid]; return const []; } /// Browse SELECT for Table Browser. PK / `rowid` keep LIMIT/OFFSET stable. +/// +/// Implicit-`rowid` tables project `"rowid", *` so DML WHERE can address the row. String sqliteBrowseDataSql({ required String qualifiedFrom, required List primaryKeys, @@ -25,8 +67,15 @@ String sqliteBrowseDataSql({ primaryKeys: primaryKeys, isView: isView, ); + final pks = sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: primaryKeys, + isView: isView, + ); + final select = sqliteBrowseNeedsRowidColumn(primaryKeys: pks, isView: isView) + ? '${SqliteConnection.quoteIdentifier(kSqliteImplicitRowid)}, *' + : '*'; final order = orderCols.isEmpty ? '' : ' ORDER BY ${orderCols.map(SqliteConnection.quoteIdentifier).join(', ')}'; - return 'SELECT * FROM $qualifiedFrom$order LIMIT $limit OFFSET $offset'; + return 'SELECT $select FROM $qualifiedFrom$order LIMIT $limit OFFSET $offset'; } diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index d9756711..8e855ec2 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -225,9 +225,21 @@ class _SqliteTableViewState extends material.State { } try { final schema = await conn.getTableSchema(table: widget.tableName); - _primaryKeys = List.from(schema.primaryKeys); + _primaryKeys = sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: schema.primaryKeys, + isView: widget.isView, + ); _columnDataTypes = columnDataTypesFromSchema(schema); _columnMeta = columnMetaFromSchema(schema); + if (sqliteBrowseNeedsRowidColumn( + primaryKeys: _primaryKeys, + isView: widget.isView, + ) && + !_columnMeta.containsKey(kSqliteImplicitRowid)) { + _columnDataTypes[kSqliteImplicitRowid] = + sqliteImplicitRowidColumn.dataType; + _columnMeta[kSqliteImplicitRowid] = sqliteImplicitRowidColumn; + } } catch (_) { _primaryKeys = []; _columnDataTypes = {}; diff --git a/test/core/database/sqlite_connection_test.dart b/test/core/database/sqlite_connection_test.dart index 557096be..be7e65e2 100644 --- a/test/core/database/sqlite_connection_test.dart +++ b/test/core/database/sqlite_connection_test.dart @@ -5,6 +5,9 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/database/sqlite_connection.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/features/sqlite/sqlite_table_utils.dart'; +import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/table_view_staging.dart'; void main() { @@ -168,6 +171,103 @@ void main() { expect(columns, containsAll(['id', 'name'])); }); + test('Table Browser UPDATE on implicit rowid table round-trips', () async { + await conn.connect(); + await conn.execute('CREATE TABLE t (name TEXT)'); + await conn.execute("INSERT INTO t (name) VALUES ('Ada')"); + + final schema = await conn.getTableSchema(table: 't'); + expect(schema.primaryKeys, isEmpty); + + final pks = sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: schema.primaryKeys, + isView: false, + ); + expect(pks, ['rowid']); + expect( + tableViewEditingEnabled( + isView: false, + customSqlActive: false, + hasPrimaryKey: pks.isNotEmpty, + ), + isTrue, + ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: pks.isNotEmpty, + schemaLoaded: true, + ), + isNull, + ); + + final sql = sqliteBrowseDataSql( + qualifiedFrom: SqliteConnection.quoteIdentifier('t'), + primaryKeys: pks, + isView: false, + limit: 200, + offset: 0, + ); + final rs = await conn.execute(sql); + expect(rs, isNotEmpty); + final cols = rs.first.keys.toList(); + expect(cols, contains('rowid')); + expect(cols, contains('name')); + + final rows = [ + for (final row in rs) + [for (final c in cols) '${row[c]}'], + ]; + final buffer = DataGridStagingBuffer(columns: cols, rows: rows); + addTearDown(buffer.dispose); + buffer.setCell(0, cols.indexOf('name'), 'Grace'); + + final plan = buffer.generateMutationPlan( + dialect: SqlDialect.sqlite, + tableName: 't', + primaryKeys: pks, + columnDataTypes: { + kSqliteImplicitRowid: 'INTEGER', + 'name': 'TEXT', + }, + columnMeta: {kSqliteImplicitRowid: sqliteImplicitRowidColumn}, + ); + expect(plan.statements, hasLength(1)); + expect(plan.statements.first.sql, contains('WHERE "rowid" =')); + + expect(await conn.executeAffected(plan.statements.first.sql), 1); + final after = await conn.execute('SELECT name FROM t'); + expect(after.first['name'], 'Grace'); + }); + + test('WITHOUT ROWID tables keep the declared PK, not implicit rowid', + () async { + await conn.connect(); + await conn.execute( + 'CREATE TABLE wr (id INTEGER PRIMARY KEY, name TEXT) WITHOUT ROWID', + ); + final schema = await conn.getTableSchema(table: 'wr'); + expect(schema.primaryKeys, ['id']); + expect( + sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: schema.primaryKeys, + isView: false, + ), + ['id'], + ); + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"wr"', + primaryKeys: schema.primaryKeys, + isView: false, + limit: 200, + offset: 0, + ), + 'SELECT * FROM "wr" ORDER BY "id" LIMIT 200 OFFSET 0', + ); + }); + test('getObjectDdl returns CREATE SQL for a table that exists', () async { await conn.connect(); await conn.execute( diff --git a/test/features/sqlite/sqlite_table_utils_test.dart b/test/features/sqlite/sqlite_table_utils_test.dart index 3f6e25f8..80750f19 100644 --- a/test/features/sqlite/sqlite_table_utils_test.dart +++ b/test/features/sqlite/sqlite_table_utils_test.dart @@ -2,6 +2,38 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/features/sqlite/sqlite_table_utils.dart'; void main() { + group('sqliteTableBrowserPrimaryKeys', () { + test('keeps a declared PRIMARY KEY', () { + expect( + sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: const ['id'], + isView: false, + ), + ['id'], + ); + }); + + test('uses rowid when a table has no declared PK', () { + expect( + sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: const [], + isView: false, + ), + ['rowid'], + ); + }); + + test('stays empty for views', () { + expect( + sqliteTableBrowserPrimaryKeys( + declaredPrimaryKeys: const [], + isView: true, + ), + isEmpty, + ); + }); + }); + group('sqliteBrowseOrderColumns', () { test('uses declared PK columns', () { expect( @@ -61,7 +93,7 @@ void main() { ); }); - test('orders by rowid when a table has no PK', () { + test('projects rowid and orders by it when a table has no PK', () { expect( sqliteBrowseDataSql( qualifiedFrom: '"t"', @@ -70,7 +102,20 @@ void main() { limit: 200, offset: 0, ), - 'SELECT * FROM "t" ORDER BY "rowid" LIMIT 200 OFFSET 0', + 'SELECT "rowid", * FROM "t" ORDER BY "rowid" LIMIT 200 OFFSET 0', + ); + }); + + test('projects rowid when PK was resolved to implicit rowid', () { + expect( + sqliteBrowseDataSql( + qualifiedFrom: '"t"', + primaryKeys: const ['rowid'], + isView: false, + limit: 200, + offset: 0, + ), + 'SELECT "rowid", * FROM "t" ORDER BY "rowid" LIMIT 200 OFFSET 0', ); }); From b41402f910280377ab1d080f68691821f3dc194d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 16:52:57 +0300 Subject: [PATCH 50/52] fix(grid): distinguish schema load failure from missing PK --- CHANGELOG.md | 1 + lib/features/mysql/mysql_table_view.dart | 20 ++++- .../postgresql/postgres_table_view.dart | 20 ++++- lib/features/sqlite/sqlite_table_view.dart | 21 +++-- .../workspace/table_view_staging.dart | 36 +++++++++ .../workspace/table_view_staging_test.dart | 79 +++++++++++++++++++ 6 files changed, 164 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbef70cf..d82d5ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Table Browser schema load vs missing PK (#772)** — A failed `getTableSchema` (permissions, disconnect) is no longer shown as “Cannot edit: no primary key detected”. Status is “schema unavailable” plus the real error and Refresh retries schema. Editing stays off until schema loads. Genuine missing PKs still use the old copy. SQLite implicit `rowid` is applied only after a successful schema load. - **SQLite implicit rowid Table Browser (#774)** — Tables with no declared PRIMARY KEY (`CREATE TABLE t (name TEXT)`) use implicit `rowid` as the DML key. Browse `SELECT` projects `"rowid", *` so Save can `UPDATE … WHERE rowid`. Status is no longer “no primary key”. `WITHOUT ROWID` tables keep their declared PK. - **Mongo 0-match write (#776)** — `updateOne` / `replaceOne` / `deleteOne` throw when `nMatched` / `nRemoved` is 0 (wrong `_id` type, deleted doc). Inspector and JSON editor surface Save Failed instead of a success toast. An identical `$set` (`nModified == 0`) still counts as a match. - **Mongo full-document Save (#778)** — JSON editor Save uses `replaceOne` (whole document, `_id` locked) instead of `$set`, so fields deleted in JSON — including nested keys — are removed on the server. diff --git a/lib/features/mysql/mysql_table_view.dart b/lib/features/mysql/mysql_table_view.dart index c9a14399..e25c14f3 100644 --- a/lib/features/mysql/mysql_table_view.dart +++ b/lib/features/mysql/mysql_table_view.dart @@ -65,6 +65,7 @@ class _MysqlTableViewState extends material.State { Map _columnDataTypes = {}; Map _columnMeta = {}; bool _schemaLoaded = false; + Object? _schemaError; bool _isSaving = false; String get _tableTitle => '${widget.database}.${widget.tableName}'; @@ -76,6 +77,7 @@ class _MysqlTableViewState extends material.State { customSqlActive: _customSqlActive, hasPrimaryKey: _primaryKeys.isNotEmpty, readOnly: widget.isReadOnly, + schemaError: _schemaError, ); String _qualifiedFrom() { @@ -130,6 +132,7 @@ class _MysqlTableViewState extends material.State { _columnDataTypes = {}; _columnMeta = {}; _schemaLoaded = false; + _schemaError = null; _isSaving = false; } @@ -264,23 +267,29 @@ class _MysqlTableViewState extends material.State { if (_schemaLoaded) return; if (widget.isView) { _schemaLoaded = true; + _schemaError = null; _primaryKeys = []; _columnDataTypes = {}; _columnMeta = {}; return; } - try { - final schema = await conn.getTableSchema( + final loaded = await loadTableViewSchema( + () => conn.getTableSchema( database: widget.database, table: widget.tableName, - ); + ), + ); + final schema = loaded.schema; + if (schema != null) { _primaryKeys = List.from(schema.primaryKeys); _columnDataTypes = columnDataTypesFromSchema(schema); _columnMeta = columnMetaFromSchema(schema); - } catch (_) { + _schemaError = null; + } else { _primaryKeys = []; _columnDataTypes = {}; _columnMeta = {}; + _schemaError = loaded.error; } _schemaLoaded = true; } @@ -509,6 +518,7 @@ class _MysqlTableViewState extends material.State { hasPrimaryKey: _primaryKeys.isNotEmpty, schemaLoaded: _schemaLoaded, readOnly: widget.isReadOnly, + schemaError: _schemaError, ); final pag = _paginationLabel(); if (reason != null) return '$pag · $reason'; @@ -518,6 +528,8 @@ class _MysqlTableViewState extends material.State { Future _onRefresh() async { if (!await _confirmDiscardIfNeeded()) return; if (!mounted) return; + _schemaLoaded = false; + _schemaError = null; if (_customSqlActive) { await _fetchCustom(); } else { diff --git a/lib/features/postgresql/postgres_table_view.dart b/lib/features/postgresql/postgres_table_view.dart index eed61a36..3d4c7c0a 100644 --- a/lib/features/postgresql/postgres_table_view.dart +++ b/lib/features/postgresql/postgres_table_view.dart @@ -74,6 +74,7 @@ class _PostgresTableViewState extends material.State { Map _columnDataTypes = {}; Map _columnMeta = {}; bool _schemaLoaded = false; + Object? _schemaError; bool _isSaving = false; String get _tableTitle => '${widget.schema}.${widget.tableName}'; @@ -85,6 +86,7 @@ class _PostgresTableViewState extends material.State { isMaterializedView: widget.isMaterializedView, customSqlActive: _customSqlActive, hasPrimaryKey: _primaryKeys.isNotEmpty, + schemaError: _schemaError, ); @override @@ -123,6 +125,7 @@ class _PostgresTableViewState extends material.State { _columnDataTypes = {}; _columnMeta = {}; _schemaLoaded = false; + _schemaError = null; _isSaving = false; } @@ -220,23 +223,29 @@ class _PostgresTableViewState extends material.State { if (_schemaLoaded) return; if (widget.isView || widget.isMaterializedView) { _schemaLoaded = true; + _schemaError = null; _primaryKeys = []; _columnDataTypes = {}; _columnMeta = {}; return; } - try { - final schema = await conn.getTableSchema( + final loaded = await loadTableViewSchema( + () => conn.getTableSchema( schema: widget.schema, table: widget.tableName, - ); + ), + ); + final schema = loaded.schema; + if (schema != null) { _primaryKeys = List.from(schema.primaryKeys); _columnDataTypes = columnDataTypesFromSchema(schema); _columnMeta = columnMetaFromSchema(schema); - } catch (_) { + _schemaError = null; + } else { _primaryKeys = []; _columnDataTypes = {}; _columnMeta = {}; + _schemaError = loaded.error; } _schemaLoaded = true; } @@ -528,6 +537,7 @@ class _PostgresTableViewState extends material.State { customSqlActive: _customSqlActive, hasPrimaryKey: _primaryKeys.isNotEmpty, schemaLoaded: _schemaLoaded, + schemaError: _schemaError, ); final pag = _paginationLabel(); if (reason != null) return '$pag · $reason'; @@ -537,6 +547,8 @@ class _PostgresTableViewState extends material.State { Future _onRefresh() async { if (!await _confirmDiscardIfNeeded()) return; if (!mounted) return; + _schemaLoaded = false; + _schemaError = null; if (_customSqlActive) { await _fetchCustom(); } else { diff --git a/lib/features/sqlite/sqlite_table_view.dart b/lib/features/sqlite/sqlite_table_view.dart index 8e855ec2..32c82b39 100644 --- a/lib/features/sqlite/sqlite_table_view.dart +++ b/lib/features/sqlite/sqlite_table_view.dart @@ -58,18 +58,19 @@ class _SqliteTableViewState extends material.State { Map _columnDataTypes = {}; Map _columnMeta = {}; bool _schemaLoaded = false; + Object? _schemaError; bool _isSaving = false; bool get _isDirty => _stagingBuffer?.isDirty ?? false; - bool get _readOnly => - widget.isReadOnly || widget.connectionRow.useSSL; + bool get _readOnly => widget.isReadOnly || widget.connectionRow.useSSL; bool get _editingEnabled => tableViewEditingEnabled( isView: widget.isView, customSqlActive: false, hasPrimaryKey: _primaryKeys.isNotEmpty, readOnly: _readOnly, + schemaError: _schemaError, ); String _qualifiedFrom() { @@ -121,6 +122,7 @@ class _SqliteTableViewState extends material.State { _columnDataTypes = {}; _columnMeta = {}; _schemaLoaded = false; + _schemaError = null; _isSaving = false; } @@ -218,13 +220,17 @@ class _SqliteTableViewState extends material.State { if (_schemaLoaded) return; if (widget.isView) { _schemaLoaded = true; + _schemaError = null; _primaryKeys = []; _columnDataTypes = {}; _columnMeta = {}; return; } - try { - final schema = await conn.getTableSchema(table: widget.tableName); + final loaded = await loadTableViewSchema( + () => conn.getTableSchema(table: widget.tableName), + ); + final schema = loaded.schema; + if (schema != null) { _primaryKeys = sqliteTableBrowserPrimaryKeys( declaredPrimaryKeys: schema.primaryKeys, isView: widget.isView, @@ -240,10 +246,12 @@ class _SqliteTableViewState extends material.State { sqliteImplicitRowidColumn.dataType; _columnMeta[kSqliteImplicitRowid] = sqliteImplicitRowidColumn; } - } catch (_) { + _schemaError = null; + } else { _primaryKeys = []; _columnDataTypes = {}; _columnMeta = {}; + _schemaError = loaded.error; } _schemaLoaded = true; } @@ -349,6 +357,8 @@ class _SqliteTableViewState extends material.State { Future _onRefresh() async { if (!await _confirmDiscardIfNeeded()) return; if (!mounted) return; + _schemaLoaded = false; + _schemaError = null; await _fetch(); } @@ -503,6 +513,7 @@ class _SqliteTableViewState extends material.State { hasPrimaryKey: _primaryKeys.isNotEmpty, schemaLoaded: _schemaLoaded, readOnly: _readOnly, + schemaError: _schemaError, ); final pag = _paginationLabel(); if (pag.isEmpty) return reason; diff --git a/lib/features/workspace/table_view_staging.dart b/lib/features/workspace/table_view_staging.dart index f4441cb3..5fbee046 100644 --- a/lib/features/workspace/table_view_staging.dart +++ b/lib/features/workspace/table_view_staging.dart @@ -5,6 +5,36 @@ import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart' import 'package:querya_desktop/features/workspace/dml_preview_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +/// Outcome of [loadTableViewSchema] (success vs swallowed getTableSchema error). +class TableViewSchemaLoad { + const TableViewSchemaLoad._({this.schema, this.error}); + + const TableViewSchemaLoad.ok(TableSchemaMeta schema) : this._(schema: schema); + + const TableViewSchemaLoad.failed(Object error) : this._(error: error); + + final TableSchemaMeta? schema; + final Object? error; + + bool get isOk => schema != null; +} + +/// Runs [fetch] and captures a failure instead of treating it as “no PK”. +Future loadTableViewSchema( + Future Function() fetch, +) async { + try { + return TableViewSchemaLoad.ok(await fetch()); + } catch (e) { + return TableViewSchemaLoad.failed(e); + } +} + +/// Status copy when [loadTableViewSchema] failed. Editing stays off. +String tableViewSchemaUnavailableReason(Object error) { + return 'Cannot edit: schema unavailable. $error. Refresh to retry.'; +} + /// Whether Table Browser should attach a [DataGridStagingBuffer] for this page. bool tableViewEditingEnabled({ required bool isView, @@ -12,8 +42,10 @@ bool tableViewEditingEnabled({ required bool customSqlActive, required bool hasPrimaryKey, bool readOnly = false, + Object? schemaError, }) { if (readOnly || isView || isMaterializedView || customSqlActive) return false; + if (schemaError != null) return false; return hasPrimaryKey; } @@ -25,10 +57,14 @@ String? tableViewEditDisabledReason({ required bool hasPrimaryKey, required bool schemaLoaded, bool readOnly = false, + Object? schemaError, }) { if (readOnly) return 'Read-only session'; if (isView || isMaterializedView) return 'Views are read-only'; if (customSqlActive) return 'Custom SQL results are read-only'; + if (schemaError != null) { + return tableViewSchemaUnavailableReason(schemaError); + } if (schemaLoaded && !hasPrimaryKey) { return 'Cannot edit: no primary key detected'; } diff --git a/test/features/workspace/table_view_staging_test.dart b/test/features/workspace/table_view_staging_test.dart index a17823ed..c39ece84 100644 --- a/test/features/workspace/table_view_staging_test.dart +++ b/test/features/workspace/table_view_staging_test.dart @@ -63,6 +63,15 @@ void main() { ), isFalse, ); + expect( + tableViewEditingEnabled( + isView: false, + customSqlActive: false, + hasPrimaryKey: true, + schemaError: StateError('permission denied'), + ), + isFalse, + ); }); }); @@ -133,6 +142,76 @@ void main() { ), isNull, ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + schemaLoaded: true, + schemaError: StateError('permission denied'), + ), + 'Cannot edit: schema unavailable. ' + 'Bad state: permission denied. Refresh to retry.', + ); + }); + + test('schema load failure is not reported as a missing PK', () { + final reason = tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + schemaLoaded: true, + schemaError: Exception('information_schema denied'), + ); + expect(reason, contains('schema unavailable')); + expect(reason, contains('information_schema denied')); + expect(reason, contains('Refresh to retry')); + expect(reason, isNot(contains('no primary key'))); + }); + }); + + group('loadTableViewSchema', () { + test('returns schema on success', () async { + const meta = TableSchemaMeta( + tableName: 'users', + primaryKeys: ['id'], + ); + final loaded = await loadTableViewSchema(() async => meta); + expect(loaded.isOk, isTrue); + expect(loaded.schema, same(meta)); + expect(loaded.error, isNull); + }); + + test('captures a throwing getTableSchema stub instead of empty PKs', + () async { + Future throwingStub() async { + throw StateError('permission denied'); + } + + final loaded = await loadTableViewSchema(throwingStub); + expect(loaded.isOk, isFalse); + expect(loaded.schema, isNull); + expect(loaded.error, isA()); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: loaded.schema?.hasPrimaryKey ?? false, + schemaLoaded: true, + schemaError: loaded.error, + ), + contains('schema unavailable'), + ); + expect( + tableViewEditDisabledReason( + isView: false, + customSqlActive: false, + hasPrimaryKey: false, + schemaLoaded: true, + schemaError: loaded.error, + ), + isNot(contains('no primary key')), + ); }); }); From 20da778609876632eb26f0e9582497574ab5cc9b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 18:19:22 +0300 Subject: [PATCH 51/52] chore(release): open the 0.4.17 cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump pubspec/About to 0.4.17, point Unreleased CHANGELOG and docs at hidden-bugs pass 2 plus perf (#867–#879), tracking #880. --- CHANGELOG.md | 7 +++ docs/release-checklist.md | 52 +++++++++++-------- docs/roadmap.md | 7 +-- .../settings/preferences_about_section.dart | 2 +- pubspec.yaml | 2 +- 5 files changed, 44 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d82d5ec7..0cb3035f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +Target cut: **0.4.17** ([milestone](https://github.com/QueryaHub/Querya-Desktop/milestone/8), tracking [#880](https://github.com/QueryaHub/Querya-Desktop/issues/880)). + +### Planned (not yet on `dev`) + +- Hidden bugs pass 2 — data-loss / fail-open: [#867](https://github.com/QueryaHub/Querya-Desktop/issues/867)–[#873](https://github.com/QueryaHub/Querya-Desktop/issues/873) +- Algorithm / first paint / 120 Hz: [#874](https://github.com/QueryaHub/Querya-Desktop/issues/874)–[#879](https://github.com/QueryaHub/Querya-Desktop/issues/879) + ### Fixed - **Table Browser schema load vs missing PK (#772)** — A failed `getTableSchema` (permissions, disconnect) is no longer shown as “Cannot edit: no primary key detected”. Status is “schema unavailable” plus the real error and Refresh retries schema. Editing stays off until schema loads. Genuine missing PKs still use the old copy. SQLite implicit `rowid` is applied only after a successful schema load. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 3878819f..a8718003 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,10 +1,30 @@ -# Pre-release checklist (release **0.4.16**) +# Pre-release checklist (release **0.4.17**) -Use this before tagging **`0.4.16`** or running the **Release** workflow. +Use this before tagging **`0.4.17`** or running the **Release** workflow. See [tags-and-releases.md](tags-and-releases.md), [CHANGELOG.md](../CHANGELOG.md). +Tracking: [#880](https://github.com/QueryaHub/Querya-Desktop/issues/880). Milestone [0.4.17](https://github.com/QueryaHub/Querya-Desktop/milestone/8). Manual 120 Hz DevTools QA: issue [#739](https://github.com/QueryaHub/Querya-Desktop/issues/739) (does not block this cut). -## Product smoke (manual) — 0.4.16 +## Product smoke (manual) — 0.4.17 + +- [ ] **SQL Execute discard (#867)** — edit a result-grid cell, Execute again: discard dialog; Cancel keeps pending edits. +- [ ] **SQL-grid Save parity (#868)** — schema load failure is not a silent healthy row-count; SQLite `CREATE TABLE t (name TEXT)` SQL-grid can Save via rowid if in scope. +- [ ] **Extension Table Browser (#869)** — no PK / failed schema: cells not editable; dirty page/Refresh confirms discard. +- [ ] **Mongo Refresh (#870)** — dirty JSON + breadcrumb Refresh: discard dialog. +- [ ] **Mongo list after Save (#871)** — full-document Save updates collection card previews without a manual Refresh. +- [ ] **Mongo dotted `$set` (#872)** — top-level key `a.b` Save does not create nested `a: { b }`. +- [ ] **Redis string dirty (#873)** — edit a string key, Refresh or keys crumb: discard dialog. +- [ ] **Postgres convert (#874)** — large SELECT first paint (no double hitch vs 0.4.16). +- [ ] **Grid sort+edit (#875)** — sorted result, edit a cell: no full re-sort hitch. +- [ ] **Mongo list first paint (#876)** — large collection opens the first 25 docs without waiting on exact count. +- [ ] **LIKE filter (#877)** — `LIKE '%x%'` on a 5k grid stays snappy. +- [ ] **Tree filter debounce (#878)** — typing in object filter / sidebar search does not rebuild every keystroke. +- [ ] **Horizontal grid scroll (#879)** — wide grid pan stays smooth at 120 Hz. +- [ ] **Table Browser edit (regression)** — Postgres/MySQL/SQLite table with a PK: double-click cell, Save via DML preview; schema error ≠ “no PK”. +- [ ] **Command Palette** — Ctrl/Cmd+P runs a command; Ctrl/Cmd+K jumps to a table. +- [ ] **Mongo field Save** — inspector `$set` still 0-match fails (#776); JSON editor is `replaceOne`. + +## Regression smoke (prior releases) - [ ] Fresh profile / empty state: create one connection per supported type (PostgreSQL, MySQL, Redis, MongoDB, SQLite). - [ ] Reopen the app: connections still appear; **connect** succeeds (secrets migrated or loaded from secure store). @@ -16,17 +36,6 @@ Manual 120 Hz DevTools QA: issue [#739](https://github.com/QueryaHub/Querya-Desk - [ ] **Extension table view** — open a sandboxed driver table (or fixture); toolbar filter + export present. - [ ] **Updater** — Check for Updates / badge sees Latest Release channel correctly after tag. - [ ] **Fluid shell** — tab strip sliding pill; dialog/dropdown fade-slide; Motion Off snaps (see also [perf-baseline.md](perf-baseline.md) Fluid §). -- [ ] **Table Browser edit** — Postgres/MySQL/SQLite table with a PK: double-click cell, Save via DML preview; view / no-PK stays read-only. -- [ ] **Command Palette** — Ctrl/Cmd+P runs a command; Ctrl/Cmd+K jumps to a table. -- [ ] **Mongo field Save** — expand a document card, tap a non-`_id` field, Save to DB, card reloads. - -## Regression smoke (prior releases) - -- [ ] Fresh profile / empty state: create one connection per supported type (PostgreSQL, MySQL, Redis, MongoDB). -- [ ] Reopen the app: connections still appear; **connect** succeeds (secrets migrated or loaded from secure store). -- [ ] Remove a connection: it disappears and reconnect is impossible without re-entering credentials. -- [ ] **Connection → New Database Connection** from the menu saves and shows in the tree. -- [ ] **Driver Manager** shows only built-in drivers (no misleading JDBC requirement). ## Custom themes (manual QA) @@ -60,23 +69,24 @@ Verify the 0.4.4 motion tokens, smooth animations, and high refresh rate support - [ ] **OS Reduced Motion** — enable reduced motion in OS settings. The app should automatically disable animations (acting as Off) regardless of in-app Full/Reduced settings (OS setting wins). - [ ] **Hz diagnostics** — start the app with `--dart-define=QUERYA_REFRESH_OVERLAY=true`. The overlay should display the monitor refresh rate (**Linux: query-only** — compositor decides Hz; see [motion-and-high-refresh.md](motion-and-high-refresh.md)). - [ ] **High refresh rate smoothness** — verify dialog fade-slide, dropdown show, and tree expand/collapse look smooth at high-Hz (90/120/144 Hz) without jank. +- [ ] Optional: full DevTools pass @ 120 Hz ([#739](https://github.com/QueryaHub/Querya-Desktop/issues/739)). ## Automated -- [x] `flutter analyze` — clean (on Linux, if the analyzer crashes with **Too many open files**, try `ulimit -n 8192`; see [CONTRIBUTING.md](../CONTRIBUTING.md)). -- [x] `flutter test` — all green. +- [ ] `flutter analyze` — clean (on Linux, if the analyzer crashes with **Too many open files**, try `ulimit -n 8192`; see [CONTRIBUTING.md](../CONTRIBUTING.md)). +- [ ] `flutter test` — all green. - [ ] CI **Flutter version** in `.github/workflows/*.yml` matches the toolchain you validated (bump intentionally when upgrading stable). ## Versioning and release -- [x] `pubspec.yaml` on the release branch is **`0.4.16+1`**. -- [ ] After merge to `main`, confirm **Auto Version Bump** yields a **0.4.17+…** placeholder (do not ship binaries as 0.4.17). -- [ ] **Tag** `0.4.16` is placed on the **main merge commit that includes `0.4.16+1`** (not the auto-bump commit). +- [x] `pubspec.yaml` on the 0.4.17 track is **`0.4.17+1`**. +- [ ] After merge to `main`, confirm **Auto Version Bump** yields a **0.4.18+…** placeholder (do not ship binaries as 0.4.18). +- [ ] **Tag** `0.4.17` is placed on the **main merge commit that includes `0.4.17+1`** (not the auto-bump commit). - [ ] Run the **Release** workflow via that tag (see [tags-and-releases.md](tags-and-releases.md)). - [ ] Verify **portable** zips (`*-linux.zip`, `*-windows.zip`, `*-macos.zip`), **installable** artifacts (`*.AppImage`, `*.deb`, `*.rpm`, `*.flatpak`, `*-windows-setup.exe`), and `SHA256SUMS.txt` on the GitHub Release. ## Docs -- [x] [CHANGELOG.md](../CHANGELOG.md) has a dated **`## [0.4.16]`** section for the release (CI copies it into the GitHub Release body). +- [ ] [CHANGELOG.md](../CHANGELOG.md) has a dated **`## [0.4.17]`** section for the release (CI copies it into the GitHub Release body). - [x] [security.md](security.md) still matches behavior if storage changed. -- [x] [roadmap.md](roadmap.md) marks 0.4.16 as this cut and 0.5.0 as next. +- [x] [roadmap.md](roadmap.md) marks 0.4.17 as this cut and 0.5.0 as next. diff --git a/docs/roadmap.md b/docs/roadmap.md index 399cdc97..ec73f12c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,8 +2,8 @@ Living document for planned work. Not a commitment order; adjust as priorities change. -**GitHub Latest Release:** [0.4.15](https://github.com/QueryaHub/Querya-Desktop/releases/tag/0.4.15) (2026-09-06). -**This cut:** **0.4.16** — Table Browser in-place editing, Command Palette, workspace/tree parity, Mongo field Save to DB — [CHANGELOG.md](../CHANGELOG.md) `[0.4.16]`. +**GitHub Latest Release:** [0.4.16](https://github.com/QueryaHub/Querya-Desktop/releases/tag/0.4.16) (2026-09-20). +**This cut:** **0.4.17** — hidden bugs pass 2 (SQL/Mongo/Redis/extension discard and Save lies) + algorithm/perf (Postgres convert, Mongo count, grid sort/scroll, LIKE, tree debounce) — [CHANGELOG.md](../CHANGELOG.md) `[Unreleased]`, milestone [0.4.17](https://github.com/QueryaHub/Querya-Desktop/milestone/8), tracking [#880](https://github.com/QueryaHub/Querya-Desktop/issues/880). **Next product release:** **0.5.0** — live Marketplace download and install — see below. ## Theme system @@ -29,7 +29,8 @@ Living document for planned work. Not a commitment order; adjust as priorities c - **0.4.11-a:** changelog prepared; GitHub tag mistargeted onto `0.4.11` — do not treat as a shipped patch. - **Shipped in 0.4.11-b:** security (#395–#402), Linux `.rpm` / Flatpak / AUR (#386), perf (#414), UI reliability (#445), code-review fixes (#463) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11-b]`. - **Shipped in 0.4.13–0.4.15:** interactive Data Grid, fluid motion, multi-tab SQL, preferences/keymap — [CHANGELOG.md](../CHANGELOG.md). -- **0.4.16 (this cut):** Table Browser in-place DML, Command Palette + Quick Switcher, workspace chrome / connections-tree parity, Mongo per-field Save to DB, updater hardening — [CHANGELOG.md](../CHANGELOG.md) `[0.4.16]`. Manual 120 Hz DevTools sign-off remains [#739](https://github.com/QueryaHub/Querya-Desktop/issues/739). +- **Shipped in 0.4.16:** Table Browser in-place DML, Command Palette + Quick Switcher, workspace chrome / connections-tree parity, Mongo per-field Save to DB, updater hardening — [CHANGELOG.md](../CHANGELOG.md) `[0.4.16]`. +- **0.4.17 (this cut):** hidden bugs pass 2 ([#867](https://github.com/QueryaHub/Querya-Desktop/issues/867)–[#873](https://github.com/QueryaHub/Querya-Desktop/issues/873)) and algorithm/perf ([#874](https://github.com/QueryaHub/Querya-Desktop/issues/874)–[#879](https://github.com/QueryaHub/Querya-Desktop/issues/879)) — tracking [#880](https://github.com/QueryaHub/Querya-Desktop/issues/880). Manual 120 Hz DevTools sign-off remains [#739](https://github.com/QueryaHub/Querya-Desktop/issues/739) (optional). - **Planned 0.5.0:** Marketplace Launch — live download, `sha256` validation, install themes (and later DB drivers) from the network. ## Query history and favorites diff --git a/lib/features/settings/preferences_about_section.dart b/lib/features/settings/preferences_about_section.dart index 14697530..fbbbefd2 100644 --- a/lib/features/settings/preferences_about_section.dart +++ b/lib/features/settings/preferences_about_section.dart @@ -10,7 +10,7 @@ import 'package:url_launcher/url_launcher_string.dart'; class PreferencesAboutSection extends StatelessWidget { const PreferencesAboutSection({super.key}); - static const String appVersion = '0.4.16'; + static const String appVersion = '0.4.17'; static const String githubRepoUrl = 'https://github.com/QueryaHub/Querya-Desktop'; diff --git a/pubspec.yaml b/pubspec.yaml index 72f489d7..060abbdd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: querya_desktop description: Lightweight desktop SQL/NoSQL client. Flutter (Dart). -version: 0.4.16+1 +version: 0.4.17+1 From cdba8388e8c44cb209297636c221bfb4b4bf5776 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 19:37:34 +0300 Subject: [PATCH 52/52] chore(release): freeze CHANGELOG for Querya Desktop 0.4.17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Unreleased Fixed into dated ## [0.4.17]; keep pass 2 / perf (#867–#879) as Planned for the next patch. --- CHANGELOG.md | 8 +++++--- docs/release-checklist.md | 25 ++++++++++--------------- docs/roadmap.md | 7 ++++--- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cb3035f..1709654d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -Target cut: **0.4.17** ([milestone](https://github.com/QueryaHub/Querya-Desktop/milestone/8), tracking [#880](https://github.com/QueryaHub/Querya-Desktop/issues/880)). - -### Planned (not yet on `dev`) +### Planned - Hidden bugs pass 2 — data-loss / fail-open: [#867](https://github.com/QueryaHub/Querya-Desktop/issues/867)–[#873](https://github.com/QueryaHub/Querya-Desktop/issues/873) - Algorithm / first paint / 120 Hz: [#874](https://github.com/QueryaHub/Querya-Desktop/issues/874)–[#879](https://github.com/QueryaHub/Querya-Desktop/issues/879) +## [0.4.17] - 2026-09-20 + +Driver and grid correctness after 0.4.16: Table Browser schema vs missing PK, SQLite implicit `rowid`, Mongo write/filter/discard, and Postgres / MySQL / SQLite / Redis session, type, and Save fixes. + ### Fixed - **Table Browser schema load vs missing PK (#772)** — A failed `getTableSchema` (permissions, disconnect) is no longer shown as “Cannot edit: no primary key detected”. Status is “schema unavailable” plus the real error and Refresh retries schema. Editing stays off until schema loads. Genuine missing PKs still use the old copy. SQLite implicit `rowid` is applied only after a successful schema load. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index a8718003..57d488da 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -7,20 +7,15 @@ Manual 120 Hz DevTools QA: issue [#739](https://github.com/QueryaHub/Querya-Desk ## Product smoke (manual) — 0.4.17 -- [ ] **SQL Execute discard (#867)** — edit a result-grid cell, Execute again: discard dialog; Cancel keeps pending edits. -- [ ] **SQL-grid Save parity (#868)** — schema load failure is not a silent healthy row-count; SQLite `CREATE TABLE t (name TEXT)` SQL-grid can Save via rowid if in scope. -- [ ] **Extension Table Browser (#869)** — no PK / failed schema: cells not editable; dirty page/Refresh confirms discard. -- [ ] **Mongo Refresh (#870)** — dirty JSON + breadcrumb Refresh: discard dialog. -- [ ] **Mongo list after Save (#871)** — full-document Save updates collection card previews without a manual Refresh. -- [ ] **Mongo dotted `$set` (#872)** — top-level key `a.b` Save does not create nested `a: { b }`. -- [ ] **Redis string dirty (#873)** — edit a string key, Refresh or keys crumb: discard dialog. -- [ ] **Postgres convert (#874)** — large SELECT first paint (no double hitch vs 0.4.16). -- [ ] **Grid sort+edit (#875)** — sorted result, edit a cell: no full re-sort hitch. -- [ ] **Mongo list first paint (#876)** — large collection opens the first 25 docs without waiting on exact count. -- [ ] **LIKE filter (#877)** — `LIKE '%x%'` on a 5k grid stays snappy. -- [ ] **Tree filter debounce (#878)** — typing in object filter / sidebar search does not rebuild every keystroke. -- [ ] **Horizontal grid scroll (#879)** — wide grid pan stays smooth at 120 Hz. -- [ ] **Table Browser edit (regression)** — Postgres/MySQL/SQLite table with a PK: double-click cell, Save via DML preview; schema error ≠ “no PK”. +- [ ] **Table Browser schema vs PK (#772)** — failed `getTableSchema` shows “schema unavailable”, not “no primary key”; Refresh retries schema. +- [ ] **SQLite implicit rowid (#774)** — `CREATE TABLE t (name TEXT)` Table Browser can Save via `rowid`; `WITHOUT ROWID` stays on declared PK. +- [ ] **Mongo 0-match (#776)** — inspector/JSON Save with a wrong `_id` is Save Failed, not a success toast. +- [ ] **Mongo full-document Save (#778)** — JSON editor Save is `replaceOne`; deleting a nested key in JSON removes it on the server. +- [ ] **Mongo dirty editor Back (#782)** — dirty JSON + breadcrumb Back: discard dialog; Cancel keeps the editor. +- [ ] **Mongo JSON filter (#783)** — `{ "_id": { "$oid": "…" } }` and 24-char hex `_id` match ObjectId documents. +- [ ] **SQL-grid Save** — simple single-table `SELECT` with a PK can Save; JOIN / no-PK stays read-only (Postgres #786, SQLite #795, MySQL #804). +- [ ] **0-row DML (#773)** — Save that matches 0 rows fails and keeps the staging buffer. +- [ ] **Table Browser edit (regression)** — Postgres/MySQL/SQLite table with a PK: double-click cell, Save via DML preview. - [ ] **Command Palette** — Ctrl/Cmd+P runs a command; Ctrl/Cmd+K jumps to a table. - [ ] **Mongo field Save** — inspector `$set` still 0-match fails (#776); JSON editor is `replaceOne`. @@ -87,6 +82,6 @@ Verify the 0.4.4 motion tokens, smooth animations, and high refresh rate support ## Docs -- [ ] [CHANGELOG.md](../CHANGELOG.md) has a dated **`## [0.4.17]`** section for the release (CI copies it into the GitHub Release body). +- [x] [CHANGELOG.md](../CHANGELOG.md) has a dated **`## [0.4.17]`** section for the release (CI copies it into the GitHub Release body). - [x] [security.md](security.md) still matches behavior if storage changed. - [x] [roadmap.md](roadmap.md) marks 0.4.17 as this cut and 0.5.0 as next. diff --git a/docs/roadmap.md b/docs/roadmap.md index ec73f12c..57fd533f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3,8 +3,8 @@ Living document for planned work. Not a commitment order; adjust as priorities change. **GitHub Latest Release:** [0.4.16](https://github.com/QueryaHub/Querya-Desktop/releases/tag/0.4.16) (2026-09-20). -**This cut:** **0.4.17** — hidden bugs pass 2 (SQL/Mongo/Redis/extension discard and Save lies) + algorithm/perf (Postgres convert, Mongo count, grid sort/scroll, LIKE, tree debounce) — [CHANGELOG.md](../CHANGELOG.md) `[Unreleased]`, milestone [0.4.17](https://github.com/QueryaHub/Querya-Desktop/milestone/8), tracking [#880](https://github.com/QueryaHub/Querya-Desktop/issues/880). -**Next product release:** **0.5.0** — live Marketplace download and install — see below. +**This cut:** **0.4.17** — driver/grid correctness after 0.4.16 (schema vs PK, SQLite `rowid`, Mongo writes, SQL sessions/types) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.17]`, tracking [#880](https://github.com/QueryaHub/Querya-Desktop/issues/880). +**Next product release:** **0.5.0** — live Marketplace download and install — see below. Hidden bugs pass 2 (#867–#873) and algorithm/perf (#874–#879) slip to the next patch. ## Theme system @@ -30,7 +30,8 @@ Living document for planned work. Not a commitment order; adjust as priorities c - **Shipped in 0.4.11-b:** security (#395–#402), Linux `.rpm` / Flatpak / AUR (#386), perf (#414), UI reliability (#445), code-review fixes (#463) — [CHANGELOG.md](../CHANGELOG.md) `[0.4.11-b]`. - **Shipped in 0.4.13–0.4.15:** interactive Data Grid, fluid motion, multi-tab SQL, preferences/keymap — [CHANGELOG.md](../CHANGELOG.md). - **Shipped in 0.4.16:** Table Browser in-place DML, Command Palette + Quick Switcher, workspace chrome / connections-tree parity, Mongo per-field Save to DB, updater hardening — [CHANGELOG.md](../CHANGELOG.md) `[0.4.16]`. -- **0.4.17 (this cut):** hidden bugs pass 2 ([#867](https://github.com/QueryaHub/Querya-Desktop/issues/867)–[#873](https://github.com/QueryaHub/Querya-Desktop/issues/873)) and algorithm/perf ([#874](https://github.com/QueryaHub/Querya-Desktop/issues/874)–[#879](https://github.com/QueryaHub/Querya-Desktop/issues/879)) — tracking [#880](https://github.com/QueryaHub/Querya-Desktop/issues/880). Manual 120 Hz DevTools sign-off remains [#739](https://github.com/QueryaHub/Querya-Desktop/issues/739) (optional). +- **0.4.17 (this cut):** driver/grid correctness after 0.4.16 — schema vs PK (#772), SQLite `rowid` (#774), Mongo writes/filter/discard (#776/#778/#782/#783), Postgres/MySQL/SQLite/Redis session and type round-trips — [CHANGELOG.md](../CHANGELOG.md) `[0.4.17]`, tracking [#880](https://github.com/QueryaHub/Querya-Desktop/issues/880). +- **Next patch:** hidden bugs pass 2 ([#867](https://github.com/QueryaHub/Querya-Desktop/issues/867)–[#873](https://github.com/QueryaHub/Querya-Desktop/issues/873)) and algorithm/perf ([#874](https://github.com/QueryaHub/Querya-Desktop/issues/874)–[#879](https://github.com/QueryaHub/Querya-Desktop/issues/879)). Manual 120 Hz DevTools sign-off remains [#739](https://github.com/QueryaHub/Querya-Desktop/issues/739) (optional). - **Planned 0.5.0:** Marketplace Launch — live download, `sha256` validation, install themes (and later DB drivers) from the network. ## Query history and favorites