Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 52 additions & 12 deletions lib/core/csv/result_grid_csv.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
/// RFC 4180–style CSV for a result grid (header + rows).
library;

import 'dart:io';
import 'dart:isolate';

String escapeCsvField(String s) {
final needsQuotes = s.contains(',') ||
s.contains('"') ||
Expand All @@ -12,18 +15,55 @@ String escapeCsvField(String s) {
return s;
}

/// Formats a single CSV data row, padding short rows to [columnCount].
String formatCsvDataRow(List<String> row, int columnCount) {
final buf = StringBuffer();
for (var i = 0; i < columnCount; i++) {
if (i > 0) buf.write(',');
if (i < row.length) buf.write(escapeCsvField(row[i]));
}
return buf.toString();
}

/// One line per row; pads short rows with empty cells to [columns.length].
///
/// Prefer [resultGridAsCsvAsync] for large grids on the UI isolate, and
/// [writeResultGridCsv] when writing to a file.
String resultGridAsCsv(List<String> columns, List<List<String>> rows) {
final lines = <String>[
columns.map(escapeCsvField).join(','),
...rows.map((r) {
final cells = List<String>.generate(
columns.length,
(i) => i < r.length ? escapeCsvField(r[i]) : '',
growable: false,
);
return cells.join(',');
}),
];
return lines.join('\n');
final buf = StringBuffer();
buf.write(columns.map(escapeCsvField).join(','));
for (final row in rows) {
buf.write('\n');
buf.write(formatCsvDataRow(row, columns.length));
}
return buf.toString();
}

/// Builds CSV off the UI isolate so large grids do not freeze the main thread.
Future<String> resultGridAsCsvAsync(
List<String> columns,
List<List<String>> rows,
) {
return Isolate.run(() => resultGridAsCsv(columns, rows));
}

/// Streams CSV to [sink] without assembling the full document in memory.
Future<void> writeResultGridCsv(
IOSink sink, {
required List<String> columns,
required List<List<String>> rows,
}) async {
sink.write(columns.map(escapeCsvField).join(','));
var written = 0;
for (final row in rows) {
sink.write('\n');
sink.write(formatCsvDataRow(row, columns.length));
written++;
// Yield periodically so a large export does not starve the event loop
// when this runs on the UI isolate.
if (written % 500 == 0) {
await Future<void>.delayed(Duration.zero);
}
}
await sink.flush();
}
16 changes: 13 additions & 3 deletions lib/core/csv/save_result_grid_csv.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,12 @@ enum SaveResultGridCsvOutcome {
error,
}

/// Opens a platform save dialog and writes [columns]/[rows] as CSV.
/// Opens a platform save dialog and streams [columns]/[rows] as CSV to disk.
Future<SaveResultGridCsvOutcome> saveResultGridCsvFile({
required List<String> columns,
required List<List<String>> rows,
String? suggestedName,
}) async {
final csv = resultGridAsCsv(columns, rows);
final name = suggestedName ??
'querya_results_${DateTime.now().toIso8601String().replaceAll(':', '-')}.csv';
final location = await getSaveLocation(
Expand All @@ -35,10 +34,21 @@ Future<SaveResultGridCsvOutcome> saveResultGridCsvFile({
if (path == null || path.isEmpty) {
return SaveResultGridCsvOutcome.cancelled;
}
IOSink? sink;
try {
await File(path).writeAsString(csv);
sink = File(path).openWrite();
await writeResultGridCsv(
sink,
columns: columns,
rows: rows,
);
await sink.close();
sink = null;
return SaveResultGridCsvOutcome.written;
} on Object {
try {
await sink?.close();
} catch (_) {}
return SaveResultGridCsvOutcome.error;
}
}
6 changes: 4 additions & 2 deletions lib/features/main_screen/results_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,10 @@ class ResultsTab extends StatelessWidget {
OutlineButton(
size: ButtonSize.small,
onPressed: () {
final csv = resultGridAsCsv(columns, rows);
Clipboard.setData(ClipboardData(text: csv));
unawaited(() async {
final csv = await resultGridAsCsvAsync(columns, rows);
await Clipboard.setData(ClipboardData(text: csv));
}());
},
leading: const material.Icon(
material.Icons.copy_rounded,
Expand Down
59 changes: 59 additions & 0 deletions test/core/csv/result_grid_csv_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/core/csv/result_grid_csv.dart';

Expand Down Expand Up @@ -29,4 +31,61 @@ void main() {
);
});
});

group('resultGridAsCsvAsync', () {
test('matches synchronous result off the UI isolate', () async {
const columns = ['a', 'b'];
const rows = [
['1', 'two,comma'],
['quote', 'say "hi"'],
];
final asyncCsv = await resultGridAsCsvAsync(columns, rows);
expect(asyncCsv, resultGridAsCsv(columns, rows));
});
});

group('writeResultGridCsv', () {
test('streams the same content as resultGridAsCsv', () async {
const columns = ['a', 'b'];
const rows = [
['1', 'two,comma'],
['quote', 'say "hi"'],
];
final file = File(
'${Directory.systemTemp.path}/querya_csv_stream_${DateTime.now().microsecondsSinceEpoch}.csv',
);
try {
final sink = file.openWrite();
await writeResultGridCsv(sink, columns: columns, rows: rows);
await sink.close();
expect(await file.readAsString(), resultGridAsCsv(columns, rows));
} finally {
if (await file.exists()) await file.delete();
}
});

test('streams large grids without building one giant string first', () async {
const columns = ['id', 'value'];
final rows = List.generate(
2500,
(i) => ['$i', 'value_$i'],
growable: false,
);
final file = File(
'${Directory.systemTemp.path}/querya_csv_large_${DateTime.now().microsecondsSinceEpoch}.csv',
);
try {
final sink = file.openWrite();
await writeResultGridCsv(sink, columns: columns, rows: rows);
await sink.close();
final lines = await file.readAsLines();
expect(lines.length, 2501);
expect(lines.first, 'id,value');
expect(lines[1], '0,value_0');
expect(lines.last, '2499,value_2499');
} finally {
if (await file.exists()) await file.delete();
}
});
});
}
Loading