fix: support mysql install scripts without selected database (#2356)

This commit is contained in:
onenewcode
2026-07-02 13:18:49 +08:00
committed by GitHub
parent 730f839e74
commit 71e618dd46
12 changed files with 975 additions and 90 deletions
@@ -13,6 +13,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
import { requiresSqlFileTargetDatabaseSelection } from "@/lib/connectionLevelDatabaseBootstrap";
import { cancelSqlFileExecution, executeSqlFile, listenSqlFileProgress, listDatabases, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/api";
import { useExportTracker } from "@/composables/useExportTracker";
import { Check, CheckSquare, FileCode, FolderOpen, Loader2, Play, Square, X } from "@lucide/vue";
@@ -55,7 +56,11 @@ const sqlConnections = computed(() => store.connections.filter((c) => !["redis",
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
const canStart = computed(() => Boolean(preview.value && selectedConnection.value && database.value.trim() && !running.value && !loadingPreview.value && !loadingDatabases.value));
const canStart = computed(() => {
const connection = selectedConnection.value;
if (!preview.value || !connection || running.value || loadingPreview.value || loadingDatabases.value) return false;
return !!database.value.trim() || !requiresSqlFileTargetDatabaseSelection(connection, preview.value.canExecuteWithoutSelectedDatabase);
});
const statusTone = computed(() => {
if (terminalStatus.value === "done") return "text-green-600";
@@ -39,8 +39,20 @@ describe("requiresDatabaseSelection", () => {
expect(requiresDatabaseSelection(queryTab(), connection("mysql"), "CREATE SCHEMA `app-db` DEFAULT CHARACTER SET utf8mb4")).toBe(false);
});
it("requires a database when a MySQL batch includes non-creation statements", () => {
expect(requiresDatabaseSelection(queryTab(), connection("mysql"), "CREATE DATABASE app_db; SELECT * FROM users")).toBe(true);
it("allows MySQL install batches that switch databases before table DDL", () => {
expect(requiresDatabaseSelection(queryTab(), connection("mysql"), "CREATE DATABASE app_db; USE app_db; CREATE TABLE users(id INT PRIMARY KEY)")).toBe(false);
});
it("allows MySQL install batches with session setup before switching databases", () => {
expect(requiresDatabaseSelection(queryTab(), connection("mysql"), "SET NAMES utf8mb4; DROP DATABASE IF EXISTS app_db; CREATE DATABASE app_db; USE app_db; INSERT INTO users VALUES (1)")).toBe(false);
});
it("requires a database when MySQL batch statements never establish database context", () => {
expect(requiresDatabaseSelection(queryTab(), connection("mysql"), "CREATE DATABASE app_db; CREATE TABLE users(id INT)")).toBe(true);
});
it("requires a database when a USE statement is not a standalone database switch", () => {
expect(requiresDatabaseSelection(queryTab(), connection("mysql"), "CREATE DATABASE app_db; USE app_db SELECT 1; CREATE TABLE users(id INT)")).toBe(true);
});
it("still requires a database for ordinary MySQL queries", () => {
@@ -6,6 +6,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { isSingleDatabase } from "@/lib/databaseCapabilities";
import { canExecuteWithoutSelectedDatabase } from "@/lib/connectionLevelDatabaseBootstrap";
import { classifySqlActivityKind } from "@/lib/historyActivityKind";
import { sqlMetadataRefreshTarget } from "@/lib/sqlMetadataRefresh";
import { classifyRedisCommandSafety, firstRedisCommandToken } from "@/lib/redisCommandSafety";
@@ -14,7 +15,6 @@ import { extractSqlParameters } from "@/lib/sqlParameters";
import type { ConnectionConfig, QueryTab } from "@/types/database";
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
const CONNECTION_LEVEL_CREATE_DATABASE_RE = /^\s*CREATE\s+(?:DATABASE|SCHEMA)\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"[\]\w$.-]+/i;
export function stripSqlComments(sql: string): string {
return sql
@@ -227,17 +227,6 @@ function supportsSqlTemplateParameters(connection: ConnectionConfig | undefined)
return connection.db_type !== "redis" && connection.db_type !== "mongodb";
}
function canExecuteWithoutSelectedDatabase(connection: ConnectionConfig, sql: string): boolean {
if (connection.db_type !== "mysql") return false;
const statements = stripSqlComments(sql)
.split(";")
.map((stmt) => stmt.trim())
.filter(Boolean);
// Only connection-level database creation may bypass the selected-database guard.
if (statements.length !== 1) return false;
return CONNECTION_LEVEL_CREATE_DATABASE_RE.test(statements[0]);
}
export function requiresDatabaseSelection(tab: QueryTab, connection: ConnectionConfig | undefined, sql = ""): boolean {
if (tab.mode !== "query") return false;
if (!connection || tab.database) return false;
@@ -0,0 +1,217 @@
import { splitSqlStatementRanges } from "@/lib/sqlStatementRanges";
import { supportsCreateDatabaseCharset } from "@/lib/createDatabaseSql";
import type { ConnectionConfig, DatabaseType } from "@/types/database";
const MYSQL_BOOTSTRAP_EXTRA_PROFILES = new Set(["selectdb", "goldendb"]);
type BootstrapConnection = Pick<ConnectionConfig, "db_type" | "driver_profile">;
type ParsedIdentifier = { identifier: string; rest: string };
function splitBootstrapStatements(sql: string, dbType?: DatabaseType): string[] {
return splitSqlStatementRanges(sql, dbType)
.map((statement) => statement.sql.trim())
.filter(Boolean);
}
function parseMysqlIdentifier(input: string): ParsedIdentifier | null {
const first = input[0];
if (!first) return null;
if (first === "`" || first === '"') {
return parseDoubledDelimitedIdentifier(input, first);
}
if (first === "[") {
return parseBracketIdentifier(input);
}
const end = input.search(/[\s;]/);
const identifier = (end === -1 ? input : input.slice(0, end)).trim();
if (!identifier) return null;
return { identifier, rest: end === -1 ? "" : input.slice(end) };
}
function parseDoubledDelimitedIdentifier(input: string, quote: "`" | '"'): ParsedIdentifier | null {
let index = 1;
let segmentStart = 1;
let identifier = "";
while (index < input.length) {
if (input[index] === quote) {
identifier += input.slice(segmentStart, index);
if (input[index + 1] === quote) {
identifier += quote;
index += 2;
segmentStart = index;
continue;
}
return { identifier, rest: input.slice(index + 1) };
}
index += 1;
}
return null;
}
function parseBracketIdentifier(input: string): ParsedIdentifier | null {
let index = 1;
let segmentStart = 1;
let identifier = "";
while (index < input.length) {
if (input[index] === "]") {
identifier += input.slice(segmentStart, index);
if (input[index + 1] === "]") {
identifier += "]";
index += 2;
segmentStart = index;
continue;
}
return { identifier, rest: input.slice(index + 1) };
}
index += 1;
}
return null;
}
function stripLeadingSqlComments(sql: string): string {
let rest = sql;
for (;;) {
rest = rest.trimStart();
if (!rest) return rest;
if (rest.startsWith("--")) {
const index = rest.indexOf("\n");
if (index === -1) return "";
rest = rest.slice(index + 1);
continue;
}
if (rest.startsWith("#")) {
const index = rest.indexOf("\n");
if (index === -1) return "";
rest = rest.slice(index + 1);
continue;
}
if (rest.startsWith("/*")) {
const index = rest.indexOf("*/");
if (index === -1) return "";
rest = rest.slice(index + 2);
continue;
}
return rest;
}
}
function leadingSqlKeyword(statement: string): [keyword: string, rest: string] | null {
const trimmed = statement.trimStart();
const match = trimmed.match(/^[A-Za-z_][A-Za-z0-9_]*/);
if (!match) return null;
return [match[0], trimmed.slice(match[0].length)];
}
function sqlRemainderIsCommentOnly(remainder: string): boolean {
let rest = remainder;
for (;;) {
rest = rest.trimStart();
if (!rest) return true;
if (rest.startsWith(";")) {
rest = rest.slice(1);
continue;
}
if (rest.startsWith("--") || rest.startsWith("#")) {
return true;
}
if (rest.startsWith("/*")) {
const index = rest.indexOf("*/");
if (index === -1) return false;
rest = rest.slice(index + 2);
continue;
}
return false;
}
}
function parseMysqlUseDatabaseTarget(statement: string): string | null {
const stripped = stripLeadingSqlComments(statement);
const match = stripped.match(/^USE\b/i);
if (!match) return null;
const parsed = parseMysqlIdentifier(stripped.slice(match[0].length).trimStart());
if (!parsed) return null;
return sqlRemainderIsCommentOnly(parsed.rest) ? parsed.identifier : null;
}
function isDatabaseKeyword(keyword: string): boolean {
const normalized = keyword.toUpperCase();
return normalized === "DATABASE" || normalized === "SCHEMA";
}
function isAllowedBootstrapPrelude(statement: string): boolean {
const stripped = stripLeadingSqlComments(statement);
const leading = leadingSqlKeyword(stripped);
if (!leading) return false;
const [keyword, rest] = leading;
const normalized = keyword.toUpperCase();
if (normalized === "SET") {
return true;
}
if (normalized !== "CREATE" && normalized !== "DROP") {
return false;
}
const next = leadingSqlKeyword(rest);
return !!next && isDatabaseKeyword(next[0]);
}
export function supportsConnectionLevelDatabaseBootstrap(connection: BootstrapConnection | undefined): boolean {
if (!connection) return false;
if (supportsCreateDatabaseCharset(connection.db_type, connection.driver_profile)) return true;
return !!connection.driver_profile && MYSQL_BOOTSTRAP_EXTRA_PROFILES.has(connection.driver_profile.toLowerCase());
}
export function canExecuteWithoutSelectedDatabase(connection: BootstrapConnection | undefined, sql: string): boolean {
if (!supportsConnectionLevelDatabaseBootstrap(connection)) return false;
const statements = splitBootstrapStatements(sql, connection?.db_type);
if (statements.length === 0) return false;
let sawStatement = false;
let hasDatabaseContext = false;
for (const statement of statements) {
const stripped = stripLeadingSqlComments(statement);
if (!stripped) continue;
sawStatement = true;
if (isAllowedBootstrapPrelude(stripped)) {
continue;
}
if (parseMysqlUseDatabaseTarget(stripped)) {
hasDatabaseContext = true;
continue;
}
if (!hasDatabaseContext) {
return false;
}
}
return sawStatement;
}
export function requiresSqlFileTargetDatabaseSelection(connection: BootstrapConnection | undefined, sqlCanExecuteWithoutSelectedDatabase = false): boolean {
if (!supportsConnectionLevelDatabaseBootstrap(connection)) return true;
return !sqlCanExecuteWithoutSelectedDatabase;
}
+1 -1
View File
@@ -13,7 +13,7 @@ export interface CreateDatabaseSqlOptions {
}
export function supportsCreateDatabaseCharset(databaseType?: DatabaseType, driverProfile?: string | null): boolean {
return MYSQL_COMPATIBLE_TYPES.has(databaseType as DatabaseType) || (!!driverProfile && MYSQL_COMPATIBLE_PROFILES.has(driverProfile));
return MYSQL_COMPATIBLE_TYPES.has(databaseType as DatabaseType) || (!!driverProfile && MYSQL_COMPATIBLE_PROFILES.has(driverProfile.toLowerCase()));
}
export function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): Promise<string> {
+1
View File
@@ -1641,6 +1641,7 @@ export interface SqlFilePreview {
filePath: string;
sizeBytes: number;
preview: string;
canExecuteWithoutSelectedDatabase: boolean;
}
export interface SqlFileProgress {