diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index b8a4b921..6e844b07 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -402,6 +402,79 @@ class GenericSqlWorkspaceState extends material.State { /// Runs the selection, else the whole text. With [statementAtCursor] (run /// statement) only the statement under the caret, see [SqlStatementSplitter]. + final Map _gridSchemaCache = {}; + + static String _schemaCacheKey(String sql, List cols) => + '${cols.join('\u0001')}\u0000$sql'; + + static final _readQueryRegex = RegExp( + r'^\s*(select|with|values|show|explain|pragma|describe)\b', + caseSensitive: false, + ); + + static bool _isReadQuery(String sql) => _readQueryRegex.hasMatch(sql); + + String _resultStatus( + SqlExecutionResult result, + List cols, + List> outRows, + String? editHint, + ) { + if (result.statusMessage != null) { + return withEditHint(result.statusMessage!, editHint); + } + if (cols.isEmpty && outRows.isEmpty) { + return result.affectedRows != null + ? 'OK. Rows affected: ${result.affectedRows}.' + : 'Command completed.'; + } + final cap = _resultMaxRows; + return withEditHint( + result.isTruncated + ? 'Showing first $cap row(s) (result capped).' + : '${outRows.length} row(s).', + editHint, + ); + } + + /// Applies the table schema to the result it was asked for. An answer that + /// arrives after the tab has moved on to another result is dropped. + void _applyGridSchema( + SqlQueryTabSession session, + String userSql, + List cols, + List> outRows, + SqlResultGridSchema gridSchema, + SqlExecutionResult result, + ) { + if (!mounted || + !identical(session.rows, outRows) || + session.lastExecutedSql != userSql) { + return; + } + final pks = gridSchema.primaryKeys; + final editHint = gridSchema.editHint(cols); + final canSave = sqlResultGridSaveEnabled( + sql: userSql, + resultColumns: cols, + primaryKeys: pks, + ); + setState(() { + session.resultGridPrimaryKeys = canSave ? pks : const []; + session.resultGridColumnDataTypes = gridSchema.columnDataTypes; + session.resultGridColumnMeta = gridSchema.columnMeta; + session.stagingBuffer?.dispose(); + session.stagingBuffer = canSave + ? DataGridStagingBuffer( + columns: cols, + rows: outRows, + primaryKeys: pks, + ) + : null; + session.statusLine = _resultStatus(result, cols, outRows, editHint); + }); + } + Future execute([ SqlQueryTabSession? targetSession, bool statementAtCursor = false, @@ -479,51 +552,42 @@ class GenericSqlWorkspaceState extends material.State { final cols = result.columns; final outRows = result.rows; - final gridSchema = await widget.delegate.resolveTableSchema(userSql, cols); - final pks = gridSchema.primaryKeys; - final editHint = gridSchema.editHint(cols); - final canSave = sqlResultGridSaveEnabled( - sql: userSql, - resultColumns: cols, - primaryKeys: pks, - ); - + // Rows show now. The table schema (keys, types, edit hint) follows: from + // the cache, or from the delegate in the background. + final cacheKey = _schemaCacheKey(userSql, cols); + final cached = _gridSchemaCache[cacheKey]; invalidatePane(session); setState(() { session.columns = cols; session.rows = outRows; session.affectedRows = result.affectedRows; session.lastExecutedSql = userSql; - session.resultGridPrimaryKeys = canSave ? pks : const []; - session.resultGridColumnDataTypes = gridSchema.columnDataTypes; - session.resultGridColumnMeta = gridSchema.columnMeta; + session.resultGridPrimaryKeys = const []; + session.resultGridColumnDataTypes = null; + session.resultGridColumnMeta = null; session.stagingBuffer?.dispose(); - session.stagingBuffer = canSave - ? DataGridStagingBuffer( - columns: cols, - rows: outRows, - primaryKeys: pks, - ) - : null; - - if (result.statusMessage != null) { - session.statusLine = withEditHint(result.statusMessage!, editHint); - } else if (cols.isEmpty && outRows.isEmpty) { - session.statusLine = result.affectedRows != null - ? 'OK. Rows affected: ${result.affectedRows}.' - : 'Command completed.'; - } else { - final n = outRows.length; - final cap = _resultMaxRows; - session.statusLine = withEditHint( - result.isTruncated - ? 'Showing first $cap row(s) (result capped).' - : '$n row(s).', - editHint, - ); - } + session.stagingBuffer = null; + session.statusLine = _resultStatus(result, cols, outRows, null); session.running = false; }); + // Anything that is not a read may change table shapes: forget the cache. + if (!_isReadQuery(userSql)) _gridSchemaCache.clear(); + + if (cached != null) { + _applyGridSchema(session, userSql, cols, outRows, cached, result); + } else { + unawaited( + widget.delegate.resolveTableSchema(userSql, cols).then( + (gridSchema) { + if (!mounted) return; + _gridSchemaCache[cacheKey] = gridSchema; + _applyGridSchema( + session, userSql, cols, outRows, gridSchema, result); + }, + onError: (_) {}, // The rows stay without save. + ), + ); + } sw.stop(); final duration = result.elapsed ?? sw.elapsed; diff --git a/test/features/workspace/generic_sql_workspace_schema_test.dart b/test/features/workspace/generic_sql_workspace_schema_test.dart new file mode 100644 index 00000000..ef96021b --- /dev/null +++ b/test/features/workspace/generic_sql_workspace_schema_test.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/database/table_mutation_engine.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/workspace/generic_sql_workspace.dart'; +import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; +import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart'; + +import '../../memory_secrets_backend.dart'; +import '../../support/fake_sql_execution_delegate.dart'; +import '../../support/local_db_test_support.dart'; +import '../../support/querya_theme_test_shell.dart'; + +/// Answers queries normally, but the table schema never arrives unless +/// [schemaAnswer] completes it, and every lookup is counted. +class _SchemaDelegate extends FakeSqlExecutionDelegate { + _SchemaDelegate({this.hang = false}) + : super( + onExecute: (sql) => const SqlExecutionResult( + columns: ['id', 'name'], + rows: [ + ['1', 'Alice'], + ], + ), + ); + + final bool hang; + final lookups = []; + + @override + Future resolveTableSchema( + String userSql, + List columns, + ) { + lookups.add(userSql); + if (hang) return Completer().future; + return Future.value(SqlResultGridSchema.none); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late int connectionId; + late ConnectionRow connection; + + setUpAll(() async => tempDir = await initTestLocalDb('generic_ws_schema_')); + tearDownAll(() => disposeTestLocalDb(tempDir)); + + setUp(() async { + connectionId = await LocalDb.instance.addConnection( + const ConnectionRow( + type: 'sqlite', + name: 'Schema DB', + host: '/tmp/schema.db', + createdAt: '2026-01-01T00:00:00Z', + ), + ); + connection = ConnectionRow( + id: connectionId, + type: 'sqlite', + name: 'Schema DB', + host: '/tmp/schema.db', + createdAt: '2026-01-01T00:00:00Z', + ); + }); + + tearDown(() async { + SqlEditorCommandBridge.instance.unregister(connectionId: connectionId); + testMemorySecrets.clear(); + for (final c in await LocalDb.instance.getConnections()) { + if (c.id != null) await LocalDb.instance.removeConnection(c.id!); + } + }); + + Future pumpWorkspace( + WidgetTester tester, + _SchemaDelegate delegate, + String initialSql, + ) async { + await tester.binding.setSurfaceSize(const material.Size(1200, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox.expand( + child: GenericSqlWorkspace( + connectionRow: connection, + delegate: delegate, + dialect: SqlDialect.sqlite, + initialSql: initialSql, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return tester.state( + find.byType(GenericSqlWorkspace), + ); + } + + Future runAndWait( + WidgetTester tester, + GenericSqlWorkspaceState state, + ) async { + unawaited(state.execute()); + for (var i = 0; i < 6; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); + await tester.pump(); + } + } + + testWidgets('rows show while the table schema lookup never completes', + (tester) async { + final delegate = _SchemaDelegate(hang: true); + final state = await pumpWorkspace(tester, delegate, 'select * from users'); + await runAndWait(tester, state); + + expect(delegate.lookups, ['select * from users']); + expect(find.text('Alice'), findsWidgets); + }); + + testWidgets('the same query is not looked up again', (tester) async { + final delegate = _SchemaDelegate(); + final state = await pumpWorkspace(tester, delegate, 'select * from users'); + await runAndWait(tester, state); + await runAndWait(tester, state); + expect(delegate.lookups.length, 1); + }); +}