diff --git a/docs/tz-block-d-external-plugin.md b/docs/tz-block-d-external-plugin.md index 0412ba5c..4dc09195 100644 --- a/docs/tz-block-d-external-plugin.md +++ b/docs/tz-block-d-external-plugin.md @@ -53,3 +53,86 @@ - **Метод `extension.getTreeSchema`**: Возвращает первичную структуру бокового меню (например, корневые папки "Databases" и "Users"). Разделение логики: Ядро занимается пикселями и дизайном, Плагин — логикой и структурами данных. + +--- + +## 5. Стандарт мутаций данных (Querya Extension Mutation Standard) + +Если плагин поддерживает интерактивное редактирование данных в 2D-таблицах (`ExtensionDriverCapabilities.supportsMutations: true`), он реализует следующие JSON-RPC методы: + +### 5.1. `db.getTableSchema` +Возвращает метаданные колонок, признак первичного ключа и возможность `NULL`. +* **Запрос:** + ```json + { + "jsonrpc": "2.0", + "method": "db.getTableSchema", + "params": { + "connectionId": 123, + "database": "analytics", + "schema": "public", + "tableName": "users" + }, + "id": 4 + } + ``` +* **Ответ:** + ```json + { + "jsonrpc": "2.0", + "result": { + "tableName": "users", + "schema": "public", + "primaryKeys": ["id"], + "columns": [ + { "name": "id", "dataType": "integer", "isPrimaryKey": true, "isNullable": false }, + { "name": "email", "dataType": "varchar", "isPrimaryKey": false, "isNullable": true }, + { "name": "age", "dataType": "integer", "isPrimaryKey": false, "isNullable": true } + ] + }, + "id": 4 + } + ``` + +### 5.2. `db.mutate` +Выполняет атомарный пакет мутаций (вставка, обновление, удаление строк). +* **Запрос:** + ```json + { + "jsonrpc": "2.0", + "method": "db.mutate", + "params": { + "connectionId": 123, + "database": "analytics", + "tableName": "users", + "mutations": [ + { + "type": "update", + "where": { "id": "42" }, + "set": { "email": "new_email@domain.com" } + }, + { + "type": "insert", + "values": { "id": "43", "email": "bob@domain.com", "age": "30" } + }, + { + "type": "delete", + "where": { "id": "10" } + } + ] + }, + "id": 5 + } + ``` +* **Ответ:** + ```json + { + "jsonrpc": "2.0", + "result": { + "success": true, + "affectedRows": 3 + }, + "id": 5 + } + ``` + diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart index a082edde..47b0c3f0 100644 --- a/lib/core/extensions/extension_driver_session.dart +++ b/lib/core/extensions/extension_driver_session.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/database/table_schema_meta.dart'; import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; @@ -378,6 +379,53 @@ class ExtensionDriverSession { } } + /// Queries table schema metadata (column types, nullability, PKs) via `db.getTableSchema`. + Future getTableSchema( + ConnectionRow row, { + required String database, + String? schema, + required String tableName, + }) async { + final bridge = await ensureConnected(row); + try { + final result = await bridge.sendRequest('db.getTableSchema', { + 'connectionId': row.id, + 'database': database, + if (schema != null && schema.isNotEmpty) 'schema': schema, + 'tableName': tableName, + }); + if (result is Map) { + return TableSchemaMeta.fromJson(Map.from(result)); + } + return TableSchemaMeta(tableName: tableName, schema: schema); + } catch (e) { + debugPrint('ExtensionDriverSession getTableSchema fallback ($e)'); + return TableSchemaMeta(tableName: tableName, schema: schema); + } + } + + /// Executes batch data mutations (insert, update, delete) via `db.mutate`. + Future> mutate( + ConnectionRow row, { + required String database, + String? schema, + required String tableName, + required List> mutations, + }) async { + final bridge = await ensureConnected(row); + final result = await bridge.sendRequest('db.mutate', { + 'connectionId': row.id, + 'database': database, + if (schema != null && schema.isNotEmpty) 'schema': schema, + 'tableName': tableName, + 'mutations': mutations, + }); + if (result is Map) { + return Map.from(result); + } + return {'success': true, 'affectedRows': mutations.length}; + } + Future disconnect(int connectionId) async { final bridge = _bridges.remove(connectionId); _manifests.remove(connectionId); diff --git a/lib/core/extensions/models/extension_driver_capabilities.dart b/lib/core/extensions/models/extension_driver_capabilities.dart index 088215fc..4c128ccb 100644 --- a/lib/core/extensions/models/extension_driver_capabilities.dart +++ b/lib/core/extensions/models/extension_driver_capabilities.dart @@ -6,6 +6,8 @@ class ExtensionDriverCapabilities { this.supportsDDLInspection = false, this.supportsPrivileges = false, this.hasServerStats = false, + this.supportsMutations = false, + this.supportsBatchMutations = false, }); /// True if `db.query` supports transaction control queries (BEGIN, COMMIT, ROLLBACK). @@ -23,6 +25,12 @@ class ExtensionDriverCapabilities { /// True if the driver supports `db.getServerStats`. final bool hasServerStats; + /// True if the driver supports `db.getTableSchema` and `db.mutate`. + final bool supportsMutations; + + /// True if the driver supports batch multi-row mutations in `db.mutate`. + final bool supportsBatchMutations; + factory ExtensionDriverCapabilities.fromRpc(Object? raw) { if (raw is! Map) return const ExtensionDriverCapabilities(); final map = raw is Map @@ -42,6 +50,11 @@ class ExtensionDriverCapabilities { map['supports_privileges'] == true, hasServerStats: map['hasServerStats'] == true || map['has_server_stats'] == true, + supportsMutations: + map['supportsMutations'] == true || map['supports_mutations'] == true, + supportsBatchMutations: + map['supportsBatchMutations'] == true || + map['supports_batch_mutations'] == true, ); } @@ -51,6 +64,8 @@ class ExtensionDriverCapabilities { 'supportsDDLInspection': supportsDDLInspection, 'supportsPrivileges': supportsPrivileges, 'hasServerStats': hasServerStats, + 'supportsMutations': supportsMutations, + 'supportsBatchMutations': supportsBatchMutations, }; @override @@ -62,7 +77,9 @@ class ExtensionDriverCapabilities { supportsCancel == other.supportsCancel && supportsDDLInspection == other.supportsDDLInspection && supportsPrivileges == other.supportsPrivileges && - hasServerStats == other.hasServerStats; + hasServerStats == other.hasServerStats && + supportsMutations == other.supportsMutations && + supportsBatchMutations == other.supportsBatchMutations; @override int get hashCode => @@ -72,5 +89,7 @@ class ExtensionDriverCapabilities { supportsDDLInspection, supportsPrivileges, hasServerStats, + supportsMutations, + supportsBatchMutations, ); } diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart index 38ca00e7..bb8429a3 100644 --- a/lib/features/extensions/extension_table_view.dart +++ b/lib/features/extensions/extension_table_view.dart @@ -1,9 +1,12 @@ import 'dart:async' show unawaited; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/extensions/models/extension_driver_capabilities.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/extensions/extension_table_toolbar.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/main_screen/results_tab.dart'; import 'package:querya_desktop/shared/services/data_export_service.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -44,6 +47,10 @@ class _ExtensionTableViewState extends material.State { bool _filterActive = false; final _filterController = material.TextEditingController(); + ExtensionDriverCapabilities? _capabilities; + DataGridStagingBuffer? _stagingBuffer; + bool _isSaving = false; + String get _qualifiedName => '`${widget.database}`.`${widget.tableName}`'; String get _whereClause { @@ -67,6 +74,7 @@ class _ExtensionTableViewState extends material.State { _totalRows = null; _filterController.clear(); _filterActive = false; + _stagingBuffer = null; unawaited(_loadPage(refreshCount: true)); } } @@ -123,9 +131,7 @@ class _ExtensionTableViewState extends material.State { _updateStatusLine(); }); } - } catch (_) { - // Ignore count errors on stream or schema tables that do not support count queries - } + } catch (_) {} } } @@ -139,6 +145,9 @@ class _ExtensionTableViewState extends material.State { }); try { + _capabilities ??= await ExtensionDriverSession.instance + .getCapabilities(widget.connectionRow); + final dataResult = await ExtensionDriverSession.instance.query( widget.connectionRow, 'SELECT * FROM $_qualifiedName$_whereClause LIMIT ${widget.pageSize} OFFSET $_offset', @@ -149,6 +158,12 @@ class _ExtensionTableViewState extends material.State { _columns = dataResult.columns; _rows = dataResult.rows; _loading = false; + if (!widget.isView && (_capabilities?.supportsMutations == true)) { + _stagingBuffer = + DataGridStagingBuffer(columns: _columns, rows: _rows); + } else { + _stagingBuffer = null; + } _updateStatusLine(); }); @@ -163,6 +178,122 @@ class _ExtensionTableViewState extends material.State { } } + Future _onApplyChanges() async { + final buffer = _stagingBuffer; + if (buffer == null || !buffer.isDirty) return; + + setState(() => _isSaving = true); + try { + final schema = await ExtensionDriverSession.instance.getTableSchema( + widget.connectionRow, + database: widget.database, + tableName: widget.tableName, + ); + + final mutations = >[]; + + // 1. Updates + for (final entry in buffer.modifiedCells.entries) { + final rowIndex = entry.key; + final colMap = entry.value; + final origRow = buffer.originalRows[rowIndex]; + + final whereMap = {}; + if (schema.primaryKeys.isNotEmpty) { + for (final pk in schema.primaryKeys) { + final idx = _columns.indexOf(pk); + if (idx != -1 && idx < origRow.length) { + whereMap[pk] = origRow[idx]; + } + } + } else { + for (var c = 0; c < _columns.length; c++) { + whereMap[_columns[c]] = c < origRow.length ? origRow[c] : null; + } + } + + final setMap = {}; + for (final colEntry in colMap.entries) { + final colName = _columns[colEntry.key]; + final val = colEntry.value; + setMap[colName] = + val == TableMutationEngine.kNullSentinel ? null : val; + } + + mutations.add({ + 'type': 'update', + 'where': whereMap, + 'set': setMap, + }); + } + + // 2. Inserts + for (final row in buffer.insertedRows) { + final valuesMap = {}; + for (var c = 0; c < _columns.length; c++) { + final val = c < row.length ? row[c] : null; + valuesMap[_columns[c]] = (val == null || + val == TableMutationEngine.kNullSentinel || + val == 'NULL') + ? null + : val; + } + mutations.add({ + 'type': 'insert', + 'values': valuesMap, + }); + } + + // 3. Deletes + for (final rowIndex in buffer.deletedRowIndices) { + final origRow = buffer.originalRows[rowIndex]; + final whereMap = {}; + if (schema.primaryKeys.isNotEmpty) { + for (final pk in schema.primaryKeys) { + final idx = _columns.indexOf(pk); + if (idx != -1 && idx < origRow.length) { + whereMap[pk] = origRow[idx]; + } + } + } else { + for (var c = 0; c < _columns.length; c++) { + whereMap[_columns[c]] = c < origRow.length ? origRow[c] : null; + } + } + mutations.add({ + 'type': 'delete', + 'where': whereMap, + }); + } + + if (mutations.isNotEmpty) { + final res = await ExtensionDriverSession.instance.mutate( + widget.connectionRow, + database: widget.database, + tableName: widget.tableName, + mutations: mutations, + ); + if (!mounted) return; + final count = res['affectedRows'] ?? mutations.length; + showAppToast( + context: context, + message: 'Successfully applied $count mutation(s).', + variant: AppToastVariant.success, + ); + unawaited(_loadPage(refreshCount: true)); + } + } catch (e) { + if (!mounted) return; + showAppToast( + context: context, + message: 'Failed to apply mutations: $e', + variant: AppToastVariant.error, + ); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + void _applyFilter() { _offset = 0; _totalRows = null; @@ -359,6 +490,9 @@ class _ExtensionTableViewState extends material.State { isLoading: _loading, statusLine: _statusLine, showExportToolbar: false, + stagingBuffer: _stagingBuffer, + onApplyChanges: _stagingBuffer != null ? _onApplyChanges : null, + isSaving: _isSaving, ), ), ], diff --git a/test/core/extensions/extension_driver_session_test.dart b/test/core/extensions/extension_driver_session_test.dart index de74d719..18301551 100644 --- a/test/core/extensions/extension_driver_session_test.dart +++ b/test/core/extensions/extension_driver_session_test.dart @@ -61,6 +61,8 @@ void main() { 'supportsDDLInspection': true, 'supportsPrivileges': false, 'hasServerStats': true, + 'supportsMutations': true, + 'supportsBatchMutations': true, }); expect(caps.supportsTransactions, isTrue); @@ -68,6 +70,12 @@ void main() { expect(caps.supportsDDLInspection, isTrue); expect(caps.supportsPrivileges, isFalse); expect(caps.hasServerStats, isTrue); + expect(caps.supportsMutations, isTrue); + expect(caps.supportsBatchMutations, isTrue); + + final json = caps.toJson(); + expect(json['supportsMutations'], isTrue); + expect(json['supportsBatchMutations'], isTrue); }); test('ExtensionServerStats.fromRpc normalizes metrics map', () { diff --git a/test/features/extensions/extension_table_view_test.dart b/test/features/extensions/extension_table_view_test.dart new file mode 100644 index 00000000..d2fce669 --- /dev/null +++ b/test/features/extensions/extension_table_view_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/extensions/models/extension_driver_capabilities.dart'; +import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart'; + +void main() { + group('ExtensionDriver Mutation Standard & Staging', () { + test('ExtensionDriverCapabilities default vs mutation flags', () { + const defaultCaps = ExtensionDriverCapabilities(); + expect(defaultCaps.supportsMutations, isFalse); + expect(defaultCaps.supportsBatchMutations, isFalse); + + final capsWithMutations = ExtensionDriverCapabilities.fromRpc({ + 'supports_mutations': true, + 'supports_batch_mutations': true, + }); + expect(capsWithMutations.supportsMutations, isTrue); + expect(capsWithMutations.supportsBatchMutations, isTrue); + }); + + test('DataGridStagingBuffer generates valid mutation payload for ExtensionDriver mutate standard', () { + final buffer = DataGridStagingBuffer( + columns: ['id', 'email', 'status'], + rows: [ + ['1', 'alice@test.com', 'active'], + ['2', 'bob@test.com', 'pending'], + ], + ); + + // 1. Stage update on row 0, col 1 (email) + buffer.setCell(0, 1, 'alice_new@test.com'); + + // 2. Stage delete on row 1 + buffer.toggleDeleteRow(1); + + // 3. Stage insert + buffer.addRow(['3', 'carol@test.com', 'active']); + + expect(buffer.isDirty, isTrue); + expect(buffer.changeCount, 3); + + final mutations = >[]; + + // Replicate ExtensionTableView mutation mapping logic + final columns = buffer.columns; + + for (final entry in buffer.modifiedCells.entries) { + final rowIndex = entry.key; + final colMap = entry.value; + final origRow = buffer.originalRows[rowIndex]; + + final whereMap = { + columns[0]: origRow[0], + }; + + final setMap = {}; + for (final colEntry in colMap.entries) { + final colName = columns[colEntry.key]; + final val = colEntry.value; + setMap[colName] = val == TableMutationEngine.kNullSentinel ? null : val; + } + + mutations.add({ + 'type': 'update', + 'where': whereMap, + 'set': setMap, + }); + } + + for (final row in buffer.insertedRows) { + final valuesMap = {}; + for (var c = 0; c < columns.length; c++) { + final val = c < row.length ? row[c] : null; + valuesMap[columns[c]] = + (val == null || val == TableMutationEngine.kNullSentinel || val == 'NULL') + ? null + : val; + } + mutations.add({ + 'type': 'insert', + 'values': valuesMap, + }); + } + + for (final rowIndex in buffer.deletedRowIndices) { + final origRow = buffer.originalRows[rowIndex]; + final whereMap = { + columns[0]: origRow[0], + }; + mutations.add({ + 'type': 'delete', + 'where': whereMap, + }); + } + + expect(mutations.length, 3); + expect(mutations[0]['type'], 'update'); + expect(mutations[0]['where'], {'id': '1'}); + expect(mutations[0]['set'], {'email': 'alice_new@test.com'}); + + expect(mutations[1]['type'], 'insert'); + expect(mutations[1]['values'], {'id': '3', 'email': 'carol@test.com', 'status': 'active'}); + + expect(mutations[2]['type'], 'delete'); + expect(mutations[2]['where'], {'id': '2'}); + }); + }); +}