2394 lines
84 KiB
TypeScript
2394 lines
84 KiB
TypeScript
import type { SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
|
||
import { cursorBelongsToTrailingStatementDelimiter } from "@/lib/sql/statementDelimiter";
|
||
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
|
||
import { readSqlBracedParameterAt, type SqlParameterOptions } from "@/lib/sql/sqlParameters";
|
||
import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, type DatabaseType } from "@/types/database";
|
||
|
||
/**
|
||
* A contiguous range of SQL text expressed as document offsets plus the
|
||
* extracted (original) substring.
|
||
*/
|
||
export interface SqlTextRange {
|
||
from: number;
|
||
to: number;
|
||
sql: string;
|
||
}
|
||
|
||
const ELASTICSEARCH_REST_REQUEST = /^(?:GET|POST|PUT|PATCH|DELETE|HEAD)\s+\S+/i;
|
||
|
||
function isHttpJsonRestDatabaseType(databaseType?: DatabaseType): boolean {
|
||
return isElasticsearchCompatibleDatabaseType(databaseType) || isMeilisearchDatabaseType(databaseType);
|
||
}
|
||
|
||
export function elasticsearchRestRequestRanges(sql: string, databaseType?: DatabaseType): SqlTextRange[] {
|
||
if (!isHttpJsonRestDatabaseType(databaseType)) return [];
|
||
const requests = splitSqlStatementRanges(sql, databaseType);
|
||
return requests.length > 0 && requests.every((request) => ELASTICSEARCH_REST_REQUEST.test(request.sql)) ? requests : [];
|
||
}
|
||
|
||
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "neo4j", "victoriametrics"]);
|
||
|
||
export function supportsExecutionTargetPicker(databaseType?: DatabaseType): boolean {
|
||
return !!databaseType && (databaseType === "redis" || isHttpJsonRestDatabaseType(databaseType) || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
|
||
}
|
||
|
||
/** Remove the MySQL CLI's trailing vertical-output command before execution. */
|
||
export function stripMysqlClientDisplayCommand(sql: string): string {
|
||
const trimmed = sql.trimEnd();
|
||
const hasTrailingSemicolon = trimmed.endsWith(";");
|
||
const withoutTrailingSemicolon = hasTrailingSemicolon ? trimmed.slice(0, -1).trimEnd() : trimmed;
|
||
if (!withoutTrailingSemicolon.endsWith("\\G") && !withoutTrailingSemicolon.endsWith("\\g")) return sql;
|
||
|
||
const markerStart = withoutTrailingSemicolon.length - 2;
|
||
const lineStart = withoutTrailingSemicolon.lastIndexOf("\n", markerStart - 1) + 1;
|
||
const linePrefix = withoutTrailingSemicolon.slice(lineStart, markerStart);
|
||
if (linePrefix.includes("--") || linePrefix.includes("#")) return sql;
|
||
|
||
const executableSql = withoutTrailingSemicolon.slice(0, markerStart).trimEnd();
|
||
return `${executableSql}${hasTrailingSemicolon ? ";" : ""}${sql.slice(trimmed.length)}`;
|
||
}
|
||
|
||
export function hasMultipleExecutionTargets(sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean {
|
||
if (databaseType === "redis") {
|
||
return redisExecutableCommandCount(sql) > 1;
|
||
}
|
||
return splitSqlStatementRanges(sql, databaseType, parameterOptions).length > 1;
|
||
}
|
||
|
||
interface RawStatement {
|
||
/** Start offset (inclusive) of whitespace that can still target this statement. */
|
||
hitFrom: number;
|
||
/** Start offset (inclusive) of the statement's first non-whitespace char. */
|
||
from: number;
|
||
/** End offset (exclusive) — up to and excluding the terminating semicolon. */
|
||
to: number;
|
||
/** The statement text, sliced from the source document. */
|
||
sql: string;
|
||
}
|
||
|
||
interface ElasticsearchRequestLine {
|
||
from: number;
|
||
to: number;
|
||
text: string;
|
||
}
|
||
|
||
interface ElasticsearchRequestLineCandidate {
|
||
lineIndex: number;
|
||
from: number;
|
||
}
|
||
|
||
export interface ElasticsearchRestRequestTarget {
|
||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
|
||
path: string;
|
||
}
|
||
|
||
const ELASTICSEARCH_REST_REQUEST_LINE = /^\s*(?:GET|POST|PUT|PATCH|DELETE|HEAD)\s+\S+/i;
|
||
|
||
function leadingElasticsearchPreambleEnd(value: string): number {
|
||
let offset = 0;
|
||
while (offset < value.length) {
|
||
while (offset < value.length && /\s/.test(value[offset] ?? "")) offset += 1;
|
||
if (offset >= value.length) return offset;
|
||
|
||
if (value[offset] === "#" || value.startsWith("//", offset)) {
|
||
const newline = value.indexOf("\n", offset);
|
||
offset = newline < 0 ? value.length : newline + 1;
|
||
continue;
|
||
}
|
||
|
||
if (value.startsWith("/*", offset)) {
|
||
const close = value.indexOf("*/", offset + 2);
|
||
offset = close < 0 ? value.length : close + 2;
|
||
continue;
|
||
}
|
||
|
||
break;
|
||
}
|
||
return offset;
|
||
}
|
||
|
||
export function stripLeadingElasticsearchComments(value: string): string {
|
||
return value.slice(leadingElasticsearchPreambleEnd(value)).trimStart();
|
||
}
|
||
|
||
function isElasticsearchRequestPreamble(value: string): boolean {
|
||
return leadingElasticsearchPreambleEnd(value) === value.length;
|
||
}
|
||
|
||
function elasticsearchRequestLines(sql: string): ElasticsearchRequestLine[] {
|
||
const lines: ElasticsearchRequestLine[] = [];
|
||
let from = 0;
|
||
while (from <= sql.length) {
|
||
const newline = sql.indexOf("\n", from);
|
||
const to = newline >= 0 ? newline : sql.length;
|
||
lines.push({ from, to, text: sql.slice(from, to) });
|
||
if (newline < 0) break;
|
||
from = newline + 1;
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
function elasticsearchRequestLineCandidates(lines: ElasticsearchRequestLine[]): ElasticsearchRequestLineCandidate[] {
|
||
const candidates: ElasticsearchRequestLineCandidate[] = [];
|
||
let inBlockComment = false;
|
||
|
||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
||
const line = lines[lineIndex];
|
||
let offset = 0;
|
||
|
||
while (offset < line.text.length) {
|
||
if (inBlockComment) {
|
||
const close = line.text.indexOf("*/", offset);
|
||
if (close < 0) break;
|
||
inBlockComment = false;
|
||
offset = close + 2;
|
||
continue;
|
||
}
|
||
|
||
while (offset < line.text.length && /\s/.test(line.text[offset] ?? "")) offset += 1;
|
||
if (offset >= line.text.length) break;
|
||
if (line.text.startsWith("/*", offset)) {
|
||
inBlockComment = true;
|
||
offset += 2;
|
||
continue;
|
||
}
|
||
if (line.text[offset] === "#" || line.text.startsWith("//", offset)) break;
|
||
if (ELASTICSEARCH_REST_REQUEST_LINE.test(line.text.slice(offset))) {
|
||
candidates.push({ lineIndex, from: line.from + offset });
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
return candidates;
|
||
}
|
||
|
||
export function parseElasticsearchRestRequestTarget(value: string): ElasticsearchRestRequestTarget | null {
|
||
const requestLine = stripLeadingElasticsearchComments(value).split("\n", 1)[0]?.trim() ?? "";
|
||
const match = requestLine.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD)\s+(\S+)/i);
|
||
if (!match) return null;
|
||
return {
|
||
method: match[1].toUpperCase() as ElasticsearchRestRequestTarget["method"],
|
||
path: match[2].startsWith("/") ? match[2] : `/${match[2]}`,
|
||
};
|
||
}
|
||
|
||
export function isElasticsearchRestRequestText(value: string): boolean {
|
||
return parseElasticsearchRestRequestTarget(value) !== null;
|
||
}
|
||
|
||
function splitElasticsearchRestRequestRanges(sql: string): RawStatement[] | undefined {
|
||
const lines = elasticsearchRequestLines(sql);
|
||
const candidates = elasticsearchRequestLineCandidates(lines);
|
||
const firstRequestIndex = candidates.findIndex((candidate) => isElasticsearchRequestPreamble(sql.slice(0, candidate.from)));
|
||
if (firstRequestIndex < 0) return undefined;
|
||
const requests = candidates.slice(firstRequestIndex);
|
||
|
||
const hitFroms = requests.map((request, requestIndex) => {
|
||
const previousRequestLine = requestIndex > 0 ? requests[requestIndex - 1].lineIndex : -1;
|
||
let hitFrom = request.from;
|
||
for (let preambleLine = previousRequestLine + 1; preambleLine < request.lineIndex; preambleLine += 1) {
|
||
const candidateFrom = lines[preambleLine].from;
|
||
if (isElasticsearchRequestPreamble(sql.slice(candidateFrom, request.from))) {
|
||
hitFrom = candidateFrom;
|
||
break;
|
||
}
|
||
}
|
||
return hitFrom;
|
||
});
|
||
|
||
return requests.map((request, requestIndex) => {
|
||
const from = request.from;
|
||
const rawTo = requestIndex + 1 < requests.length ? hitFroms[requestIndex + 1] : sql.length;
|
||
const to = trimRangeEnd(sql, from, rawTo);
|
||
return {
|
||
hitFrom: hitFroms[requestIndex],
|
||
from,
|
||
to,
|
||
sql: sql.slice(from, to),
|
||
};
|
||
});
|
||
}
|
||
|
||
type QuoteState = "none" | "single" | "double" | "backtick" | "bracket" | "dollar";
|
||
|
||
function usesBracketIdentifierQuotes(databaseType?: DatabaseType): boolean {
|
||
return databaseType !== "doris" && databaseType !== "starrocks";
|
||
}
|
||
|
||
const COMMON_SOFT_STATEMENT_START_KEYWORDS = [
|
||
"SELECT",
|
||
"WITH",
|
||
"CREATE",
|
||
"ALTER",
|
||
"DROP",
|
||
"INSERT",
|
||
"UPDATE",
|
||
"DELETE",
|
||
"MERGE",
|
||
"REPLACE",
|
||
"TRUNCATE",
|
||
"GRANT",
|
||
"REVOKE",
|
||
"COMMENT",
|
||
"EXPLAIN",
|
||
"SHOW",
|
||
"DESCRIBE",
|
||
"DESC",
|
||
"USE",
|
||
"SET",
|
||
"CALL",
|
||
"EXEC",
|
||
"EXECUTE",
|
||
"BEGIN",
|
||
"COMMIT",
|
||
"ROLLBACK",
|
||
"DECLARE",
|
||
"ANALYZE",
|
||
"VACUUM",
|
||
"PRAGMA",
|
||
"REFRESH",
|
||
"COPY",
|
||
] as const;
|
||
|
||
const SOFT_STATEMENT_FUNCTION_KEYWORDS = new Set(["REPLACE", "TRUNCATE"]);
|
||
|
||
const DATABASE_SOFT_STATEMENT_KEYWORDS: Partial<Record<DatabaseType, readonly string[]>> = {
|
||
mysql: ["HANDLER", "LOAD", "OPTIMIZE", "REPAIR"],
|
||
postgres: ["DO", "LISTEN", "NOTIFY", "UNLISTEN"],
|
||
sqlite: ["ATTACH", "DETACH", "REINDEX"],
|
||
duckdb: ["ATTACH", "DETACH", "EXPORT", "IMPORT", "INSTALL", "LOAD"],
|
||
clickhouse: ["ATTACH", "CHECK", "DETACH", "EXCHANGE", "KILL", "OPTIMIZE", "SYSTEM"],
|
||
sqlserver: ["BACKUP", "DBCC", "DENY", "KILL", "RESTORE"],
|
||
saphana: ["DO"],
|
||
oracle: ["FLASHBACK", "LOCK", "PURGE"],
|
||
dameng: ["FLASHBACK", "LOCK", "PURGE"],
|
||
gaussdb: ["DO", "LOCK"],
|
||
"oceanbase-oracle": ["FLASHBACK", "LOCK", "PURGE"],
|
||
redis: [],
|
||
mongodb: [],
|
||
elasticsearch: [],
|
||
easysearch: [],
|
||
qdrant: [],
|
||
milvus: [],
|
||
weaviate: [],
|
||
chromadb: [],
|
||
mq: [],
|
||
etcd: [],
|
||
zookeeper: [],
|
||
};
|
||
|
||
const WITH_MAIN_STATEMENT_KEYWORDS = new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE"]);
|
||
const EXPLAIN_STATEMENT_KEYWORDS = new Set(["SELECT", "WITH", "INSERT", "UPDATE", "DELETE", "MERGE", "CREATE", "ALTER", "DROP"]);
|
||
const CREATE_BODY_KEYWORDS = new Set(["SELECT", "WITH", "BEGIN", "DECLARE"]);
|
||
const STARROCKS_CREATE_MATERIALIZED_VIEW_REFRESH_MODIFIERS = new Set(["ASYNC", "MANUAL", "SCHEDULE", "DEFERRED", "IMMEDIATE"]);
|
||
const INSERT_BODY_KEYWORDS = new Set(["SELECT", "WITH"]);
|
||
const ALTER_BODY_KEYWORDS = new Set(["ADD", "ALTER", "COMMENT", "DROP", "MODIFY", "RENAME", "SET"]);
|
||
const CLICKHOUSE_ALTER_TABLE_HEADER = /^ALTER\s+TABLE\s+(?:(?:[A-Za-z_][\w$]*|`(?:``|[^`])+`|"(?:""|[^"])+")\s*\.\s*)?(?:[A-Za-z_][\w$]*|`(?:``|[^`])+`|"(?:""|[^"])+")(?:\s+ON\s+CLUSTER\s+(?:[A-Za-z_][\w$]*|`(?:``|[^`])+`|"(?:""|[^"])+"|'(?:''|[^'])+'))?\s*$/i;
|
||
const SET_OPERATION_KEYWORDS = new Set(["UNION", "INTERSECT", "EXCEPT", "MINUS"]);
|
||
const SET_OPERATION_MODIFIER_KEYWORDS = new Set(["ALL", "DISTINCT"]);
|
||
// Mirrors the backend list in dbx-core/src/sql.rs is_oracle_like_database — keep both
|
||
// in sync. ArgoDB (Transwarp Hive/Inceptor fork) ships a PL/SQL-compatible procedure
|
||
// language (`CREATE [OR REPLACE] PROCEDURE ... IS BEGIN ... END;`), so its statement
|
||
// ranges must stay whole instead of splitting at every body semicolon.
|
||
const ORACLE_LIKE_PL_SQL_DATABASES: ReadonlySet<DatabaseType> = new Set(["oracle", "dameng", "gaussdb", "yashandb", "oscar", "oceanbase-oracle", "xugu", "argo"]);
|
||
const MYSQL_ROUTINE_BLOCK_DATABASES: ReadonlySet<DatabaseType> = new Set(["mysql", "doris", "starrocks", "manticoresearch", "goldendb"]);
|
||
// Backslash escaping inside '...'/"..." strings is a MySQL-family extension; in standard SQL '\'
|
||
// is a complete one-char string and quotes are escaped by doubling (''). Treating backslash as an
|
||
// escape unconditionally makes ESCAPE '\' swallow its closing quote and the following statement
|
||
// boundary, so the next statement loses its run button (#8189). Gate it by dialect, matching the
|
||
// tokenizer/completion side.
|
||
export const BACKSLASH_ESCAPE_STRING_DIALECTS: ReadonlySet<DatabaseType> = new Set(["mysql", "doris", "starrocks", "hive", "argo", "impala", "spark", "databend"]);
|
||
function allowsBackslashStringEscape(databaseType?: DatabaseType): boolean {
|
||
return !!databaseType && BACKSLASH_ESCAPE_STRING_DIALECTS.has(databaseType);
|
||
}
|
||
const MYSQL_CREATE_TABLE_OPTION_DATABASES: ReadonlySet<DatabaseType> = new Set(["mysql", "doris", "starrocks", "manticoresearch", "goldendb", "gbase"]);
|
||
const MYSQL_ROUTINE_OBJECT_TYPES = new Set(["PROCEDURE", "FUNCTION", "TRIGGER", "EVENT"]);
|
||
const MYSQL_NON_ROUTINE_CREATE_TYPES = new Set(["DATABASE", "INDEX", "LOGFILE", "ROLE", "SCHEMA", "SERVER", "SPATIAL", "TABLE", "TEMPORARY", "UNIQUE", "USER", "VIEW"]);
|
||
const MYSQL_CONTROL_BLOCK_SUFFIXES = new Set(["IF", "LOOP", "CASE", "REPEAT", "WHILE"]);
|
||
const ORACLE_PL_SQL_BLOCK_STARTERS = new Set(["DECLARE", "BEGIN"]);
|
||
// Plain CREATE TYPE ... AS OBJECT (...); ends with ");" and is not a PL/SQL block.
|
||
// Only PACKAGE (spec), PACKAGE/TYPE BODY, and routine/trigger objects are PL/SQL blocks.
|
||
const ORACLE_PL_SQL_CREATE_OBJECT_TYPES = new Set(["FUNCTION", "PROCEDURE", "TRIGGER", "PACKAGE"]);
|
||
const ORACLE_PL_SQL_TERMINATORS = new Set(["IF", "LOOP", "CASE"]);
|
||
const SAP_HANA_SCRIPT_BLOCK_TERMINATORS = new Set(["IF", "FOR", "WHILE"]);
|
||
|
||
/**
|
||
* Parse the SQL document into top-level statement ranges delimited by `;`.
|
||
*
|
||
* Delimiters inside string literals, double/backtick/bracket quoted
|
||
* identifiers, dollar-quoted bodies (Postgres), line comments (`--`, `#`) and
|
||
* block comments (`/* */`) are ignored, mirroring the backend splitter in
|
||
* `dbx-core/src/sql.rs`. Ranges are returned as `[from, to)` offsets covering
|
||
* only the statement text (the trailing semicolon and inter-statement
|
||
* whitespace are excluded so editor highlights stay tight).
|
||
*/
|
||
export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): RawStatement[] {
|
||
if (isHttpJsonRestDatabaseType(databaseType)) {
|
||
const requests = splitElasticsearchRestRequestRanges(sql);
|
||
if (requests) return requests;
|
||
}
|
||
|
||
const statements: RawStatement[] = [];
|
||
const len = sql.length;
|
||
const supportsDelimiterCommands = databaseType === "mysql";
|
||
const backslashEscapes = allowsBackslashStringEscape(databaseType);
|
||
|
||
let statementStart = -1;
|
||
let statementEnd = -1;
|
||
let statementHitStart = 0;
|
||
let pendingHintStart = -1;
|
||
let pendingMysqlDirectiveStart = -1;
|
||
let pendingMysqlDirectiveLineEnd = -1;
|
||
let customDelimiter: string | null = null;
|
||
let state: QuoteState = "none";
|
||
let dollarTag = "";
|
||
let postgresDollarQuotedRoutine = false;
|
||
let oraclePlSqlStatementEnd: number | null | undefined;
|
||
let i = 0;
|
||
|
||
// Incremental cache for the MySQL routine-block check below: without it, every
|
||
// semicolon inside a CREATE PROCEDURE/FUNCTION/TRIGGER body (which, unlike a
|
||
// DELIMITER-wrapped body, still contains ordinary internal semicolons) would
|
||
// re-tokenize the whole prefix back to statementStart from scratch, and re-walk
|
||
// the whole BEGIN/CASE/END nesting from scratch, turning a single edit to an
|
||
// N-statement routine body into O(N^2) work (see mysqlRoutineBlockCompleteness).
|
||
let mysqlRoutineScan: {
|
||
from: number;
|
||
scannedTo: number;
|
||
lexState: MysqlRoutineLexState;
|
||
tokens: MysqlRoutineToken[];
|
||
completeness: MysqlRoutineBlockCompleteness;
|
||
} | null = null;
|
||
const mysqlRoutineTokensUpTo = (to: number): MysqlRoutineToken[] => {
|
||
if (!mysqlRoutineScan || mysqlRoutineScan.from !== statementStart) {
|
||
mysqlRoutineScan = { from: statementStart, scannedTo: statementStart, lexState: "none", tokens: [], completeness: newMysqlRoutineBlockCompleteness() };
|
||
}
|
||
if (to > mysqlRoutineScan.scannedTo) {
|
||
const { tokens: newTokens, endState } = mysqlRoutineTokens(sql, parameterOptions, mysqlRoutineScan.scannedTo, to, mysqlRoutineScan.lexState);
|
||
mysqlRoutineScan.tokens.push(...newTokens);
|
||
mysqlRoutineScan.lexState = endState;
|
||
mysqlRoutineScan.scannedTo = to;
|
||
}
|
||
return mysqlRoutineScan.tokens;
|
||
};
|
||
const mysqlRoutineBlockIsCompleteUpTo = (to: number): boolean => {
|
||
const tokens = mysqlRoutineTokensUpTo(to);
|
||
return advanceMysqlRoutineBlockCompleteness(mysqlRoutineScan!.completeness, tokens);
|
||
};
|
||
|
||
const isWhitespace = (ch: string) => ch === " " || ch === "\t" || ch === "\r" || ch === "\n";
|
||
const nextLineBreak = (from: number) => {
|
||
const carriageReturn = sql.indexOf("\r", from);
|
||
const lineFeed = sql.indexOf("\n", from);
|
||
if (carriageReturn === -1) return lineFeed === -1 ? len : lineFeed;
|
||
if (lineFeed === -1) return carriageReturn;
|
||
return Math.min(carriageReturn, lineFeed);
|
||
};
|
||
|
||
const markContent = (pos: number) => {
|
||
if (statementStart === -1) {
|
||
// TDSQL accepts arbitrary leading block directives on the SQL line. The
|
||
// exact /*proxy*/ directive also keeps its historical multiline support.
|
||
const directiveStart = pendingMysqlDirectiveStart !== -1 && (sql.startsWith("/*proxy*/", pendingMysqlDirectiveStart) || pos < pendingMysqlDirectiveLineEnd) ? pendingMysqlDirectiveStart : -1;
|
||
statementStart = directiveStart !== -1 ? directiveStart : pendingHintStart === -1 ? pos : pendingHintStart;
|
||
pendingHintStart = -1;
|
||
pendingMysqlDirectiveStart = -1;
|
||
pendingMysqlDirectiveLineEnd = -1;
|
||
}
|
||
statementEnd = pos + 1;
|
||
};
|
||
|
||
const flush = (to = statementEnd) => {
|
||
if (statementStart === -1) {
|
||
statementEnd = -1;
|
||
pendingHintStart = -1;
|
||
pendingMysqlDirectiveStart = -1;
|
||
pendingMysqlDirectiveLineEnd = -1;
|
||
postgresDollarQuotedRoutine = false;
|
||
oraclePlSqlStatementEnd = undefined;
|
||
return;
|
||
}
|
||
const trimmedTo = trimRangeEnd(sql, statementStart, to);
|
||
if (trimmedTo > statementStart) {
|
||
statements.push({ hitFrom: statementHitStart, from: statementStart, to: trimmedTo, sql: sql.slice(statementStart, trimmedTo) });
|
||
}
|
||
statementStart = -1;
|
||
statementEnd = -1;
|
||
pendingHintStart = -1;
|
||
pendingMysqlDirectiveStart = -1;
|
||
pendingMysqlDirectiveLineEnd = -1;
|
||
postgresDollarQuotedRoutine = false;
|
||
oraclePlSqlStatementEnd = undefined;
|
||
mysqlRoutineScan = null;
|
||
};
|
||
|
||
while (i < len) {
|
||
const ch = sql[i];
|
||
const next = sql[i + 1] ?? "";
|
||
|
||
if (state === "dollar") {
|
||
// Inside a Postgres dollar-quoted body; look for the closing $tag$.
|
||
if (ch === "$") {
|
||
const closingTag = `$${dollarTag}$`;
|
||
if (sql.startsWith(closingTag, i)) {
|
||
markContent(i);
|
||
for (let k = 0; k < closingTag.length; k += 1) {
|
||
markContent(i + k);
|
||
}
|
||
i += closingTag.length;
|
||
state = "none";
|
||
dollarTag = "";
|
||
continue;
|
||
}
|
||
}
|
||
markContent(i);
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "single") {
|
||
markContent(i);
|
||
// Only MySQL-family dialects treat backslash as an escape inside '...' (see
|
||
// BACKSLASH_ESCAPE_STRING_DIALECTS); in standard SQL '\' is a literal char and must not
|
||
// consume the next char, otherwise the closing quote is swallowed (#8189).
|
||
if (ch === "\\" && next && backslashEscapes) {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'") {
|
||
// Doubled single quote '' is an escaped quote, not a terminator.
|
||
if (next === "'") {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
state = "none";
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "double") {
|
||
markContent(i);
|
||
if (ch === '"') {
|
||
if (next === '"') {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
state = "none";
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "backtick") {
|
||
markContent(i);
|
||
if (ch === "`") {
|
||
if (next === "`") {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
state = "none";
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "bracket") {
|
||
markContent(i);
|
||
if (ch === "]") {
|
||
state = "none";
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
// state === "none"
|
||
if (supportsDelimiterCommands && isAtLineStart(sql, i) && startsDelimiterCommand(sql, i)) {
|
||
const lineEnd = findLineEnd(sql, i);
|
||
const delimiter = parseDelimiterCommand(sql.slice(i, lineEnd));
|
||
if (delimiter !== null) {
|
||
flush();
|
||
customDelimiter = delimiter === ";" ? null : delimiter;
|
||
i = nextLineStart(sql, lineEnd);
|
||
statementHitStart = i;
|
||
continue;
|
||
}
|
||
}
|
||
if (isOracleLikeDatabase(databaseType, parameterOptions) && isAtLineStart(sql, i) && isSlashLine(sql, i)) {
|
||
const lineEnd = findLineEnd(sql, i);
|
||
flush(i);
|
||
i = nextLineStart(sql, lineEnd);
|
||
statementHitStart = i;
|
||
continue;
|
||
}
|
||
|
||
// Line comments consume up to (and including) the newline.
|
||
if (ch === "-" && next === "-") {
|
||
pendingMysqlDirectiveStart = -1;
|
||
pendingMysqlDirectiveLineEnd = -1;
|
||
const newline = sql.indexOf("\n", i);
|
||
i = newline === -1 ? len : newline + 1;
|
||
continue;
|
||
}
|
||
if (startsHashLineComment(sql, i, databaseType, parameterOptions)) {
|
||
pendingMysqlDirectiveStart = -1;
|
||
pendingMysqlDirectiveLineEnd = -1;
|
||
const newline = sql.indexOf("\n", i);
|
||
i = newline === -1 ? len : newline + 1;
|
||
continue;
|
||
}
|
||
// Block comments consume until the closing */.
|
||
if (ch === "/" && next === "*") {
|
||
const hintMarker = sql[i + 2];
|
||
if (statementStart === -1 && pendingHintStart === -1 && (hintMarker === "+" || hintMarker === "@" || hintMarker === "&")) pendingHintStart = i;
|
||
const close = sql.indexOf("*/", i + 2);
|
||
if (statementStart === -1) {
|
||
if (databaseType === "mysql") {
|
||
if (pendingMysqlDirectiveStart === -1 || i > pendingMysqlDirectiveLineEnd) {
|
||
pendingMysqlDirectiveStart = i;
|
||
pendingMysqlDirectiveLineEnd = nextLineBreak(i);
|
||
}
|
||
} else {
|
||
pendingMysqlDirectiveStart = -1;
|
||
pendingMysqlDirectiveLineEnd = -1;
|
||
}
|
||
}
|
||
i = close === -1 ? len : close + 2;
|
||
continue;
|
||
}
|
||
|
||
if (supportsSqlServerGoCommands(databaseType) && isAtLineStart(sql, i) && isSqlServerGoLine(sql, i)) {
|
||
const lineEnd = findLineEnd(sql, i);
|
||
flush(i);
|
||
i = nextLineStart(sql, lineEnd);
|
||
statementHitStart = i;
|
||
continue;
|
||
}
|
||
|
||
if (ch === "'") {
|
||
markContent(i);
|
||
state = "single";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
markContent(i);
|
||
state = "double";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "`") {
|
||
markContent(i);
|
||
state = "backtick";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "[" && usesBracketIdentifierQuotes(databaseType)) {
|
||
markContent(i);
|
||
state = "bracket";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
// Postgres dollar quoting: $tag$ ... $tag$ (tag may be empty, i.e. $$)
|
||
if (!customDelimiter && ch === "$") {
|
||
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
|
||
if (tagMatch) {
|
||
markContent(i);
|
||
if ((databaseType === "gaussdb" || isOpenGaussOracleCompatibility(databaseType, parameterOptions)) && statementStart !== -1 && startsWithPostgresDollarQuotedRoutinePrefix(sql.slice(statementStart, i))) {
|
||
postgresDollarQuotedRoutine = true;
|
||
}
|
||
dollarTag = tagMatch[0].slice(1, -1);
|
||
i += tagMatch[0].length;
|
||
state = "dollar";
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (customDelimiter) {
|
||
if (sql.startsWith(customDelimiter, i)) {
|
||
flush(i);
|
||
i += customDelimiter.length;
|
||
statementHitStart = i;
|
||
continue;
|
||
}
|
||
} else if (ch === ";") {
|
||
const routineTokensBeforeSemicolon = isMysqlRoutineBlockDatabase(databaseType) && statementStart !== -1 ? mysqlRoutineTokensUpTo(i) : null;
|
||
const isMysqlRoutineBlock = routineTokensBeforeSemicolon !== null && isMysqlRoutineDdlStartFromWords(mysqlRoutineDdlStartWords(routineTokensBeforeSemicolon)) && mysqlRoutineTokensContainBegin(routineTokensBeforeSemicolon);
|
||
if (isMysqlRoutineBlock) {
|
||
if (!mysqlRoutineBlockIsCompleteUpTo(i + 1)) {
|
||
markContent(i);
|
||
i += 1;
|
||
continue;
|
||
}
|
||
// The final semicolon is the client-side statement delimiter.
|
||
// Internal semicolons remain part of the routine body.
|
||
flush();
|
||
} else {
|
||
if (oraclePlSqlStatementEnd === undefined && isOracleLikeDatabase(databaseType, parameterOptions) && !postgresDollarQuotedRoutine && statementStart !== -1) {
|
||
const statementSoFar = sql.slice(statementStart, i);
|
||
oraclePlSqlStatementEnd = startsWithOraclePlSqlBlock(statementSoFar) ? statementStart + (oraclePlSqlBlockEnd(sql.slice(statementStart)) ?? sql.length - statementStart) : null;
|
||
}
|
||
const resolvedOraclePlSqlStatementEnd = oraclePlSqlStatementEnd;
|
||
const isOraclePlSql = typeof resolvedOraclePlSqlStatementEnd === "number";
|
||
const isSapHanaScriptBlock = isSapHanaScriptBlockDatabase(databaseType) && statementStart !== -1 && startsWithSapHanaScriptBlock(sql.slice(statementStart, i));
|
||
if (isOraclePlSql || isSapHanaScriptBlock) {
|
||
markContent(i);
|
||
if (isOraclePlSql && i + 1 < resolvedOraclePlSqlStatementEnd) {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (isSapHanaScriptBlock && !sapHanaScriptBlockIsComplete(sql.slice(statementStart, i + 1))) {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
flush(i + 1);
|
||
} else {
|
||
flush();
|
||
}
|
||
}
|
||
statementHitStart = i + 1;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (!isWhitespace(ch)) {
|
||
markContent(i);
|
||
}
|
||
i += 1;
|
||
}
|
||
|
||
// Flush any trailing statement that lacks a terminating semicolon.
|
||
flush();
|
||
|
||
return statements;
|
||
}
|
||
|
||
/**
|
||
* Returns the statement that contains `cursorPos`, or `null` when the cursor
|
||
* sits on a blank line or no statement can be resolved.
|
||
*
|
||
* The returned range covers only the statement's own text (no trailing `;`),
|
||
* which lets the editor highlight a tight preview range.
|
||
*/
|
||
export function statementRangeAtCursor(sql: string, cursorPos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): SqlTextRange | null {
|
||
const pos = clampCursor(sql, cursorPos);
|
||
if (isCursorOnBlankLine(sql, pos)) return null;
|
||
|
||
const statements = splitSqlStatementRanges(sql, databaseType, parameterOptions);
|
||
for (let index = 0; index < statements.length; index += 1) {
|
||
const statement = statements[index];
|
||
const softRanges = splitStatementRangeAtSoftStarts(sql, statement, databaseType, parameterOptions);
|
||
// Cursor inside the statement body, including the exact start/end.
|
||
if (pos >= statement.from && pos <= statement.to) {
|
||
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
|
||
}
|
||
const next = statements[index + 1];
|
||
// A caret after a statement's semicolon still belongs to that statement
|
||
// until the next statement's text begins.
|
||
if (pos > statement.to && (!next || pos < next.from) && isCursorInTrailingDelimiterGap(sql, statement.to, pos)) {
|
||
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(softRanges[softRanges.length - 1] ?? statement, sql);
|
||
}
|
||
|
||
// Cursor in indentation or inter-statement whitespace immediately before
|
||
// the statement should still target that statement, while the returned
|
||
// execution range remains tight around the SQL text itself.
|
||
if (pos >= statement.hitFrom && pos < statement.from && (sql.slice(pos, statement.from).trim() === "" || (isHttpJsonRestDatabaseType(databaseType) && isElasticsearchRequestPreamble(sql.slice(statement.hitFrom, statement.from))))) {
|
||
const previous = statements[index - 1];
|
||
if (previous && isCursorInTrailingDelimiterGap(sql, previous.to, pos)) {
|
||
const previousSoftRanges = splitStatementRangeAtSoftStarts(sql, previous, databaseType, parameterOptions);
|
||
return rangeForCursorInSoftRanges(sql, previousSoftRanges, pos) ?? rangeFor(previousSoftRanges[previousSoftRanges.length - 1] ?? previous, sql);
|
||
}
|
||
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
|
||
}
|
||
|
||
if (pos > statement.to && (!next || pos < next.hitFrom)) {
|
||
const softRange = rangeForCursorInSoftRanges(sql, softRanges, pos);
|
||
if (softRange) return softRange;
|
||
if (isCursorOnRangeEndLine(sql, pos, statement)) return rangeFor(statement, sql);
|
||
}
|
||
}
|
||
|
||
// A MySQL `delimiter X` line is a client directive, not executable SQL, so it
|
||
// never forms a statement range of its own. When the caret rests on such a
|
||
// line, target the statement the directive introduces (or the closest
|
||
// preceding statement when the directive ends the script) instead of
|
||
// reporting that there is nothing to run. See issue #9485.
|
||
const directiveRange = mysqlDelimiterDirectiveCursorRange(sql, pos, databaseType, parameterOptions, statements);
|
||
if (directiveRange) return directiveRange;
|
||
|
||
return null;
|
||
}
|
||
|
||
export function mongoCommandRangeAtCursor(sql: string, cursorPos: number): SqlTextRange | null {
|
||
const pos = clampCursor(sql, cursorPos);
|
||
if (isCursorOnBlankLine(sql, pos)) return null;
|
||
|
||
const commands = splitMongoCommandRanges(sql);
|
||
for (let index = 0; index < commands.length; index += 1) {
|
||
const command = commands[index];
|
||
const range = { from: command.from, to: command.to, sql: command.text };
|
||
|
||
if (pos >= command.from && pos <= command.to) return range;
|
||
|
||
const next = commands[index + 1];
|
||
if (pos > command.to && (!next || pos < next.from) && isCursorInTrailingDelimiterGap(sql, command.to, pos)) return range;
|
||
|
||
if (pos < command.from && sql.slice(pos, command.from).trim() === "" && isCursorOnStatementLine(sql, pos, command)) return range;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function isCursorInTrailingDelimiterGap(sql: string, previousStatementEnd: number, cursorPos: number): boolean {
|
||
return cursorBelongsToTrailingStatementDelimiter(sql, previousStatementEnd, cursorPos);
|
||
}
|
||
|
||
function rangeForCursorInSoftRanges(sql: string, ranges: RawStatement[], pos: number): SqlTextRange | null {
|
||
for (let index = 0; index < ranges.length; index += 1) {
|
||
const range = ranges[index];
|
||
if (pos >= range.from && pos <= range.to) {
|
||
return rangeFor(range, sql);
|
||
}
|
||
if (pos >= range.hitFrom && pos < range.from && sql.slice(pos, range.from).trim() === "") {
|
||
return rangeFor(range, sql);
|
||
}
|
||
|
||
const next = ranges[index + 1];
|
||
if (pos > range.to && (!next || pos < next.hitFrom) && isCursorOnRangeEndLine(sql, pos, range)) {
|
||
return rangeFor(range, sql);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): RawStatement[] {
|
||
if (isOraclePlSqlStatement(statement.sql, databaseType, parameterOptions)) return [statement];
|
||
if (isSapHanaScriptBlockStatement(statement.sql, databaseType)) return [statement];
|
||
// Routine bodies contain top-level-looking SET/INSERT/SELECT lines that are not independent statements.
|
||
if (isMysqlRoutineBlockDatabase(databaseType) && startsWithMysqlRoutineBlock(statement.sql, parameterOptions)) return [statement];
|
||
// SQL Server control-flow batches use line-oriented BEGIN/EXEC tokens inside one IF/ELSE statement.
|
||
if (isSqlServerIfElseControlFlowBatch(sql, statement, databaseType, parameterOptions)) return [statement];
|
||
|
||
const lineStarts = topLevelSoftStatementLineStarts(sql, statement, databaseType, parameterOptions);
|
||
if (lineStarts.length <= 1) return [statement];
|
||
|
||
const boundaries: Array<{ hitFrom: number; from: number; keyword: string }> = [];
|
||
let currentKeyword = softStatementKeywordAt(sql, statement.from, databaseType, parameterOptions);
|
||
let currentExplainTargetKeyword = explainLikeTargetKeywordAt(sql, statement.from, databaseType, parameterOptions);
|
||
let currentBodyKeyword = currentExplainTargetKeyword ?? currentKeyword;
|
||
let consumedWithMainStatement = false;
|
||
let consumedExplainStatement = false;
|
||
|
||
boundaries.push({ hitFrom: statement.hitFrom, from: statement.from, keyword: currentKeyword ?? "" });
|
||
|
||
for (const lineStart of lineStarts) {
|
||
if (lineStart.from <= statement.from) continue;
|
||
|
||
if (currentBodyKeyword === "WITH" && !consumedWithMainStatement && WITH_MAIN_STATEMENT_KEYWORDS.has(lineStart.keyword)) {
|
||
consumedWithMainStatement = true;
|
||
// The CTE main statement also satisfies a pending EXPLAIN target, and its
|
||
// own body rules (e.g. UPDATE ... SET) must take over from here.
|
||
consumedExplainStatement = true;
|
||
currentBodyKeyword = lineStart.keyword;
|
||
continue;
|
||
}
|
||
|
||
if (isSetOperationQueryContinuation(sql, statement.from, lineStart.from, lineStart.keyword, databaseType, parameterOptions)) {
|
||
continue;
|
||
}
|
||
|
||
if (!consumedExplainStatement && EXPLAIN_STATEMENT_KEYWORDS.has(lineStart.keyword) && (currentKeyword === "EXPLAIN" || currentExplainTargetKeyword !== null)) {
|
||
consumedExplainStatement = true;
|
||
currentBodyKeyword = lineStart.keyword;
|
||
continue;
|
||
}
|
||
|
||
if (currentBodyKeyword === "CREATE" && CREATE_BODY_KEYWORDS.has(lineStart.keyword)) {
|
||
continue;
|
||
}
|
||
|
||
if (currentBodyKeyword === "CREATE" && isStarRocksCreateMaterializedViewRefreshContinuation(sql, statement.from, lineStart.from, lineStart.keyword, databaseType, parameterOptions)) {
|
||
continue;
|
||
}
|
||
|
||
if (currentBodyKeyword === "CREATE" && isMysqlCreateTableOptionContinuation(sql, statement.from, lineStart.from, lineStart.keyword, databaseType)) {
|
||
continue;
|
||
}
|
||
|
||
if (currentBodyKeyword === "INSERT" && INSERT_BODY_KEYWORDS.has(lineStart.keyword)) {
|
||
// Hand over to the source query's own rules so only its continuations
|
||
// (CTE main statement, set operations) stay attached to the INSERT.
|
||
currentBodyKeyword = lineStart.keyword;
|
||
if (lineStart.keyword === "WITH") consumedWithMainStatement = false;
|
||
continue;
|
||
}
|
||
|
||
if (currentBodyKeyword === "UPDATE" && lineStart.keyword === "SET") {
|
||
continue;
|
||
}
|
||
|
||
if (currentBodyKeyword === "MERGE" && isMergeActionContinuation(sql, statement.from, lineStart.from, lineStart.keyword, databaseType, parameterOptions)) {
|
||
continue;
|
||
}
|
||
|
||
if (currentBodyKeyword === "ALTER" && isClickHouseAlterTableUpdateContinuation(sql, boundaries[boundaries.length - 1].from, lineStart.from, lineStart.keyword, databaseType)) {
|
||
// ClickHouse mutations use UPDATE as the first ALTER TABLE action, not as
|
||
// a standalone statement. Keep this dialect-specific to preserve soft boundaries elsewhere.
|
||
continue;
|
||
}
|
||
|
||
if (currentBodyKeyword === "ALTER" && isMysqlAlterTableTruncatePartitionContinuation(sql, boundaries[boundaries.length - 1].from, lineStart.from, lineStart.keyword, databaseType)) {
|
||
continue;
|
||
}
|
||
|
||
if (currentBodyKeyword === "ALTER" && ALTER_BODY_KEYWORDS.has(lineStart.keyword)) {
|
||
continue;
|
||
}
|
||
|
||
boundaries.push(lineStart);
|
||
currentKeyword = lineStart.keyword;
|
||
currentExplainTargetKeyword = explainLikeTargetKeywordAt(sql, lineStart.from, databaseType, parameterOptions);
|
||
currentBodyKeyword = currentExplainTargetKeyword ?? currentKeyword;
|
||
consumedWithMainStatement = false;
|
||
consumedExplainStatement = false;
|
||
}
|
||
|
||
if (boundaries.length <= 1) return [statement];
|
||
|
||
const ranges: RawStatement[] = [];
|
||
for (let index = 0; index < boundaries.length; index += 1) {
|
||
const boundary = boundaries[index];
|
||
const next = boundaries[index + 1];
|
||
const to = next ? trimRangeEndBeforeNextBoundary(sql, boundary.from, next.from, databaseType, parameterOptions) : trimRangeEnd(sql, boundary.from, statement.to);
|
||
if (to > boundary.from) {
|
||
ranges.push({
|
||
hitFrom: boundary.hitFrom,
|
||
from: boundary.from,
|
||
to,
|
||
sql: sql.slice(boundary.from, to),
|
||
});
|
||
}
|
||
}
|
||
|
||
return ranges.length > 0 ? ranges : [statement];
|
||
}
|
||
|
||
function isSqlServerIfElseControlFlowBatch(sql: string, statement: RawStatement, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean {
|
||
if (databaseType !== "sqlserver" || !startsWithSqlWords(sql, statement.from, ["IF"], databaseType, parameterOptions)) return false;
|
||
|
||
const words = topLevelWordsBefore(sql, statement.from, statement.to, 64, databaseType, parameterOptions);
|
||
return words.includes("ELSE") && words.includes("BEGIN") && words.includes("END");
|
||
}
|
||
|
||
function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): Array<{ hitFrom: number; from: number; keyword: string }> {
|
||
const starts: Array<{ hitFrom: number; from: number; keyword: string }> = [];
|
||
const len = statement.to;
|
||
const explainOptionsStart = explainOptionsParenAt(sql, statement.from);
|
||
// Recover soft statement boundaries while the user is still typing an
|
||
// EXPLAIN option list; otherwise its unmatched opener hides every later line.
|
||
const unclosedExplainOptionsStart = explainOptionsStart !== null && skipBalancedParens(sql, explainOptionsStart, databaseType, parameterOptions) === null ? explainOptionsStart : null;
|
||
const backslashEscapes = allowsBackslashStringEscape(databaseType);
|
||
let state: QuoteState | "lineComment" | "blockComment" = "none";
|
||
let dollarTag = "";
|
||
let parenDepth = 0;
|
||
let lineStart = statement.from;
|
||
let firstNonWhitespaceOnLine = -1;
|
||
let i = statement.from;
|
||
|
||
while (i < len) {
|
||
const ch = sql[i];
|
||
const next = sql[i + 1] ?? "";
|
||
|
||
if (state === "none" && firstNonWhitespaceOnLine === -1 && ch !== "\n" && ch !== "\r" && !isSqlWhitespace(ch) && !startsLineComment(sql, i, databaseType, parameterOptions) && !startsBlockComment(sql, i)) {
|
||
firstNonWhitespaceOnLine = i;
|
||
if (parenDepth === 0) {
|
||
const keyword = softStatementKeywordAt(sql, i, databaseType, parameterOptions);
|
||
if (keyword) {
|
||
starts.push({ hitFrom: lineStart, from: i, keyword });
|
||
}
|
||
}
|
||
}
|
||
|
||
if (ch === "\n") {
|
||
if (state === "lineComment") state = "none";
|
||
lineStart = i + 1;
|
||
firstNonWhitespaceOnLine = -1;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "lineComment") {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "blockComment") {
|
||
if (ch === "*" && next === "/") {
|
||
state = "none";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "dollar") {
|
||
if (ch === "$") {
|
||
const closingTag = `$${dollarTag}$`;
|
||
if (sql.startsWith(closingTag, i)) {
|
||
i += closingTag.length;
|
||
state = "none";
|
||
dollarTag = "";
|
||
continue;
|
||
}
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "single") {
|
||
if (ch === "\\" && next && backslashEscapes) {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'") {
|
||
if (next === "'") {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
state = "none";
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "double") {
|
||
if (ch === '"') {
|
||
if (next === '"') {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
state = "none";
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "backtick") {
|
||
if (ch === "`") {
|
||
if (next === "`") {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
state = "none";
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "bracket") {
|
||
if (ch === "]") state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
// state === "none"
|
||
if (ch === "-" && next === "-") {
|
||
state = "lineComment";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (startsHashLineComment(sql, i, databaseType, parameterOptions)) {
|
||
state = "lineComment";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "/" && next === "*") {
|
||
state = "blockComment";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'") {
|
||
state = "single";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
state = "double";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "`") {
|
||
state = "backtick";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "[" && usesBracketIdentifierQuotes(databaseType)) {
|
||
state = "bracket";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "$") {
|
||
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
|
||
if (tagMatch) {
|
||
dollarTag = tagMatch[0].slice(1, -1);
|
||
i += tagMatch[0].length;
|
||
state = "dollar";
|
||
continue;
|
||
}
|
||
}
|
||
if (ch === "(" && i !== unclosedExplainOptionsStart) {
|
||
parenDepth += 1;
|
||
} else if (ch === ")" && parenDepth > 0) {
|
||
parenDepth -= 1;
|
||
}
|
||
i += 1;
|
||
}
|
||
|
||
return starts;
|
||
}
|
||
|
||
function softStatementKeywordAt(sql: string, pos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): string | null {
|
||
const match = /^[A-Za-z_][\w$]*/.exec(sql.slice(pos));
|
||
if (!match) return null;
|
||
const keyword = match[0].toUpperCase();
|
||
if (SOFT_STATEMENT_FUNCTION_KEYWORDS.has(keyword) && nextNonWhitespaceChar(sql, pos + match[0].length) === "(") return null;
|
||
// COMMENT is also a common column name. Only COMMENT ON starts a standalone
|
||
// SQL command; otherwise a line-start projection column must stay in SELECT.
|
||
if (keyword === "COMMENT" && nextSqlWord(sql, pos + match[0].length, databaseType, parameterOptions) !== "ON") return null;
|
||
return softStatementStartKeywords(databaseType).has(keyword) ? keyword : null;
|
||
}
|
||
|
||
function softStatementStartKeywords(databaseType?: DatabaseType): Set<string> {
|
||
return new Set([...COMMON_SOFT_STATEMENT_START_KEYWORDS, ...(databaseType ? (DATABASE_SOFT_STATEMENT_KEYWORDS[databaseType] ?? []) : [])]);
|
||
}
|
||
|
||
function isSetOperationQueryContinuation(sql: string, from: number, to: number, keyword: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean {
|
||
if (keyword !== "SELECT" && keyword !== "WITH") return false;
|
||
const words = topLevelWordsBefore(sql, from, to, 3, databaseType, parameterOptions);
|
||
const last = words[words.length - 1];
|
||
if (last && SET_OPERATION_KEYWORDS.has(last)) return true;
|
||
if (last && SET_OPERATION_MODIFIER_KEYWORDS.has(last)) {
|
||
const previous = words[words.length - 2];
|
||
return !!previous && SET_OPERATION_KEYWORDS.has(previous);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function isMysqlCreateTableOptionContinuation(sql: string, statementFrom: number, lineStartFrom: number, keyword: string, databaseType?: DatabaseType): boolean {
|
||
if (databaseType && !MYSQL_CREATE_TABLE_OPTION_DATABASES.has(databaseType)) return false;
|
||
if (keyword !== "COMMENT") return false;
|
||
if (!startsWithMysqlCreateTable(sql, statementFrom)) return false;
|
||
|
||
const next = nextNonWhitespaceChar(sql, lineStartFrom + keyword.length);
|
||
return next === "=" || next === "'" || next === '"';
|
||
}
|
||
|
||
function isStarRocksCreateMaterializedViewRefreshContinuation(sql: string, statementFrom: number, lineStartFrom: number, keyword: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean {
|
||
if (databaseType !== "starrocks" || keyword !== "REFRESH") return false;
|
||
if (!startsWithSqlWords(sql, statementFrom, ["CREATE", "MATERIALIZED", "VIEW"], databaseType, parameterOptions)) return false;
|
||
|
||
const modifier = nextSqlWord(sql, lineStartFrom + keyword.length, databaseType, parameterOptions);
|
||
return modifier !== null && STARROCKS_CREATE_MATERIALIZED_VIEW_REFRESH_MODIFIERS.has(modifier);
|
||
}
|
||
|
||
function isClickHouseAlterTableUpdateContinuation(sql: string, statementFrom: number, lineStartFrom: number, keyword: string, databaseType?: DatabaseType): boolean {
|
||
if (databaseType !== "clickhouse" || keyword !== "UPDATE") return false;
|
||
return CLICKHOUSE_ALTER_TABLE_HEADER.test(sql.slice(statementFrom, lineStartFrom));
|
||
}
|
||
|
||
function isMysqlAlterTableTruncatePartitionContinuation(sql: string, statementFrom: number, lineStartFrom: number, keyword: string, databaseType?: DatabaseType): boolean {
|
||
if (databaseType !== "mysql" || keyword !== "TRUNCATE") return false;
|
||
if (!startsWithSqlWords(sql, statementFrom, ["ALTER", "TABLE"], databaseType)) return false;
|
||
return nextSqlWord(sql, lineStartFrom + keyword.length, databaseType) === "PARTITION";
|
||
}
|
||
|
||
function isMergeActionContinuation(sql: string, statementFrom: number, lineStartFrom: number, keyword: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean {
|
||
if (keyword !== "INSERT" || !startsWithSqlWords(sql, statementFrom, ["MERGE"], databaseType, parameterOptions)) return false;
|
||
const words = topLevelWordsBefore(sql, statementFrom, lineStartFrom, 5, databaseType, parameterOptions);
|
||
return words[words.length - 1] === "THEN" && words.includes("WHEN") && words.includes("MATCHED");
|
||
}
|
||
|
||
function startsWithMysqlCreateTable(sql: string, statementFrom: number): boolean {
|
||
const text = sql.slice(statementFrom, statementFrom + 256);
|
||
return /^CREATE\s+(?:TEMPORARY\s+)?TABLE\b/i.test(text);
|
||
}
|
||
|
||
function topLevelWordsBefore(sql: string, from: number, to: number, limit: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): string[] {
|
||
const words: string[] = [];
|
||
const backslashEscapes = allowsBackslashStringEscape(databaseType);
|
||
let state: QuoteState | "lineComment" | "blockComment" = "none";
|
||
let dollarTag = "";
|
||
let parenDepth = 0;
|
||
let i = from;
|
||
|
||
while (i < to) {
|
||
const ch = sql[i];
|
||
const next = sql[i + 1] ?? "";
|
||
|
||
if (state === "lineComment") {
|
||
if (ch === "\n") state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "blockComment") {
|
||
if (ch === "*" && next === "/") {
|
||
state = "none";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "dollar") {
|
||
if (ch === "$") {
|
||
const closingTag = `$${dollarTag}$`;
|
||
if (sql.startsWith(closingTag, i)) {
|
||
i += closingTag.length;
|
||
state = "none";
|
||
dollarTag = "";
|
||
continue;
|
||
}
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "single") {
|
||
if (ch === "\\" && next && backslashEscapes) {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'") {
|
||
if (next === "'") {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
state = "none";
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "double") {
|
||
if (ch === '"') {
|
||
if (next === '"') {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
state = "none";
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "backtick") {
|
||
if (ch === "`") {
|
||
if (next === "`") {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
state = "none";
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "bracket") {
|
||
if (ch === "]") state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (ch === "-" && next === "-") {
|
||
state = "lineComment";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (startsHashLineComment(sql, i, databaseType, parameterOptions)) {
|
||
state = "lineComment";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "/" && next === "*") {
|
||
state = "blockComment";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'") {
|
||
state = "single";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
state = "double";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "`") {
|
||
state = "backtick";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "[" && usesBracketIdentifierQuotes(databaseType)) {
|
||
state = "bracket";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "$") {
|
||
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
|
||
if (tagMatch) {
|
||
dollarTag = tagMatch[0].slice(1, -1);
|
||
i += tagMatch[0].length;
|
||
state = "dollar";
|
||
continue;
|
||
}
|
||
}
|
||
if (ch === "(") {
|
||
parenDepth += 1;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === ")") {
|
||
if (parenDepth > 0) parenDepth -= 1;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (parenDepth === 0) {
|
||
const match = /^[A-Za-z_][\w$]*/.exec(sql.slice(i));
|
||
if (match) {
|
||
words.push(match[0].toUpperCase());
|
||
if (words.length > limit) words.shift();
|
||
i += match[0].length;
|
||
continue;
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
|
||
return words;
|
||
}
|
||
|
||
function nextNonWhitespaceChar(sql: string, pos: number): string | null {
|
||
let i = pos;
|
||
while (i < sql.length && isSqlWhitespace(sql[i])) i += 1;
|
||
return i < sql.length ? sql[i] : null;
|
||
}
|
||
|
||
function nextSqlWord(sql: string, pos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): string | null {
|
||
return nextSqlWordToken(sql, pos, databaseType, parameterOptions)?.word ?? null;
|
||
}
|
||
|
||
function startsWithSqlWords(sql: string, pos: number, expectedWords: readonly string[], databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean {
|
||
let i = pos;
|
||
for (const expectedWord of expectedWords) {
|
||
const token = nextSqlWordToken(sql, i, databaseType, parameterOptions);
|
||
if (token?.word !== expectedWord) return false;
|
||
i = token.end;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function nextSqlWordToken(sql: string, pos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): { word: string; end: number } | null {
|
||
let i = pos;
|
||
// SQL comments may legally appear anywhere whitespace can separate keywords.
|
||
while (i < sql.length) {
|
||
if (isSqlWhitespace(sql[i])) {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (sql[i] === "-" && sql[i + 1] === "-") {
|
||
i += 2;
|
||
while (i < sql.length && sql[i] !== "\n" && sql[i] !== "\r") i += 1;
|
||
continue;
|
||
}
|
||
if (startsHashLineComment(sql, i, databaseType, parameterOptions)) {
|
||
i += 1;
|
||
while (i < sql.length && sql[i] !== "\n" && sql[i] !== "\r") i += 1;
|
||
continue;
|
||
}
|
||
if (sql[i] === "/" && sql[i + 1] === "*") {
|
||
const commentEnd = sql.indexOf("*/", i + 2);
|
||
if (commentEnd < 0) return null;
|
||
i = commentEnd + 2;
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
|
||
const match = /^[A-Za-z_][\w$]*/.exec(sql.slice(i));
|
||
if (!match) return null;
|
||
return { word: match[0].toUpperCase(), end: i + match[0].length };
|
||
}
|
||
|
||
function isExplainLikeKeyword(keyword: string | null): boolean {
|
||
return keyword === "EXPLAIN" || keyword === "DESCRIBE" || keyword === "DESC";
|
||
}
|
||
|
||
function explainOptionsParenAt(sql: string, pos: number): number | null {
|
||
const prefixMatch = /^[A-Za-z_][\w$]*/.exec(sql.slice(pos));
|
||
if (prefixMatch?.[0]?.toUpperCase() !== "EXPLAIN") return null;
|
||
|
||
let i = pos + prefixMatch[0].length;
|
||
while (i < sql.length && isSqlWhitespace(sql[i])) i += 1;
|
||
return sql[i] === "(" ? i : null;
|
||
}
|
||
|
||
function explainLikeTargetKeywordAt(sql: string, pos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): string | null {
|
||
const prefixMatch = /^[A-Za-z_][\w$]*/.exec(sql.slice(pos));
|
||
const prefix = prefixMatch?.[0]?.toUpperCase();
|
||
if (!isExplainLikeKeyword(prefix ?? null)) return null;
|
||
|
||
let i = pos + (prefixMatch?.[0].length ?? 0);
|
||
while (i < sql.length && isSqlWhitespace(sql[i])) i += 1;
|
||
// Parenthesized EXPLAIN options (e.g. Postgres `EXPLAIN (ANALYZE, BUFFERS) ...`)
|
||
// sit between the keyword and its target statement. DESC/DESCRIBE take no
|
||
// options — a paren there is a subquery (ClickHouse `DESCRIBE (SELECT ...)`).
|
||
if (prefix === "EXPLAIN" && sql[i] === "(") {
|
||
const optionsEnd = skipBalancedParens(sql, i, databaseType, parameterOptions);
|
||
if (optionsEnd === null) return null;
|
||
i = optionsEnd;
|
||
while (i < sql.length && isSqlWhitespace(sql[i])) i += 1;
|
||
}
|
||
const targetMatch = /^[A-Za-z_][\w$]*/.exec(sql.slice(i));
|
||
const targetKeyword = targetMatch?.[0]?.toUpperCase();
|
||
return targetKeyword && EXPLAIN_STATEMENT_KEYWORDS.has(targetKeyword) ? targetKeyword : null;
|
||
}
|
||
|
||
function skipBalancedParens(sql: string, pos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): number | null {
|
||
let state: "none" | "single" | "double" | "lineComment" | "blockComment" = "none";
|
||
let depth = 0;
|
||
let i = pos;
|
||
|
||
while (i < sql.length) {
|
||
const ch = sql[i];
|
||
const next = sql[i + 1] ?? "";
|
||
|
||
if (state === "lineComment") {
|
||
if (ch === "\n") state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (state === "blockComment") {
|
||
if (ch === "*" && next === "/") {
|
||
state = "none";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (state === "single") {
|
||
if (ch === "'" && next === "'") {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'") state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (state === "double") {
|
||
if (ch === '"' && next === '"') {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === '"') state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (ch === "-" && next === "-") {
|
||
state = "lineComment";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (startsHashLineComment(sql, i, databaseType, parameterOptions)) {
|
||
state = "lineComment";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "/" && next === "*") {
|
||
state = "blockComment";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'") {
|
||
state = "single";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
state = "double";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "(") depth += 1;
|
||
if (ch === ")") {
|
||
depth -= 1;
|
||
if (depth === 0) return i + 1;
|
||
}
|
||
i += 1;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function startsLineComment(sql: string, pos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean {
|
||
return (sql[pos] === "-" && sql[pos + 1] === "-") || startsHashLineComment(sql, pos, databaseType, parameterOptions);
|
||
}
|
||
|
||
function startsHashLineComment(sql: string, pos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean {
|
||
// `#` is a MySQL-family line-comment marker. Oracle-family engines also allow
|
||
// it in unquoted identifiers (for example `V$DATAFILE.FILE#`), so treating it
|
||
// as a comment there truncates otherwise valid statements.
|
||
if ((databaseType !== undefined && ORACLE_LIKE_PL_SQL_DATABASES.has(databaseType)) || databaseType === "sqlserver" || sql[pos] !== "#") return false;
|
||
return readSqlBracedParameterAt(sql, pos, parameterOptions)?.syntax !== "mybatis";
|
||
}
|
||
|
||
function startsBlockComment(sql: string, pos: number): boolean {
|
||
return sql[pos] === "/" && sql[pos + 1] === "*";
|
||
}
|
||
|
||
function trimRangeEnd(sql: string, from: number, to: number): number {
|
||
let end = to;
|
||
while (end > from && isSqlWhitespace(sql[end - 1])) {
|
||
end -= 1;
|
||
}
|
||
return end;
|
||
}
|
||
|
||
function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryFrom: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): number {
|
||
const backslashEscapes = allowsBackslashStringEscape(databaseType);
|
||
let state: QuoteState | "lineComment" | "blockComment" = "none";
|
||
let dollarTag = "";
|
||
let lastContentEnd = from;
|
||
let i = from;
|
||
|
||
while (i < nextBoundaryFrom) {
|
||
const ch = sql[i];
|
||
const next = sql[i + 1] ?? "";
|
||
|
||
if (state === "lineComment") {
|
||
if (ch === "\n") state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "blockComment") {
|
||
if (ch === "*" && next === "/") {
|
||
state = "none";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "dollar") {
|
||
lastContentEnd = i + 1;
|
||
if (ch === "$") {
|
||
const closingTag = `$${dollarTag}$`;
|
||
if (sql.startsWith(closingTag, i)) {
|
||
i += closingTag.length;
|
||
lastContentEnd = i;
|
||
state = "none";
|
||
dollarTag = "";
|
||
continue;
|
||
}
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "single") {
|
||
lastContentEnd = i + 1;
|
||
if (ch === "\\" && next && backslashEscapes) {
|
||
i += 2;
|
||
lastContentEnd = i;
|
||
continue;
|
||
}
|
||
if (ch === "'") {
|
||
if (next === "'") {
|
||
i += 2;
|
||
lastContentEnd = i;
|
||
continue;
|
||
}
|
||
state = "none";
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "double") {
|
||
lastContentEnd = i + 1;
|
||
if (ch === '"') {
|
||
if (next === '"') {
|
||
i += 2;
|
||
lastContentEnd = i;
|
||
continue;
|
||
}
|
||
state = "none";
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "backtick") {
|
||
lastContentEnd = i + 1;
|
||
if (ch === "`") {
|
||
if (next === "`") {
|
||
i += 2;
|
||
lastContentEnd = i;
|
||
continue;
|
||
}
|
||
state = "none";
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (state === "bracket") {
|
||
lastContentEnd = i + 1;
|
||
if (ch === "]") state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (ch === "-" && next === "-") {
|
||
state = "lineComment";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (startsHashLineComment(sql, i, databaseType, parameterOptions)) {
|
||
state = "lineComment";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "/" && next === "*") {
|
||
state = "blockComment";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'") {
|
||
state = "single";
|
||
lastContentEnd = i + 1;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
state = "double";
|
||
lastContentEnd = i + 1;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "`") {
|
||
state = "backtick";
|
||
lastContentEnd = i + 1;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "[" && usesBracketIdentifierQuotes(databaseType)) {
|
||
state = "bracket";
|
||
lastContentEnd = i + 1;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "$") {
|
||
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
|
||
if (tagMatch) {
|
||
state = "dollar";
|
||
dollarTag = tagMatch[0].slice(1, -1);
|
||
i += tagMatch[0].length;
|
||
lastContentEnd = i;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (!isSqlWhitespace(ch)) {
|
||
lastContentEnd = i + 1;
|
||
}
|
||
i += 1;
|
||
}
|
||
|
||
return trimRangeEnd(sql, from, lastContentEnd);
|
||
}
|
||
|
||
function isSqlWhitespace(ch: string): boolean {
|
||
return ch === " " || ch === "\t" || ch === "\r" || ch === "\n";
|
||
}
|
||
|
||
function isOpenGaussOracleCompatibility(databaseType?: DatabaseType, options?: SqlParameterOptions): boolean {
|
||
if (databaseType !== "opengauss") return false;
|
||
const mode = options?.compatibilityMode?.trim().toUpperCase();
|
||
// Unknown mode is handled conservatively so an A-mode package is never
|
||
// split into executable fragments before metadata loading finishes.
|
||
return mode === undefined || mode === "A";
|
||
}
|
||
|
||
export function sqlStatementParameterOptionsForCompatibility(databaseType?: DatabaseType, compatibilityMode?: string): SqlParameterOptions | undefined {
|
||
if (databaseType !== "opengauss") return undefined;
|
||
// Keep the browser-side splitter aligned with the backend's conservative
|
||
// behavior while the compatibility probe is still cold or unavailable.
|
||
return { compatibilityMode: compatibilityMode?.trim() || "A" };
|
||
}
|
||
|
||
export function isOracleLikeDatabase(databaseType?: DatabaseType, options?: SqlParameterOptions): boolean {
|
||
return !!databaseType && (ORACLE_LIKE_PL_SQL_DATABASES.has(databaseType) || isOpenGaussOracleCompatibility(databaseType, options));
|
||
}
|
||
|
||
export function isOraclePlSqlStatement(sql: string, databaseType?: DatabaseType, options?: SqlParameterOptions): boolean {
|
||
return isOracleLikeDatabase(databaseType, options) && startsWithOraclePlSqlBlock(sql);
|
||
}
|
||
|
||
function isSapHanaScriptBlockDatabase(databaseType?: DatabaseType): boolean {
|
||
return databaseType === "saphana";
|
||
}
|
||
|
||
function isSapHanaScriptBlockStatement(sql: string, databaseType?: DatabaseType): boolean {
|
||
return isSapHanaScriptBlockDatabase(databaseType) && startsWithSapHanaScriptBlock(sql);
|
||
}
|
||
|
||
function isMysqlRoutineBlockDatabase(databaseType?: DatabaseType): boolean {
|
||
return !!databaseType && MYSQL_ROUTINE_BLOCK_DATABASES.has(databaseType);
|
||
}
|
||
|
||
function startsWithMysqlRoutineBlock(sql: string, parameterOptions?: SqlParameterOptions): boolean {
|
||
return isMysqlRoutineDdlStart(sql, parameterOptions) && mysqlRoutineTokensContainBegin(mysqlRoutineTokens(sql, parameterOptions).tokens);
|
||
}
|
||
|
||
function mysqlRoutineTokensContainBegin(tokens: readonly MysqlRoutineToken[]): boolean {
|
||
return tokens.some((token) => token.kind === "word" && token.value === "BEGIN");
|
||
}
|
||
|
||
function isMysqlRoutineDdlStart(sql: string, parameterOptions?: SqlParameterOptions): boolean {
|
||
return isMysqlRoutineDdlStartFromWords(mysqlRoutineWords(sql, parameterOptions).slice(0, 16));
|
||
}
|
||
|
||
/** First 16 word tokens, scanned without ever visiting the tail of `tokens` -
|
||
* the routine-detection check only ever needs this small fixed-size prefix. */
|
||
function mysqlRoutineDdlStartWords(tokens: readonly MysqlRoutineToken[], limit = 16): string[] {
|
||
const words: string[] = [];
|
||
for (const token of tokens) {
|
||
if (token.kind !== "word") continue;
|
||
words.push(token.value);
|
||
if (words.length >= limit) break;
|
||
}
|
||
return words;
|
||
}
|
||
|
||
function isMysqlRoutineDdlStartFromWords(words: readonly string[]): boolean {
|
||
if (words[0] !== "CREATE") return false;
|
||
|
||
for (const word of words.slice(1)) {
|
||
if (MYSQL_ROUTINE_OBJECT_TYPES.has(word)) return true;
|
||
if (MYSQL_NON_ROUTINE_CREATE_TYPES.has(word)) return false;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Incremental, resumable equivalent of walking `mysqlRoutineTokens(...)` with a
|
||
* fresh `blockStack`/`sawBegin` on every call. Driven token-by-token from
|
||
* `mysqlRoutineBlockIsCompleteUpTo` in splitSqlStatementRanges, so a routine body
|
||
* with N internal semicolons costs O(N) total instead of O(N^2) (re-walking the
|
||
* whole BEGIN/CASE/END nesting from the top on every semicolon while typing).
|
||
*
|
||
* Semantics mirror the non-incremental token walk exactly: `lastWordToken` plays
|
||
* the role of `previousWordToken(tokens, index)` (both reset to null once a
|
||
* semicolon is crossed), and `pendingEndAwaitingSuffix` defers resolving an "END"
|
||
* token's effect on `blockStack` until the following token arrives, which plays
|
||
* the role of `nextWordToken(tokens, index)`'s lookahead without needing to
|
||
* revisit already-processed tokens.
|
||
*/
|
||
interface MysqlRoutineBlockCompleteness {
|
||
processedTokenCount: number;
|
||
blockStack: Array<"BEGIN" | "CASE">;
|
||
sawBegin: boolean;
|
||
lastWordToken: string | null;
|
||
pendingEndAwaitingSuffix: boolean;
|
||
lastTokenKind: "word" | "semicolon" | null;
|
||
}
|
||
|
||
function newMysqlRoutineBlockCompleteness(): MysqlRoutineBlockCompleteness {
|
||
return { processedTokenCount: 0, blockStack: [], sawBegin: false, lastWordToken: null, pendingEndAwaitingSuffix: false, lastTokenKind: null };
|
||
}
|
||
|
||
/** Folds any `tokens` not yet seen into `state` and returns whether the routine body is complete so far. */
|
||
function advanceMysqlRoutineBlockCompleteness(state: MysqlRoutineBlockCompleteness, tokens: readonly MysqlRoutineToken[]): boolean {
|
||
while (state.processedTokenCount < tokens.length) {
|
||
const token = tokens[state.processedTokenCount];
|
||
state.processedTokenCount += 1;
|
||
|
||
if (token.kind === "semicolon") {
|
||
if (state.pendingEndAwaitingSuffix) {
|
||
state.blockStack.pop();
|
||
state.pendingEndAwaitingSuffix = false;
|
||
}
|
||
state.lastWordToken = null;
|
||
state.lastTokenKind = "semicolon";
|
||
continue;
|
||
}
|
||
|
||
const value = token.value;
|
||
if (state.pendingEndAwaitingSuffix) {
|
||
if (value === "CASE") {
|
||
if (state.blockStack[state.blockStack.length - 1] === "CASE") state.blockStack.pop();
|
||
} else if (!MYSQL_CONTROL_BLOCK_SUFFIXES.has(value)) {
|
||
state.blockStack.pop();
|
||
}
|
||
state.pendingEndAwaitingSuffix = false;
|
||
}
|
||
|
||
if (value === "BEGIN") {
|
||
if (state.lastWordToken !== "END") {
|
||
state.sawBegin = true;
|
||
state.blockStack.push("BEGIN");
|
||
}
|
||
} else if (value === "CASE") {
|
||
if (state.lastWordToken !== "END") {
|
||
state.blockStack.push("CASE");
|
||
}
|
||
} else if (value === "END" && state.sawBegin) {
|
||
state.pendingEndAwaitingSuffix = true;
|
||
}
|
||
state.lastWordToken = value;
|
||
state.lastTokenKind = "word";
|
||
}
|
||
|
||
return state.sawBegin && state.blockStack.length === 0 && state.lastTokenKind === "semicolon";
|
||
}
|
||
|
||
function mysqlRoutineWords(sql: string, parameterOptions?: SqlParameterOptions): string[] {
|
||
return mysqlRoutineTokens(sql, parameterOptions)
|
||
.tokens.filter((token): token is { kind: "word"; value: string } => token.kind === "word")
|
||
.map((token) => token.value);
|
||
}
|
||
|
||
type MysqlRoutineToken = { kind: "word" | "semicolon"; value: string };
|
||
type MysqlRoutineLexState = QuoteState | "lineComment" | "blockComment";
|
||
|
||
const MYSQL_ROUTINE_WORD_RE = /[A-Za-z_][\w$]*/y;
|
||
|
||
/**
|
||
* Tokenizes `sql` starting at `fromIndex` (resuming from `initialState`, the lexer
|
||
* state left over from any earlier chunk) and stops at `toIndex`. Callers that
|
||
* re-check an ever-growing prefix on every semicolon (see splitSqlStatementRanges)
|
||
* rely on this being resumable so they only re-scan the newly typed suffix instead
|
||
* of the whole prefix from scratch every time.
|
||
*/
|
||
function mysqlRoutineTokens(sql: string, parameterOptions?: SqlParameterOptions, fromIndex = 0, toIndex = sql.length, initialState: MysqlRoutineLexState = "none"): { tokens: MysqlRoutineToken[]; endState: MysqlRoutineLexState } {
|
||
const tokens: MysqlRoutineToken[] = [];
|
||
let state: MysqlRoutineLexState = initialState;
|
||
let i = fromIndex;
|
||
|
||
while (i < toIndex) {
|
||
const ch = sql[i];
|
||
const next = sql[i + 1] ?? "";
|
||
|
||
if (state === "lineComment") {
|
||
if (ch === "\n") state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (state === "blockComment") {
|
||
if (ch === "*" && next === "/") {
|
||
state = "none";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (state === "single") {
|
||
// mysqlRoutineTokens runs only for MYSQL_ROUTINE_BLOCK_DATABASES (all MySQL-family), so
|
||
// backslash escaping here (and in the double branch below) is unconditionally correct.
|
||
if (ch === "\\" && next) {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'" && next === "'") {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'") state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (state === "double") {
|
||
if (ch === "\\" && next) {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === '"' && next === '"') {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === '"') state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (state === "backtick") {
|
||
if (ch === "`" && next === "`") {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "`") state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (ch === "-" && next === "-") {
|
||
state = "lineComment";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (startsHashLineComment(sql, i, undefined, parameterOptions)) {
|
||
state = "lineComment";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "/" && next === "*") {
|
||
state = "blockComment";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'") {
|
||
state = "single";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
state = "double";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === "`") {
|
||
state = "backtick";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === ";") {
|
||
tokens.push({ kind: "semicolon", value: ";" });
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
MYSQL_ROUTINE_WORD_RE.lastIndex = i;
|
||
const word = MYSQL_ROUTINE_WORD_RE.exec(sql)?.[0];
|
||
if (word) {
|
||
tokens.push({ kind: "word", value: word.toUpperCase() });
|
||
i += word.length;
|
||
continue;
|
||
}
|
||
i += 1;
|
||
}
|
||
|
||
return { tokens, endState: state };
|
||
}
|
||
|
||
function startsWithOraclePlSqlBlock(sql: string): boolean {
|
||
return startsWithOraclePlSqlBlockWords(oraclePlSqlWords(sql));
|
||
}
|
||
|
||
function startsWithOraclePlSqlBlockWords(words: readonly string[]): boolean {
|
||
const first = words[0];
|
||
if (!first) return false;
|
||
if (ORACLE_PL_SQL_BLOCK_STARTERS.has(first)) return first !== "BEGIN" || words[1] !== "TRANSACTION";
|
||
if (first !== "CREATE") return false;
|
||
|
||
const index = skipOraclePlSqlCreateModifiers(words, 1);
|
||
// PACKAGE BODY / TYPE BODY are programmable blocks with an outer END.
|
||
if (words[index] === "PACKAGE" && words[index + 1] === "BODY") return true;
|
||
if (words[index] === "TYPE" && words[index + 1] === "BODY") return true;
|
||
// Plain CREATE TYPE ... AS OBJECT (...); is ordinary SQL terminated by ';'.
|
||
if (words[index] === "TYPE") return false;
|
||
return ORACLE_PL_SQL_CREATE_OBJECT_TYPES.has(words[index] ?? "");
|
||
}
|
||
|
||
function startsWithPostgresDollarQuotedRoutinePrefix(sql: string): boolean {
|
||
const words = oraclePlSqlWords(sql);
|
||
if (words[0] !== "CREATE") return false;
|
||
const objectIndex = skipOraclePlSqlCreateModifiers(words, 1);
|
||
return (words[objectIndex] === "FUNCTION" || words[objectIndex] === "PROCEDURE") && words[words.length - 1] === "AS";
|
||
}
|
||
|
||
/** Skip OR REPLACE / FORCE / NOFORCE / EDITIONABLE modifiers after CREATE. */
|
||
function skipOraclePlSqlCreateModifiers(words: readonly string[], startIndex: number): number {
|
||
let index = startIndex;
|
||
while (index < words.length) {
|
||
if (words[index] === "OR" && words[index + 1] === "REPLACE") {
|
||
index += 2;
|
||
continue;
|
||
}
|
||
if (["FORCE", "NOFORCE", "EDITIONABLE", "NONEDITIONABLE"].includes(words[index] ?? "")) {
|
||
index += 1;
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
return index;
|
||
}
|
||
|
||
function startsWithSapHanaScriptBlock(sql: string): boolean {
|
||
return oraclePlSqlWords(sql)[0] === "DO";
|
||
}
|
||
|
||
function sapHanaScriptBlockIsComplete(sql: string): boolean {
|
||
if (!startsWithSapHanaScriptBlock(sql)) return false;
|
||
|
||
const tokens = oraclePlSqlTokens(sql);
|
||
const stack: string[] = [];
|
||
let sawBegin = false;
|
||
|
||
for (let index = 0; index < tokens.length; index += 1) {
|
||
const token = tokens[index];
|
||
if (token.kind !== "word") continue;
|
||
|
||
if (token.value === "BEGIN") {
|
||
if (previousWordToken(tokens, index) === "END") continue;
|
||
stack.push("BLOCK");
|
||
sawBegin = true;
|
||
continue;
|
||
}
|
||
if (token.value === "IF" || token.value === "FOR" || token.value === "WHILE" || token.value === "CASE") {
|
||
if (previousWordToken(tokens, index) !== "END") stack.push(token.value);
|
||
continue;
|
||
}
|
||
if (token.value === "END") {
|
||
const next = nextWordToken(tokens, index);
|
||
const top = stack[stack.length - 1];
|
||
const target = SAP_HANA_SCRIPT_BLOCK_TERMINATORS.has(next ?? "") ? next : top === "CASE" ? "CASE" : "BLOCK";
|
||
if (top === target) stack.pop();
|
||
}
|
||
}
|
||
|
||
return sawBegin && stack.length === 0 && tokens[tokens.length - 1]?.kind === "semicolon";
|
||
}
|
||
|
||
function oraclePlSqlBlockEnd(sql: string): number | null {
|
||
const tokens = oraclePlSqlTokens(sql);
|
||
const words = oraclePlSqlWordValues(tokens);
|
||
if (!startsWithOraclePlSqlBlockWords(words)) return null;
|
||
|
||
// Package/type specifications have no BEGIN — only declarations closed by
|
||
// END [name];. Bodies also own an outer END beyond nested routine END pairs.
|
||
const objectKind = oraclePlSqlCreateObjectKind(words);
|
||
const stack: string[] = objectKind === "body" ? ["OBJECT_BODY"] : objectKind === "spec" ? ["OBJECT_SPEC"] : [];
|
||
let sawBegin = false;
|
||
for (let index = 0; index < tokens.length; index += 1) {
|
||
const token = tokens[index];
|
||
if (token.kind === "semicolon") {
|
||
const complete = objectKind !== null ? stack.length === 0 : sawBegin && stack.length === 0;
|
||
if (complete) return token.to;
|
||
continue;
|
||
}
|
||
if (token.kind !== "word") continue;
|
||
|
||
if (token.value === "DECLARE") {
|
||
if (stack[stack.length - 1] !== "DECLARATION") stack.push("DECLARATION");
|
||
continue;
|
||
}
|
||
if (token.value === "BEGIN") {
|
||
if (tokens[index - 1]?.kind === "word" && tokens[index - 1]?.value === "TRANSACTION") continue;
|
||
const previous = previousWordToken(tokens, index);
|
||
if (previous === "END") continue;
|
||
sawBegin = true;
|
||
if (stack[stack.length - 1] === "DECLARATION") stack[stack.length - 1] = "BLOCK";
|
||
else stack.push("BLOCK");
|
||
continue;
|
||
}
|
||
if (token.value === "IF") {
|
||
const previous = previousWordToken(tokens, index);
|
||
if (previous !== "END" && previous !== "ELSIF") stack.push("IF");
|
||
continue;
|
||
}
|
||
if (token.value === "LOOP") {
|
||
if (previousWordToken(tokens, index) !== "END") stack.push("LOOP");
|
||
continue;
|
||
}
|
||
if (token.value === "CASE") {
|
||
// Both CASE statements and CASE expressions own an END. The CASE token
|
||
// following END CASE is ignored below, so it cannot start a new scope.
|
||
if (previousWordToken(tokens, index) !== "END") stack.push("CASE");
|
||
continue;
|
||
}
|
||
if (token.value === "END") {
|
||
const top = stack[stack.length - 1];
|
||
// CASE expressions close as END; while CASE statements close as END CASE;.
|
||
// In either form, this END belongs to CASE rather than the surrounding block.
|
||
if (top === "CASE") {
|
||
stack.pop();
|
||
continue;
|
||
}
|
||
const next = nextWordToken(tokens, index);
|
||
const target = ORACLE_PL_SQL_TERMINATORS.has(next ?? "") ? next : top === "OBJECT_BODY" || top === "OBJECT_SPEC" ? top : "BLOCK";
|
||
if (top === target || (target === "BLOCK" && top === "BLOCK")) stack.pop();
|
||
continue;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Classify CREATE programmable objects:
|
||
* - body: PACKAGE BODY / TYPE BODY (outer END beyond nested routines)
|
||
* - spec: PACKAGE specification only (declarations + END, no BEGIN)
|
||
* - null: ordinary SQL / other objects (including plain CREATE TYPE ... AS OBJECT)
|
||
*/
|
||
function oraclePlSqlCreateObjectKind(words: readonly string[]): "body" | "spec" | null {
|
||
if (words[0] !== "CREATE") return null;
|
||
|
||
const index = skipOraclePlSqlCreateModifiers(words, 1);
|
||
if ((words[index] === "PACKAGE" || words[index] === "TYPE") && words[index + 1] === "BODY") {
|
||
return "body";
|
||
}
|
||
// Only PACKAGE specs lack BEGIN; plain TYPE objects end with ");".
|
||
if (words[index] === "PACKAGE") {
|
||
return "spec";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function oraclePlSqlWords(sql: string): string[] {
|
||
return oraclePlSqlWordValues(oraclePlSqlTokens(sql));
|
||
}
|
||
|
||
interface OraclePlSqlToken {
|
||
kind: "word" | "semicolon";
|
||
value: string;
|
||
from: number;
|
||
to: number;
|
||
}
|
||
|
||
function oraclePlSqlWordValues(tokens: readonly OraclePlSqlToken[]): string[] {
|
||
return tokens.filter((token) => token.kind === "word").map((token) => token.value);
|
||
}
|
||
|
||
function oraclePlSqlTokens(sql: string): OraclePlSqlToken[] {
|
||
const tokens: OraclePlSqlToken[] = [];
|
||
let state: QuoteState | "lineComment" | "blockComment" = "none";
|
||
let i = 0;
|
||
|
||
while (i < sql.length) {
|
||
const ch = sql[i];
|
||
const next = sql[i + 1] ?? "";
|
||
|
||
if (state === "lineComment") {
|
||
if (ch === "\n") state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (state === "blockComment") {
|
||
if (ch === "*" && next === "/") {
|
||
state = "none";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (state === "single") {
|
||
if (ch === "'" && next === "'") {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'") state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (state === "double") {
|
||
if (ch === '"' && next === '"') {
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === '"') state = "none";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (ch === "-" && next === "-") {
|
||
state = "lineComment";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "/" && next === "*") {
|
||
state = "blockComment";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (ch === "'") {
|
||
state = "single";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === '"') {
|
||
state = "double";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === ";") {
|
||
tokens.push({ kind: "semicolon", value: ";", from: i, to: i + 1 });
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (/[A-Za-z_]/.test(ch)) {
|
||
const from = i;
|
||
i += 1;
|
||
while (i < sql.length && /[A-Za-z0-9_$]/.test(sql[i] ?? "")) i += 1;
|
||
tokens.push({ kind: "word", value: sql.slice(from, i).toUpperCase(), from, to: i });
|
||
continue;
|
||
}
|
||
i += 1;
|
||
}
|
||
|
||
return tokens;
|
||
}
|
||
|
||
function previousWordToken(tokens: Array<{ kind: "word" | "semicolon"; value: string }>, index: number): string | null {
|
||
for (let i = index - 1; i >= 0; i -= 1) {
|
||
if (tokens[i].kind === "semicolon") return null;
|
||
if (tokens[i].kind === "word") return tokens[i].value;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function nextWordToken(tokens: Array<{ kind: "word" | "semicolon"; value: string }>, index: number): string | null {
|
||
for (let i = index + 1; i < tokens.length; i += 1) {
|
||
if (tokens[i].kind === "semicolon") return null;
|
||
if (tokens[i].kind === "word") return tokens[i].value;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isAtLineStart(sql: string, pos: number): boolean {
|
||
for (let i = pos - 1; i >= 0; i -= 1) {
|
||
const ch = sql[i];
|
||
if (ch === "\n" || ch === "\r") return true;
|
||
if (ch !== " " && ch !== "\t") return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function isSlashLine(sql: string, pos: number): boolean {
|
||
const lineEnd = findLineEnd(sql, pos);
|
||
return sql.slice(pos, lineEnd).trim() === "/";
|
||
}
|
||
|
||
function supportsSqlServerGoCommands(databaseType?: DatabaseType): boolean {
|
||
return databaseType === "sqlserver";
|
||
}
|
||
|
||
function isSqlServerGoLine(sql: string, pos: number): boolean {
|
||
const lineEnd = findLineEnd(sql, pos);
|
||
return /^go(?:\s+\d+)?$/i.test(sql.slice(pos, lineEnd).trim());
|
||
}
|
||
|
||
function startsDelimiterCommand(sql: string, pos: number): boolean {
|
||
const prefix = sql.slice(pos, pos + 9);
|
||
return prefix.toLowerCase() === "delimiter" && (sql[pos + 9] === " " || sql[pos + 9] === "\t");
|
||
}
|
||
|
||
function parseDelimiterCommand(line: string): string | null {
|
||
const match = /^delimiter[ \t]+(.+)$/i.exec(line.trim());
|
||
const delimiter = match?.[1]?.trim();
|
||
return delimiter ? delimiter : null;
|
||
}
|
||
|
||
/**
|
||
* Resolves a caret resting on a MySQL `delimiter X` client-directive line to
|
||
* the nearest executable statement: the statement the directive introduces, or
|
||
* the statement just before it when the directive has nothing after it.
|
||
*/
|
||
function mysqlDelimiterDirectiveCursorRange(sql: string, pos: number, databaseType: DatabaseType | undefined, parameterOptions: SqlParameterOptions | undefined, statements: RawStatement[]): SqlTextRange | null {
|
||
if (databaseType !== "mysql" || statements.length === 0) return null;
|
||
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
|
||
const lineEnd = findLineEnd(sql, pos);
|
||
let directiveStart = lineStart;
|
||
while (directiveStart < lineEnd && (sql[directiveStart] === " " || sql[directiveStart] === "\t")) directiveStart += 1;
|
||
if (!startsDelimiterCommand(sql, directiveStart)) return null;
|
||
if (parseDelimiterCommand(sql.slice(directiveStart, lineEnd)) === null) return null;
|
||
|
||
const following = statements.find((statement) => statement.from >= lineEnd);
|
||
if (following) {
|
||
return rangeFor(splitStatementRangeAtSoftStarts(sql, following, databaseType, parameterOptions)[0] ?? following, sql);
|
||
}
|
||
const preceding = statements[statements.length - 1];
|
||
const precedingSoftRanges = splitStatementRangeAtSoftStarts(sql, preceding, databaseType, parameterOptions);
|
||
return rangeFor(precedingSoftRanges[precedingSoftRanges.length - 1] ?? preceding, sql);
|
||
}
|
||
|
||
function findLineEnd(sql: string, pos: number): number {
|
||
const newline = sql.indexOf("\n", pos);
|
||
const carriageReturn = sql.indexOf("\r", pos);
|
||
if (newline === -1) return carriageReturn === -1 ? sql.length : carriageReturn;
|
||
if (carriageReturn === -1) return newline;
|
||
return Math.min(newline, carriageReturn);
|
||
}
|
||
|
||
function nextLineStart(sql: string, lineEnd: number): number {
|
||
if (sql[lineEnd] === "\r" && sql[lineEnd + 1] === "\n") return lineEnd + 2;
|
||
if (sql[lineEnd] === "\n" || sql[lineEnd] === "\r") return lineEnd + 1;
|
||
return lineEnd;
|
||
}
|
||
|
||
function rangeFor(statement: RawStatement, sql: string): SqlTextRange {
|
||
return {
|
||
from: statement.from,
|
||
to: statement.to,
|
||
sql: sql.slice(statement.from, statement.to),
|
||
};
|
||
}
|
||
|
||
function clampCursor(sql: string, cursorPos: number): number {
|
||
if (!Number.isFinite(cursorPos)) return 0;
|
||
if (cursorPos < 0) return 0;
|
||
if (cursorPos > sql.length) return sql.length;
|
||
return cursorPos;
|
||
}
|
||
|
||
function isCursorOnBlankLine(sql: string, pos: number): boolean {
|
||
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
|
||
let lineEnd = sql.indexOf("\n", pos);
|
||
if (lineEnd === -1) lineEnd = sql.length;
|
||
return sql.slice(lineStart, lineEnd).trim() === "";
|
||
}
|
||
|
||
function isCursorOnStatementLine(sql: string, pos: number, statement: Pick<RawStatement, "from">): boolean {
|
||
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
|
||
let lineEnd = sql.indexOf("\n", pos);
|
||
if (lineEnd === -1) lineEnd = sql.length;
|
||
return statement.from >= lineStart && statement.from <= lineEnd;
|
||
}
|
||
|
||
function isCursorOnRangeEndLine(sql: string, pos: number, range: Pick<RawStatement, "to">): boolean {
|
||
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
|
||
let lineEnd = sql.indexOf("\n", pos);
|
||
if (lineEnd === -1) lineEnd = sql.length;
|
||
return range.to >= lineStart && range.to <= lineEnd;
|
||
}
|
||
|
||
/**
|
||
* Returns the full document as a range, or `null` when it is empty/whitespace.
|
||
*/
|
||
export function fullSqlRange(sql: string): SqlTextRange | null {
|
||
const trimmed = sql.trim();
|
||
if (!trimmed) return null;
|
||
const from = sql.length - sql.trimStart().length;
|
||
const to = from + trimmed.length;
|
||
return { from, to, sql: sql.slice(from, to) };
|
||
}
|
||
|
||
function normalizeSql(sql: string): string {
|
||
return sql.replace(/\s+/g, " ").replace(/;\s*$/, "").trim();
|
||
}
|
||
|
||
/**
|
||
* Build the ordered list of execution candidates to show in the picker.
|
||
*
|
||
* Order is always `[cursor, all]` when both are available, except when the
|
||
* cursor statement and the full document are effectively the same SQL — in
|
||
* that case only a single candidate is returned to avoid duplicates.
|
||
*/
|
||
export function executableStatementRanges(sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): SqlTextRange[] {
|
||
if (databaseType === "redis") return redisExecutableCommandRanges(sql);
|
||
if (databaseType === "mongodb") return splitMongoCommandRanges(sql).map(({ from, to, text }) => ({ from, to, sql: text }));
|
||
return splitSqlStatementRanges(sql, databaseType, parameterOptions).flatMap((statement) => splitStatementRangeAtSoftStarts(sql, statement, databaseType, parameterOptions).map((range) => rangeFor(range, sql)));
|
||
}
|
||
|
||
export function currentExecutableStatementRange(sql: string, cursorPos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): SqlTextRange | null {
|
||
if (databaseType === "redis") return redisCommandRangeAtCursor(sql, cursorPos);
|
||
if (databaseType === "mongodb") return mongoCommandRangeAtCursor(sql, cursorPos);
|
||
return statementRangeAtCursor(sql, cursorPos, databaseType, parameterOptions);
|
||
}
|
||
|
||
export function buildExecutionCandidates(sql: string, cursorPos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): SqlExecutionCandidate[] {
|
||
const full = fullSqlRange(sql);
|
||
const cursorStatement = currentExecutableStatementRange(sql, cursorPos, databaseType, parameterOptions);
|
||
|
||
if (!full && !cursorStatement) return [];
|
||
if (!full) {
|
||
return cursorStatement ? [candidateFromRange(cursorStatement, "cursor", databaseType)] : [];
|
||
}
|
||
if (!cursorStatement) {
|
||
return [candidateFromRange(full, "all", databaseType)];
|
||
}
|
||
|
||
const sameContent = normalizeSql(cursorStatement.sql) === normalizeSql(full.sql);
|
||
if (sameContent) {
|
||
return [candidateFromRange(full, "all", databaseType, ["cursor", "all"])];
|
||
}
|
||
|
||
return [candidateFromRange(cursorStatement, "cursor", databaseType), candidateFromRange(full, "all", databaseType)];
|
||
}
|
||
|
||
function candidateFromRange(range: SqlTextRange, kind: SqlExecutionCandidate["kind"], databaseType?: DatabaseType, supportedKinds: SqlExecutionCandidate["supportedKinds"] = [kind]): SqlExecutionCandidate {
|
||
const isRedis = databaseType === "redis";
|
||
return {
|
||
kind,
|
||
supportedKinds,
|
||
label: kind === "cursor" ? (isRedis ? "currentCommand" : "currentStatement") : isRedis ? "allCommands" : "allStatements",
|
||
sql: range.sql,
|
||
from: range.from,
|
||
to: range.to,
|
||
};
|
||
}
|
||
|
||
function redisExecutableCommandCount(sql: string): number {
|
||
let count = 0;
|
||
for (const range of redisExecutableCommandRanges(sql)) {
|
||
if (!range.sql.trim()) continue;
|
||
count += 1;
|
||
if (count > 1) return count;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
function redisExecutableCommandRanges(sql: string): SqlTextRange[] {
|
||
const ranges: SqlTextRange[] = [];
|
||
let lineStart = 0;
|
||
while (lineStart <= sql.length) {
|
||
let lineEnd = sql.indexOf("\n", lineStart);
|
||
if (lineEnd === -1) lineEnd = sql.length;
|
||
const rawLine = sql.slice(lineStart, lineEnd);
|
||
const leadingWhitespace = rawLine.length - rawLine.trimStart().length;
|
||
const trailingWhitespace = rawLine.length - rawLine.trimEnd().length;
|
||
const trimmedLine = rawLine.trim();
|
||
if (trimmedLine && !trimmedLine.startsWith("#")) {
|
||
const from = lineStart + leadingWhitespace;
|
||
const to = lineStart + rawLine.length - trailingWhitespace;
|
||
ranges.push({ from, to, sql: sql.slice(from, to) });
|
||
}
|
||
if (lineEnd >= sql.length) break;
|
||
lineStart = lineEnd + 1;
|
||
}
|
||
return ranges;
|
||
}
|
||
|
||
function redisCommandRangeAtCursor(sql: string, cursorPos: number): SqlTextRange | null {
|
||
const pos = clampCursor(sql, cursorPos);
|
||
if (isCursorOnBlankLine(sql, pos)) return null;
|
||
|
||
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
|
||
let lineEnd = sql.indexOf("\n", pos);
|
||
if (lineEnd === -1) lineEnd = sql.length;
|
||
|
||
const rawLine = sql.slice(lineStart, lineEnd);
|
||
const leadingWhitespace = rawLine.length - rawLine.trimStart().length;
|
||
const trimmedLine = rawLine.trim();
|
||
if (!trimmedLine || trimmedLine.startsWith("#")) return null;
|
||
|
||
const from = lineStart + leadingWhitespace;
|
||
const to = lineStart + rawLine.length - (rawLine.length - rawLine.trimEnd().length);
|
||
return {
|
||
from,
|
||
to,
|
||
sql: sql.slice(from, to),
|
||
};
|
||
}
|