From c94f4035d002e614ef25077e180d037efd9ea3b5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 00:02:49 +0300 Subject: [PATCH] perf(sqlite): eliminate redundant double string conversion pass in SqliteSqlExecutionDelegate --- lib/features/sqlite/sqlite_result_utils.dart | 53 +++++++++++++++++-- lib/features/sqlite/sqlite_sql_workspace.dart | 17 +++--- .../result_conversion_perf_test.dart | 53 +++++++++++++++++++ 3 files changed, 113 insertions(+), 10 deletions(-) diff --git a/lib/features/sqlite/sqlite_result_utils.dart b/lib/features/sqlite/sqlite_result_utils.dart index 7cbe4cca..91129088 100644 --- a/lib/features/sqlite/sqlite_result_utils.dart +++ b/lib/features/sqlite/sqlite_result_utils.dart @@ -1,29 +1,72 @@ -import 'dart:typed_data'; - +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; /// Serializable row batch for [convertSqliteResultRowsToStrings] in a worker isolate. class SqliteResultConvertJob { const SqliteResultConvertJob({ required this.rowValues, + this.columnDataTypes, }); final List> rowValues; + final List? columnDataTypes; } /// Converts SQLite result cell values to display strings off the UI thread. List> convertSqliteResultRowsToStrings( SqliteResultConvertJob job) { + final types = job.columnDataTypes; return [ for (final row in job.rowValues) - [for (final value in row) sqliteResultCellToDisplayString(value)], + [ + for (var i = 0; i < row.length; i++) + sqliteResultCellToDisplayString( + row[i], + dataTypeName: types != null && i < types.length ? types[i] : null, + ), + ], ]; } +/// Typed conversion in a single pass: converts each cell to SQLite display string, +/// interns the resulting string (repeated values share one instance), and yields +/// to the event loop every [yieldEvery] rows. +Future>> convertSqliteResultRowsToStringsAdaptive( + SqliteResultConvertJob job, { + int yieldEvery = kResultStringConvertYieldEvery, + StringInternPool? pool, +}) async { + final rowValues = job.rowValues; + if (rowValues.isEmpty) return const []; + + final types = job.columnDataTypes; + final activePool = pool ?? StringInternPool(); + final out = >[]; + for (var r = 0; r < rowValues.length; r++) { + final row = rowValues[r]; + out.add([ + for (var i = 0; i < row.length; i++) + activePool.intern( + sqliteResultCellToDisplayString( + row[i], + dataTypeName: types != null && i < types.length ? types[i] : null, + pool: activePool, + ), + ), + ]); + if (yieldEvery > 0 && (r + 1) % yieldEvery == 0) { + await Future.delayed(Duration.zero); + } + } + return out; +} + /// BLOB as `X'deadbeef'`; other values via [Object.toString]. Null → `NULL`. String sqliteResultCellToDisplayString( Object? value, { String? dataTypeName, + StringInternPool? pool, }) { if (value == null) return 'NULL'; if (value is Uint8List) { @@ -37,6 +80,10 @@ String sqliteResultCellToDisplayString( TableMutationEngine.isBinaryType(dataTypeName)) { return sqliteBlobHexLiteralFromCell(value); } + if (pool != null) { + return pool.internObject(value); + } + if (value is bool) return value ? 'true' : 'false'; return value.toString(); } diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index e1ddd296..3e4e3f8f 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; -import 'package:querya_desktop/core/database/result_row_string_convert.dart'; import 'package:querya_desktop/core/database/sql_limit.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; @@ -105,13 +104,17 @@ class SqliteSqlExecutionDelegate extends BaseSqlExecutionDelegate { final limitCount = truncated ? cap : results.length; final injectedLimit = effectiveSql != sql; - final rawRows = results.take(limitCount).map((row) { - return cols - .map((col) => sqliteResultCellToDisplayString(row[col])) - .toList(); - }).toList(); + final rawRows = >[]; + var count = 0; + for (final row in results) { + if (count >= limitCount) break; + rawRows.add([for (final col in cols) row[col]]); + count++; + } - final outRows = await convertResultRowsToStringsAdaptive(rawRows); + final outRows = await convertSqliteResultRowsToStringsAdaptive( + SqliteResultConvertJob(rowValues: rawRows), + ); final isTruncated = truncated || (injectedLimit && results.length >= cap); final statusMsg = formatStatusMessage( diff --git a/test/features/sql_workspaces/result_conversion_perf_test.dart b/test/features/sql_workspaces/result_conversion_perf_test.dart index 9fe63a38..54db140b 100644 --- a/test/features/sql_workspaces/result_conversion_perf_test.dart +++ b/test/features/sql_workspaces/result_conversion_perf_test.dart @@ -156,4 +156,57 @@ void main() { expect(ticks, 1, reason: 'a queued task ran while conversion was in flight'); }); }); + + group('convertSqliteResultRowsToStringsAdaptive (single typed pass)', () { + List> sample(int n) => [ + for (var i = 0; i < n; i++) + [ + i, + i.isEven ? 'active' : 'custom_$i', + i % 3 == 0 ? Uint8List.fromList([0xca, 0xfe]) : null, + null, + ], + ]; + + test('matches synchronous convertSqliteResultRowsToStrings', () async { + final job = SqliteResultConvertJob(rowValues: sample(1200)); + expect( + await convertSqliteResultRowsToStringsAdaptive(job), + convertSqliteResultRowsToStrings(job), + ); + }); + + test('interns repeated values so they share one string instance', () async { + final out = await convertSqliteResultRowsToStringsAdaptive( + SqliteResultConvertJob( + rowValues: [ + for (var i = 0; i < 6; i++) [i, 'status_${i % 2}_${'x' * 4}'], + ], + ), + ); + expect(identical(out[0][1], out[2][1]), isTrue); + expect(identical(out[1][1], out[3][1]), isTrue); + }); + + test('empty input yields empty output', () async { + expect( + await convertSqliteResultRowsToStringsAdaptive( + const SqliteResultConvertJob(rowValues: []), + ), + isEmpty, + ); + }); + + test('formats blobs correctly as hex literals in adaptive mode', () async { + final out = await convertSqliteResultRowsToStringsAdaptive( + SqliteResultConvertJob( + rowValues: [ + ['ok', Uint8List.fromList([0xde, 0xad]), null], + ], + ), + ); + expect(out.single, ['ok', "X'dead'", 'NULL']); + }); + }); } +