mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-28 16:09:17 +00:00
fix: support mysql install scripts without selected database (#2356)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -1641,6 +1641,7 @@ export interface SqlFilePreview {
|
||||
filePath: string;
|
||||
sizeBytes: number;
|
||||
preview: string;
|
||||
canExecuteWithoutSelectedDatabase: boolean;
|
||||
}
|
||||
|
||||
export interface SqlFileProgress {
|
||||
|
||||
Reference in New Issue
Block a user