diff --git a/lib/core/database/sql_limit.dart b/lib/core/database/sql_limit.dart index 42205440..0d4b9148 100644 --- a/lib/core/database/sql_limit.dart +++ b/lib/core/database/sql_limit.dart @@ -20,25 +20,55 @@ int _blockCommentEnd(String sql, int start) { return sql.length; } -/// Removes leading whitespace, `--` line comments and `/* */` block comments. -String stripLeadingWhitespaceAndLineComments(String sql) { - var s = sql.trimLeft(); +/// The characters `String.trimLeft` removes: Unicode White_Space and the BOM. +bool _isTrimWhitespace(int c) => + (c >= 0x09 && c <= 0x0D) || + c == 0x20 || + c == 0x85 || + c == 0xA0 || + c == 0x1680 || + (c >= 0x2000 && c <= 0x200A) || + c == 0x2028 || + c == 0x2029 || + c == 0x202F || + c == 0x205F || + c == 0x3000 || + c == 0xFEFF; + +/// Offset of the first character of [sql] that is not whitespace, a `--` line +/// comment or a `/* */` block comment; `sql.length` when nothing else is there. +/// +/// Walks an index, so a header of many comment lines in front of a large script +/// costs one pass and no copies (#1515). +int _skipLeadingCommentsOffset(String sql) { + final length = sql.length; + var i = 0; while (true) { - if (s.isEmpty) return s; - if (s.startsWith('--')) { - final nl = s.indexOf('\n'); - if (nl == -1) return ''; - s = s.substring(nl + 1).trimLeft(); + while (i < length && _isTrimWhitespace(sql.codeUnitAt(i))) { + i++; + } + if (i >= length) return length; + if (sql.startsWith('--', i)) { + final nl = sql.indexOf('\n', i); + if (nl == -1) return length; + i = nl + 1; continue; } - if (s.startsWith('/*')) { - s = s.substring(_blockCommentEnd(s, 0)).trimLeft(); + if (sql.startsWith('/*', i)) { + i = _blockCommentEnd(sql, i); continue; } - return s; + return i; } } +/// Removes leading whitespace, `--` line comments and `/* */` block comments. +String stripLeadingWhitespaceAndLineComments(String sql) { + final start = _skipLeadingCommentsOffset(sql); + if (start >= sql.length) return ''; + return start == 0 ? sql : sql.substring(start); +} + final _limitAll = RegExp(r'\bLIMIT\s+ALL\b', caseSensitive: false); final _limitCount = RegExp( r'\bLIMIT\s+(\d+)(\s+OFFSET\s+\d+)?', diff --git a/test/core/database/sql_limit_strip_test.dart b/test/core/database/sql_limit_strip_test.dart new file mode 100644 index 00000000..c1465370 --- /dev/null +++ b/test/core/database/sql_limit_strip_test.dart @@ -0,0 +1,108 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart'; + +/// `stripLeadingWhitespaceAndLineComments` before #1515: one substring + trimLeft +/// per comment, the reference the offset scanner has to match. +String _reference(String sql) { + int blockEnd(String s, int start) { + var depth = 0; + var i = start; + while (i < s.length) { + if (s.startsWith('/*', i)) { + depth++; + i += 2; + } else if (s.startsWith('*/', i)) { + depth--; + i += 2; + if (depth == 0) return i; + } else { + i++; + } + } + return s.length; + } + + var s = sql.trimLeft(); + while (true) { + if (s.isEmpty) return s; + if (s.startsWith('--')) { + final nl = s.indexOf('\n'); + if (nl == -1) return ''; + s = s.substring(nl + 1).trimLeft(); + continue; + } + if (s.startsWith('/*')) { + s = s.substring(blockEnd(s, 0)).trimLeft(); + continue; + } + return s; + } +} + +void main() { + group('stripLeadingWhitespaceAndLineComments offset scanner (#1515)', () { + test('agrees with the previous implementation on listed scripts', () { + const cases = [ + '', + ' ', + '\n\t \r\n', + 'SELECT 1', + ' SELECT 1', + '-- c\nSELECT 1', + '-- c\r\nSELECT 1', + '-- c\n-- d\n\n -- e\nSELECT 1', + '-- only a comment', + '-- c\n', + '/* c */SELECT 1', + '/* a */ /* b */\n SELECT 1', + '/* a /* nested */ still */ SELECT 1', + '/* unterminated', + '/* a */ -- b\n/* c */ SELECT 1', + '--\nSELECT 1', + '-', + '/', + 'SELECT 1 -- trailing\nFROM t', + '  -- c\nSELECT 1', + '\u0085SELECT 1', + '᠎SELECT 1', + '/*/ x */ SELECT 1', + '/**/SELECT 1', + ]; + for (final sql in cases) { + expect( + stripLeadingWhitespaceAndLineComments(sql), + _reference(sql), + reason: sql, + ); + } + }); + + test('agrees with the previous implementation on random scripts', () { + const pieces = [ + '--', '/*', '*/', '\n', '\r\n', ' ', '\t', ' ', '', + 'SELECT', '1', 'x', '-', '/', '*', + ]; + final random = Random(1515); + for (var n = 0; n < 4000; n++) { + final sql = [ + for (var k = random.nextInt(24); k > 0; k--) + pieces[random.nextInt(pieces.length)], + ].join(); + expect( + stripLeadingWhitespaceAndLineComments(sql), + _reference(sql), + reason: sql, + ); + } + }); + + test('a long comment header in front of a large script is one pass', () { + final header = List.generate(500, (i) => '-- header line $i').join('\n'); + final body = 'SELECT ${List.filled(20000, 'col').join(', ')} FROM t'; + final sql = '$header\n$body'; + expect(stripLeadingWhitespaceAndLineComments(sql), body); + }); + }); +}