fix(gaussdb): quote identifiers by compatibility mode

This commit is contained in:
zipg authored and GitHub committed 2026-07-29 23:34:58 +08:00
1 parent d0c3a3fd2e
commit 66f94f23f1
28 files changed
+1207 -134

No files matched your search

@@ -98,6 +98,7 @@ import { translateBackendError } from "@/i18n/backend-errors";
import { applyHiveKerberosSubmitConfig, hiveKerberosFormConfig, type HiveKerberosAuthMode } from "@/lib/database/hiveKerberosOptions";
import { hasCloudflareD1Credentials, isCloudflareD1Connection, normalizeCloudflareD1Connection } from "@/lib/connection/cloudflareD1";
import { buildElasticsearchExternalConfig, elasticsearchConnectionModeFromConfig, elasticsearchConnectivityCheckPathFromConfig, elasticsearchKibanaBasePathFromConfig, type ElasticsearchConnectionMode } from "@/lib/connection/elasticsearchKibanaProxy";
import { gaussdbIdentifierQuoteStyle, setGaussdbIdentifierQuoteStyle, supportsGaussdbIdentifierQuoteStyle, type GaussdbIdentifierQuoteStyle } from "@/lib/database/jdbcDialect";
type DbOption = { value: string; label: string };
type DbCategoryKey = "sql" | "analytics" | "domestic" | "lightweight" | "document" | "graph_ai" | "timeseries" | "mq" | "registry_config";
@@ -450,6 +451,14 @@ function sshLayersForConfig(config: LegacyConnectionConfig): SshTunnelConfig[] {
const form = ref(defaultForm());
const noteTextareaRef = ref<HTMLTextAreaElement | null>(null);
const showGaussdbIdentifierQuoteStyle = computed(() => supportsGaussdbIdentifierQuoteStyle(form.value));
const gaussdbQuoteStyle = computed<GaussdbIdentifierQuoteStyle>({
get: () => gaussdbIdentifierQuoteStyle(form.value),
set: (style) => {
setGaussdbIdentifierQuoteStyle(form.value, style);
resetTestState();
},
});
function resizeNoteTextarea() {
const textarea = noteTextareaRef.value;
@@ -3220,6 +3229,10 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
config.external_config = buildElasticsearchExternalConfig(elasticsearchConnectionMode.value, elasticsearchKibanaBasePath.value, elasticsearchConnectivityCheckPath.value);
} else if (config.db_type === "sqlserver") {
config.external_config = sqlServerPortExplicitFromConfig(config) ? { portExplicit: true } : undefined;
} else if (supportsGaussdbIdentifierQuoteStyle(config)) {
const style = gaussdbIdentifierQuoteStyle(config);
config.external_config = undefined;
setGaussdbIdentifierQuoteStyle(config, style);
} else {
config.external_config = undefined;
}
@@ -6535,6 +6548,22 @@ function openExternalUrl(url: string) {
<TabsContent value="advanced" class="m-0 min-h-0 flex-1 overflow-hidden">
<div class="connection-form-body grid h-full min-h-0 gap-4 overflow-y-auto pt-4 pr-2">
<div v-if="showGaussdbIdentifierQuoteStyle" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.gaussdbIdentifierQuoteStyle") }}</Label>
<div class="col-span-3 grid gap-1">
<Select v-model="gaussdbQuoteStyle">
<SelectTrigger class="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{{ t("connection.gaussdbIdentifierQuoteAuto") }}</SelectItem>
<SelectItem value="double">{{ t("connection.gaussdbIdentifierQuoteDouble") }}</SelectItem>
<SelectItem value="backtick">{{ t("connection.gaussdbIdentifierQuoteBacktick") }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.gaussdbIdentifierQuoteHint") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.connectTimeout") }}</Label>
<Input v-model.number="form.connect_timeout_secs" type="number" min="1" max="300" step="1" class="col-span-3" />
+5
View File
@@ -503,6 +503,11 @@ export default {
sqlServerLegacyCompatibilityModeEnable: "Enable",
sqlServerLegacyCompatibilityModeHint: "For SQL Server instances limited to TLS 1.0, login-only encryption, or no encryption.\nSome features may be limited; use only on trusted networks.",
sqlServerLegacyCompatibilityComponent: "SQL Server legacy compatibility component",
gaussdbIdentifierQuoteStyle: "Identifier compatibility",
gaussdbIdentifierQuoteAuto: "Auto (driver/default)",
gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL (double quotes ")',
gaussdbIdentifierQuoteBacktick: "MySQL/M (backticks `)",
gaussdbIdentifierQuoteHint: "Auto uses JDBC driver metadata or the native GaussDB default. Select a mode manually only when generated SQL uses the wrong identifier quotes.",
saveAndConnect: "Save & Connect",
save: "Save",
editTitle: "Edit Connection",
+5
View File
@@ -404,6 +404,11 @@ export default withEnglishFallback({
sqlServerLegacyCompatibilityModeEnable: "Activar",
sqlServerLegacyCompatibilityModeHint: "Para instancias de SQL Server limitadas a TLS 1.0, cifrado solo al iniciar sesión o sin cifrado.\nAlgunas funciones pueden estar limitadas; úsalo solo en redes de confianza.",
sqlServerLegacyCompatibilityComponent: "Componente de compatibilidad heredada de SQL Server",
gaussdbIdentifierQuoteStyle: "Compatibilidad de identificadores",
gaussdbIdentifierQuoteAuto: "Automático (controlador/predeterminado)",
gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL (comillas dobles ")',
gaussdbIdentifierQuoteBacktick: "MySQL/M (acentos graves `)",
gaussdbIdentifierQuoteHint: "El modo automático usa los metadatos del controlador JDBC o el valor predeterminado de GaussDB nativo. Seleccione un modo manualmente solo si el SQL generado usa comillas de identificador incorrectas.",
saveAndConnect: "Guardar y conectar",
save: "Guardar",
editTitle: "Editar conexión",
+5
View File
@@ -403,6 +403,11 @@ export default withEnglishFallback({
sqlServerLegacyCompatibilityModeEnable: "Abilita",
sqlServerLegacyCompatibilityModeHint: "Per istanze di SQL Server limitate a TLS 1.0, crittografia solo all'accesso o nessuna crittografia.\nAlcune funzionalità potrebbero essere limitate; usala solo su reti attendibili.",
sqlServerLegacyCompatibilityComponent: "Componente di compatibilità legacy SQL Server",
gaussdbIdentifierQuoteStyle: "Compatibilità identificatori",
gaussdbIdentifierQuoteAuto: "Automatico (driver/predefinito)",
gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL (virgolette doppie ")',
gaussdbIdentifierQuoteBacktick: "MySQL/M (accenti gravi `)",
gaussdbIdentifierQuoteHint: "La modalità automatica usa i metadati del driver JDBC o il valore predefinito di GaussDB nativo. Seleziona manualmente una modalità solo se l'SQL generato usa virgolette errate per gli identificatori.",
saveAndConnect: "Salva & Connetti",
save: "Salva",
editTitle: "Modifica Connessione",
+5
View File
@@ -397,6 +397,11 @@ export default withEnglishFallback({
sqlServerLegacyCompatibilityModeEnable: "有効にする",
sqlServerLegacyCompatibilityModeHint: "TLS 1.0、ログイン時のみの暗号化、または暗号化なしに限定された SQL Server への接続に使用します。\n一部の機能が制限される場合があるため、信頼できるネットワークでのみ使用してください。",
sqlServerLegacyCompatibilityComponent: "SQL Server レガシー互換コンポーネント",
gaussdbIdentifierQuoteStyle: "識別子互換モード",
gaussdbIdentifierQuoteAuto: "自動(ドライバー/既定値)",
gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL(二重引用符 ")',
gaussdbIdentifierQuoteBacktick: "MySQL/M(バッククォート `)",
gaussdbIdentifierQuoteHint: "自動モードでは JDBC ドライバーのメタデータまたはネイティブ GaussDB の既定値を使用します。生成された SQL の識別子引用符が誤っている場合のみ手動で選択してください。",
saveAndConnect: "保存して接続",
save: "保存",
editTitle: "接続を編集",
+5
View File
@@ -404,6 +404,11 @@ export default withEnglishFallback({
sqlServerLegacyCompatibilityModeEnable: "Ativar",
sqlServerLegacyCompatibilityModeHint: "Para instâncias do SQL Server limitadas a TLS 1.0, criptografia apenas no login ou sem criptografia.\nAlguns recursos podem ser limitados; use apenas em redes confiáveis.",
sqlServerLegacyCompatibilityComponent: "Componente de compatibilidade legada do SQL Server",
gaussdbIdentifierQuoteStyle: "Compatibilidade de identificadores",
gaussdbIdentifierQuoteAuto: "Automático (driver/padrão)",
gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL (aspas duplas ")',
gaussdbIdentifierQuoteBacktick: "MySQL/M (crases `)",
gaussdbIdentifierQuoteHint: "O modo automático usa os metadados do driver JDBC ou o padrão do GaussDB nativo. Selecione um modo manualmente apenas quando o SQL gerado usar aspas de identificador incorretas.",
saveAndConnect: "Salvar e Conectar",
save: "Salvar",
editTitle: "Editar Conexão",
+5
View File
@@ -504,6 +504,11 @@ export default withEnglishFallback({
sqlServerLegacyCompatibilityModeEnable: "启用",
sqlServerLegacyCompatibilityModeHint: "用于连接仅支持 TLS 1.0、仅登录阶段加密或不加密的 SQL Server;\n部分功能可能受限,请仅在可信网络中使用",
sqlServerLegacyCompatibilityComponent: "SQL Server 旧版兼容组件",
gaussdbIdentifierQuoteStyle: "标识符兼容模式",
gaussdbIdentifierQuoteAuto: "自动(驱动/默认)",
gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL(双引号 ")',
gaussdbIdentifierQuoteBacktick: "MySQL/M(反引号 `)",
gaussdbIdentifierQuoteHint: "自动模式使用 JDBC 驱动元数据或原生 GaussDB 默认值。仅当生成的 SQL 使用了错误的标识符引号时手动选择。",
saveAndConnect: "保存并连接",
save: "保存",
editTitle: "编辑连接",
+5
View File
@@ -403,6 +403,11 @@ export default withEnglishFallback({
sqlServerLegacyCompatibilityModeEnable: "啟用",
sqlServerLegacyCompatibilityModeHint: "用於連線僅支援 TLS 1.0、僅登入階段加密或未加密的 SQL Server;\n部分功能可能受限,請僅在可信網路中使用",
sqlServerLegacyCompatibilityComponent: "SQL Server 舊版相容元件",
gaussdbIdentifierQuoteStyle: "識別字相容模式",
gaussdbIdentifierQuoteAuto: "自動(驅動程式/預設)",
gaussdbIdentifierQuoteDouble: 'Oracle/PostgreSQL(雙引號 ")',
gaussdbIdentifierQuoteBacktick: "MySQL/M(反引號 `)",
gaussdbIdentifierQuoteHint: "自動模式使用 JDBC 驅動程式中繼資料或原生 GaussDB 預設值。僅在產生的 SQL 使用錯誤的識別字引號時手動選擇。",
saveAndConnect: "儲存並連線",
save: "儲存",
editTitle: "編輯連線",
+62 -1
View File
@@ -1,5 +1,16 @@
import { describe, expect, it } from "vitest";
import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, inferJdbcDialect } from "@/lib/database/jdbcDialect";
import {
connectionObjectTreeNodeSchema,
connectionQueryExecutionSchema,
connectionShouldLoadIdentifierQuote,
connectionUsesDatabaseObjectTreeMode,
effectiveDatabaseTypeForConnection,
gaussdbIdentifierQuoteOverride,
gaussdbIdentifierQuoteStyle,
inferJdbcDialect,
setGaussdbIdentifierQuoteStyle,
supportsGaussdbIdentifierQuoteStyle,
} from "@/lib/database/jdbcDialect";
describe("jdbc dialect inference", () => {
it("detects InterSystems IRIS and Caché JDBC connections", () => {
@@ -65,6 +76,56 @@ describe("jdbc dialect inference", () => {
expect(connectionUsesDatabaseObjectTreeMode(opengaussConnection)).toBe(false);
});
it("loads driver-reported identifier quotes for compatible JDBC connections", () => {
expect(connectionShouldLoadIdentifierQuote({ db_type: "jdbc", jdbc_driver_paths: ["/drivers/gaussdb.jar"] })).toBe(true);
expect(connectionShouldLoadIdentifierQuote({ db_type: "jdbc", jdbc_driver_class: "org.opengauss.Driver" })).toBe(true);
expect(connectionShouldLoadIdentifierQuote({ db_type: "jdbc", jdbc_driver_class: "org.postgresql.Driver" })).toBe(true);
expect(connectionShouldLoadIdentifierQuote({ db_type: "kingbase" })).toBe(true);
expect(connectionShouldLoadIdentifierQuote({ db_type: "gaussdb" })).toBe(true);
expect(
connectionShouldLoadIdentifierQuote({
db_type: "jdbc",
jdbc_driver_class: "org.postgresql.Driver",
external_config: { gaussdbIdentifierQuoteStyle: "backtick" },
}),
).toBe(false);
});
it("recognizes GaussDB reached through PostgreSQL-compatible JDBC drivers", () => {
const connection = {
db_type: "jdbc" as const,
connection_string: "jdbc:postgresql://localhost:5432/postgres",
jdbc_driver_class: "org.postgresql.Driver",
database_info: { productName: "GaussDB Kernel", driverName: "PostgreSQL JDBC Driver" },
};
expect(inferJdbcDialect(connection)).toBe("gaussdb");
expect(effectiveDatabaseTypeForConnection(connection)).toBe("gaussdb");
expect(supportsGaussdbIdentifierQuoteStyle(connection)).toBe(true);
});
it("supports persisted GaussDB identifier quote overrides", () => {
const native = { db_type: "gaussdb" as const, external_config: undefined as unknown };
const jdbc = { db_type: "jdbc" as const, jdbc_driver_paths: ["/drivers/gaussdb.jar"], external_config: { retained: true } as unknown };
expect(supportsGaussdbIdentifierQuoteStyle(native)).toBe(true);
expect(gaussdbIdentifierQuoteStyle(native)).toBe("auto");
expect(gaussdbIdentifierQuoteOverride(native)).toBeUndefined();
setGaussdbIdentifierQuoteStyle(native, "backtick");
expect(gaussdbIdentifierQuoteStyle(native)).toBe("backtick");
expect(gaussdbIdentifierQuoteOverride(native)).toBe("`");
setGaussdbIdentifierQuoteStyle(jdbc, "double");
expect(jdbc.external_config).toEqual({ retained: true, gaussdbIdentifierQuoteStyle: "double" });
expect(gaussdbIdentifierQuoteOverride(jdbc)).toBe('"');
expect(connectionShouldLoadIdentifierQuote(jdbc)).toBe(false);
setGaussdbIdentifierQuoteStyle(jdbc, "auto");
expect(jdbc.external_config).toEqual({ retained: true });
expect(connectionShouldLoadIdentifierQuote(jdbc)).toBe(true);
});
it("detects Dameng JDBC connections", () => {
const damengConnection = {
db_type: "jdbc" as const,
@@ -54,6 +54,42 @@ describe("quoteTableIdentifier", () => {
expect(quoteTableDataIdentifier("kingbase", "order detail", "`")).toBe("`order detail`");
});
it("selectively quotes GaussDB JDBC identifiers with the driver-reported quote", () => {
expect(quoteTableDataIdentifier("gaussdb", "table_01", '"')).toBe("table_01");
expect(quoteTableDataIdentifier("gaussdb", "MixedCase", '"')).toBe('"MixedCase"');
expect(quoteTableDataIdentifier("gaussdb", "order", '"')).toBe('"order"');
expect(quoteTableDataIdentifier("gaussdb", "order detail", '"')).toBe('"order detail"');
expect(quoteTableDataIdentifier("gaussdb", 'already"quoted', '"')).toBe('"already""quoted"');
expect(quoteTableDataIdentifier("gaussdb", '"AlreadyQuoted"', '"')).toBe('"AlreadyQuoted"');
expect(quoteTableDataIdentifier("gaussdb", "table_01", "`")).toBe("table_01");
expect(quoteTableDataIdentifier("gaussdb", "MixedCase", "`")).toBe("`MixedCase`");
expect(quoteTableDataIdentifier("gaussdb", "order", "`")).toBe("`order`");
expect(quoteTableDataIdentifier("gaussdb", "order detail", "`")).toBe("`order detail`");
expect(quoteTableDataIdentifier("gaussdb", "already`quoted", "`")).toBe("`already``quoted`");
expect(quoteTableDataIdentifier("gaussdb", "`AlreadyQuoted`", "`")).toBe("`AlreadyQuoted`");
});
it("uses detected GaussDB compatibility quotes through PostgreSQL-compatible JDBC dialects", () => {
for (const databaseType of ["postgres", "opengauss"] as const) {
expect(quoteTableDataIdentifier(databaseType, "table_01", "`")).toBe("table_01");
expect(quoteTableDataIdentifier(databaseType, "MixedCase", "`")).toBe("`MixedCase`");
expect(quoteTableDataIdentifier(databaseType, "order", "`")).toBe("`order`");
expect(quoteTableDataIdentifier(databaseType, "order detail", "`")).toBe("`order detail`");
expect(quoteTableDataIdentifier(databaseType, "`AlreadyQuoted`", "`")).toBe("`AlreadyQuoted`");
expect(quoteTableDataIdentifier(databaseType, "MixedCase", '"')).toBe('"MixedCase"');
}
});
it("preserves native GaussDB and openGauss quoting behavior", () => {
expect(quoteTableDataIdentifier("gaussdb", "table_01")).toBe('"table_01"');
expect(quoteTableDataIdentifier("gaussdb", "MixedCase")).toBe('"MixedCase"');
expect(quoteTableDataIdentifier("gaussdb", '"AlreadyQuoted"')).toBe('"AlreadyQuoted"');
expect(quoteTableDataIdentifier("opengauss", "table_01")).toBe('"table_01"');
expect(quoteTableDataIdentifier("opengauss", "MixedCase")).toBe('"MixedCase"');
expect(quoteTableDataIdentifier("opengauss", '"AlreadyQuoted"')).toBe('"AlreadyQuoted"');
});
it("escapes Kingbase identifiers without maintaining a reserved-word list", () => {
expect(quoteTableDataIdentifier("kingbase", "ANALYZE", "`")).toBe("`ANALYZE`");
expect(quoteTableDataIdentifier("kingbase", "AUTHORIZATION", '"')).toBe('"AUTHORIZATION"');
+51 -2
View File
@@ -2,7 +2,11 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { isSchemaAware, usesDatabaseObjectTreeMode, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect";
type JdbcDialectConnection = Pick<ConnectionConfig, "db_type"> & Partial<Pick<ConnectionConfig, "driver_profile" | "driver_label" | "connection_string" | "jdbc_driver_class" | "jdbc_driver_paths" | "database_info">>;
type JdbcDialectConnection = Pick<ConnectionConfig, "db_type"> & Partial<Pick<ConnectionConfig, "driver_profile" | "driver_label" | "connection_string" | "jdbc_driver_class" | "jdbc_driver_paths" | "database_info" | "external_config">>;
export type GaussdbIdentifierQuoteStyle = "auto" | "double" | "backtick";
const GAUSSDB_IDENTIFIER_QUOTE_STYLE_KEY = "gaussdbIdentifierQuoteStyle";
const DATABASE_AS_EXECUTION_SCHEMA_TYPES = new Set<DatabaseType>(["hive", "spark"]);
@@ -34,7 +38,9 @@ const JDBC_ASE_PROFILE_PATTERNS = [/(?:^|[\s_-])ase(?:$|[\s_-])/i, /\bsap[\s_-]+
export function inferJdbcDialect(connection?: JdbcDialectConnection): DatabaseType | undefined {
if (!connection || connection.db_type !== "jdbc") return undefined;
const haystack = [connection.driver_profile, connection.connection_string, connection.jdbc_driver_class, ...(connection.jdbc_driver_paths ?? [])].filter(Boolean).join("\n");
const haystack = [connection.driver_profile, connection.driver_label, connection.connection_string, connection.jdbc_driver_class, ...(connection.jdbc_driver_paths ?? []), connection.database_info?.productName, connection.database_info?.serverComment, connection.database_info?.driverName]
.filter(Boolean)
.join("\n");
if (!haystack) return undefined;
return JDBC_DIALECT_MATCHERS.find((matcher) => matcher.patterns.some((pattern) => pattern.test(haystack)))?.type;
}
@@ -57,6 +63,45 @@ export function effectiveDatabaseTypeForConnection(connection?: JdbcDialectConne
return inferJdbcDialect(connection) ?? "jdbc";
}
export function connectionShouldLoadIdentifierQuote(connection: JdbcDialectConnection | undefined): boolean {
if (!connection) return false;
if (connection.db_type === "kingbase") return true;
if (gaussdbIdentifierQuoteStyle(connection) !== "auto") return false;
if (connection.db_type === "gaussdb") return true;
if (connection.db_type !== "jdbc") return false;
return ["gaussdb", "opengauss", "postgres"].includes(inferJdbcDialect(connection) ?? "");
}
export function supportsGaussdbIdentifierQuoteStyle(connection: JdbcDialectConnection | undefined): boolean {
return effectiveDatabaseTypeForConnection(connection) === "gaussdb";
}
export function gaussdbIdentifierQuoteStyle(connection: JdbcDialectConnection | undefined): GaussdbIdentifierQuoteStyle {
const external = externalConfigRecord(connection?.external_config);
const style = external[GAUSSDB_IDENTIFIER_QUOTE_STYLE_KEY];
return style === "double" || style === "backtick" ? style : "auto";
}
export function gaussdbIdentifierQuoteOverride(connection: JdbcDialectConnection | undefined): string | undefined {
const style = gaussdbIdentifierQuoteStyle(connection);
if (style === "double") return '"';
if (style === "backtick") return "`";
return undefined;
}
export function setGaussdbIdentifierQuoteStyle(
connection: Pick<ConnectionConfig, "db_type"> & Partial<Pick<ConnectionConfig, "driver_profile" | "driver_label" | "connection_string" | "jdbc_driver_class" | "jdbc_driver_paths" | "database_info" | "external_config">>,
style: GaussdbIdentifierQuoteStyle,
) {
const external = externalConfigRecord(connection.external_config);
if (style === "auto") {
delete external[GAUSSDB_IDENTIFIER_QUOTE_STYLE_KEY];
} else {
external[GAUSSDB_IDENTIFIER_QUOTE_STYLE_KEY] = style;
}
connection.external_config = Object.keys(external).length > 0 ? external : undefined;
}
export function sqlSnippetDatabaseTypeForConnection(connection?: JdbcDialectConnection): DatabaseType | undefined {
// ASE uses T-SQL snippets, but mapping it globally to SQL Server would also
// enable incompatible SQL Server metadata and pagination behavior.
@@ -142,3 +187,7 @@ function isJdbcAseProfile(connection?: JdbcDialectConnection): boolean {
function isGbase8sProfile(driverProfile?: string): boolean {
return driverProfile === "gbase8s";
}
function externalConfigRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record<string, unknown>) } : {};
}
+3 -115
View File
@@ -7,6 +7,7 @@ import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
import { findActiveSqlStatementSpan, tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens";
import type { SqlSemanticBuildOptions, SqlSemanticSpan } from "@/lib/sql/semantic/types";
import { DEFAULT_SQL_SNIPPETS, MANTICORESEARCH_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates";
import { requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier";
export { DEFAULT_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates";
@@ -2781,124 +2782,11 @@ function unquoteIdentifier(value: string): string {
}
export function quoteSqlIdentifier(identifier: string, dialect?: "mysql" | "postgres" | "sqlserver"): string {
if (dialect !== "postgres" || !requiresPostgresIdentifierQuote(identifier)) return identifier;
if (dialect !== "postgres" || !requiresPostgresIdentifierQuote(identifier, POSTGRES_IDENTIFIER_KEYWORDS)) return identifier;
return `"${identifier.replaceAll('"', '""')}"`;
}
function requiresPostgresIdentifierQuote(identifier: string): boolean {
if (!/^[a-z_][a-z0-9_$]*$/.test(identifier)) return true;
return POSTGRES_IDENTIFIER_KEYWORDS.has(identifier);
}
// PostgreSQL reserved_keyword + type_func_name_keyword categories (pg_get_keywords()
// catcodes R and T) — identifiers matching these cannot appear as a bare column
// reference and must be quoted. SQL_KEYWORDS alone misses most of them because it is
// a completion phrase list ("ORDER BY", not "order").
const POSTGRES_RESERVED_KEYWORDS = [
"all",
"analyse",
"analyze",
"and",
"any",
"array",
"as",
"asc",
"asymmetric",
"authorization",
"binary",
"both",
"case",
"cast",
"check",
"collate",
"collation",
"column",
"concurrently",
"constraint",
"create",
"cross",
"current_catalog",
"current_date",
"current_role",
"current_schema",
"current_time",
"current_timestamp",
"current_user",
"default",
"deferrable",
"desc",
"distinct",
"do",
"else",
"end",
"except",
"false",
"fetch",
"for",
"foreign",
"freeze",
"from",
"full",
"grant",
"group",
"having",
"ilike",
"in",
"initially",
"inner",
"intersect",
"into",
"is",
"isnull",
"join",
"lateral",
"leading",
"left",
"like",
"limit",
"localtime",
"localtimestamp",
"natural",
"not",
"notnull",
"null",
"offset",
"on",
"only",
"or",
"order",
"outer",
"overlaps",
"placing",
"primary",
"references",
"returning",
"right",
"select",
"session_user",
"similar",
"some",
"symmetric",
"system_user",
"table",
"tablesample",
"then",
"to",
"trailing",
"true",
"union",
"unique",
"user",
"using",
"variadic",
"verbose",
"when",
"where",
"window",
"with",
];
const POSTGRES_IDENTIFIER_KEYWORDS = new Set(SQL_KEYWORDS.map((keyword) => keyword.toLowerCase()).concat(POSTGRES_RESERVED_KEYWORDS));
const POSTGRES_IDENTIFIER_KEYWORDS = new Set(SQL_KEYWORDS.map((keyword) => keyword.toLowerCase()));
function buildTableItems(
context: Pick<SqlCompletionContext, "prefix" | "qualifier">,
+200
View File
@@ -0,0 +1,200 @@
const SIMPLE_LOWER_IDENTIFIER = /^[a-z_][a-z0-9_$]*$/;
// PostgreSQL pg_get_keywords() categories R and T. GaussDB's PostgreSQL-
// compatible modes use the same identifier constraints for bare names.
const POSTGRES_RESERVED_IDENTIFIER_KEYWORDS = new Set([
"all",
"analyse",
"analyze",
"and",
"any",
"array",
"as",
"asc",
"asymmetric",
"authorization",
"binary",
"both",
"case",
"cast",
"check",
"collate",
"collation",
"column",
"concurrently",
"constraint",
"create",
"cross",
"current_catalog",
"current_date",
"current_role",
"current_schema",
"current_time",
"current_timestamp",
"current_user",
"default",
"deferrable",
"desc",
"distinct",
"do",
"else",
"end",
"except",
"false",
"fetch",
"for",
"foreign",
"freeze",
"from",
"full",
"grant",
"group",
"having",
"ilike",
"in",
"initially",
"inner",
"intersect",
"into",
"is",
"isnull",
"join",
"lateral",
"leading",
"left",
"like",
"limit",
"localtime",
"localtimestamp",
"natural",
"not",
"notnull",
"null",
"offset",
"on",
"only",
"or",
"order",
"outer",
"overlaps",
"placing",
"primary",
"references",
"returning",
"right",
"select",
"session_user",
"similar",
"some",
"symmetric",
"system_user",
"table",
"tablesample",
"then",
"to",
"trailing",
"true",
"union",
"unique",
"user",
"using",
"variadic",
"verbose",
"when",
"where",
"window",
"with",
]);
// GaussDB MySQL-compatible drivers report a backtick. These additional words
// cover MySQL-only names that cannot safely be emitted as bare identifiers.
const MYSQL_ONLY_RESERVED_IDENTIFIER_KEYWORDS = new Set([
"accessible",
"auto_increment",
"change",
"database",
"databases",
"delayed",
"describe",
"div",
"dual",
"enclosed",
"escaped",
"explain",
"force",
"fulltext",
"high_priority",
"ignore",
"index",
"infile",
"key",
"keys",
"kill",
"linear",
"lines",
"load",
"lock",
"low_priority",
"master_ssl_verify_server_cert",
"maxvalue",
"mediumint",
"mod",
"no_write_to_binlog",
"optimize",
"optionally",
"outfile",
"partition",
"purge",
"range",
"read_write",
"regexp",
"release",
"rename",
"replace",
"require",
"rlike",
"schema",
"schemas",
"separator",
"show",
"spatial",
"sql_big_result",
"sql_calc_found_rows",
"sql_small_result",
"ssl",
"starting",
"straight_join",
"terminated",
"tinyint",
"unlock",
"unsigned",
"use",
"utc_date",
"utc_time",
"utc_timestamp",
"values",
"varbinary",
"varchar",
"write",
"xor",
"zerofill",
]);
export function isExplicitlyQuotedSqlIdentifier(value: string): boolean {
if (value.length < 2) return false;
return (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("`") && value.endsWith("`")) || (value.startsWith("[") && value.endsWith("]"));
}
export function requiresPostgresIdentifierQuote(identifier: string, additionalKeywords?: ReadonlySet<string>): boolean {
if (!SIMPLE_LOWER_IDENTIFIER.test(identifier)) return true;
return POSTGRES_RESERVED_IDENTIFIER_KEYWORDS.has(identifier) || additionalKeywords?.has(identifier) === true;
}
export function quoteGaussDbJdbcIdentifier(identifier: string, identifierQuote: string): string {
if (isExplicitlyQuotedSqlIdentifier(identifier)) return identifier;
const quote = identifierQuote.trim();
if (!quote) return identifier;
const requiresQuote = !SIMPLE_LOWER_IDENTIFIER.test(identifier) || POSTGRES_RESERVED_IDENTIFIER_KEYWORDS.has(identifier) || (quote === "`" && MYSQL_ONLY_RESERVED_IDENTIFIER_KEYWORDS.has(identifier));
if (!requiresQuote) return identifier;
return `${quote}${identifier.replaceAll(quote, quote + quote)}${quote}`;
}
@@ -2,6 +2,7 @@ import type { DatabaseType } from "@/types/database.ts";
import { isSchemaAware, usesDatabaseObjectTreeMode } from "@/lib/database/databaseCapabilities.ts";
import * as api from "@/lib/backend/api.ts";
import { parseSqlServerLinkedSchema, sqlServerLinkedTableName } from "@/lib/database/sqlServerLinkedServers.ts";
import { isExplicitlyQuotedSqlIdentifier, quoteGaussDbJdbcIdentifier } from "@/lib/sql/sqlIdentifier.ts";
export interface BuildTableSelectSqlOptions {
databaseType?: DatabaseType;
@@ -22,6 +23,7 @@ export interface BuildTableSelectSqlOptions {
}
export function quoteTableIdentifier(databaseType: DatabaseType | undefined, name: string): string {
if ((databaseType === "gaussdb" || databaseType === "opengauss") && isExplicitlyQuotedSqlIdentifier(name)) return name;
if (databaseType === "iotdb") return name;
// JDBC connections use the driver-reported identifier quote string
// (DatabaseMetaData.getIdentifierQuoteString()) — pass through unquoted.
@@ -36,6 +38,7 @@ export function quoteTableIdentifier(databaseType: DatabaseType | undefined, nam
}
export function quoteTableDataIdentifier(databaseType: DatabaseType | undefined, name: string, identifierQuote?: string): string {
if ((databaseType === "gaussdb" || databaseType === "opengauss" || databaseType === "postgres") && identifierQuote != null) return quoteGaussDbJdbcIdentifier(name, identifierQuote);
if (databaseType === "kingbase" && identifierQuote != null) {
if (!identifierQuote) return name;
return `${identifierQuote}${name.replaceAll(identifierQuote, identifierQuote + identifierQuote)}${identifierQuote}`;
@@ -0,0 +1,75 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function gaussdbConnection(style: "double" | "backtick"): ConnectionConfig {
return {
id: `gaussdb-${style}`,
name: "GaussDB",
db_type: "gaussdb",
host: "127.0.0.1",
port: 5432,
username: "gaussdb",
password: "",
database: "postgres",
external_config: { gaussdbIdentifierQuoteStyle: style },
};
}
describe("connectionStore GaussDB identifier quote override", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
it("resolves the persisted compatibility mode before runtime driver metadata", async () => {
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const double = gaussdbConnection("double");
const backtick = gaussdbConnection("backtick");
store.connections = [double, backtick];
expect(store.connectionIdentifierQuote(double.id)).toBe('"');
expect(store.connectionIdentifierQuote(backtick.id)).toBe("`");
});
it("loads and caches auto compatibility quotes once per connection", async () => {
const config: ConnectionConfig = {
...gaussdbConnection("double"),
id: "gaussdb-auto",
external_config: undefined,
};
const connectionIdentifierQuote = vi.fn().mockResolvedValue("`");
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
connectDb: vi.fn().mockResolvedValue(config.id),
connectionDatabaseInfo: vi.fn().mockRejectedValue(new Error("metadata unavailable")),
connectionIdentifierQuote,
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
await store.addConnection(config);
await store.connect(config);
expect(connectionIdentifierQuote).toHaveBeenCalledOnce();
expect(connectionIdentifierQuote).toHaveBeenCalledWith(config.id);
expect(store.connectionIdentifierQuote(config.id)).toBe("`");
expect(store.connectionIdentifierQuote(config.id)).toBe("`");
expect(connectionIdentifierQuote).toHaveBeenCalledOnce();
});
});
+4 -2
View File
@@ -61,7 +61,7 @@ import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionShouldDiscoverJdbcSchemas, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionShouldDiscoverJdbcSchemas, connectionShouldLoadIdentifierQuote, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbIdentifierQuoteOverride } from "@/lib/database/jdbcDialect";
import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, compareSidebarNames, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree";
import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree";
import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse";
@@ -499,6 +499,8 @@ export const useConnectionStore = defineStore("connection", () => {
function connectionIdentifierQuote(connectionId?: string): string | undefined {
if (!connectionId) return undefined;
const override = gaussdbIdentifierQuoteOverride(getConfig(connectionId));
if (override != null) return override;
return identifierQuotes.value[connectionId];
}
@@ -511,7 +513,7 @@ export const useConnectionStore = defineStore("connection", () => {
async function refreshConnectionIdentifierQuote(connectionId: string, config: ConnectionConfig) {
clearConnectionIdentifierQuote(connectionId);
if (config.db_type !== "kingbase") return;
if (!connectionShouldLoadIdentifierQuote(config)) return;
const quote = await api.connectionIdentifierQuote(connectionId).catch(() => undefined);
if (quote != null) identifierQuotes.value = { ...identifierQuotes.value, [connectionId]: quote };
}