diff --git a/lib/core/database/compact_result_dataset.dart b/lib/core/database/compact_result_dataset.dart index 13627607..fc9bab91 100644 --- a/lib/core/database/compact_result_dataset.dart +++ b/lib/core/database/compact_result_dataset.dart @@ -1,6 +1,7 @@ import 'dart:collection'; import 'dart:typed_data'; +import 'result_columns_builder.dart'; import 'result_row_string_convert.dart'; /// Supported compact column storage kinds. @@ -215,173 +216,25 @@ 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). - /// Non-null cells per column that go through the cell formatter while - /// [fromRawRows] decides the column's kind; the rest are type-tested. - static const int _formattedSamples = 60; - + /// An optional [cellFormatter] callback can be provided to customize display + /// string formatting for driver-specific types (e.g. blobs, timestamps, + /// boolean tinyints). A driver that has the rows one at a time uses a + /// [ResultColumnsBuilder] instead and never holds them all. static CompactResultDataset fromRawRows( List columnNames, List> rawRows, { String Function(Object? value, int colIndex)? cellFormatter, StringInternPool? pool, }) { - final rowCount = rawRows.length; - final colCount = columnNames.length; - final internPool = pool ?? StringInternPool(); - - if (rowCount == 0 || colCount == 0) { - return CompactResultDataset( - columnNames: columnNames, - columns: const [], - rowCount: 0, - pool: internPool, - ); - } - - // 1. Detect column types by inspecting non-null samples - final kinds = List.filled(colCount, CompactColumnKind.generic); - for (var col = 0; col < colCount; col++) { - var allInt = true; - var allFloat = true; - var allString = true; - var sampleCount = 0; - - for (var row = 0; row < rowCount; row++) { - final val = rawRows[row].length > col ? rawRows[row][col] : null; - if (val == null) continue; - sampleCount++; - - // The driver formatters print a column's ints, numbers and strings - // by the column's type (bytea, binary, boolean...), not by the value, - // so the first [_formattedSamples] cells show whether the column - // prints as its values do. Formatting every cell only to decide the - // kind cost more than the packing itself (#1548). - if (cellFormatter != null && sampleCount <= _formattedSamples) { - 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; - } - } - - // Nothing left to learn: the column is generic. - if (!allInt && !allFloat && !allString) break; - } - - if (sampleCount > 0) { - if (allInt) { - kinds[col] = CompactColumnKind.int64; - } else if (allFloat) { - kinds[col] = CompactColumnKind.float64; - } else if (allString) { - kinds[col] = CompactColumnKind.string; - } else { - kinds[col] = CompactColumnKind.generic; - } - } else { - kinds[col] = CompactColumnKind.string; - } - } - - // 2. Allocate packed columnar buffers - final compactColumns = []; - final bitmapBytes = (rowCount + 7) >> 3; - - for (var col = 0; col < colCount; col++) { - final kind = kinds[col]; - switch (kind) { - case CompactColumnKind.int64: - final values = Int64List(rowCount); - final nullBitmap = Uint8List(bitmapBytes); - for (var row = 0; row < rowCount; row++) { - final val = rawRows[row].length > col ? rawRows[row][col] : null; - if (val == null) { - final byte = row >> 3; - final bit = row & 7; - nullBitmap[byte] |= (1 << bit); - } else if (val is int) { - values[row] = val; - } else if (val is num) { - values[row] = val.toInt(); - } - } - compactColumns.add(Int64CompactColumn( - values: values, - nullBitmap: nullBitmap, - length: rowCount, - )); - - case CompactColumnKind.float64: - final values = Float64List(rowCount); - final nullBitmap = Uint8List(bitmapBytes); - for (var row = 0; row < rowCount; row++) { - final val = rawRows[row].length > col ? rawRows[row][col] : null; - if (val == null) { - final byte = row >> 3; - final bit = row & 7; - nullBitmap[byte] |= (1 << bit); - } else if (val is num) { - values[row] = val.toDouble(); - } - } - compactColumns.add(Float64CompactColumn( - values: values, - nullBitmap: nullBitmap, - length: rowCount, - )); - - case CompactColumnKind.string: - final values = List.filled(rowCount, null); - for (var row = 0; row < rowCount; row++) { - final val = rawRows[row].length > col ? rawRows[row][col] : null; - if (val is String) { - values[row] = internPool.intern(val); - } else if (val != null) { - values[row] = internPool.intern(val.toString()); - } - } - compactColumns.add(StringCompactColumn( - values: values, - length: rowCount, - )); - - case CompactColumnKind.generic: - final values = List.filled(rowCount, null); - for (var row = 0; row < rowCount; row++) { - final val = rawRows[row].length > col ? rawRows[row][col] : null; - values[row] = val; - } - compactColumns.add(GenericCompactColumn( - values: values, - length: rowCount, - formatter: cellFormatter != null ? (v) => cellFormatter(v, col) : null, - )); - } - } - - return CompactResultDataset( - columnNames: columnNames, - columns: compactColumns, - rowCount: rowCount, - pool: internPool, + final builder = ResultColumnsBuilder( + columnNames, + cellFormatter: cellFormatter, + pool: pool, ); + for (final row in rawRows) { + builder.addRow(row); + } + return builder.build(); } } diff --git a/lib/core/database/result_columns_builder.dart b/lib/core/database/result_columns_builder.dart new file mode 100644 index 00000000..db1219d6 --- /dev/null +++ b/lib/core/database/result_columns_builder.dart @@ -0,0 +1,291 @@ +import 'dart:typed_data'; + +import 'compact_result_dataset.dart'; +import 'result_row_string_convert.dart'; + +/// Builds a [CompactResultDataset] while a driver delivers the rows, without +/// first keeping a `List>` of the whole result (#1552). +/// +/// A column's kind is what [CompactResultDataset.fromRawRows] always decided: +/// all ints is int64, all numbers float64, all strings string, nothing +/// (all NULL) string, anything else generic. Here that is learnt as values +/// arrive, so a column can be demoted late: ints stay in the int64 buffer and +/// doubles go to a second buffer, so a column that ends up generic still holds +/// its ints as ints. +/// +/// [cellFormatter] is the driver's display formatter; the first +/// [formattedSamples] non-null cells of a column go through it to see whether +/// the column prints as its values do (bytea, boolean, ...). +class ResultColumnsBuilder { + ResultColumnsBuilder( + this.columnNames, { + this.cellFormatter, + StringInternPool? pool, + }) : pool = pool ?? StringInternPool(), + _columns = [ + for (var i = 0; i < columnNames.length; i++) _ColumnBuilder(i), + ]; + + /// Non-null cells per column checked against the formatter (#1548). + static const formattedSamples = 60; + + final List columnNames; + final String Function(Object? value, int colIndex)? cellFormatter; + final StringInternPool pool; + final List<_ColumnBuilder> _columns; + + int _rows = 0; + + int get rowCount => _rows; + + /// Adds one row. A short row is padded with NULLs. + void addRow(List row) { + final n = _columns.length; + for (var i = 0; i < n; i++) { + _columns[i].add(i < row.length ? row[i] : null, cellFormatter); + } + _rows++; + } + + /// Adds one row cell by cell: call [addCell] for every column in order, then + /// [endRow]. For drivers whose rows are not lists. + void addCell(int col, Object? value) => + _columns[col].add(value, cellFormatter); + + void endRow() => _rows++; + + CompactResultDataset build() { + if (_rows == 0 || _columns.isEmpty) { + return CompactResultDataset( + columnNames: columnNames, + columns: const [], + rowCount: 0, + pool: pool, + ); + } + return CompactResultDataset( + columnNames: columnNames, + columns: [ + for (final c in _columns) + c.finish(_rows, pool, cellFormatter == null + ? null + : (v) => cellFormatter!(v, c.index)), + ], + rowCount: _rows, + pool: pool, + ); + } +} + +enum _Mode { empty, numeric, string, generic } + +class _ColumnBuilder { + _ColumnBuilder(this.index); + + final int index; + + var _mode = _Mode.empty; + var _allInt = true; + var _allFloat = true; + var _allString = true; + var _nonNull = 0; + var _length = 0; + + // numeric: ints in [_ints], doubles in [_doubles] where [_isDouble] has the + // bit; [_nulls] has a bit per NULL row (also kept while empty). + var _capacity = 0; + var _ints = Int64List(0); + Float64List? _doubles; + Uint8List? _isDouble; + var _nulls = Uint8List(0); + + // string / generic + List _strings = []; + List _objects = []; + + void add(Object? value, String Function(Object?, int)? formatter) { + if (value == null) { + _addNull(); + _length++; + return; + } + _nonNull++; + // The driver formatters print a column's ints, numbers and strings by the + // column's type, not by the value, so the first cells show whether the + // column prints as its values do. Past those, only the type is tested. + if (formatter != null && _nonNull <= ResultColumnsBuilder.formattedSamples) { + final formatted = formatter(value, index); + if (_allInt && (value is! int || formatted != value.toString())) { + _allInt = false; + } + if (_allFloat && (value is! num || formatted != value.toString())) { + _allFloat = false; + } + if (_allString && (value is! String || formatted != value)) { + _allString = false; + } + } else { + if (_allInt && value is! int) _allInt = false; + if (_allFloat && value is! num) _allFloat = false; + if (_allString && value is! String) _allString = false; + } + + final target = !_allInt && !_allFloat && !_allString + ? _Mode.generic + : _allString + ? _Mode.string + : _Mode.numeric; + if (_mode != target) _switchTo(target); + + switch (_mode) { + case _Mode.numeric: + _ensureCapacity(); + if (value is int) { + _ints[_length] = value; + } else { + final number = value as num; + _doubles ??= Float64List(_capacity); + _isDouble ??= Uint8List((_capacity + 7) >> 3); + _doubles![_length] = number.toDouble(); + _isDouble![_length >> 3] |= 1 << (_length & 7); + } + case _Mode.string: + _strings.add(value as String); + case _Mode.generic: + _objects.add(value); + case _Mode.empty: + break; + } + _length++; + } + + void _addNull() { + switch (_mode) { + case _Mode.empty: + case _Mode.numeric: + _ensureCapacity(); + _nulls[_length >> 3] |= 1 << (_length & 7); + case _Mode.string: + _strings.add(null); + case _Mode.generic: + _objects.add(null); + } + } + + void _ensureCapacity() { + if (_length < _capacity) return; + final grown = _capacity == 0 ? 256 : _capacity * 2; + _ints = _grow64(_ints, grown); + if (_doubles != null) _doubles = _growF(_doubles!, grown); + if (_isDouble != null) _isDouble = _growBytes(_isDouble!, (grown + 7) >> 3); + _nulls = _growBytes(_nulls, (grown + 7) >> 3); + _capacity = grown; + } + + static Int64List _grow64(Int64List a, int n) => Int64List(n)..setAll(0, a); + static Float64List _growF(Float64List a, int n) => + Float64List(n)..setAll(0, a); + static Uint8List _growBytes(Uint8List a, int n) => + Uint8List(n)..setAll(0, a); + + bool _isNullRow(int i) => (_nulls[i >> 3] & (1 << (i & 7))) != 0; + + Object? _numericAt(int i) { + if (_isNullRow(i)) return null; + final dbl = _isDouble; + if (dbl != null && (dbl[i >> 3] & (1 << (i & 7))) != 0) { + return _doubles![i]; + } + return _ints[i]; + } + + /// Moves what was stored so far into the storage of [target]. + void _switchTo(_Mode target) { + final from = _mode; + _mode = target; + switch (target) { + case _Mode.numeric: + // Only from empty: the rows so far are NULLs, already in the bitmap. + break; + case _Mode.string: + // Only from empty. + _strings = List.filled(_length, null, growable: true); + case _Mode.generic: + if (from == _Mode.string) { + _objects = List.of(_strings, growable: true); + _strings = []; + } else if (from == _Mode.numeric) { + _objects = [for (var i = 0; i < _length; i++) _numericAt(i)]; + _releaseNumeric(); + } else { + _objects = List.filled(_length, null, growable: true); + } + case _Mode.empty: + break; + } + } + + void _releaseNumeric() { + _capacity = 0; + _ints = Int64List(0); + _doubles = null; + _isDouble = null; + _nulls = Uint8List(0); + } + + CompactColumn finish( + int rows, + StringInternPool pool, + String Function(Object?)? formatter, + ) { + // Rows a column never saw (a short last row) are NULLs. + while (_length < rows) { + _addNull(); + _length++; + } + final bitmapBytes = (rows + 7) >> 3; + if (_nonNull == 0) { + return StringCompactColumn( + values: List.filled(rows, null), + length: rows, + ); + } + if (_mode == _Mode.numeric) { + final nulls = Uint8List.fromList(Uint8List.sublistView(_nulls, 0, bitmapBytes)); + if (_allInt) { + return Int64CompactColumn( + values: Int64List.fromList(Int64List.sublistView(_ints, 0, rows)), + nullBitmap: nulls, + length: rows, + ); + } + // Numbers of both kinds: all float64. + final values = Float64List(rows); + final dbl = _isDouble; + for (var i = 0; i < rows; i++) { + if (_isNullRow(i)) continue; + values[i] = dbl != null && (dbl[i >> 3] & (1 << (i & 7))) != 0 + ? _doubles![i] + : _ints[i].toDouble(); + } + return Float64CompactColumn( + values: values, + nullBitmap: nulls, + length: rows, + ); + } + if (_mode == _Mode.string) { + return StringCompactColumn( + values: [ + for (final s in _strings) s == null ? null : pool.intern(s), + ], + length: rows, + ); + } + return GenericCompactColumn( + values: _objects, + length: rows, + formatter: formatter, + ); + } +} diff --git a/lib/core/database/stream_take_drain.dart b/lib/core/database/stream_take_drain.dart index 9353bb28..770660d0 100644 --- a/lib/core/database/stream_take_drain.dart +++ b/lib/core/database/stream_take_drain.dart @@ -10,16 +10,35 @@ Future<({List items, bool truncated})> takeThenDrain( Future Function(int count)? onProgress, }) async { final items = []; + final r = await forEachThenDrain( + stream, + cap, + onItem: items.add, + onProgress: onProgress, + ); + return (items: items, truncated: r.truncated); +} + +/// [takeThenDrain] for a caller that consumes each of the first [cap] events +/// as it arrives (packing it into columns) and has no use for the list. +Future<({int count, bool truncated})> forEachThenDrain( + Stream stream, + int cap, { + required void Function(T item) onItem, + Future Function(int count)? onProgress, +}) async { + var count = 0; var truncated = false; await for (final item in stream) { - if (items.length >= cap) { + if (count >= cap) { truncated = true; continue; } - items.add(item); + onItem(item); + count++; if (onProgress != null) { - await onProgress(items.length); + await onProgress(count); } } - return (items: items, truncated: truncated); + return (count: count, truncated: truncated); } diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 3135af26..409b0a10 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -1,7 +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/result_columns_builder.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'; @@ -98,32 +98,30 @@ class MysqlSqlExecutionDelegate extends BaseSqlExecutionDelegate { cols.add(c.name.isNotEmpty ? c.name : 'col_${cols.length}'); } - final taken = await takeThenDrain( + final colList = rs.cols.toList(); + final builder = ResultColumnsBuilder( + cols, + cellFormatter: (val, colIndex) { + final col = colIndex < colList.length ? colList[colIndex] : null; + return mysqlResultCellToDisplayString(val, column: col); + }, + ); + final taken = await forEachThenDrain( rs.rowsStream, cap, + onItem: (row) { + for (var i = 0; i < row.numOfColumns; i++) { + builder.addCell(i, row.colAt(i)); + } + builder.endRow(); + }, onProgress: (n) async { if (n % kResultStringConvertYieldEvery == 0) { await Future.delayed(Duration.zero); } }, ); - final colList = rs.cols.toList(); - final rawRows = >[]; - for (var r = 0; r < taken.items.length; r++) { - final row = taken.items[r]; - 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 dataset = builder.build(); final truncated = taken.truncated || (effectiveSql != sql && dataset.rowCount >= cap); diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 89decbb8..3d973b80 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -1,7 +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/result_columns_builder.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'; @@ -122,17 +122,8 @@ class PostgresSqlExecutionDelegate extends BaseSqlExecutionDelegate { ); } - final rawRows = >[]; - var n = 0; - for (final row in result) { - if (n >= cap) break; - rawRows.add(row.toList()); - n++; - } - - final dataset = CompactResultDataset.fromRawRows( + final builder = ResultColumnsBuilder( cols, - rawRows, cellFormatter: (val, colIndex) { final typeOid = colIndex < schema.columns.length ? schema.columns[colIndex].typeOid @@ -140,6 +131,13 @@ class PostgresSqlExecutionDelegate extends BaseSqlExecutionDelegate { return postgresResultCellToDisplayString(val, typeOid: typeOid); }, ); + var n = 0; + for (final row in result) { + if (n >= cap) break; + builder.addRow(row); + n++; + } + final dataset = builder.build(); final isTruncated = result.length >= cap; final statusMsg = formatStatusMessage( diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 38969b5c..945fb55b 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -1,7 +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/result_columns_builder.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,19 +105,20 @@ class SqliteSqlExecutionDelegate extends BaseSqlExecutionDelegate { final limitCount = truncated ? cap : results.length; final injectedLimit = effectiveSql != sql; - final rawRows = >[]; + final builder = ResultColumnsBuilder( + cols, + cellFormatter: (val, _) => sqliteResultCellToDisplayString(val), + ); var count = 0; for (final row in results) { if (count >= limitCount) break; - rawRows.add([for (final col in cols) row[col]]); + for (var i = 0; i < cols.length; i++) { + builder.addCell(i, row[cols[i]]); + } + builder.endRow(); count++; } - - final dataset = CompactResultDataset.fromRawRows( - cols, - rawRows, - cellFormatter: (val, _) => sqliteResultCellToDisplayString(val), - ); + final dataset = builder.build(); final isTruncated = truncated || (injectedLimit && results.length >= cap); final statusMsg = formatStatusMessage( diff --git a/test/core/database/result_columns_builder_test.dart b/test/core/database/result_columns_builder_test.dart new file mode 100644 index 00000000..718e7237 --- /dev/null +++ b/test/core/database/result_columns_builder_test.dart @@ -0,0 +1,167 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/compact_result_dataset.dart'; +import 'package:querya_desktop/core/database/result_columns_builder.dart'; + +CompactColumnKind _expectedKind(List col) { + final cells = col.whereType().toList(); + if (cells.isEmpty) return CompactColumnKind.string; + if (cells.every((v) => v is int)) return CompactColumnKind.int64; + if (cells.every((v) => v is num)) return CompactColumnKind.float64; + if (cells.every((v) => v is String)) return CompactColumnKind.string; + return CompactColumnKind.generic; +} + +Object? _expectedRaw(CompactColumnKind kind, Object? v) { + if (v == null) return null; + return kind == CompactColumnKind.float64 ? (v as num).toDouble() : v; +} + +void main() { + CompactResultDataset build(List names, List> rows, + {String Function(Object?, int)? formatter}) { + final b = ResultColumnsBuilder(names, cellFormatter: formatter); + for (final r in rows) { + b.addRow(r); + } + return b.build(); + } + + test('each column gets the kind its values call for, with its raw values', () { + final rows = [ + [1, 1.5, 'a', 1, null, 'x'], + [2, 2, 'b', 'two', null, 'y'], + [null, null, null, 3.5, null, 7], + [4, 0.25, 'd', null, null, null], + ]; + final d = build(['i', 'f', 's', 'mix', 'nulls', 'm2'], rows); + expect([for (final c in d.columns) c.kind], [ + CompactColumnKind.int64, + CompactColumnKind.float64, + CompactColumnKind.string, + CompactColumnKind.generic, + CompactColumnKind.string, + CompactColumnKind.generic, + ]); + expect(d.rowCount, 4); + // A float column holds its ints as doubles. + expect(d.rawCellAt(1, 1), 2.0); + // A generic column keeps its ints as ints and its doubles as doubles. + expect(d.rawCellAt(0, 3), 1); + expect(d.rawCellAt(0, 3), isA()); + expect(d.rawCellAt(2, 3), 3.5); + expect(d.rawCellAt(1, 3), 'two'); + expect(d.rawCellAt(2, 0), isNull); + expect(d.cellAt(2, 0), 'NULL'); + }); + + test('a late value that does not fit demotes the whole column', () { + final rows = [ + for (var i = 0; i < 700; i++) [i], + ['late'], + ]; + final d = build(['c'], rows); + expect(d.columns.single.kind, CompactColumnKind.generic); + expect(d.rowCount, 701); + expect(d.rawCellAt(0, 0), 0); + expect(d.rawCellAt(699, 0), 699); + expect(d.rawCellAt(700, 0), 'late'); + }); + + test('ints that meet a double late become a float64 column', () { + final rows = [ + for (var i = 0; i < 300; i++) [i], + [0.5], + ]; + final d = build(['c'], rows); + expect(d.columns.single.kind, CompactColumnKind.float64); + expect(d.rawCellAt(299, 0), 299.0); + expect(d.rawCellAt(300, 0), 0.5); + }); + + test('a short row is padded with NULLs', () { + final d = build(['a', 'b'], [ + [1, 'x'], + [2], + ]); + expect(d.rawCellAt(1, 1), isNull); + expect(d.columns[1].kind, CompactColumnKind.string); + }); + + test('cell by cell gives the same dataset as whole rows', () { + final rows = [ + [1, 'a', null], + [2, null, 3.5], + ]; + final whole = build(['x', 'y', 'z'], rows); + final b = ResultColumnsBuilder(['x', 'y', 'z']); + for (final r in rows) { + for (var i = 0; i < r.length; i++) { + b.addCell(i, r[i]); + } + b.endRow(); + } + final cells = b.build(); + for (var r = 0; r < 2; r++) { + expect(cells.rawRow(r), whole.rawRow(r)); + } + }); + + test('the driver formatter decides: values it prints differently stay generic', + () { + // A "boolean" column: ints that print as true/false. + String fmt(Object? v, int col) => + col == 0 ? (v == 1 ? 'true' : 'false') : '$v'; + final d = build(['flag', 'n'], [ + [1, 5], + [0, 6], + ], formatter: fmt); + expect(d.columns[0].kind, CompactColumnKind.generic); + expect(d.columns[1].kind, CompactColumnKind.int64); + expect(d.cellAt(0, 0), 'true'); + expect(d.cellAt(1, 0), 'false'); + }); + + test('nothing but NULLs is a string column; no rows is an empty dataset', () { + expect(build(['a'], [[null], [null]]).columns.single.kind, + CompactColumnKind.string); + final empty = build(['a'], []); + expect(empty.rowCount, 0); + expect(empty.columns, isEmpty); + }); + + test('random columns agree with the kind and values the rules give', () { + final rnd = Random(1552); + Object? pick(int family) { + if (rnd.nextInt(6) == 0) return null; + switch (family) { + case 0: + return rnd.nextInt(1000) - 500; + case 1: + return rnd.nextBool() ? rnd.nextInt(100) : rnd.nextDouble() * 10; + case 2: + return 's${rnd.nextInt(50)}'; + default: + return switch (rnd.nextInt(3)) { + 0 => rnd.nextInt(100), + 1 => 's${rnd.nextInt(9)}', + _ => rnd.nextDouble(), + }; + } + } + + for (var trial = 0; trial < 150; trial++) { + final family = rnd.nextInt(4); + final n = 1 + rnd.nextInt(700); + final col = [for (var i = 0; i < n; i++) pick(family)]; + final d = build(['c'], [for (final v in col) [v]]); + final kind = _expectedKind(col); + expect(d.columns.single.kind, kind, reason: 'trial $trial'); + for (var i = 0; i < n; i++) { + expect(d.rawCellAt(i, 0), _expectedRaw(kind, col[i]), + reason: 'trial $trial row $i'); + } + } + }); +}