From 97039f203b3f5c010d3ddb619f993cd3f0e6aaf3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 08:11:55 +0300 Subject: [PATCH] perf(grid): stream selection cells into the buffer, no regex per JSON cell toCsv and toTsv built a List per row and joined it; toJson compiled RegExp(r'^0\d+$') for every cell and parsed each number twice. Cells are written straight into the StringBuffer with a delimiter before every column but the first. The leading-zero check is a code unit loop, and ints and doubles are parsed once. Output is unchanged, including the trimRight of the whole text. Closes #1524 --- .../grid/grid_selection_manager.dart | 49 ++++---- .../workspace/grid_selection_export_test.dart | 114 ++++++++++++++++++ 2 files changed, 142 insertions(+), 21 deletions(-) create mode 100644 test/features/workspace/grid_selection_export_test.dart diff --git a/lib/features/workspace/grid/grid_selection_manager.dart b/lib/features/workspace/grid/grid_selection_manager.dart index 3766828c..9ceb4e23 100644 --- a/lib/features/workspace/grid/grid_selection_manager.dart +++ b/lib/features/workspace/grid/grid_selection_manager.dart @@ -66,23 +66,22 @@ class ResultGridSelection { String toTsv(List> rows, {List? columns}) { if (rows.isEmpty) return ''; final buffer = StringBuffer(); + // Cells go straight into the buffer: no per-row list and join (#1524). if (columns != null && columns.isNotEmpty) { - final headerCells = []; for (var c = startColumn; c <= endColumn; c++) { - final col = c < columns.length ? columns[c] : ''; - headerCells.add(_escapeTsv(col)); + if (c > startColumn) buffer.write('\t'); + buffer.write(_escapeTsv(c < columns.length ? columns[c] : '')); } - buffer.writeln(headerCells.join('\t')); + buffer.writeln(); } for (var r = startRow; r <= endRow; r++) { if (r < 0 || r >= rows.length) continue; final rowData = rows[r]; - final cells = []; for (var c = startColumn; c <= endColumn; c++) { - final val = c < rowData.length ? rowData[c] : ''; - cells.add(_escapeTsv(val)); + if (c > startColumn) buffer.write('\t'); + buffer.write(_escapeTsv(c < rowData.length ? rowData[c] : '')); } - buffer.writeln(cells.join('\t')); + buffer.writeln(); } return buffer.toString().trimRight(); } @@ -93,22 +92,20 @@ class ResultGridSelection { if (rows.isEmpty) return ''; final buffer = StringBuffer(); if (columns != null && columns.isNotEmpty) { - final headerCells = []; for (var c = startColumn; c <= endColumn; c++) { - final col = c < columns.length ? columns[c] : ''; - headerCells.add(_escapeCsv(col)); + if (c > startColumn) buffer.write(','); + buffer.write(_escapeCsv(c < columns.length ? columns[c] : '')); } - buffer.writeln(headerCells.join(',')); + buffer.writeln(); } for (var r = startRow; r <= endRow; r++) { if (r < 0 || r >= rows.length) continue; final rowData = rows[r]; - final cells = []; for (var c = startColumn; c <= endColumn; c++) { - final val = c < rowData.length ? rowData[c] : ''; - cells.add(_escapeCsv(val)); + if (c > startColumn) buffer.write(','); + buffer.write(_escapeCsv(c < rowData.length ? rowData[c] : '')); } - buffer.writeln(cells.join(',')); + buffer.writeln(); } return buffer.toString().trimRight(); } @@ -127,13 +124,13 @@ class ResultGridSelection { final val = c < rowData.length ? rowData[c] : ''; if (val == 'NULL') { map[colName] = null; - } else if (RegExp(r'^0\d+$').hasMatch(val.trim())) { + } else if (_hasLeadingZeroDigits(val.trim())) { // Preserve numeric strings with leading zeros (e.g. '01234', '007') map[colName] = val; - } else if (int.tryParse(val) != null) { - map[colName] = int.parse(val); - } else if (double.tryParse(val) != null) { - map[colName] = double.parse(val); + } else if (int.tryParse(val) case final asInt?) { + map[colName] = asInt; + } else if (double.tryParse(val) case final asDouble?) { + map[colName] = asDouble; } else if (val.toLowerCase() == 'true') { map[colName] = true; } else if (val.toLowerCase() == 'false') { @@ -150,6 +147,16 @@ class ResultGridSelection { return const JsonEncoder.withIndent(' ').convert(result); } + /// `^0\d+$`: a zero followed by one or more ASCII digits and nothing else. + static bool _hasLeadingZeroDigits(String s) { + if (s.length < 2 || s.codeUnitAt(0) != 0x30) return false; + for (var i = 1; i < s.length; i++) { + final c = s.codeUnitAt(i); + if (c < 0x30 || c > 0x39) return false; + } + return true; + } + static String _escapeTsv(String val) { if (val.contains('\t') || val.contains('\n') || diff --git a/test/features/workspace/grid_selection_export_test.dart b/test/features/workspace/grid_selection_export_test.dart new file mode 100644 index 00000000..b2c904f3 --- /dev/null +++ b/test/features/workspace/grid_selection_export_test.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/grid/grid_selection_manager.dart'; + +void main() { + group('ResultGridSelection clipboard export (#1524)', () { + const columns = ['x', 'y', 'z']; + const rows = [ + ['a', 'b c', 'd,e'], + ['1', '', '"q"'], + ]; + const all = ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: 1, + endColumn: 2, + ); + + test('CSV: header, quoting and row order', () { + expect( + all.toCsv(rows, columns: columns), + 'x,y,z\na,b c,"d,e"\n1,,"""q"""', + ); + expect(all.toCsv(rows), 'a,b c,"d,e"\n1,,"""q"""'); + }); + + test('TSV: header, quoting and row order', () { + expect( + all.toTsv(rows, columns: columns), + 'x\ty\tz\na\tb c\td,e\n1\t\t"""q"""', + ); + expect(all.toTsv(rows), 'a\tb c\td,e\n1\t\t"""q"""'); + }); + + test('a column sub-range and rows past the end', () { + const sub = ResultGridSelection( + startRow: 1, + startColumn: 1, + endRow: 5, + endColumn: 3, + ); + // Column 3 does not exist: an empty cell; row 5 does not exist: skipped. + expect(sub.toCsv(rows, columns: columns), 'y,z,\n,"""q""",'); + expect(sub.toTsv(rows), '\t"""q"""'); + }); + + test('trailing empty cells of the last row are trimmed in TSV only', () { + const one = ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: 0, + endColumn: 1, + ); + const data = [ + ['a', ''], + ]; + expect(one.toTsv(data), 'a'); + expect(one.toCsv(data), 'a,'); + }); + + test('empty input gives an empty string', () { + expect(all.toCsv(const []), ''); + expect(all.toTsv(const []), ''); + }); + + test('JSON keeps leading zeros, nulls and booleans', () { + const sel = ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: 0, + endColumn: 9, + ); + final json = sel.toJson( + ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], + const [ + ['007', '12', '1.5', 'NULL', 'true', 'FALSE', 'x', ' 007 ', '0', '0.5'], + ], + ); + expect(jsonDecode(json), { + 'a': '007', + 'b': 12, + 'c': 1.5, + 'd': null, + 'e': true, + 'f': false, + 'g': 'x', + 'h': ' 007 ', + 'i': 0, + 'j': 0.5, + }); + }); + + test('JSON of several rows is an array', () { + const sel = ResultGridSelection( + startRow: 0, + startColumn: 0, + endRow: 1, + endColumn: 0, + ); + final json = sel.toJson( + ['n'], + const [ + ['1'], + ['2'], + ], + ); + expect(jsonDecode(json), [ + {'n': 1}, + {'n': 2}, + ]); + }); + }); +}