diff --git a/lib/features/results/charts/chart_data.dart b/lib/features/results/charts/chart_data.dart index c9c8e19e..0ec7916e 100644 --- a/lib/features/results/charts/chart_data.dart +++ b/lib/features/results/charts/chart_data.dart @@ -45,32 +45,103 @@ class ChartData { /// Name of the slice that collects the categories beyond Top N. static const String otherLabel = 'Other'; - static final _isoTime = RegExp( - r'^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?(Z|[+-]\d{2}:?\d{2})?$', - ); - static final _hasZone = RegExp(r'(Z|[+-]\d{2}:?\d{2})$'); + static bool _digitsAt(String s, int at, int count) { + if (at + count > s.length) return false; + for (var i = at; i < at + count; i++) { + final c = s.codeUnitAt(i); + if (c < 0x30 || c > 0x39) return false; + } + return true; + } + + /// The decimal value of the [count] digits at [at], or -1 when they are not + /// all digits. + static int _digitsValue(String s, int at, int count) { + if (!_digitsAt(s, at, count)) return -1; + var v = 0; + for (var i = at; i < at + count; i++) { + v = v * 10 + (s.codeUnitAt(i) - 0x30); + } + return v; + } + + /// Checks [s] against + /// `YYYY-MM-DD([T ]HH:MM(:SS(.f+)?)?)?(Z|[+-]HH:?MM)?` without a regex: + /// -1 when it does not fit, 1 when it ends in a zone, 0 otherwise. + static int _scanIso(String s) { + final n = s.length; + if (n < 10 || + !_digitsAt(s, 0, 4) || + s.codeUnitAt(4) != 0x2D || + !_digitsAt(s, 5, 2) || + s.codeUnitAt(7) != 0x2D || + !_digitsAt(s, 8, 2)) { + return -1; + } + var i = 10; + if (i < n) { + final sep = s.codeUnitAt(i); + if (sep == 0x54 || sep == 0x20) { + // [T ]HH:MM + if (!_digitsAt(s, i + 1, 2) || + i + 3 >= n || + s.codeUnitAt(i + 3) != 0x3A || + !_digitsAt(s, i + 4, 2)) { + return -1; + } + i += 6; + // :SS + if (i < n && s.codeUnitAt(i) == 0x3A && _digitsAt(s, i + 1, 2)) { + i += 3; + // .f+ + if (i < n && s.codeUnitAt(i) == 0x2E && _digitsAt(s, i + 1, 1)) { + i++; + while (i < n && _digitsAt(s, i, 1)) { + i++; + } + } + } + } + } + if (i == n) return 0; + final z = s.codeUnitAt(i); + if (z == 0x5A) return i + 1 == n ? 1 : -1; // Z + if (z != 0x2B && z != 0x2D) return -1; // + or - + i++; + if (!_digitsAt(s, i, 2)) return -1; + i += 2; + if (i < n && s.codeUnitAt(i) == 0x3A) i++; + if (!_digitsAt(s, i, 2)) return -1; + return i + 2 == n ? 1 : -1; + } /// Parses an ISO date (`2026-01-31`) or timestamp (`2026-01-31 10:00`, /// `2026-01-31T10:00:00Z`). Without a zone the wall-clock fields are kept, so /// a day stays a day whatever the machine's time zone is. + /// + /// Called for every cell of a candidate X column (up to 100,000 rows), so the + /// shape check and the field read-back work on code units: no regex, no + /// substring, no `replaceFirst` (#1514). static DateTime? parseTime(String raw) { final text = raw.trim(); - if (!_isoTime.hasMatch(text)) return null; - final parsed = DateTime.tryParse(text.replaceFirst(' ', 'T')); + final zone = _scanIso(text); + if (zone < 0) return null; + // DateTime.tryParse takes a space as well as `T` between date and time. + final parsed = DateTime.tryParse(text); if (parsed == null) return null; // DateTime rolls an impossible date over (2026-13-45 becomes 2027-02-14); // the fields must read back as typed. - if (parsed.year != int.parse(text.substring(0, 4)) || - parsed.month != int.parse(text.substring(5, 7)) || - parsed.day != int.parse(text.substring(8, 10))) { + if (parsed.year != _digitsValue(text, 0, 4) || + parsed.month != _digitsValue(text, 5, 2) || + parsed.day != _digitsValue(text, 8, 2)) { return null; } if (text.length >= 16 && - (parsed.hour != int.parse(text.substring(11, 13)) || - parsed.minute != int.parse(text.substring(14, 16)))) { + (parsed.hour != _digitsValue(text, 11, 2) || + parsed.minute != _digitsValue(text, 14, 2))) { return null; } - if (_hasZone.hasMatch(text)) return parsed.toUtc(); + if (zone == 1) return parsed.toUtc(); return DateTime.utc(parsed.year, parsed.month, parsed.day, parsed.hour, parsed.minute, parsed.second, parsed.millisecond, parsed.microsecond); } diff --git a/test/features/results/charts/chart_data_parse_time_test.dart b/test/features/results/charts/chart_data_parse_time_test.dart new file mode 100644 index 00000000..5854f427 --- /dev/null +++ b/test/features/results/charts/chart_data_parse_time_test.dart @@ -0,0 +1,123 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/results/charts/chart_data.dart'; + +/// `ChartData.parseTime` before #1514 (regex, replaceFirst, substrings): the +/// reference the code-unit version has to agree with. +final _isoTime = RegExp( + r'^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?(Z|[+-]\d{2}:?\d{2})?$', +); +final _hasZone = RegExp(r'(Z|[+-]\d{2}:?\d{2})$'); + +DateTime? _reference(String raw) { + final text = raw.trim(); + if (!_isoTime.hasMatch(text)) return null; + final parsed = DateTime.tryParse(text.replaceFirst(' ', 'T')); + if (parsed == null) return null; + if (parsed.year != int.parse(text.substring(0, 4)) || + parsed.month != int.parse(text.substring(5, 7)) || + parsed.day != int.parse(text.substring(8, 10))) { + return null; + } + if (text.length >= 16 && + (parsed.hour != int.parse(text.substring(11, 13)) || + parsed.minute != int.parse(text.substring(14, 16)))) { + return null; + } + if (_hasZone.hasMatch(text)) return parsed.toUtc(); + return DateTime.utc(parsed.year, parsed.month, parsed.day, parsed.hour, + parsed.minute, parsed.second, parsed.millisecond, parsed.microsecond); +} + +void main() { + group('ChartData.parseTime scanner (#1514)', () { + test('agrees with the previous implementation on listed values', () { + const cases = [ + '', + ' ', + '2026-01-31', + ' 2026-01-31 ', + '2026-01-31 10:30', + '2026-01-31T10:30', + '2026-01-31T10:30:15', + '2026-01-31T10:30:15.123', + '2026-01-31T10:30:15.123456', + '2026-01-31T10:30:15.', + '2026-01-31T10:30:1', + '2026-01-31T10:3', + '2026-01-31T10', + '2026-01-31T10:30Z', + '2026-01-31T10:30:00Z', + '2026-01-31 10:30:00+02:00', + '2026-01-31 10:30:00-0530', + '2026-01-31 10:30:00+02', + '2026-01-31 10:30:00 +02:00', + '2026-01-31Z', + '2026-01-31+01:00', + '2026-01-31+0100', + '2026-13-45', + '2026-02-30', + '2026-02-29', + '2024-02-29', + '2026-01-31T25:00', + '2026-01-31T10:61', + '2026-01-31T24:00', + '2026-1-31', + '26-01-31', + '31.01.2026', + '2026/01/31', + '2026-01-31x', + '2026-01-31T10:30:00zZ', + '2026-01-31t10:30', + '2026-01-31 10:30', + '0000-01-01', + '9999-12-31T23:59:59.999Z', + '2026-01-31', + ]; + for (final raw in cases) { + expect(ChartData.parseTime(raw), _reference(raw), reason: '"$raw"'); + } + }); + + test('agrees with the previous implementation on random strings', () { + const pieces = [ + '2026', '2024', '0000', '99', '01', '12', '13', '31', '45', '10', '24', + '60', '-', ':', '.', ' ', 'T', 'Z', '+', 'z', 't', '5', 'x', + ]; + final random = Random(1514); + for (var n = 0; n < 6000; n++) { + // Mostly shaped like a date, with random pieces dropped and swapped in. + final parts = [ + '2026', '-', '01', '-', '31', + if (random.nextBool()) ...[ + random.nextBool() ? 'T' : ' ', + '10', ':', '30', + if (random.nextBool()) ...[':', '15'], + if (random.nextBool()) ...['.', '123'], + ], + if (random.nextBool()) ...[ + random.nextBool() ? 'Z' : '+', + '02', if (random.nextBool()) ':', '00', + ], + ]; + for (var k = random.nextInt(3); k > 0; k--) { + parts[random.nextInt(parts.length)] = + pieces[random.nextInt(pieces.length)]; + } + final raw = parts.join(); + expect(ChartData.parseTime(raw), _reference(raw), reason: '"$raw"'); + } + }); + + test('a 100,000 row column is checked in one pass', () { + final rows = [ + for (var i = 0; i < 100000; i++) + ['2026-01-${(i % 28 + 1).toString().padLeft(2, '0')} 10:30:00'], + ]; + expect(ChartData.isTimeColumn(rows, 0), isTrue); + rows.add(['not a date']); + expect(ChartData.isTimeColumn(rows, 0), isFalse); + }); + }); +}