Skip to content

Commit 9e5cd4e

Browse files
Merge pull request #338 from QueryaHub/issue/334-optimize-result-mapping
perf(workspace): optimize large SQL result set mapping and DDL rendering (#334)
2 parents fe15c71 + d61e945 commit 9e5cd4e

11 files changed

Lines changed: 236 additions & 41 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import 'package:flutter/material.dart' as material;
2+
3+
/// Renders long text efficiently. If line count <= [threshold], uses a single
4+
/// `SelectableText` inside `SingleChildScrollView` so multi-line selection across
5+
/// the entire block works seamlessly.
6+
/// If line count > [threshold], virtualizes lines using `ListView.builder` to
7+
/// ensure 60 FPS scrolling and rendering without UI jank.
8+
class VirtualSelectableTextView extends material.StatelessWidget {
9+
const VirtualSelectableTextView({
10+
super.key,
11+
required this.text,
12+
this.style,
13+
this.threshold = 200,
14+
this.padding = const material.EdgeInsets.all(16),
15+
});
16+
17+
final String text;
18+
final material.TextStyle? style;
19+
final int threshold;
20+
final material.EdgeInsets padding;
21+
22+
@override
23+
material.Widget build(material.BuildContext context) {
24+
final lines = text.split('\n');
25+
if (lines.length <= threshold) {
26+
return material.SingleChildScrollView(
27+
padding: padding,
28+
child: material.SelectableText(
29+
text,
30+
style: style,
31+
),
32+
);
33+
}
34+
35+
return material.ListView.builder(
36+
padding: padding,
37+
itemCount: lines.length,
38+
itemBuilder: (context, index) {
39+
return material.SelectableText(
40+
lines[index],
41+
style: style,
42+
);
43+
},
44+
);
45+
}
46+
}

lib/features/main_screen/results_tab.dart

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:async' show unawaited;
22

33
import 'package:flutter/material.dart' as material;
4+
import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart';
45
import 'package:querya_desktop/features/main_screen/result_grid_view.dart';
56
import 'package:querya_desktop/shared/services/data_export_service.dart';
67
import 'package:querya_desktop/shared/widgets/widgets.dart';
@@ -32,15 +33,12 @@ class ResultsTab extends StatelessWidget {
3233
);
3334
}
3435
if (errorMessage != null && errorMessage!.isNotEmpty) {
35-
return material.SingleChildScrollView(
36-
padding: const material.EdgeInsets.all(16),
37-
child: material.SelectableText(
38-
errorMessage!,
39-
style: material.TextStyle(
40-
fontFamily: 'monospace',
41-
fontSize: 12,
42-
color: Theme.of(context).colorScheme.destructive,
43-
),
36+
return VirtualSelectableTextView(
37+
text: errorMessage!,
38+
style: material.TextStyle(
39+
fontFamily: 'monospace',
40+
fontSize: 12,
41+
color: Theme.of(context).colorScheme.destructive,
4442
),
4543
);
4644
}

lib/features/mysql/mysql_routine_view.dart

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:flutter/material.dart' as material;
22
import 'package:querya_desktop/core/database/mysql_service.dart';
33
import 'package:querya_desktop/core/storage/local_db.dart';
4+
import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart';
45
import 'package:querya_desktop/shared/widgets/widgets.dart';
56

67
/// Displays MySQL routine DDL (`SHOW CREATE PROCEDURE` / `SHOW CREATE FUNCTION`).
@@ -165,23 +166,18 @@ class _MysqlRoutineViewState extends material.State<MysqlRoutineView> {
165166
)
166167
else if (_error != null)
167168
material.Expanded(
168-
child: material.Center(
169-
child: material.SelectableText(
170-
_error!,
171-
style: material.TextStyle(color: cs.destructive, fontSize: 13),
172-
),
169+
child: VirtualSelectableTextView(
170+
text: _error!,
171+
style: material.TextStyle(color: cs.destructive, fontSize: 13),
173172
),
174173
)
175174
else
176175
material.Expanded(
177-
child: material.SingleChildScrollView(
178-
padding: const material.EdgeInsets.all(16),
179-
child: material.SelectableText(
180-
_ddlText ?? '',
181-
style: const material.TextStyle(
182-
fontFamily: 'monospace',
183-
fontSize: 13,
184-
),
176+
child: VirtualSelectableTextView(
177+
text: _ddlText ?? '',
178+
style: const material.TextStyle(
179+
fontFamily: 'monospace',
180+
fontSize: 13,
185181
),
186182
),
187183
),

lib/features/mysql/mysql_sql_workspace.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,10 +207,10 @@ class _MysqlSqlWorkspaceState extends material.State<MysqlSqlWorkspace> {
207207
n++;
208208
}
209209

210-
final outRows = await compute(
211-
convertMysqlResultRowsToStrings,
212-
MysqlResultConvertJob(rowValues: rawRows),
213-
);
210+
final job = MysqlResultConvertJob(rowValues: rawRows);
211+
final outRows = rawRows.length > 500
212+
? await compute(convertMysqlResultRowsToStrings, job)
213+
: convertMysqlResultRowsToStrings(job);
214214

215215
int? affected;
216216
if (cols.isEmpty && outRows.isEmpty) {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/// Serializable row batch for [convertPostgresResultRowsToStrings] in a worker isolate.
2+
class PostgresResultConvertJob {
3+
const PostgresResultConvertJob({
4+
required this.rowValues,
5+
});
6+
7+
final List<List<Object?>> rowValues;
8+
}
9+
10+
/// Converts PostgreSQL result cell values to display strings off the UI thread.
11+
List<List<String>> convertPostgresResultRowsToStrings(PostgresResultConvertJob job) {
12+
return job.rowValues
13+
.map(
14+
(row) => row
15+
.map((value) => value == null ? 'NULL' : value.toString())
16+
.toList(),
17+
)
18+
.toList();
19+
}

lib/features/postgresql/postgres_routine_view.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart' as material;
22
import 'package:querya_desktop/core/database/postgres_connection.dart';
33
import 'package:querya_desktop/core/database/postgres_service.dart';
44
import 'package:querya_desktop/core/storage/local_db.dart';
5+
import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart';
56
import 'package:querya_desktop/shared/widgets/widgets.dart';
67

78
/// Shows [pg_get_functiondef] for each overload of a PostgreSQL function.
@@ -243,8 +244,9 @@ class _PostgresRoutineViewState extends material.State<PostgresRoutineView> {
243244
color: cs.border.withValues(alpha: 0.4),
244245
),
245246
),
246-
child: material.SelectableText(
247-
_overloads[i].definition,
247+
child: VirtualSelectableTextView(
248+
text: _overloads[i].definition,
249+
padding: material.EdgeInsets.zero,
248250
style: material.TextStyle(
249251
fontFamily: 'monospace',
250252
fontSize: 12,

lib/features/postgresql/postgres_sql_workspace.dart

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import 'dart:async';
22
import 'dart:io';
33

4+
import 'package:flutter/foundation.dart' show compute;
45
import 'package:flutter/material.dart' as material;
56
import 'package:flutter/services.dart' show LogicalKeyboardKey;
67
import 'package:file_selector/file_selector.dart';
8+
import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart';
79
import 'package:querya_desktop/core/actions/sql_editor_actions.dart';
810
import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart';
911
import 'package:postgres/postgres.dart' as pg;
@@ -348,15 +350,20 @@ class _PostgresSqlWorkspaceState extends material.State<PostgresSqlWorkspace> {
348350
);
349351
}
350352

351-
final outRows = <List<String>>[];
353+
final rawRows = <List<Object?>>[];
352354
var n = 0;
353355
final cap = _resultMaxRows;
354356
for (final row in result) {
355357
if (n >= cap) break;
356-
outRows.add(row.map(_cellText).toList());
358+
rawRows.add(row.toList());
357359
n++;
358360
}
359361

362+
final job = PostgresResultConvertJob(rowValues: rawRows);
363+
final outRows = rawRows.length > 500
364+
? await compute(convertPostgresResultRowsToStrings, job)
365+
: convertPostgresResultRowsToStrings(job);
366+
360367
setState(() {
361368
_columns = cols;
362369
_rows = outRows;
@@ -410,11 +417,6 @@ class _PostgresSqlWorkspaceState extends material.State<PostgresSqlWorkspace> {
410417
}
411418
}
412419

413-
static String _cellText(Object? v) {
414-
if (v == null) return 'NULL';
415-
return v.toString();
416-
}
417-
418420
Future<void> _openSqlFile() async {
419421
try {
420422
final file = await openFile(
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/// Serializable row batch for [convertSqliteResultRowsToStrings] in a worker isolate.
2+
class SqliteResultConvertJob {
3+
const SqliteResultConvertJob({
4+
required this.rowValues,
5+
});
6+
7+
final List<List<Object?>> rowValues;
8+
}
9+
10+
/// Converts SQLite result cell values to display strings off the UI thread.
11+
List<List<String>> convertSqliteResultRowsToStrings(SqliteResultConvertJob job) {
12+
return job.rowValues
13+
.map(
14+
(row) => row
15+
.map((value) => value == null ? 'NULL' : value.toString())
16+
.toList(),
17+
)
18+
.toList();
19+
}

lib/features/sqlite/sqlite_sql_workspace.dart

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import 'dart:async';
22
import 'dart:io';
3+
import 'package:flutter/foundation.dart' show compute;
34
import 'package:flutter/material.dart' as material;
45
import 'package:flutter/services.dart' show LogicalKeyboardKey;
56
import 'package:file_selector/file_selector.dart';
7+
import 'package:querya_desktop/features/sqlite/sqlite_result_utils.dart';
68
import 'package:querya_desktop/core/actions/sql_editor_actions.dart';
79
import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart';
810
import 'package:querya_desktop/core/database/sqlite_service.dart';
@@ -167,14 +169,15 @@ class _SqliteSqlWorkspaceState extends material.State<SqliteSqlWorkspace> {
167169
final truncated = results.length > cap;
168170
final limitCount = truncated ? cap : results.length;
169171

170-
final rawRows = results.take(limitCount).toList();
171-
final outRows = rawRows.map((row) {
172-
return cols.map((col) {
173-
final val = row[col];
174-
return val == null ? 'NULL' : val.toString();
175-
}).toList();
172+
final rawRows = results.take(limitCount).map((row) {
173+
return cols.map((col) => row[col]).toList();
176174
}).toList();
177175

176+
final job = SqliteResultConvertJob(rowValues: rawRows);
177+
final outRows = rawRows.length > 500
178+
? await compute(convertSqliteResultRowsToStrings, job)
179+
: convertSqliteResultRowsToStrings(job);
180+
178181
setState(() {
179182
_columns = cols;
180183
_rows = outRows;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import 'package:flutter/material.dart' as material;
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:querya_desktop/core/widgets/virtual_selectable_text_view.dart';
4+
5+
void main() {
6+
group('VirtualSelectableTextView', () {
7+
testWidgets('renders SingleChildScrollView + SelectableText below threshold', (tester) async {
8+
const text = 'line 1\nline 2\nline 3';
9+
await tester.pumpWidget(
10+
const material.MaterialApp(
11+
home: material.Scaffold(
12+
body: VirtualSelectableTextView(
13+
text: text,
14+
threshold: 10,
15+
),
16+
),
17+
),
18+
);
19+
20+
expect(find.byType(material.SingleChildScrollView), findsOneWidget);
21+
expect(find.byType(material.ListView), findsNothing);
22+
expect(find.text(text), findsOneWidget);
23+
});
24+
25+
testWidgets('renders ListView.builder above threshold', (tester) async {
26+
final text = List.generate(50, (i) => 'Virtual Line $i').join('\n');
27+
await tester.pumpWidget(
28+
material.MaterialApp(
29+
home: material.Scaffold(
30+
body: VirtualSelectableTextView(
31+
text: text,
32+
threshold: 10,
33+
),
34+
),
35+
),
36+
);
37+
38+
expect(find.byType(material.SingleChildScrollView), findsNothing);
39+
expect(find.byType(material.ListView), findsOneWidget);
40+
expect(find.text('Virtual Line 0'), findsOneWidget);
41+
expect(find.text('Virtual Line 1'), findsOneWidget);
42+
});
43+
});
44+
}

0 commit comments

Comments
 (0)