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
136 changes: 100 additions & 36 deletions lib/features/workspace/generic_sql_workspace.dart
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,79 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {

/// Runs the selection, else the whole text. With [statementAtCursor] (run
/// statement) only the statement under the caret, see [SqlStatementSplitter].
final Map<String, SqlResultGridSchema> _gridSchemaCache = {};

static String _schemaCacheKey(String sql, List<String> 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<String> cols,
List<List<String>> 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<String> cols,
List<List<String>> 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<void> execute([
SqlQueryTabSession? targetSession,
bool statementAtCursor = false,
Expand Down Expand Up @@ -479,51 +552,42 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
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;
Expand Down
136 changes: 136 additions & 0 deletions test/features/workspace/generic_sql_workspace_schema_test.dart
Original file line number Diff line number Diff line change
@@ -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 = <String>[];

@override
Future<SqlResultGridSchema> resolveTableSchema(
String userSql,
List<String> columns,
) {
lookups.add(userSql);
if (hang) return Completer<SqlResultGridSchema>().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<GenericSqlWorkspaceState> 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<GenericSqlWorkspaceState>(
find.byType(GenericSqlWorkspace),
);
}

Future<void> runAndWait(
WidgetTester tester,
GenericSqlWorkspaceState state,
) async {
unawaited(state.execute());
for (var i = 0; i < 6; i++) {
await tester.runAsync(
() => Future<void>.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);
});
}
Loading