diff --git a/lib/core/database/compact_result_dataset.dart b/lib/core/database/compact_result_dataset.dart index 979a5ee4..50f8e5d9 100644 --- a/lib/core/database/compact_result_dataset.dart +++ b/lib/core/database/compact_result_dataset.dart @@ -131,11 +131,13 @@ class GenericCompactColumn implements CompactColumn { GenericCompactColumn({ required this.values, required this.length, + this.formatter, }); final List values; @override final int length; + final String Function(Object?)? formatter; @override CompactColumnKind get kind => CompactColumnKind.generic; @@ -150,6 +152,10 @@ class GenericCompactColumn implements CompactColumn { String stringValueAt(int index, [StringInternPool? pool]) { final val = values[index]; if (val == null) return 'NULL'; + if (formatter != null) { + final str = formatter!(val); + return pool != null ? pool.intern(str) : str; + } return pool != null ? pool.internObject(val) : val.toString(); } } @@ -208,9 +214,13 @@ class CompactResultDataset { /// Constructs a [CompactResultDataset] from raw rows, detecting column types /// and packing numeric columns into [Int64List] / [Float64List]. + /// + /// An optional [cellFormatter] callback can be provided to customize display string + /// formatting for driver-specific types (e.g. blobs, timestamps, boolean tinyints). static CompactResultDataset fromRawRows( List columnNames, List> rawRows, { + String Function(Object? value, int colIndex)? cellFormatter, StringInternPool? pool, }) { final rowCount = rawRows.length; @@ -239,14 +249,27 @@ class CompactResultDataset { if (val == null) continue; sampleCount++; - if (val is! int) { - allInt = false; - } - if (val is! num) { - allFloat = false; - } - if (val is! String) { - allString = false; + if (cellFormatter != null) { + final formatted = cellFormatter(val, col); + if (allInt && (val is! int || formatted != val.toString())) { + allInt = false; + } + if (allFloat && (val is! num || formatted != val.toString())) { + allFloat = false; + } + if (allString && (val is! String || formatted != val)) { + allString = false; + } + } else { + if (val is! int) { + allInt = false; + } + if (val is! num) { + allFloat = false; + } + if (val is! String) { + allString = false; + } } if (sampleCount >= 60 && !allInt && !allFloat && !allString) { @@ -340,6 +363,7 @@ class CompactResultDataset { compactColumns.add(GenericCompactColumn( values: values, length: rowCount, + formatter: cellFormatter != null ? (v) => cellFormatter(v, col) : null, )); } } diff --git a/lib/core/database/sql_query_runner.dart b/lib/core/database/sql_query_runner.dart index cc81271e..2d4db74c 100644 --- a/lib/core/database/sql_query_runner.dart +++ b/lib/core/database/sql_query_runner.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/database/compact_result_dataset.dart'; /// Read side of a SQL session: run a statement, explain it, release it. /// @@ -32,12 +33,38 @@ class SqlExecutionResult { this.statusMessage, this.elapsed, this.isTruncated = false, + this.dataset, }); + /// Creates a [SqlExecutionResult] backed by a [CompactResultDataset]. + /// + /// [rows] defaults to [dataset.asLazyRowList()], providing unformatted on-demand + /// access to cells for virtualized grid rendering while avoiding upfront + /// allocation of millions of string objects. + factory SqlExecutionResult.fromDataset({ + required CompactResultDataset dataset, + int? affectedRows, + String? statusMessage, + Duration? elapsed, + bool isTruncated = false, + List>? rows, + }) { + return SqlExecutionResult( + columns: dataset.columnNames, + rows: rows ?? dataset.asLazyRowList(), + affectedRows: affectedRows, + statusMessage: statusMessage, + elapsed: elapsed, + isTruncated: isTruncated, + dataset: dataset, + ); + } + final List columns; final List> rows; final int? affectedRows; final String? statusMessage; final Duration? elapsed; final bool isTruncated; + final CompactResultDataset? dataset; } diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index c8d13e9d..3135af26 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/compact_result_dataset.dart'; import 'package:querya_desktop/core/database/mysql_result_cells.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/result_row_string_convert.dart'; @@ -107,34 +108,29 @@ class MysqlSqlExecutionDelegate extends BaseSqlExecutionDelegate { }, ); final colList = rs.cols.toList(); - final pool = StringInternPool(); - final outRows = >[]; + final rawRows = >[]; for (var r = 0; r < taken.items.length; r++) { final row = taken.items[r]; - outRows.add( - List.generate( - row.numOfColumns, - (i) { - final col = i < colList.length ? colList[i] : null; - return mysqlResultCellToDisplayString( - row.colAt(i), - column: col, - pool: pool, - ); - }, - ), - ); - if (kResultStringConvertYieldEvery > 0 && - (r + 1) % kResultStringConvertYieldEvery == 0) { - await Future.delayed(Duration.zero); - } + rawRows.add([ + for (var i = 0; i < row.numOfColumns; i++) row.colAt(i), + ]); } + + final dataset = CompactResultDataset.fromRawRows( + cols, + rawRows, + cellFormatter: (val, colIndex) { + final col = colIndex < colList.length ? colList[colIndex] : null; + return mysqlResultCellToDisplayString(val, column: col); + }, + ); + final truncated = - taken.truncated || (effectiveSql != sql && outRows.length >= cap); - final n = outRows.length; + taken.truncated || (effectiveSql != sql && dataset.rowCount >= cap); + final n = dataset.rowCount; int? affected; - if (cols.isEmpty && outRows.isEmpty) { + if (cols.isEmpty && dataset.rowCount == 0) { affected = _affectedInt(rs.affectedRows); } @@ -146,9 +142,8 @@ class MysqlSqlExecutionDelegate extends BaseSqlExecutionDelegate { cap: cap, ); - return SqlExecutionResult( - columns: cols, - rows: outRows, + return SqlExecutionResult.fromDataset( + dataset: dataset, affectedRows: affected, statusMessage: statusMsg, isTruncated: truncated, diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 2d7b9375..89decbb8 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/compact_result_dataset.dart'; +import 'package:querya_desktop/core/database/postgres_result_cells.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; import 'package:querya_desktop/core/database/sql_table_target_extractor.dart'; @@ -8,7 +10,6 @@ import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/postgresql/postgres_object_kind.dart'; -import 'package:querya_desktop/features/postgresql/postgres_result_utils.dart'; import 'package:querya_desktop/features/postgresql/postgres_table_utils.dart'; import 'package:querya_desktop/features/workspace/workspace.dart'; import 'package:querya_desktop/features/workspace/query_plan.dart'; @@ -129,28 +130,29 @@ class PostgresSqlExecutionDelegate extends BaseSqlExecutionDelegate { n++; } - final outRows = await convertPostgresResultRowsToStringsAdaptive( - PostgresResultConvertJob( - rowValues: rawRows, - columnTypeOids: [ - for (final c in schema.columns) c.typeOid, - ], - ), + final dataset = CompactResultDataset.fromRawRows( + cols, + rawRows, + cellFormatter: (val, colIndex) { + final typeOid = colIndex < schema.columns.length + ? schema.columns[colIndex].typeOid + : null; + return postgresResultCellToDisplayString(val, typeOid: typeOid); + }, ); final isTruncated = result.length >= cap; final statusMsg = formatStatusMessage( columnCount: cols.length, - rowCount: outRows.length, + rowCount: dataset.rowCount, affectedRows: result.affectedRows, isTruncated: isTruncated, cap: cap, successPrefix: 'Command completed.', ); - return SqlExecutionResult( - columns: cols, - rows: outRows, + return SqlExecutionResult.fromDataset( + dataset: dataset, affectedRows: result.affectedRows, statusMessage: statusMsg, isTruncated: isTruncated, diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 3e4e3f8f..38969b5c 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/compact_result_dataset.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'; @@ -112,8 +113,10 @@ class SqliteSqlExecutionDelegate extends BaseSqlExecutionDelegate { count++; } - final outRows = await convertSqliteResultRowsToStringsAdaptive( - SqliteResultConvertJob(rowValues: rawRows), + final dataset = CompactResultDataset.fromRawRows( + cols, + rawRows, + cellFormatter: (val, _) => sqliteResultCellToDisplayString(val), ); final isTruncated = truncated || (injectedLimit && results.length >= cap); @@ -124,11 +127,10 @@ class SqliteSqlExecutionDelegate extends BaseSqlExecutionDelegate { cap: cap, ); - return SqlExecutionResult( - columns: cols, - rows: outRows, + return SqlExecutionResult.fromDataset( + dataset: dataset, statusMessage: statusMsg, - isTruncated: truncated || (injectedLimit && results.length >= cap), + isTruncated: isTruncated, ); } diff --git a/test/core/database/compact_result_dataset_test.dart b/test/core/database/compact_result_dataset_test.dart index 1537556a..0248ed56 100644 --- a/test/core/database/compact_result_dataset_test.dart +++ b/test/core/database/compact_result_dataset_test.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/compact_result_dataset.dart'; +import 'package:querya_desktop/core/database/sql_query_runner.dart'; void main() { group('CompactResultDataset', () { @@ -162,5 +163,75 @@ void main() { expect(column.stringValueAt(2), '0.0'); expect(column.stringValueAt(3), '100.0'); }); + + test('fromRawRows with cellFormatter customizes generic column formatting and overrides primitives', () { + final columns = ['id', 'is_active', 'raw_bytes']; + final rows = >[ + [101, 1, [0xde, 0xad]], + [102, 0, [0xbe, 0xef]], + [103, null, null], + ]; + + final dataset = CompactResultDataset.fromRawRows( + columns, + rows, + cellFormatter: (val, colIndex) { + if (colIndex == 1) { + return val == 1 ? 'true' : (val == 0 ? 'false' : 'NULL'); + } + if (colIndex == 2 && val is List) { + return '0x${val.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'; + } + return val.toString(); + }, + ); + + expect(dataset.columns[0].kind, CompactColumnKind.int64); + // is_active was 1 and 0, but cellFormatter returned 'true' / 'false' != '1' / '0', + // so it was correctly recognized as generic with formatter + expect(dataset.columns[1].kind, CompactColumnKind.generic); + expect(dataset.columns[2].kind, CompactColumnKind.generic); + + expect(dataset.cellAt(0, 0), '101'); + expect(dataset.cellAt(0, 1), 'true'); + expect(dataset.cellAt(0, 2), '0xdead'); + + expect(dataset.cellAt(1, 0), '102'); + expect(dataset.cellAt(1, 1), 'false'); + expect(dataset.cellAt(1, 2), '0xbeef'); + + expect(dataset.cellAt(2, 0), '103'); + expect(dataset.cellAt(2, 1), 'NULL'); + expect(dataset.cellAt(2, 2), 'NULL'); + }); + + test('SqlExecutionResult.fromDataset provides transparent lazy rows and backwards compatibility', () { + final columns = ['title', 'views']; + final rows = >[ + ['First Post', 42], + ['Second Post', 100], + ]; + + final dataset = CompactResultDataset.fromRawRows(columns, rows); + final result = SqlExecutionResult.fromDataset( + dataset: dataset, + affectedRows: null, + statusMessage: '2 rows fetched', + isTruncated: false, + ); + + expect(result.dataset, same(dataset)); + expect(result.columns, ['title', 'views']); + expect(result.statusMessage, '2 rows fetched'); + expect(result.isTruncated, isFalse); + + // rows is lazy and fully matches List> contract + expect(result.rows.length, 2); + expect(result.rows[0], ['First Post', '42']); + expect(result.rows[1], ['Second Post', '100']); + expect(result.rows[0][0], 'First Post'); + expect(result.rows[1][1], '100'); + }); }); } +