Skip to content

Commit c254f41

Browse files
feat(workspaces): wire staging buffer and save handler in SQLite, Postgres, and MySQL workspaces (#565)
Closes #565
1 parent 7cb80fc commit c254f41

5 files changed

Lines changed: 290 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/// Result of extracting target table and schema from an SQL query.
2+
class SqlTableTarget {
3+
const SqlTableTarget({
4+
required this.tableName,
5+
this.schema,
6+
});
7+
8+
final String tableName;
9+
final String? schema;
10+
11+
@override
12+
bool operator ==(Object other) =>
13+
identical(this, other) ||
14+
other is SqlTableTarget &&
15+
tableName == other.tableName &&
16+
schema == other.schema;
17+
18+
@override
19+
int get hashCode => Object.hash(tableName, schema);
20+
}
21+
22+
/// Helper utility to infer the primary target table from a simple SELECT query.
23+
abstract final class SqlTableTargetExtractor {
24+
static final _fromTableRegex = RegExp(
25+
r'\bfrom\s+(?:(?:"([^"]+)"|`([^`]+)`|([a-zA-Z_]\w*))\.)?(?:"([^"]+)"|`([^`]+)`|([a-zA-Z_]\w*))',
26+
caseSensitive: false,
27+
);
28+
29+
/// Extracts the target schema and table name from [sql].
30+
/// Returns `null` if no simple target table can be determined (e.g. subqueries, joins).
31+
static SqlTableTarget? extract(String sql) {
32+
final trimmed = sql.trim();
33+
if (trimmed.isEmpty) return null;
34+
35+
// If query has JOIN or multiple tables separated by comma, avoid auto-generating DML
36+
final hasJoin = RegExp(r'\bjoin\b', caseSensitive: false).hasMatch(trimmed);
37+
if (hasJoin) return null;
38+
39+
final match = _fromTableRegex.firstMatch(trimmed);
40+
if (match == null) return null;
41+
42+
final schema = match.group(1) ?? match.group(2) ?? match.group(3);
43+
final table = match.group(4) ?? match.group(5) ?? match.group(6);
44+
45+
if (table == null || table.isEmpty) return null;
46+
47+
return SqlTableTarget(
48+
tableName: table,
49+
schema: schema,
50+
);
51+
}
52+
}

‎lib/features/mysql/mysql_sql_workspace.dart‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import 'package:querya_desktop/core/actions/sql_editor_actions.dart';
88
import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart';
99
import 'package:querya_desktop/core/database/mysql_service.dart';
1010
import 'package:querya_desktop/core/database/result_row_string_convert.dart';
11+
import 'package:querya_desktop/core/database/sql_table_target_extractor.dart';
12+
import 'package:querya_desktop/core/database/table_mutation_engine.dart';
1113
import 'package:querya_desktop/core/layout/vertical_split_pane.dart';
1214
import 'package:querya_desktop/core/storage/app_settings.dart';
1315
import 'package:querya_desktop/core/storage/local_db.dart';
1416
import 'package:querya_desktop/features/settings/preferences_dialog.dart';
1517
import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart';
18+
import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart';
1619
import 'package:querya_desktop/features/main_screen/query_editor_tab.dart';
1720
import 'package:querya_desktop/features/main_screen/results_tab.dart';
1821
import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart';
@@ -46,6 +49,9 @@ class _MysqlSqlWorkspaceState extends material.State<MysqlSqlWorkspace> {
4649
List<List<String>> _rows = [];
4750
int? _affectedRows;
4851
String? _statusLine;
52+
DataGridStagingBuffer? _stagingBuffer;
53+
String? _lastExecutedSql;
54+
bool _savingChanges = false;
4955

5056
int? _queryTimeoutSeconds;
5157

@@ -224,6 +230,10 @@ class _MysqlSqlWorkspaceState extends material.State<MysqlSqlWorkspace> {
224230
_columns = cols;
225231
_rows = outRows;
226232
_affectedRows = affected;
233+
_lastExecutedSql = userSql;
234+
_stagingBuffer = cols.isNotEmpty
235+
? DataGridStagingBuffer(columns: cols, rows: outRows)
236+
: null;
227237
if (cols.isEmpty && outRows.isEmpty) {
228238
_statusLine = affected != null
229239
? 'OK. Rows affected: $affected.'
@@ -264,6 +274,60 @@ class _MysqlSqlWorkspaceState extends material.State<MysqlSqlWorkspace> {
264274
}
265275
}
266276

277+
Future<void> _applyStagedChanges() async {
278+
if (_stagingBuffer == null || !_stagingBuffer!.isDirty || _savingChanges) return;
279+
final target = _lastExecutedSql != null ? SqlTableTargetExtractor.extract(_lastExecutedSql!) : null;
280+
final tableName = target?.tableName ?? 'table';
281+
final schemaName = target?.schema;
282+
283+
setState(() => _savingChanges = true);
284+
try {
285+
final plan = _stagingBuffer!.generateMutationPlan(
286+
dialect: SqlDialect.mysql,
287+
tableName: tableName,
288+
schema: schemaName,
289+
);
290+
if (plan.isEmpty) {
291+
setState(() => _savingChanges = false);
292+
return;
293+
}
294+
295+
await _ensureLease();
296+
final conn = _lease?.connection;
297+
if (conn == null || !conn.isConnected) {
298+
throw StateError('Could not connect to MySQL.');
299+
}
300+
301+
for (final stmt in plan.statements) {
302+
await conn.execute(stmt.sql);
303+
}
304+
305+
if (!mounted) return;
306+
setState(() {
307+
_rows = _stagingBuffer!.effectiveRows;
308+
_stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows);
309+
_savingChanges = false;
310+
});
311+
} catch (e) {
312+
if (mounted) {
313+
setState(() => _savingChanges = false);
314+
await showAppDialog<void>(
315+
context: context,
316+
builder: (ctx) => material.AlertDialog(
317+
title: const material.Text('Save Changes Failed'),
318+
content: material.Text(e.toString()),
319+
actions: [
320+
material.TextButton(
321+
onPressed: () => material.Navigator.of(ctx).pop(),
322+
child: const material.Text('OK'),
323+
),
324+
],
325+
),
326+
);
327+
}
328+
}
329+
}
330+
267331
static int? _affectedInt(BigInt v) {
268332
if (v == BigInt.zero) return null;
269333
return v.toInt();
@@ -453,6 +517,9 @@ class _MysqlSqlWorkspaceState extends material.State<MysqlSqlWorkspace> {
453517
isLoading: _running,
454518
affectedRows: _affectedRows,
455519
statusLine: _statusLine,
520+
stagingBuffer: _stagingBuffer,
521+
onApplyChanges: widget.isReadOnly ? null : _applyStagedChanges,
522+
isSaving: _savingChanges,
456523
),
457524
),
458525
],

‎lib/features/postgresql/postgres_sql_workspace.dart‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@ import 'package:postgres/postgres.dart' as pg;
1010
import 'package:querya_desktop/core/database/postgres_service.dart';
1111
import 'package:querya_desktop/core/database/postgres_sql.dart';
1212
import 'package:querya_desktop/core/database/result_row_string_convert.dart';
13+
import 'package:querya_desktop/core/database/sql_table_target_extractor.dart';
14+
import 'package:querya_desktop/core/database/table_mutation_engine.dart';
1315
import 'package:querya_desktop/core/layout/vertical_split_pane.dart';
1416
import 'package:querya_desktop/core/storage/app_settings.dart';
1517
import 'package:querya_desktop/core/storage/local_db.dart';
1618
import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart';
1719
import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart';
1820
import 'package:querya_desktop/features/settings/preferences_dialog.dart';
1921
import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart';
22+
import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart';
2023
import 'package:querya_desktop/features/main_screen/query_editor_tab.dart';
2124
import 'package:querya_desktop/features/main_screen/results_tab.dart';
2225
import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart';
@@ -80,6 +83,9 @@ class _PostgresSqlWorkspaceState extends material.State<PostgresSqlWorkspace> {
8083
List<List<String>> _rows = [];
8184
int? _affectedRows;
8285
String? _statusLine;
86+
DataGridStagingBuffer? _stagingBuffer;
87+
String? _lastExecutedSql;
88+
bool _savingChanges = false;
8389

8490
/// PostgreSQL default: each statement is its own transaction unless you use
8591
/// `BEGIN` / `BEGIN`+implicit when autocommit is off.
@@ -367,6 +373,10 @@ class _PostgresSqlWorkspaceState extends material.State<PostgresSqlWorkspace> {
367373
_columns = cols;
368374
_rows = outRows;
369375
_affectedRows = result.affectedRows;
376+
_lastExecutedSql = userSql;
377+
_stagingBuffer = cols.isNotEmpty
378+
? DataGridStagingBuffer(columns: cols, rows: outRows)
379+
: null;
370380
if (cols.isEmpty && outRows.isEmpty) {
371381
_statusLine =
372382
'Command completed. Rows affected: ${result.affectedRows}.';
@@ -416,6 +426,61 @@ class _PostgresSqlWorkspaceState extends material.State<PostgresSqlWorkspace> {
416426
}
417427
}
418428

429+
Future<void> _applyStagedChanges() async {
430+
if (_stagingBuffer == null || !_stagingBuffer!.isDirty || _savingChanges) return;
431+
final target = _lastExecutedSql != null ? SqlTableTargetExtractor.extract(_lastExecutedSql!) : null;
432+
final tableName = target?.tableName ?? 'table';
433+
final schemaName = target?.schema;
434+
435+
setState(() => _savingChanges = true);
436+
try {
437+
final plan = _stagingBuffer!.generateMutationPlan(
438+
dialect: SqlDialect.postgres,
439+
tableName: tableName,
440+
schema: schemaName,
441+
);
442+
if (plan.isEmpty) {
443+
setState(() => _savingChanges = false);
444+
return;
445+
}
446+
447+
await _ensureLease();
448+
final conn = _lease?.connection;
449+
if (conn == null || !conn.isConnected) {
450+
throw StateError('Could not connect to PostgreSQL.');
451+
}
452+
453+
final txSql = plan.toTransactionSql();
454+
final to = _statementTimeout();
455+
await conn.execute(txSql, timeout: to);
456+
457+
if (!mounted) return;
458+
setState(() {
459+
_rows = _stagingBuffer!.effectiveRows;
460+
_stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows);
461+
_savingChanges = false;
462+
});
463+
await _refreshTxStatus();
464+
} catch (e) {
465+
if (mounted) {
466+
setState(() => _savingChanges = false);
467+
await showAppDialog<void>(
468+
context: context,
469+
builder: (ctx) => material.AlertDialog(
470+
title: const material.Text('Save Changes Failed'),
471+
content: material.Text(e.toString()),
472+
actions: [
473+
material.TextButton(
474+
onPressed: () => material.Navigator.of(ctx).pop(),
475+
child: const material.Text('OK'),
476+
),
477+
],
478+
),
479+
);
480+
}
481+
}
482+
}
483+
419484
Future<void> _openSqlFile() async {
420485
try {
421486
final file = await openFile(
@@ -597,6 +662,9 @@ class _PostgresSqlWorkspaceState extends material.State<PostgresSqlWorkspace> {
597662
isLoading: _running,
598663
affectedRows: _affectedRows,
599664
statusLine: _statusLine,
665+
stagingBuffer: _stagingBuffer,
666+
onApplyChanges: widget.isReadOnly ? null : _applyStagedChanges,
667+
isSaving: _savingChanges,
600668
),
601669
),
602670
],

‎lib/features/sqlite/sqlite_sql_workspace.dart‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import 'package:file_selector/file_selector.dart';
66
import 'package:querya_desktop/core/actions/sql_editor_actions.dart';
77
import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart';
88
import 'package:querya_desktop/core/database/result_row_string_convert.dart';
9+
import 'package:querya_desktop/core/database/sql_table_target_extractor.dart';
910
import 'package:querya_desktop/core/database/sqlite_service.dart';
1011
import 'package:querya_desktop/core/database/sql_limit.dart';
12+
import 'package:querya_desktop/core/database/table_mutation_engine.dart';
1113
import 'package:querya_desktop/core/layout/vertical_split_pane.dart';
1214
import 'package:querya_desktop/core/storage/app_settings.dart';
1315
import 'package:querya_desktop/core/storage/local_db.dart';
1416
import 'package:querya_desktop/features/settings/preferences_dialog.dart';
17+
import 'package:querya_desktop/features/main_screen/data_grid_staging_buffer.dart';
1518
import 'package:querya_desktop/features/main_screen/query_editor_tab.dart';
1619
import 'package:querya_desktop/features/main_screen/results_tab.dart';
1720
import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart';
@@ -45,6 +48,9 @@ class _SqliteSqlWorkspaceState extends material.State<SqliteSqlWorkspace> {
4548
List<List<String>> _rows = [];
4649
int? _affectedRows;
4750
String? _statusLine;
51+
DataGridStagingBuffer? _stagingBuffer;
52+
String? _lastExecutedSql;
53+
bool _savingChanges = false;
4854

4955
int _resultMaxRows = kDefaultSqlResultMaxRows;
5056
int _historyMaxEntries = kDefaultSqlHistoryMaxEntries;
@@ -186,6 +192,10 @@ class _SqliteSqlWorkspaceState extends material.State<SqliteSqlWorkspace> {
186192
_columns = cols;
187193
_rows = outRows;
188194
_affectedRows = null;
195+
_lastExecutedSql = userSql;
196+
_stagingBuffer = cols.isNotEmpty
197+
? DataGridStagingBuffer(columns: cols, rows: outRows)
198+
: null;
189199
if (cols.isEmpty && outRows.isEmpty) {
190200
_statusLine = 'Command completed.';
191201
} else if (truncated || (injectedLimit && results.length >= cap)) {
@@ -225,6 +235,59 @@ class _SqliteSqlWorkspaceState extends material.State<SqliteSqlWorkspace> {
225235
}
226236
}
227237

238+
Future<void> _applyStagedChanges() async {
239+
if (_stagingBuffer == null || !_stagingBuffer!.isDirty || _savingChanges) return;
240+
final target = _lastExecutedSql != null ? SqlTableTargetExtractor.extract(_lastExecutedSql!) : null;
241+
final tableName = target?.tableName ?? 'table';
242+
243+
setState(() => _savingChanges = true);
244+
try {
245+
final plan = _stagingBuffer!.generateMutationPlan(
246+
dialect: SqlDialect.sqlite,
247+
tableName: tableName,
248+
schema: target?.schema,
249+
);
250+
if (plan.isEmpty) {
251+
setState(() => _savingChanges = false);
252+
return;
253+
}
254+
255+
await _ensureLease();
256+
final conn = _lease?.connection;
257+
if (conn == null || !conn.isConnected) {
258+
throw StateError('Could not connect to SQLite.');
259+
}
260+
261+
for (final stmt in plan.statements) {
262+
await conn.execute(stmt.sql);
263+
}
264+
265+
if (!mounted) return;
266+
setState(() {
267+
_rows = _stagingBuffer!.effectiveRows;
268+
_stagingBuffer = DataGridStagingBuffer(columns: _columns, rows: _rows);
269+
_savingChanges = false;
270+
});
271+
} catch (e) {
272+
if (mounted) {
273+
setState(() => _savingChanges = false);
274+
await showAppDialog<void>(
275+
context: context,
276+
builder: (ctx) => material.AlertDialog(
277+
title: const material.Text('Save Changes Failed'),
278+
content: material.Text(e.toString()),
279+
actions: [
280+
material.TextButton(
281+
onPressed: () => material.Navigator.of(ctx).pop(),
282+
child: const material.Text('OK'),
283+
),
284+
],
285+
),
286+
);
287+
}
288+
}
289+
}
290+
228291
Future<void> _openSqlFile() async {
229292
try {
230293
final file = await openFile(
@@ -396,6 +459,9 @@ class _SqliteSqlWorkspaceState extends material.State<SqliteSqlWorkspace> {
396459
isLoading: _running,
397460
affectedRows: _affectedRows,
398461
statusLine: _statusLine,
462+
stagingBuffer: _stagingBuffer,
463+
onApplyChanges: widget.isReadOnly ? null : _applyStagedChanges,
464+
isSaving: _savingChanges,
399465
),
400466
),
401467
],

0 commit comments

Comments
 (0)