From fb8ace1cc22d6ea20267c056ac4ce055150e5b1f Mon Sep 17 00:00:00 2001 From: yamato kobayashi <18324681+ymadd@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:51:00 +0900 Subject: [PATCH] fix(editor): skip string literals and comments in query param detection A plain regex over the whole SQL text treated `:y` in `WHERE v = 'x:y'` as a bind parameter, popping the Query Parameters modal and corrupting the literal on interpolation (#458). Detection and interpolation now run on a length-preserving mask built from a new sqlSplitter `scanNonCodeSpans()` export, which reuses the dialect-aware tokenizer to locate string literals and comments (dollar quotes, E-strings, q-quotes, bracket identifiers, nested comments, and MySQL executable-comment payloads included). Replacement is position-based on the original text so values never land inside a literal. Without a known dialect nothing is masked and the previous behavior is preserved verbatim, because lexing with the wrong quote rules could swallow real parameters (e.g. generic rules on MySQL's backslash escapes). The editor threads the active driver dialect into all call sites and memoizes the Params-button probe that runs in the render path. Hardening from adversarial review: the executable-comment rescan is an iterative worklist with a depth cap instead of native recursion (a pasted ~15KB chain of `/*!` markers used to overflow the call stack from the render path), dialect lookup uses Object.hasOwn so prototype member names like '__proto__' fall back cleanly instead of crashing the tokenizer (normalizeDialect had the same latent issue), and interpolation ignores parameter names that only resolve through Object.prototype instead of splicing function source into the SQL. Dialect-preset corrections surfaced by the same review (these also fix statement splitting, with canary tests): MySQL `#` line comments are now recognized, MSSQL gains double-quoted identifiers and nested block comments per T-SQL semantics, PostgreSQL dollar quotes glued to an identifier (`foo$tag$`) are no longer treated as string openers, and MariaDB `/*M! ... */` conditional comments are rescanned at the masking layer only (the splitter still folds them as comments so MySQL proper never receives a comment-only statement). Known limitations left for follow-up: a dedicated MariaDB dialect (tokenizer-level `/*M!` statement semantics) and the session-dependent NO_BACKSLASH_ESCAPES sql_mode, which a static lexer cannot know. --- src/pages/Editor.tsx | 32 ++- src/utils/queryParameters.ts | 67 ++++-- src/utils/sqlSplitter/index.ts | 61 ++++- src/utils/sqlSplitter/nonCodeSpans.ts | 149 ++++++++++++ src/utils/sqlSplitter/tokenizer.ts | 12 + tests/utils/queryParameters.test.ts | 213 ++++++++++++++++ tests/utils/sqlSplitter/dialects.test.ts | 29 +++ tests/utils/sqlSplitter/nonCodeSpans.test.ts | 240 +++++++++++++++++++ 8 files changed, 775 insertions(+), 28 deletions(-) create mode 100644 src/utils/sqlSplitter/nonCodeSpans.ts create mode 100644 tests/utils/sqlSplitter/nonCodeSpans.test.ts diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 89890d9da..8d754de4b 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -760,7 +760,7 @@ export const Editor = () => { if (!(await guardDangerousQuery(textToRun))) return; // Check for parameters - const params = extractQueryParams(textToRun); + const params = extractQueryParams(textToRun, activeDialect); if (params.length > 0) { const storedParams = paramsOverride || targetTab.queryParams || {}; const missingParams = params.filter( @@ -785,7 +785,7 @@ export const Editor = () => { } // Interpolate parameters before execution - textToRun = interpolateQueryParams(textToRun, storedParams); + textToRun = interpolateQueryParams(textToRun, storedParams, activeDialect); } // Automatically open the results panel when running a query — but only @@ -985,6 +985,7 @@ export const Editor = () => { activeDatabaseName, addHistoryEntry, guardDangerousQuery, + activeDialect, ], ); @@ -1000,7 +1001,7 @@ export const Editor = () => { // Collect all unique parameters across all queries const allParams = [ - ...new Set(queries.flatMap((q) => extractQueryParams(q))), + ...new Set(queries.flatMap((q) => extractQueryParams(q, activeDialect))), ]; if (allParams.length > 0) { const storedParams = @@ -1022,7 +1023,9 @@ export const Editor = () => { return; } // Interpolate all queries with the stored params - queries = queries.map((q) => interpolateQueryParams(q, storedParams)); + queries = queries.map((q) => + interpolateQueryParams(q, storedParams, activeDialect), + ); } const pageSize = @@ -1168,7 +1171,7 @@ export const Editor = () => { }); updateTab(targetTabId, { isLoading: false }); }, - [activeConnectionId, updateTab, patchResultEntry, settings.resultPageSize, activeSchema, t, isMultiDb, activeDatabaseName, addHistoryEntry, guardDangerousQuery], + [activeConnectionId, updateTab, patchResultEntry, settings.resultPageSize, activeSchema, t, isMultiDb, activeDatabaseName, addHistoryEntry, guardDangerousQuery, activeDialect], ); // Auto-run entry point for navigation-initiated executions (sidebar "open @@ -2574,7 +2577,7 @@ export const Editor = () => { const handleEditParams = useCallback(() => { if (!activeTab || !activeTab.query) return; - const params = extractQueryParams(activeTab.query); + const params = extractQueryParams(activeTab.query, activeDialect); if (params.length === 0) return; setQueryParamsModal({ @@ -2585,7 +2588,17 @@ export const Editor = () => { pendingTabId: activeTab.id, mode: "save", }); - }, [activeTab]); + }, [activeTab, activeDialect]); + + // Drives the Params button. Memoized because it lives in the render + // path (a `disabled` prop) and would otherwise rescan the whole query + // text (tokenizer + regex) on every keystroke. + const hasQueryParams = useMemo( + () => + !!activeTab?.query && + extractQueryParams(activeTab.query, activeDialect).length > 0, + [activeTab?.query, activeDialect], + ); const handleRollbackChanges = useCallback(() => { if (!activeTab) return; @@ -3297,10 +3310,7 @@ export const Editor = () => { {!isTableTab && (