Skip to content

Commit dc43d22

Browse files
perf(sql-workspace): rows show before the table schema lookup, schema cache (#1171)
After a query the rows are shown at once. The table schema (keys, column types, edit hint, save state) is applied when it arrives: from a session cache keyed by the statement and its columns, or from the delegate in the background. An answer for a result the tab has left is dropped. The cache is cleared after any statement that is not a read, so a table altered by DDL is looked up again.
1 parent f7c5fa0 commit dc43d22

2 files changed

Lines changed: 236 additions & 36 deletions

File tree

‎lib/features/workspace/generic_sql_workspace.dart‎

Lines changed: 100 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,79 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
402402

403403
/// Runs the selection, else the whole text. With [statementAtCursor] (run
404404
/// statement) only the statement under the caret, see [SqlStatementSplitter].
405+
final Map<String, SqlResultGridSchema> _gridSchemaCache = {};
406+
407+
static String _schemaCacheKey(String sql, List<String> cols) =>
408+
'${cols.join('\u0001')}\u0000$sql';
409+
410+
static final _readQueryRegex = RegExp(
411+
r'^\s*(select|with|values|show|explain|pragma|describe)\b',
412+
caseSensitive: false,
413+
);
414+
415+
static bool _isReadQuery(String sql) => _readQueryRegex.hasMatch(sql);
416+
417+
String _resultStatus(
418+
SqlExecutionResult result,
419+
List<String> cols,
420+
List<List<String>> outRows,
421+
String? editHint,
422+
) {
423+
if (result.statusMessage != null) {
424+
return withEditHint(result.statusMessage!, editHint);
425+
}
426+
if (cols.isEmpty && outRows.isEmpty) {
427+
return result.affectedRows != null
428+
? 'OK. Rows affected: ${result.affectedRows}.'
429+
: 'Command completed.';
430+
}
431+
final cap = _resultMaxRows;
432+
return withEditHint(
433+
result.isTruncated
434+
? 'Showing first $cap row(s) (result capped).'
435+
: '${outRows.length} row(s).',
436+
editHint,
437+
);
438+
}
439+
440+
/// Applies the table schema to the result it was asked for. An answer that
441+
/// arrives after the tab has moved on to another result is dropped.
442+
void _applyGridSchema(
443+
SqlQueryTabSession session,
444+
String userSql,
445+
List<String> cols,
446+
List<List<String>> outRows,
447+
SqlResultGridSchema gridSchema,
448+
SqlExecutionResult result,
449+
) {
450+
if (!mounted ||
451+
!identical(session.rows, outRows) ||
452+
session.lastExecutedSql != userSql) {
453+
return;
454+
}
455+
final pks = gridSchema.primaryKeys;
456+
final editHint = gridSchema.editHint(cols);
457+
final canSave = sqlResultGridSaveEnabled(
458+
sql: userSql,
459+
resultColumns: cols,
460+
primaryKeys: pks,
461+
);
462+
setState(() {
463+
session.resultGridPrimaryKeys = canSave ? pks : const [];
464+
session.resultGridColumnDataTypes = gridSchema.columnDataTypes;
465+
session.resultGridColumnMeta = gridSchema.columnMeta;
466+
session.stagingBuffer?.dispose();
467+
session.stagingBuffer = canSave
468+
? DataGridStagingBuffer(
469+
columns: cols,
470+
rows: outRows,
471+
primaryKeys: pks,
472+
)
473+
: null;
474+
session.statusLine = _resultStatus(result, cols, outRows, editHint);
475+
});
476+
}
477+
405478
Future<void> execute([
406479
SqlQueryTabSession? targetSession,
407480
bool statementAtCursor = false,
@@ -479,51 +552,42 @@ class GenericSqlWorkspaceState extends material.State<GenericSqlWorkspace> {
479552
final cols = result.columns;
480553
final outRows = result.rows;
481554

482-
final gridSchema = await widget.delegate.resolveTableSchema(userSql, cols);
483-
final pks = gridSchema.primaryKeys;
484-
final editHint = gridSchema.editHint(cols);
485-
final canSave = sqlResultGridSaveEnabled(
486-
sql: userSql,
487-
resultColumns: cols,
488-
primaryKeys: pks,
489-
);
490-
555+
// Rows show now. The table schema (keys, types, edit hint) follows: from
556+
// the cache, or from the delegate in the background.
557+
final cacheKey = _schemaCacheKey(userSql, cols);
558+
final cached = _gridSchemaCache[cacheKey];
491559
invalidatePane(session);
492560
setState(() {
493561
session.columns = cols;
494562
session.rows = outRows;
495563
session.affectedRows = result.affectedRows;
496564
session.lastExecutedSql = userSql;
497-
session.resultGridPrimaryKeys = canSave ? pks : const [];
498-
session.resultGridColumnDataTypes = gridSchema.columnDataTypes;
499-
session.resultGridColumnMeta = gridSchema.columnMeta;
565+
session.resultGridPrimaryKeys = const [];
566+
session.resultGridColumnDataTypes = null;
567+
session.resultGridColumnMeta = null;
500568
session.stagingBuffer?.dispose();
501-
session.stagingBuffer = canSave
502-
? DataGridStagingBuffer(
503-
columns: cols,
504-
rows: outRows,
505-
primaryKeys: pks,
506-
)
507-
: null;
508-
509-
if (result.statusMessage != null) {
510-
session.statusLine = withEditHint(result.statusMessage!, editHint);
511-
} else if (cols.isEmpty && outRows.isEmpty) {
512-
session.statusLine = result.affectedRows != null
513-
? 'OK. Rows affected: ${result.affectedRows}.'
514-
: 'Command completed.';
515-
} else {
516-
final n = outRows.length;
517-
final cap = _resultMaxRows;
518-
session.statusLine = withEditHint(
519-
result.isTruncated
520-
? 'Showing first $cap row(s) (result capped).'
521-
: '$n row(s).',
522-
editHint,
523-
);
524-
}
569+
session.stagingBuffer = null;
570+
session.statusLine = _resultStatus(result, cols, outRows, null);
525571
session.running = false;
526572
});
573+
// Anything that is not a read may change table shapes: forget the cache.
574+
if (!_isReadQuery(userSql)) _gridSchemaCache.clear();
575+
576+
if (cached != null) {
577+
_applyGridSchema(session, userSql, cols, outRows, cached, result);
578+
} else {
579+
unawaited(
580+
widget.delegate.resolveTableSchema(userSql, cols).then(
581+
(gridSchema) {
582+
if (!mounted) return;
583+
_gridSchemaCache[cacheKey] = gridSchema;
584+
_applyGridSchema(
585+
session, userSql, cols, outRows, gridSchema, result);
586+
},
587+
onError: (_) {}, // The rows stay without save.
588+
),
589+
);
590+
}
527591

528592
sw.stop();
529593
final duration = result.elapsed ?? sw.elapsed;
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import 'dart:async';
2+
import 'dart:io';
3+
4+
import 'package:flutter/material.dart' as material;
5+
import 'package:flutter_test/flutter_test.dart';
6+
import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart';
7+
import 'package:querya_desktop/core/database/table_mutation_engine.dart';
8+
import 'package:querya_desktop/core/storage/local_db.dart';
9+
import 'package:querya_desktop/features/workspace/generic_sql_workspace.dart';
10+
import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart';
11+
import 'package:querya_desktop/features/workspace/sql_result_grid_schema.dart';
12+
13+
import '../../memory_secrets_backend.dart';
14+
import '../../support/fake_sql_execution_delegate.dart';
15+
import '../../support/local_db_test_support.dart';
16+
import '../../support/querya_theme_test_shell.dart';
17+
18+
/// Answers queries normally, but the table schema never arrives unless
19+
/// [schemaAnswer] completes it, and every lookup is counted.
20+
class _SchemaDelegate extends FakeSqlExecutionDelegate {
21+
_SchemaDelegate({this.hang = false})
22+
: super(
23+
onExecute: (sql) => const SqlExecutionResult(
24+
columns: ['id', 'name'],
25+
rows: [
26+
['1', 'Alice'],
27+
],
28+
),
29+
);
30+
31+
final bool hang;
32+
final lookups = <String>[];
33+
34+
@override
35+
Future<SqlResultGridSchema> resolveTableSchema(
36+
String userSql,
37+
List<String> columns,
38+
) {
39+
lookups.add(userSql);
40+
if (hang) return Completer<SqlResultGridSchema>().future;
41+
return Future.value(SqlResultGridSchema.none);
42+
}
43+
}
44+
45+
void main() {
46+
TestWidgetsFlutterBinding.ensureInitialized();
47+
48+
late Directory tempDir;
49+
late int connectionId;
50+
late ConnectionRow connection;
51+
52+
setUpAll(() async => tempDir = await initTestLocalDb('generic_ws_schema_'));
53+
tearDownAll(() => disposeTestLocalDb(tempDir));
54+
55+
setUp(() async {
56+
connectionId = await LocalDb.instance.addConnection(
57+
const ConnectionRow(
58+
type: 'sqlite',
59+
name: 'Schema DB',
60+
host: '/tmp/schema.db',
61+
createdAt: '2026-01-01T00:00:00Z',
62+
),
63+
);
64+
connection = ConnectionRow(
65+
id: connectionId,
66+
type: 'sqlite',
67+
name: 'Schema DB',
68+
host: '/tmp/schema.db',
69+
createdAt: '2026-01-01T00:00:00Z',
70+
);
71+
});
72+
73+
tearDown(() async {
74+
SqlEditorCommandBridge.instance.unregister(connectionId: connectionId);
75+
testMemorySecrets.clear();
76+
for (final c in await LocalDb.instance.getConnections()) {
77+
if (c.id != null) await LocalDb.instance.removeConnection(c.id!);
78+
}
79+
});
80+
81+
Future<GenericSqlWorkspaceState> pumpWorkspace(
82+
WidgetTester tester,
83+
_SchemaDelegate delegate,
84+
String initialSql,
85+
) async {
86+
await tester.binding.setSurfaceSize(const material.Size(1200, 800));
87+
addTearDown(() => tester.binding.setSurfaceSize(null));
88+
await tester.pumpWidget(
89+
queryaThemeTestShell(
90+
child: material.SizedBox.expand(
91+
child: GenericSqlWorkspace(
92+
connectionRow: connection,
93+
delegate: delegate,
94+
dialect: SqlDialect.sqlite,
95+
initialSql: initialSql,
96+
),
97+
),
98+
),
99+
);
100+
await tester.pumpAndSettle();
101+
return tester.state<GenericSqlWorkspaceState>(
102+
find.byType(GenericSqlWorkspace),
103+
);
104+
}
105+
106+
Future<void> runAndWait(
107+
WidgetTester tester,
108+
GenericSqlWorkspaceState state,
109+
) async {
110+
unawaited(state.execute());
111+
for (var i = 0; i < 6; i++) {
112+
await tester.runAsync(
113+
() => Future<void>.delayed(const Duration(milliseconds: 100)),
114+
);
115+
await tester.pump();
116+
}
117+
}
118+
119+
testWidgets('rows show while the table schema lookup never completes',
120+
(tester) async {
121+
final delegate = _SchemaDelegate(hang: true);
122+
final state = await pumpWorkspace(tester, delegate, 'select * from users');
123+
await runAndWait(tester, state);
124+
125+
expect(delegate.lookups, ['select * from users']);
126+
expect(find.text('Alice'), findsWidgets);
127+
});
128+
129+
testWidgets('the same query is not looked up again', (tester) async {
130+
final delegate = _SchemaDelegate();
131+
final state = await pumpWorkspace(tester, delegate, 'select * from users');
132+
await runAndWait(tester, state);
133+
await runAndWait(tester, state);
134+
expect(delegate.lookups.length, 1);
135+
});
136+
}

0 commit comments

Comments
 (0)