fix(query): fold unquoted postgresql table identities for grid saves

This commit is contained in:
dienaso
2026-09-29 14:10:14 +08:00
committed by GitHub
parent 9540f51dce
commit 4949cce746
5 changed files with 306 additions and 4 deletions
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { DATABASE_TYPES } from "@/types/generated/databaseTypes";
import { foldUnquotedPostgresMetadataIdentifier, POSTGRES_FOLDED_IDENTIFIER_TYPES } from "@/lib/sql/sqlAnalysis";
/**
* Grid saves re-quote the table identity that the query result carried. On
* PostgreSQL-compatible engines an unquoted identifier only resolves after the
* server folds it to lower case, so the folded spelling is the one the save
* must use (issue #10567: `UPDATE term."MSS_CHECK_SALES_ITEM"` fails against a
* table stored as `mss_check_sales_item` while the unquoted SELECT worked).
*
* The polarity is locked over the full DATABASE_TYPES enum: every listed
* PostgreSQL-compatible member folds, every unlisted member must NOT fold —
* engines such as Kingbase (Oracle-compat mode) fold in the opposite direction
* and would break if they were silently swept into the lower-case set.
*/
describe("foldUnquotedPostgresMetadataIdentifier", () => {
it("folds unquoted identifiers to lower case for every listed PostgreSQL-compatible type", () => {
expect(POSTGRES_FOLDED_IDENTIFIER_TYPES.size).toBeGreaterThan(0);
for (const databaseType of POSTGRES_FOLDED_IDENTIFIER_TYPES) {
expect(DATABASE_TYPES).toContain(databaseType);
expect(foldUnquotedPostgresMetadataIdentifier(databaseType, "MSS_CHECK_SALES_ITEM", false)).toBe("mss_check_sales_item");
expect(foldUnquotedPostgresMetadataIdentifier(databaseType, "Term", false)).toBe("term");
// Already-lower-case identifiers are unchanged, quoted ones stay exact.
expect(foldUnquotedPostgresMetadataIdentifier(databaseType, "mss_check_sales_item", false)).toBe("mss_check_sales_item");
expect(foldUnquotedPostgresMetadataIdentifier(databaseType, "MSS_CHECK_SALES_ITEM", true)).toBe("MSS_CHECK_SALES_ITEM");
}
});
it("never folds members outside the PostgreSQL-compatible set", () => {
const outside = DATABASE_TYPES.filter((databaseType) => !POSTGRES_FOLDED_IDENTIFIER_TYPES.has(databaseType));
expect(outside).toEqual(expect.arrayContaining(["mysql", "oracle", "sqlserver", "jdbc", "kingbase", "dameng", "saphana", "duckdb"]));
for (const databaseType of outside) {
expect(foldUnquotedPostgresMetadataIdentifier(databaseType, "MSS_CHECK_SALES_ITEM", false)).toBe("MSS_CHECK_SALES_ITEM");
}
});
it("keeps every enum member classified as either folding or deliberately untouched", () => {
for (const databaseType of DATABASE_TYPES) {
const folded = foldUnquotedPostgresMetadataIdentifier(databaseType, "Mixed_Case", false);
expect([folded === "mixed_case", folded === "Mixed_Case"]).toContain(true);
}
});
it("passes through missing identifiers so caller fallbacks survive", () => {
expect(foldUnquotedPostgresMetadataIdentifier("postgres", undefined, false)).toBeUndefined();
expect(foldUnquotedPostgresMetadataIdentifier("postgres", "", false)).toBe("");
});
});
+15 -1
View File
@@ -56,9 +56,23 @@ export interface EditableQuerySource {
alias?: string;
}
const POSTGRES_FOLDED_IDENTIFIER_TYPES = new Set(["postgres", "redshift", "gaussdb", "highgo", "uxdb", "vastbase", "kwdb", "opengauss", "questdb"]);
export const POSTGRES_FOLDED_IDENTIFIER_TYPES = new Set(["postgres", "redshift", "gaussdb", "highgo", "uxdb", "vastbase", "kwdb", "opengauss", "questdb"]);
const ORACLE_FOLDED_IDENTIFIER_TYPES = new Set(["oracle", "dameng", "oceanbase-oracle"]);
/**
* Fold a SQL source identifier the way the server resolved it when the
* identifier was written unquoted: PostgreSQL-compatible engines store and
* resolve it lower case, so the folded spelling — not the query's original
* casing — is the only name that stays addressable when the identifier is
* later quoted (grid saves, generated SQL). Quoted identifiers keep their
* exact spelling. `undefined` passes through so callers can fall back to
* other identity sources.
*/
export function foldUnquotedPostgresMetadataIdentifier(databaseType: string, identifier: string | undefined, quoted: boolean | undefined): string | undefined {
if (!identifier || quoted || !POSTGRES_FOLDED_IDENTIFIER_TYPES.has(databaseType)) return identifier;
return identifier.toLowerCase();
}
/**
* Resolve a SQL source identifier to the canonical name returned by table
* metadata. Quoted identifiers are always exact. PostgreSQL-compatible
@@ -0,0 +1,167 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ColumnInfo } from "@/types/database";
const executeMulti = vi.fn();
const executeQuery = vi.fn();
const analyzeEditableQueryEditability = vi.fn();
const getColumns = vi.fn();
const listIndexes = vi.fn();
const listObjects = vi.fn();
const listTables = vi.fn();
const getConnectionConfig = vi.fn();
const lookupLocalCompletionTables = vi.fn();
const buildSortedQuerySql = vi.fn();
const buildDataGridCountSql = vi.fn();
const prepareQueryPaginationExecutionPlan = vi.fn(async (options) => ({
sqlToExecute: options.sql,
pageSql: undefined,
pageLimit: undefined,
pageOffset: undefined,
countSql: undefined,
useAgentResultSession: false,
}));
const editorSettings = {
pageSize: 100,
autoCalculateTotalRows: false,
};
vi.mock("@/lib/backend/api", () => ({
analyzeEditableQueryEditability,
buildDataGridCountSql,
buildSortedQuerySql,
closeClientConnectionSession: vi.fn().mockResolvedValue(undefined),
closeQuerySession: vi.fn().mockResolvedValue(undefined),
beginManualTransaction: vi.fn().mockResolvedValue("txn-1"),
cancelQuery: vi.fn().mockResolvedValue(false),
executeInManualTransaction: vi.fn(),
executeMulti,
executeQuery,
getColumns,
listIndexes,
listObjects,
listTables,
prepareQueryPaginationExecutionPlan,
saveOpenTabsState: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: vi.fn().mockResolvedValue(undefined),
getConfig: getConnectionConfig,
lookupLocalCompletionTables,
recordConnectionLostError: vi.fn(),
metadataGenerationFor: () => 0,
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings,
}),
}));
// #10567: the SELECT resolves through the server's case folding (`FROM
// MSS_CHECK_SALES_ITEM` reads `mss_check_sales_item`), but the grid save
// re-quotes the SQL-text spelling — `term."MSS_CHECK_SALES_ITEM"` — which no
// longer resolves. The write identity carried by tableMeta must use the folded
// spelling for PostgreSQL-compatible engines.
function column(name: string, isPrimaryKey: boolean): ColumnInfo {
return { name, data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: isPrimaryKey, extra: null };
}
function mockAnalysis(overrides: Record<string, unknown> = {}) {
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
schema: "term",
tableName: "MSS_CHECK_SALES_ITEM",
tableNameQuoted: false,
selectStar: false,
columns: [{ sourceName: "price", resultName: "price", expression: "price" }],
...overrides,
},
});
}
describe("query store folds unquoted PostgreSQL table identities for writes", () => {
beforeEach(async () => {
vi.clearAllMocks();
const { clearTableMetadataCache } = await import("@/lib/metadata/tableMetadataCache");
clearTableMetadataCache();
setActivePinia(createPinia());
getColumns.mockResolvedValue([column("check_sales_item_id", true), column("price", false)]);
listIndexes.mockResolvedValue([]);
listObjects.mockResolvedValue([]);
listTables.mockResolvedValue([]);
lookupLocalCompletionTables.mockReturnValue([]);
buildSortedQuerySql.mockImplementation(async (options) => ({ ok: true, sql: options.originalSql }));
buildDataGridCountSql.mockResolvedValue("SELECT COUNT(*) FROM t");
executeMulti.mockResolvedValue([{ columns: ["price"], rows: [["94.1"]], affected_rows: 0, execution_time_ms: 1 }]);
executeQuery.mockResolvedValue({ columns: ["row_count"], rows: [[0]], affected_rows: 0, execution_time_ms: 1 });
mockAnalysis();
});
it("folds the unquoted SQL-text table name so the save targets the stored relation", async () => {
getConnectionConfig.mockReturnValue({ id: "pg-1", name: "PG", db_type: "postgres", database: "dbx", query_timeout_secs: 30 });
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "dbx", "Query");
await store.executeTabSql(tabId, "SELECT price FROM MSS_CHECK_SALES_ITEM");
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.tableMeta).toBeDefined());
expect(tab.tableMeta?.tableName).toBe("mss_check_sales_item");
expect(tab.tableMeta?.schema).toBe("term");
// The metadata request must have gone out with the folded spelling too.
expect(getColumns).toHaveBeenCalledWith("pg-1", "dbx", "term", "mss_check_sales_item", undefined);
}, 10_000);
it("keeps quoted SQL-text identifiers exact", async () => {
getConnectionConfig.mockReturnValue({ id: "pg-1", name: "PG", db_type: "postgres", database: "dbx", query_timeout_secs: 30 });
mockAnalysis({ tableNameQuoted: true });
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "dbx", "Query");
await store.executeTabSql(tabId, 'SELECT price FROM "MSS_CHECK_SALES_ITEM"');
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.tableMeta).toBeDefined());
expect(tab.tableMeta?.tableName).toBe("MSS_CHECK_SALES_ITEM");
}, 10_000);
it("leaves non-PostgreSQL-compatible engines untouched (kingbase folds independently)", async () => {
getConnectionConfig.mockReturnValue({ id: "kb-1", name: "Kingbase", db_type: "kingbase", database: "dbx", query_timeout_secs: 30 });
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("kb-1", "dbx", "Query");
await store.executeTabSql(tabId, "SELECT price FROM MSS_CHECK_SALES_ITEM");
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.tableMeta).toBeDefined());
expect(tab.tableMeta?.tableName).toBe("MSS_CHECK_SALES_ITEM");
}, 10_000);
it("folds jdbc connections whose dialect infers a PostgreSQL-compatible engine", async () => {
getConnectionConfig.mockReturnValue({
id: "jdbc-1",
name: "JDBC PG",
db_type: "jdbc",
database: "dbx",
query_timeout_secs: 30,
connection_string: "jdbc:postgresql://db.example.com:5432/dbx",
});
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("jdbc-1", "dbx", "Query");
await store.executeTabSql(tabId, "SELECT price FROM MSS_CHECK_SALES_ITEM");
const tab = store.tabs.find((item) => item.id === tabId)!;
await vi.waitFor(() => expect(tab.tableMeta).toBeDefined());
expect(tab.tableMeta?.tableName).toBe("mss_check_sales_item");
}, 10_000);
});
+20 -3
View File
@@ -31,7 +31,18 @@ import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
import { isSqlErrorPositionDebugEnabled, logSqlErrorPosition, sqlErrorHasMessagePosition, sqlErrorMessageText } from "@/lib/sql/errorPosition";
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult, type ExplainPlanDatabaseType } from "@/lib/diagram/explainPlan";
import { mysqlExplainCompatibilityHint } from "@/lib/diagram/mysqlExplainCompatibility";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, analyzeSelectStructureForDisplay, resolveMetadataColumnName, resolveSourceColumnsByOrdinal, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis";
import {
allEditableColumnsWriteable,
allPrimaryKeysPresent,
analyzeEditableQueryEditability,
analyzeSelectStructureForDisplay,
foldUnquotedPostgresMetadataIdentifier,
resolveMetadataColumnName,
resolveSourceColumnsByOrdinal,
sourceColumnsForResult,
type EditableQueryInfo,
type EditableQuerySource,
} from "@/lib/sql/sqlAnalysis";
import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes, type HiddenPrimaryKeyProjection } from "@/lib/sql/editableQueryHiddenKeys";
import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsPayload, restoreOpenTabsState, serializeOpenTabs, type OpenTabsStatePayload } from "@/lib/app/openTabsPersistence";
import {
@@ -5896,7 +5907,13 @@ export const useQueryStore = defineStore("query", () => {
// unqualified object reference. Resolve metadata through the login's
// default schema (with the driver's dbo fallback) so metadata and writes
// target the same object as the original SELECT.
const schema = source.schema || (dbType === "sqlserver" ? "" : tab.schema) || "";
// PostgreSQL-compatible engines resolve an unquoted identifier by folding
// it to lower case, so the query's original spelling is not addressable:
// quoting it (grid saves) fails with `relation ... does not exist`
// (issue #10567). Fold the SQL-text schema, not a tab-selected one — the
// object tree already reports the stored spelling.
const foldedSourceSchema = foldUnquotedPostgresMetadataIdentifier(metadataDbType, source.schema, source.schemaQuoted);
const schema = foldedSourceSchema || (dbType === "sqlserver" ? "" : tab.schema) || "";
// Oracle-family connection databases are service names, not schemas. When
// the query does not qualify a schema, let the driver resolve the current
// login user's schema instead of looking up metadata under the service name.
@@ -5911,7 +5928,7 @@ export const useQueryStore = defineStore("query", () => {
const useCurrentPostgresSchema = (dbType === "postgres" || dbType === "kwdb") && !source.schema && !tab.schema;
const resolvedSchema = (dbType === "sqlserver" && !source.schema) || (ORACLE_LIKE_METADATA_TYPES.has(metadataDbType) && !schema) || resolveAgentSearchPathSchema || useCurrentPostgresSchema ? "" : metadataSchemaForConnection(conn, metadataDatabase, schema || undefined);
const metadataSchema = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, resolvedSchema || undefined, source.schema ? source.schemaQuoted : false) || "";
const metadataTableName = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, source.tableName, source.tableNameQuoted)!;
const metadataTableName = normalizeUppercaseFoldedMetadataIdentifier(metadataDbType, foldUnquotedPostgresMetadataIdentifier(metadataDbType, source.tableName, source.tableNameQuoted)!, source.tableNameQuoted)!;
// An unqualified source resolves in the tab's current external catalog on
// Doris-family federation: the statement executed there through the
// catalog execution context, so the column lookup must carry the same
@@ -1,3 +1,4 @@
use super::identifiers::quote_gaussdb_jdbc_identifier;
use super::*;
use crate::models::connection::DatabaseType;
@@ -1694,3 +1695,57 @@ fn normalizes_where_input_with_multibyte_identifier_prefix() {
assert_eq!(normalize_where_input(Some("`客户名称` = '示例客户'")), "`客户名称` = '示例客户'");
assert_eq!(normalize_where_input(Some("WHERE `客户名称` = '示例客户';")), "`客户名称` = '示例客户'");
}
// Grid saves re-quote the table identity carried by the query result. For
// PostgreSQL-family engines reached through an agent-reported identifier quote,
// `quote_gaussdb_jdbc_identifier` only leaves all-lower-case identifiers
// unquoted, so a folded (`mss_check_sales_item`) write resolves while the
// query's original casing (`term."MSS_CHECK_SALES_ITEM"`) fails with
// `relation ... does not exist` on a lower-case-stored table (issue #10567).
// The frontend folds unquoted SQL-text identifiers before they reach this
// layer; these assertions lock the quoting contract that makes that fix work.
#[test]
fn postgres_family_table_data_quoting_resolves_folded_identifiers() {
let quote = Some("\"".to_string());
assert_eq!(quote_gaussdb_jdbc_identifier("mss_check_sales_item", "\""), "mss_check_sales_item");
assert_eq!(quote_gaussdb_jdbc_identifier("MSS_CHECK_SALES_ITEM", "\""), "\"MSS_CHECK_SALES_ITEM\"");
assert_eq!(quote_gaussdb_jdbc_identifier("term", "\""), "term");
assert_eq!(
table_data_qualified_table_name(
Some(DatabaseType::Postgres),
Some("term"),
"mss_check_sales_item",
quote.as_deref()
),
"term.mss_check_sales_item"
);
assert_eq!(
table_data_qualified_table_name(
Some(DatabaseType::Postgres),
Some("term"),
"MSS_CHECK_SALES_ITEM",
quote.as_deref()
),
"term.\"MSS_CHECK_SALES_ITEM\""
);
assert_eq!(
table_data_qualified_table_name(
Some(DatabaseType::Gaussdb),
Some("term"),
"mss_check_sales_item",
quote.as_deref()
),
"term.mss_check_sales_item"
);
// Engines outside the GaussDB/PG identifier-quote path quote both parts.
assert_eq!(
table_data_qualified_table_name(
Some(DatabaseType::Jdbc),
Some("term"),
"mss_check_sales_item",
quote.as_deref()
),
"\"term\".\"mss_check_sales_item\""
);
}