mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(query): fold unquoted postgresql table identities for grid saves
This commit is contained in:
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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\""
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user