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
18 changes: 12 additions & 6 deletions lib/core/database/table_mutation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,7 @@ abstract final class TableMutationEngine {

if (_isTextType(dataTypeName)) {
// String columns: preserve literal 'NULL' or 'null' as a text string
final escaped = value.replaceAll("'", "''");
return "'$escaped'";
return _formatStringLiteral(value, dialect);
}

if (_isBoolType(dataTypeName)) {
Expand Down Expand Up @@ -216,8 +215,7 @@ abstract final class TableMutationEngine {
// Fallback heuristic:
// Leading zeros with more digits (e.g. '01234', '007') are preserved as strings
if (RegExp(r'^0\d+$').hasMatch(trimmed)) {
final escaped = value.replaceAll("'", "''");
return "'$escaped'";
return _formatStringLiteral(value, dialect);
}

// Number literals (integer or floating point, e.g. '123', '0', '0.45', '-5.2')
Expand All @@ -233,8 +231,16 @@ abstract final class TableMutationEngine {
return dialect == SqlDialect.sqlite ? '0' : 'FALSE';
}

// String literal with single quote escape
final escaped = value.replaceAll("'", "''");
// String literal with single quote escape and MySQL backslash escape
return _formatStringLiteral(value, dialect);
}

static String _formatStringLiteral(String value, SqlDialect dialect) {
var escaped = value;
if (dialect == SqlDialect.mysql) {
escaped = escaped.replaceAll(r'\', r'\\');
}
escaped = escaped.replaceAll("'", "''");
return "'$escaped'";
}

Expand Down
54 changes: 42 additions & 12 deletions lib/features/workspace/result_grid_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,8 @@ class ResultGridSelection {
if (columns != null && columns.isNotEmpty) {
final headerCells = <String>[];
for (var c = startColumn; c <= endColumn; c++) {
headerCells.add(c < columns.length ? columns[c] : '');
final col = c < columns.length ? columns[c] : '';
headerCells.add(_escapeTsv(col));
}
buffer.writeln(headerCells.join('\t'));
}
Expand All @@ -348,7 +349,8 @@ class ResultGridSelection {
final rowData = rows[r];
final cells = <String>[];
for (var c = startColumn; c <= endColumn; c++) {
cells.add(c < rowData.length ? rowData[c] : '');
final val = c < rowData.length ? rowData[c] : '';
cells.add(_escapeTsv(val));
}
buffer.writeln(cells.join('\t'));
}
Expand Down Expand Up @@ -395,6 +397,9 @@ class ResultGridSelection {
final val = c < rowData.length ? rowData[c] : '';
if (val == 'NULL') {
map[colName] = null;
} else if (RegExp(r'^0\d+$').hasMatch(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) {
Expand All @@ -415,6 +420,13 @@ class ResultGridSelection {
return const JsonEncoder.withIndent(' ').convert(result);
}

static String _escapeTsv(String val) {
if (val.contains('\t') || val.contains('\n') || val.contains('\r') || val.contains('"')) {
return '"${val.replaceAll('"', '""')}"';
}
return val;
}

static String _escapeCsv(String val) {
if (val.contains(',') || val.contains('"') || val.contains('\n') || val.contains('\r')) {
return '"${val.replaceAll('"', '""')}"';
Expand Down Expand Up @@ -568,6 +580,7 @@ class VirtualResultGrid extends material.StatefulWidget {
required this.columns,
required this.rows,
this.stagingBuffer,
this.rowIndicesMapping,
this.onRowSelected,
this.onSelectionValuesChanged,
this.onCellFocused,
Expand All @@ -577,6 +590,7 @@ class VirtualResultGrid extends material.StatefulWidget {
final List<String> columns;
final List<List<String>> rows;
final DataGridStagingBuffer? stagingBuffer;
final List<int>? rowIndicesMapping;
final material.ValueChanged<int?>? onRowSelected;
final material.ValueChanged<List<String>>? onSelectionValuesChanged;
final void Function(String columnName, String cellValue, int rowIndex)? onCellFocused;
Expand Down Expand Up @@ -638,7 +652,8 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
}
if (oldWidget.columns != widget.columns ||
oldWidget.rows != widget.rows ||
oldWidget.stagingBuffer != widget.stagingBuffer) {
oldWidget.stagingBuffer != widget.stagingBuffer ||
oldWidget.rowIndicesMapping != widget.rowIndicesMapping) {
_widthsNeedUpdate = true;
if (oldWidget.columns != widget.columns) {
_userHasResized = false;
Expand Down Expand Up @@ -810,7 +825,7 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
rowIndex: row,
);
if (result != null && widget.stagingBuffer != null) {
widget.stagingBuffer!.setCell(row, column, result);
widget.stagingBuffer!.setCell(_toModelRowIndex(row), column, result);
}
}

Expand Down Expand Up @@ -883,22 +898,37 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
});
}

List<List<String>> get _baseRows =>
widget.stagingBuffer?.effectiveRows ?? widget.rows;
List<List<String>> get _baseRows {
if (widget.rowIndicesMapping != null) {
return widget.rows;
}
return widget.stagingBuffer?.effectiveRows ?? widget.rows;
}

void _updateSortedRows() {
final rows = _baseRows;
if (_sortColumnIndex == null || _sortOrder == null) {
_sortedRows = rows;
_sortedToModelIndices = List<int>.generate(rows.length, (i) => i, growable: false);
if (widget.rowIndicesMapping != null) {
_sortedToModelIndices = List<int>.from(widget.rowIndicesMapping!);
} else {
_sortedToModelIndices = List<int>.generate(rows.length, (i) => i, growable: false);
}
} else {
final sortedData = sortResultGridRowsWithIndices(
rows: rows,
columnIndex: _sortColumnIndex!,
order: _sortOrder!,
);
_sortedRows = sortedData.rows;
_sortedToModelIndices = sortedData.sortedToModelIndices;
if (widget.rowIndicesMapping != null) {
final mapping = widget.rowIndicesMapping!;
_sortedToModelIndices = sortedData.sortedToModelIndices
.map((i) => i < mapping.length ? mapping[i] : i)
.toList(growable: false);
} else {
_sortedToModelIndices = sortedData.sortedToModelIndices;
}
}
}

Expand Down Expand Up @@ -1387,31 +1417,31 @@ class _VirtualResultGridState extends material.State<VirtualResultGrid> {
control: true,
): () {
if (widget.stagingBuffer != null && _selection != null) {
widget.stagingBuffer!.toggleDeleteRow(_selection!.startRow);
widget.stagingBuffer!.toggleDeleteRow(_toModelRowIndex(_selection!.startRow));
}
},
const material.SingleActivator(
LogicalKeyboardKey.backspace,
meta: true,
): () {
if (widget.stagingBuffer != null && _selection != null) {
widget.stagingBuffer!.toggleDeleteRow(_selection!.startRow);
widget.stagingBuffer!.toggleDeleteRow(_toModelRowIndex(_selection!.startRow));
}
},
const material.SingleActivator(
LogicalKeyboardKey.keyZ,
control: true,
): () {
if (widget.stagingBuffer != null && _selection != null) {
widget.stagingBuffer!.revertRow(_selection!.startRow);
widget.stagingBuffer!.revertRow(_toModelRowIndex(_selection!.startRow));
}
},
const material.SingleActivator(
LogicalKeyboardKey.keyZ,
meta: true,
): () {
if (widget.stagingBuffer != null && _selection != null) {
widget.stagingBuffer!.revertRow(_selection!.startRow);
widget.stagingBuffer!.revertRow(_toModelRowIndex(_selection!.startRow));
}
},
const material.SingleActivator(
Expand Down
7 changes: 6 additions & 1 deletion lib/features/workspace/results_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class _ResultsTabState extends material.State<ResultsTab> {
List<String>? _memoColumns;
List<List<String>>? _memoEffectiveRows;
List<List<String>> _cachedFilteredRows = const [];
List<int>? _cachedFilteredIndices;

List<List<String>> _getFilteredRows(
List<List<String>> effectiveRows,
Expand All @@ -89,14 +90,16 @@ class _ResultsTabState extends material.State<ResultsTab> {
rows: effectiveRows,
);

final filteredRows = filteredIndices.length == effectiveRows.length
final isFiltered = filteredIndices.length != effectiveRows.length;
final filteredRows = !isFiltered
? effectiveRows
: filteredIndices.map((i) => effectiveRows[i]).toList();

_memoFilterText = _filterText;
_memoEffectiveRows = effectiveRows;
_memoColumns = columns;
_cachedFilteredRows = filteredRows;
_cachedFilteredIndices = isFiltered ? filteredIndices : null;

return filteredRows;
}
Expand All @@ -106,6 +109,7 @@ class _ResultsTabState extends material.State<ResultsTab> {
_memoColumns = null;
_memoEffectiveRows = null;
_cachedFilteredRows = const [];
_cachedFilteredIndices = null;
super.dispose();
}

Expand Down Expand Up @@ -387,6 +391,7 @@ class _ResultsTabState extends material.State<ResultsTab> {
columns: widget.columns,
rows: filteredRows,
stagingBuffer: widget.stagingBuffer,
rowIndicesMapping: _cachedFilteredIndices,
onRowSelected: (row) => setState(() => _selectedRowIndex = row),
onSelectionValuesChanged: (values) {
if (values.isEmpty) {
Expand Down
1 change: 1 addition & 0 deletions lib/features/workspace/sql_query_tab_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ class SqlQueryTabSession {
void dispose() {
controller.dispose();
topFraction.dispose();
stagingBuffer?.dispose();
}
}
29 changes: 29 additions & 0 deletions test/core/database/table_mutation_engine_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -323,5 +323,34 @@ void main() {
'NULL',
);
});

test('escapes backslashes in MySQL dialect string literals', () {
// Path with backslashes in MySQL
expect(
TableMutationEngine.formatLiteral(
r'C:\Program Files\App\',
SqlDialect.mysql,
),
r"'C:\\Program Files\\App\\'",
);

// Trailing backslash with single quote
expect(
TableMutationEngine.formatLiteral(
r"test\'end",
SqlDialect.mysql,
),
r"'test\\''end'",
);

// Postgres does not escape backslashes with double backslash
expect(
TableMutationEngine.formatLiteral(
r'C:\Program Files\',
SqlDialect.postgres,
),
r"'C:\Program Files\'",
);
});
});
}
39 changes: 39 additions & 0 deletions test/features/workspace/data_grid_context_menu_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,45 @@ void main() {
expect(decoded[1]['name'], equals('Bob, Jr.'));
expect(decoded[1]['score'], equals(80));
});

test('toTsv escapes cells with tabs, newlines, and quotes', () {
final rowsWithSpecialChars = [
['1', 'Line1\nLine2', 'Tab\there', 'Quoted "value"'],
];
const sel = ResultGridSelection(
startRow: 0,
startColumn: 0,
endRow: 0,
endColumn: 3,
);

final tsv = sel.toTsv(rowsWithSpecialChars);
expect(
tsv,
equals('1\t"Line1\nLine2"\t"Tab\there"\t"Quoted ""value"""'),
);
});

test('toJson preserves strings with leading zeros', () {
final rowsWithLeadingZeros = [
['007', '01234', '0', '42'],
];
const cols = ['agent_id', 'zip', 'zero_num', 'plain_num'];
const sel = ResultGridSelection(
startRow: 0,
startColumn: 0,
endRow: 0,
endColumn: 3,
);

final jsonStr = sel.toJson(cols, rowsWithLeadingZeros);
final decoded = jsonDecode(jsonStr) as Map<String, dynamic>;

expect(decoded['agent_id'], equals('007'));
expect(decoded['zip'], equals('01234'));
expect(decoded['zero_num'], equals(0));
expect(decoded['plain_num'], equals(42));
});
});

group('VirtualResultGrid Non-Destructive Secondary Click', () {
Expand Down
67 changes: 67 additions & 0 deletions test/features/workspace/results_tab_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,73 @@ void main() {
expect(find.text('Bob'), findsNothing);
expect(find.text('2 of 3 rows'), findsOneWidget);
});

testWidgets('filters rows and mutates correct underlying row when StagingBuffer is attached', (tester) async {
final buffer = DataGridStagingBuffer(
columns: const ['id', 'name', 'department'],
rows: const [
['1', 'Alice', 'Engineering'],
['2', 'Bob', 'Marketing'],
['3', 'Charlie', 'Engineering'],
],
);

await tester.pumpWidget(
resultsShell(
child: material.Scaffold(
body: material.SizedBox(
width: 800,
height: 600,
child: ResultsTab(
columns: const ['id', 'name', 'department'],
rows: buffer.effectiveRows,
stagingBuffer: buffer,
),
),
),
),
);
await tester.pumpAndSettle();

expect(find.text('Alice'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
expect(find.text('Charlie'), findsOneWidget);

// Open quick filter bar
await tester.tap(find.byTooltip('Toggle Quick Filter'));
await tester.pumpAndSettle();

// Enter filter 'Bob'
await tester.enterText(find.byType(material.TextField), 'Bob');
await tester.pumpAndSettle();

// Only Bob should be displayed in the grid
final bobGridCell = find.descendant(
of: find.byType(VirtualResultGrid),
matching: find.text('Bob'),
);
expect(bobGridCell, findsOneWidget);
expect(find.text('Alice'), findsNothing);
expect(find.text('Charlie'), findsNothing);

// Tap Bob cell to select the row
await tester.tap(bobGridCell);
await tester.pump(const Duration(milliseconds: 350));
await tester.pumpAndSettle();

// Tap 'Delete Row' on the staging toolbar
final deleteBtn = find.text('Delete Row');
expect(deleteBtn, findsOneWidget);
await tester.tap(deleteBtn);
await tester.pumpAndSettle();

// Verify that underlying buffer index 1 (Bob's row) was marked deleted, NOT Alice (index 0)
expect(buffer.getRowStatus(1), equals(StagedRowStatus.deleted));
expect(buffer.getRowStatus(0), equals(StagedRowStatus.unchanged));
expect(buffer.getRowStatus(2), equals(StagedRowStatus.unchanged));

buffer.dispose();
});
});
}

Loading