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
145 changes: 142 additions & 3 deletions lib/shared/services/data_export_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ class DataExportService {
}

/// Copies data in [format] to the system clipboard.
///
/// Clipboard still materializes a full string (OS API). Prefer [saveToFile]
/// for large grids; this path stays isolate-backed via [formatAsync].
static Future<void> copyToClipboard(
DataExportFormat format, {
required List<String> columns,
Expand Down Expand Up @@ -165,7 +168,136 @@ class DataExportService {
});
}

/// Opens a native file save dialog and writes the exported text to disk.
/// Streams formatted output to [sink] without assembling one giant [String].
static Future<void> writeToSink(
IOSink sink,
DataExportFormat format, {
required List<String> columns,
required List<List<String>> rows,
String tableName = 'export_table',
}) async {
switch (format) {
case DataExportFormat.csv:
await writeResultGridCsv(sink, columns: columns, rows: rows);
return;
case DataExportFormat.json:
await _writeJsonObjectArray(sink, columns: columns, rows: rows);
return;
case DataExportFormat.markdown:
await _writeMarkdownTable(sink, columns: columns, rows: rows);
return;
case DataExportFormat.sqlDump:
await _writeSqlInsertDump(
sink,
tableName: tableName,
columns: columns,
rows: rows,
);
return;
}
}

static Future<void> _writeJsonObjectArray(
IOSink sink, {
required List<String> columns,
required List<List<String>> rows,
}) async {
sink.write('[\n');
var written = 0;
for (var r = 0; r < rows.length; r++) {
final row = rows[r];
final obj = <String, Object?>{};
for (var i = 0; i < columns.length; i++) {
final val = i < row.length ? row[i] : null;
obj[columns[i]] = (val == 'NULL' || val == null) ? null : val;
}
sink.write(const JsonEncoder.withIndent(' ').convert(obj));
if (r + 1 < rows.length) sink.write(',');
sink.write('\n');
written++;
if (written % 500 == 0) {
await Future<void>.delayed(Duration.zero);
}
}
sink.write(']\n');
await sink.flush();
}

static Future<void> _writeMarkdownTable(
IOSink sink, {
required List<String> columns,
required List<List<String>> rows,
}) async {
if (columns.isEmpty) {
await sink.flush();
return;
}

String escapeMd(String s) {
if (s == 'NULL') return '`NULL`';
return s
.replaceAll('|', '\\|')
.replaceAll('\r\n', ' ')
.replaceAll('\n', ' ');
}

sink.write('| ${columns.map(escapeMd).join(' | ')} |\n');
sink.write('| ${columns.map((_) => '---').join(' | ')} |\n');
var written = 0;
for (final row in rows) {
final padded = List<String>.generate(
columns.length,
(i) => i < row.length ? escapeMd(row[i]) : '`NULL`',
growable: false,
);
sink.write('| ${padded.join(' | ')} |\n');
written++;
if (written % 500 == 0) {
await Future<void>.delayed(Duration.zero);
}
}
await sink.flush();
}

static Future<void> _writeSqlInsertDump(
IOSink sink, {
required String tableName,
required List<String> columns,
required List<List<String>> rows,
}) async {
if (columns.isEmpty || rows.isEmpty) {
await sink.flush();
return;
}
final safeTable =
tableName.trim().isEmpty ? 'export_table' : tableName.trim();
final colList = columns
.map((c) => c.contains(' ') || c.contains('-') ? '"$c"' : c)
.join(', ');

var written = 0;
for (final row in rows) {
sink.write('INSERT INTO $safeTable ($colList) VALUES (');
for (var i = 0; i < columns.length; i++) {
if (i > 0) sink.write(', ');
if (i >= row.length || row[i] == 'NULL') {
sink.write('NULL');
} else {
final escaped = row[i].replaceAll("'", "''");
sink.write("'$escaped'");
}
}
sink.write(');\n');
written++;
if (written % 500 == 0) {
await Future<void>.delayed(Duration.zero);
}
}
await sink.flush();
}

/// Opens a native file save dialog and **streams** the export to disk
/// (no full-document [String] for the file path).
static Future<SaveExportOutcome> saveToFile(
DataExportFormat format, {
required List<String> columns,
Expand Down Expand Up @@ -208,16 +340,23 @@ class DataExportService {
return SaveExportOutcome.cancelled;
}

IOSink? sink;
try {
final content = await formatAsync(
sink = File(path).openWrite();
await writeToSink(
sink,
format,
columns: columns,
rows: rows,
tableName: tableName,
);
await File(path).writeAsString(content);
await sink.close();
sink = null;
return SaveExportOutcome.written;
} on Object {
try {
await sink?.close();
} catch (_) {}
return SaveExportOutcome.error;
}
}
Expand Down
68 changes: 68 additions & 0 deletions test/shared/data_export_service_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import 'dart:convert';
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/shared/services/data_export_service.dart';

Expand Down Expand Up @@ -64,5 +66,71 @@ void main() {
);
expect(md, contains('| id | user_name | note |'));
});

test('writeToSink streams CSV/JSON/SQL matching format helpers', () async {
Future<String> sinkToString(
Future<void> Function(IOSink sink) write,
) async {
final path =
'${Directory.systemTemp.path}/querya_export_${DateTime.now().microsecondsSinceEpoch}.txt';
final out = File(path);
final sink = out.openWrite();
try {
await write(sink);
} finally {
await sink.close();
}
final text = await out.readAsString();
await out.delete();
return text;
}

final csv = await sinkToString(
(s) => DataExportService.writeToSink(
s,
DataExportFormat.csv,
columns: columns,
rows: rows,
),
);
expect(csv, DataExportService.formatCsv(columns, rows));

final jsonStr = await sinkToString(
(s) => DataExportService.writeToSink(
s,
DataExportFormat.json,
columns: columns,
rows: rows,
),
);
final decoded = jsonDecode(jsonStr) as List;
expect(decoded.length, 2);
expect(decoded[0]['id'], '101');
expect(decoded[1]['note'], isNull);

final sql = await sinkToString(
(s) => DataExportService.writeToSink(
s,
DataExportFormat.sqlDump,
columns: columns,
rows: rows,
tableName: 'users',
),
);
expect(
sql,
DataExportService.formatSqlInsertDump('users', columns, rows),
);

final md = await sinkToString(
(s) => DataExportService.writeToSink(
s,
DataExportFormat.markdown,
columns: columns,
rows: rows,
),
);
expect(md, DataExportService.formatMarkdownTable(columns, rows));
});
});
}
Loading