diff --git a/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart b/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart index 11e4a074..fb5d185a 100644 --- a/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart +++ b/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart @@ -1,7 +1,16 @@ import 'dart:convert'; +import 'package:querya_desktop/core/actions/sql_script_format.dart'; +import 'package:querya_desktop/core/database/sql_lexical_scanner.dart'; +import 'package:querya_desktop/core/database/sql_limit.dart' + show stripLeadingWhitespaceAndLineComments; import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart'; +import 'package:querya_desktop/core/theme/parser/jsonc_preprocessor.dart'; + +final _blanks = RegExp(r'[ \t]+'); +final _semicolonWithSpace = RegExp(r'\s*;\s*'); +final _firstWord = RegExp(r'\S+'); /// Pure-Dart Level-1 engine for JSON / JSONC declarative modules. /// @@ -126,41 +135,26 @@ class DeclarativeEmbeddedEngine implements EmbeddedSandboxEngine { return EmbeddedInvokeResult.failure('sql.format requires args.sql string.'); } - var out = sql.replaceAll(RegExp(r'[ \t]+'), ' ').trim(); - out = out.replaceAll(RegExp(r'\s*;\s*'), ';\n'); - final upperKeywords = module['uppercaseKeywords'] != false; - if (upperKeywords) { - const keywords = [ - 'select', - 'from', - 'where', - 'and', - 'or', - 'join', - 'left', - 'right', - 'inner', - 'outer', - 'on', - 'group', - 'by', - 'order', - 'limit', - 'insert', - 'into', - 'values', - 'update', - 'set', - 'delete', - ]; - for (final kw in keywords) { - out = out.replaceAllMapped( - RegExp('\\b$kw\\b', caseSensitive: false), - (m) => kw.toUpperCase(), - ); - } + // Blanks and `;` are laid out in code only: a literal, quoted identifier + // or comment keeps every character it was written with (#1567). + final out = StringBuffer(); + final source = sql.trim(); + for (final segment in SqlLexicalScanner.segments(source)) { + final text = source.substring(segment.start, segment.end); + out.write( + segment.kind == SqlSegmentKind.code + ? text + .replaceAll(_blanks, ' ') + .replaceAll(_semicolonWithSpace, ';\n') + : text, + ); } - return EmbeddedInvokeResult.success(out); + final laidOut = out.toString(); + return EmbeddedInvokeResult.success( + module['uppercaseKeywords'] != false + ? formatSqlScript(laidOut) + : laidOut, + ); } EmbeddedInvokeResult _hintsGenerate( @@ -216,7 +210,11 @@ class DeclarativeEmbeddedEngine implements EmbeddedSandboxEngine { return EmbeddedInvokeResult.failure('sql.parse requires args.sql string.'); } final trimmed = sql.trim(); - final first = trimmed.split(RegExp(r'\s+')).first.toUpperCase(); + final first = _firstWord + .firstMatch(stripLeadingWhitespaceAndLineComments(trimmed)) + ?.group(0) + ?.toUpperCase() ?? + ''; final dialect = module['dialect'] as String? ?? 'generic'; return EmbeddedInvokeResult.success({ 'dialect': dialect, @@ -226,11 +224,10 @@ class DeclarativeEmbeddedEngine implements EmbeddedSandboxEngine { }); } - /// Strips `//` and `/* */` comments then `jsonDecode`s an object. + /// Strips comments and trailing commas (string-aware, see [stripJsonc]), + /// then `jsonDecode`s an object. static Map _parseJsoncObject(String source) { - final withoutBlock = source.replaceAll(RegExp(r'/\*[\s\S]*?\*/'), ''); - final withoutLine = withoutBlock.replaceAll(RegExp(r'//[^\n]*'), ''); - final decoded = jsonDecode(withoutLine); + final decoded = jsonDecode(stripJsonc(source)); if (decoded is! Map) { throw const FormatException('Module root must be a JSON object.'); } diff --git a/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart b/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart index db6bf48f..c863c6df 100644 --- a/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart +++ b/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart @@ -59,6 +59,37 @@ void main() { expect(result.value, 'SELECT * FROM users WHERE id=1;\n'); }); + test('sql.format leaves literals, quoted identifiers and comments alone (#1567)', + () async { + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlFormat, + source: '{"kind":"sql.format"}', + args: { + 'sql': "select 'a b; order by', \"select\" from t -- from x\n" + 'where id=1;', + }, + ), + ); + expect(result.ok, isTrue); + expect( + result.value, + "SELECT 'a b; order by', \"select\" FROM t -- from x\n" + 'WHERE id=1;\n', + ); + }); + + test('sql.format can leave keyword case alone', () async { + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlFormat, + source: '{"kind":"sql.format","uppercaseKeywords":false}', + args: {'sql': 'select 1'}, + ), + ); + expect(result.value, 'select 1'); + }); + test('generates hints filtered by prefix', () async { const source = ''' { @@ -95,6 +126,37 @@ void main() { expect(ast['statement'], 'UPDATE'); expect(ast['dialect'], 'postgresql'); }); + + test('sql.parse reads the statement after leading comments (#1567)', + () async { + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlParse, + source: '{"kind":"sql.parse"}', + args: {'sql': '-- note\n/* more */ select 1'}, + ), + ); + expect((result.value! as Map)['statement'], 'SELECT'); + }); + + test('JSONC modules keep // and /* inside strings, allow trailing commas (#1567)', + () async { + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlParse, + source: ''' +{ + // comment + "kind": "sql.parse", + "homepage": "https://example.com/*not a comment*/", + /* block */ "dialect": "pg", +}''', + args: {'sql': 'select 1'}, + ), + ); + expect(result.ok, isTrue); + expect((result.value! as Map)['dialect'], 'pg'); + }); }); group('EmbeddedSandboxRuntime', () {