Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions src/pages/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -985,6 +985,7 @@ export const Editor = () => {
activeDatabaseName,
addHistoryEntry,
guardDangerousQuery,
activeDialect,
],
);

Expand All @@ -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 =
Expand All @@ -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 =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand All @@ -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;
Expand Down Expand Up @@ -3297,10 +3310,7 @@ export const Editor = () => {
{!isTableTab && (
<button
onClick={handleEditParams}
disabled={
!activeTab?.query ||
extractQueryParams(activeTab.query).length === 0
}
disabled={!hasQueryParams}
className="flex items-center gap-2 px-2 @[640px]:px-3 py-1.5 bg-surface-secondary hover:bg-surface text-primary rounded text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed border border-strong shrink-0"
title={t("editor.queryParameters")}
>
Expand Down
67 changes: 52 additions & 15 deletions src/utils/queryParameters.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { scanNonCodeSpans } from "./sqlSplitter";

/**
* Converts an arbitrary identifier (e.g. a column name) into a valid named
* bind-parameter name compatible with the editor's `:param` syntax.
Expand All @@ -14,28 +16,63 @@ export const toBindParamName = (name: string): string => {
return sanitized;
};

export const extractQueryParams = (sql: string): string[] => {
// Matches :paramName but ignores ::cast (Postgres)
// Look for colon followed by word characters, ensuring it's not preceded by a colon
const PARAM_RE = /(?<!:):([a-zA-Z_][a-zA-Z0-9_]*)(?!\w)/g;

/**
* Returns `sql` with every string literal and comment replaced by spaces
* so the parameter regex only ever sees executable SQL (issue #458). The
* mask is length-preserving: a match index on it is valid on the
* original. Without a known dialect there is nothing to mask (see
* scanNonCodeSpans) and detection behaves exactly as it did before
* dialects were threaded through.
*/
const maskNonCode = (sql: string, dialect?: string): string => {
const spans = scanNonCodeSpans(sql, dialect);
if (spans.length === 0) return sql;
let masked = "";
let cursor = 0;
for (const { start, end } of spans) {
masked += sql.slice(cursor, start) + " ".repeat(end - start);
cursor = end;
}
return masked + sql.slice(cursor);
};

export const extractQueryParams = (sql: string, dialect?: string): string[] => {
if (!sql) return [];
// Matches :paramName but ignores ::cast (Postgres)
// Look for colon followed by word characters, ensuring it's not preceded by a colon
const regex = /(?<!:):([a-zA-Z_][a-zA-Z0-9_]*)(?!\w)/g;
const matches = sql.match(regex);
const matches = maskNonCode(sql, dialect).match(PARAM_RE);
if (!matches) return [];

// Remove duplicate parameters and the leading colon
const uniqueParams = new Set(matches.map(m => m.substring(1)));
const uniqueParams = new Set(matches.map((m) => m.substring(1)));
return Array.from(uniqueParams);
};

export const interpolateQueryParams = (sql: string, params: Record<string, string>): string => {
export const interpolateQueryParams = (
sql: string,
params: Record<string, string>,
dialect?: string,
): string => {
if (!sql) return "";

// Values are substituted verbatim; callers are responsible for quoting.
// SQL injection risk is accepted at the UI layer — this is a developer tool.
return sql.replace(/(?<!:):([a-zA-Z_][a-zA-Z0-9_]*)(?!\w)/g, (match, paramName) => {
if (params[paramName] !== undefined) {
return params[paramName];
}
return match; // Leave it if no value found (though logic should prevent this)
});
// Matches are found on the masked text so a `:name` inside a string
// literal or comment is never rewritten; the output is assembled from
// slices of the original (the mask is length-preserving, so indices
// transfer 1:1).
const masked = maskNonCode(sql, dialect);
let result = "";
let cursor = 0;
for (const match of masked.matchAll(PARAM_RE)) {
// Own-property check: a plain `params[name]` lookup would resolve
// names like :toString via Object.prototype and splice function
// source into the SQL.
if (!Object.hasOwn(params, match[1])) continue; // Leave it if no value found (though logic should prevent this)
result += sql.slice(cursor, match.index) + params[match[1]];
cursor = match.index + match[0].length;
}
return result + sql.slice(cursor);
};
61 changes: 59 additions & 2 deletions src/utils/sqlSplitter/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { splitInto } from './splitter';
import { collectNonCodeSpans } from './nonCodeSpans';

export type Dialect =
| 'postgres'
Expand Down Expand Up @@ -30,6 +31,15 @@ export interface StatementRange {
readonly end: number;
}

/**
* String-index span (same coordinate system as `StatementRange`) of a
* single string literal or comment in the source.
*/
export interface NonCodeSpan {
readonly start: number;
readonly end: number;
}

export interface Statement {
readonly text: string;
readonly range: StatementRange;
Expand Down Expand Up @@ -74,6 +84,13 @@ export interface DialectOptions {
readonly qQuoting: boolean;
readonly lineComments: boolean;
readonly blockComments: boolean;
/**
* MySQL/MariaDB `#` line comments (run to end of line, from any
* position outside a string, no whitespace requirement). Kept off
* for dialects where `#` is a valid identifier character (e.g.
* Oracle's `emp#`).
*/
readonly hashComments: boolean;
/**
* Treat MySQL/MariaDB conditional comments (the ones opening with
* `/*!`) as meaningful statements instead of noop comments. The
Expand Down Expand Up @@ -115,6 +132,7 @@ const POSTGRES: DialectOptions = {
qQuoting: false,
lineComments: true,
blockComments: true,
hashComments: false,
executableComments: false,
nestedBlockComments: true,
lineCommentRequiresSpace: false,
Expand All @@ -135,6 +153,7 @@ const MYSQL: DialectOptions = {
qQuoting: false,
lineComments: true,
blockComments: true,
hashComments: true,
executableComments: true,
nestedBlockComments: false,
lineCommentRequiresSpace: true,
Expand All @@ -145,6 +164,10 @@ const MSSQL: DialectOptions = {
{ open: "'", close: "'", doubleClose: true },
// T-SQL: `]]` inside `[...]` is the literal `]` escape.
{ open: '[', close: ']', doubleClose: true },
// Double quotes delimit identifiers under the default
// QUOTED_IDENTIFIER ON (and strings when it is OFF) — non-code
// either way.
{ open: '"', close: '"', doubleClose: true },
],
eString: false,
dollarQuoting: false,
Expand All @@ -155,8 +178,10 @@ const MSSQL: DialectOptions = {
qQuoting: false,
lineComments: true,
blockComments: true,
hashComments: false,
executableComments: false,
nestedBlockComments: false,
// T-SQL block comments nest (documented behavior).
nestedBlockComments: true,
lineCommentRequiresSpace: false,
};

Expand All @@ -176,6 +201,7 @@ const SQLITE: DialectOptions = {
qQuoting: false,
lineComments: true,
blockComments: true,
hashComments: false,
executableComments: false,
nestedBlockComments: false,
lineCommentRequiresSpace: false,
Expand All @@ -192,6 +218,7 @@ const ORACLE: DialectOptions = {
qQuoting: true,
lineComments: true,
blockComments: true,
hashComments: false,
executableComments: false,
nestedBlockComments: false,
lineCommentRequiresSpace: false,
Expand All @@ -208,6 +235,7 @@ const GENERIC: DialectOptions = {
qQuoting: false,
lineComments: true,
blockComments: true,
hashComments: false,
executableComments: false,
nestedBlockComments: false,
lineCommentRequiresSpace: false,
Expand Down Expand Up @@ -235,7 +263,11 @@ export function dialectOptions(dialect: Dialect): DialectOptions {

function normalizeDialect(dialect: Dialect | string | undefined): Dialect {
if (dialect === undefined) return 'postgres';
return dialect in DIALECT_TABLE ? (dialect as Dialect) : 'generic';
// Own-property check: `in` would also accept prototype members such
// as '__proto__' or 'toString' and return a non-DialectOptions value.
return Object.hasOwn(DIALECT_TABLE, dialect)
? (dialect as Dialect)
: 'generic';
}

/**
Expand All @@ -258,6 +290,31 @@ export function splitQueries(
return splitStatements(sql, dialect).map((s) => s.text);
}

/**
* Spans of every string literal and comment in `sql` — the regions
* where a `:name` is data, not a potential bind parameter.
*
* When `dialect` is undefined or unknown this returns `[]` (nothing is
* treated as non-code) instead of guessing a dialect: lexing with the
* wrong quote rules can hide real SQL — e.g. the generic rules read
* MySQL's `'a\'b'` as an unterminated string and would swallow every
* parameter after it — so degrading to "no spans" keeps callers exactly
* as accurate as they were before they knew about dialects. Pass an
* explicit `'generic'` to scan with the ANSI-ish fallback rules.
*/
export function scanNonCodeSpans(
sql: string,
dialect?: Dialect | string,
): NonCodeSpan[] {
// Own-property check: `in` would also accept prototype members such
// as '__proto__' or 'toString' and hand a non-DialectOptions value to
// the scanner.
if (dialect === undefined || !Object.hasOwn(DIALECT_TABLE, dialect)) {
return [];
}
return collectNonCodeSpans(sql, DIALECT_TABLE[dialect as Dialect]);
}

export {
isSelect,
returnsResultSet,
Expand Down
Loading