fix(postgresql): show trigger source details

This commit is contained in:
zipg 2026-07-23 21:16:43 +08:00 committed by GitHub
parent 6115771b34
commit 6b2f55e4de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 137 additions and 19 deletions

View File

@ -22,6 +22,7 @@ const props = withDefaults(
database: string;
schema?: string;
name: string;
relationName?: string;
signature?: string;
objectType: ObjectSourceKind;
databaseType?: DatabaseType;
@ -59,7 +60,7 @@ const canEdit = computed(() => sourceEditable.value && props.objectType !== "SEQ
const title = computed(() => `${editing.value ? t("contextMenu.editView") : t("contextMenu.viewSource")} - ${props.name}`);
watch(
() => [props.open, props.connectionId, props.database, props.schema, props.name, props.signature, props.objectType, props.initialEditing] as const,
() => [props.open, props.connectionId, props.database, props.schema, props.name, props.relationName, props.signature, props.objectType, props.initialEditing] as const,
() => {
if (props.open) void loadSource();
},
@ -79,7 +80,7 @@ async function loadSource(nextEditing = props.initialEditing && canEdit.value) {
try {
if (!props.databaseType) throw new Error("Connection type is unavailable.");
const schema = props.schema || props.database;
const result = await api.getObjectSource(props.connectionId, props.database, schema, props.name, props.objectType, props.signature);
const result = await api.getObjectSource(props.connectionId, props.database, schema, props.name, props.objectType, props.signature, props.relationName);
const editableAllowed = result.editable !== false;
const editable = await buildEditableObjectSource({
databaseType: props.databaseType,

View File

@ -1552,6 +1552,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
:database="sidebarObjectSourceTarget.node.database!"
:schema="sidebarObjectSourceTarget.node.schema"
:name="sidebarObjectSourceTarget.node.objectName || sidebarObjectSourceTarget.node.label"
:relation-name="sidebarObjectSourceTarget.node.tableName"
:signature="sidebarObjectSourceTarget.node.signature"
:object-type="sidebarObjectSourceType"
:database-type="sidebarObjectSourceDatabaseType"

View File

@ -681,10 +681,10 @@ function runRowClickAction(clickDetail: number) {
if (!shouldRunTreeNodeRowAction(action, clickDetail)) return;
if (action === "open-data") {
scheduleOpenData(node);
} else if (action === "open-source") {
openObjectSourceDialog(false);
} else if (isDocumentBrowserTreeNode(node.type)) {
openMongoTreeData(node);
} else if (node.type === "procedure" || node.type === "function" || node.type === "trigger" || node.type === "sequence" || node.type === "package" || node.type === "package-body" || node.type === "type" || node.type === "type-body") {
openObjectSourceDialog(false);
} else if (action === "toggle") {
toggle();
}

View File

@ -1,7 +1,7 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting";
import type { ConnectionConfig, ObjectInfo, TreeNode } from "@/types/database";
import type { ConnectionConfig, ObjectInfo, TreeNode, TriggerInfo } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
@ -92,7 +92,13 @@ describe("sidebar object-group routing", () => {
it("keeps table-level trigger groups on listTriggers", async () => {
const listObjects = vi.fn<() => Promise<ObjectInfo[]>>().mockResolvedValue([]);
const listTriggers = vi.fn<() => Promise<never[]>>().mockResolvedValue([]);
const listTriggers = vi.fn<() => Promise<TriggerInfo[]>>().mockResolvedValue([
{
name: "trg_orders_audit",
timing: "AFTER",
event: "UPDATE",
},
]);
const { connection, store } = await createStore({ listObjects, listTriggers });
const tableTriggerGroup: TreeNode = {
...objectGroup("group-triggers", `${connection.id}:app:app:orders:__triggers`),
@ -105,7 +111,18 @@ describe("sidebar object-group routing", () => {
expect(listTriggers).toHaveBeenCalledWith(connection.id, "app", "app", "orders", undefined);
expect(listObjects).not.toHaveBeenCalled();
expect(storedTriggerGroup).toMatchObject({ isExpanded: true, isLoading: false, children: [] });
expect(storedTriggerGroup).toMatchObject({
isExpanded: true,
isLoading: false,
children: [
{
label: "trg_orders_audit (AFTER UPDATE)",
objectName: "trg_orders_audit",
tableName: "orders",
type: "trigger",
},
],
});
});
it("propagates rejected schema-level metadata while clearing the loading state", async () => {

View File

@ -656,8 +656,8 @@ export async function completionAssistantSearch(request: CompletionAssistantRequ
return post("/api/schema/completion-assistant", request);
}
export async function getObjectSource(connectionId: string, database: string, schema: string, name: string, objectType: ObjectSourceKind, signature?: string): Promise<ObjectSource> {
return get(`/api/schema/object-source?${qs({ connection_id: connectionId, database, schema, table: name, object_type: objectType, signature })}`);
export async function getObjectSource(connectionId: string, database: string, schema: string, name: string, objectType: ObjectSourceKind, signature?: string, relationName?: string): Promise<ObjectSource> {
return get(`/api/schema/object-source?${qs({ connection_id: connectionId, database, schema, table: name, object_type: objectType, signature, relation_name: relationName })}`);
}
export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string): Promise<ColumnInfo[]> {

View File

@ -832,8 +832,8 @@ export async function completionAssistantSearch(request: CompletionAssistantRequ
return invoke("completion_assistant_search", { request });
}
export async function getObjectSource(connectionId: string, database: string, schema: string, name: string, objectType: ObjectSourceKind, signature?: string): Promise<ObjectSource> {
return invoke("get_object_source", { connectionId, database, schema, name, objectType, signature });
export async function getObjectSource(connectionId: string, database: string, schema: string, name: string, objectType: ObjectSourceKind, signature?: string, relationName?: string): Promise<ObjectSource> {
return invoke("get_object_source", { connectionId, database, schema, name, objectType, signature, relationName });
}
export async function listSchemas(connectionId: string, database: string, applyVisibleFilter = false): Promise<string[]> {

View File

@ -1,7 +1,7 @@
import type { ObjectSourceKind, TreeNode, TreeNodeType } from "@/types/database";
import { matchesShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts";
export type TreeNodeRowAction = "open-data" | "toggle" | "none";
export type TreeNodeRowAction = "open-data" | "open-source" | "toggle" | "none";
export type TreeNodeRowDoubleClickAction = "open-data" | "activate-data" | "open-object-browser" | "open-object-browser-and-expand" | "open-source" | "open-saved-sql" | "toggle" | "none";
export type SidebarSelectionCopyAction = "copy-name" | "none";
export type SidebarActivation = "single" | "double";
@ -36,6 +36,7 @@ export function isDocumentBrowserTreeNode(type: TreeNodeType): boolean {
export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activation: SidebarActivation = "single"): TreeNodeRowAction {
if (activation === "double") return "none";
if (dataNodeTypes.has(type)) return "open-data";
if (sourceNodeTypes.has(type)) return "open-source";
if (toggleLeafNodeTypes.has(type)) return "toggle";
if (canExpand) return "toggle";
return "none";

View File

@ -4013,6 +4013,7 @@ export const useConnectionStore = defineStore("connection", () => {
triggers.map((tr) => ({
id: `${parentId}:${tr.name}`,
label: `${tr.name} (${tr.timing} ${tr.event})`,
objectName: tr.name,
type: "trigger" as const,
connectionId,
database,

View File

@ -1851,6 +1851,7 @@ pub async fn export_database_sql_core(
view_name,
crate::db::ObjectSourceKind::View,
None,
None,
)
.await
{
@ -1901,6 +1902,7 @@ pub async fn export_database_sql_core(
proc_name,
crate::db::ObjectSourceKind::Procedure,
procedure.signature.as_deref(),
None,
)
.await
{
@ -1955,6 +1957,7 @@ pub async fn export_database_sql_core(
func_name,
crate::db::ObjectSourceKind::Function,
function.signature.as_deref(),
None,
)
.await
{

View File

@ -5373,9 +5373,17 @@ pub async fn get_table_ddl_core(
return Err("DDL is not supported for SQL Server linked server tables".to_string());
}
if matches!(object_type, Some(db::ObjectSourceKind::View)) {
let source =
get_object_source_core(state, connection_id, database, schema, table, db::ObjectSourceKind::View, None)
.await?;
let source = get_object_source_core(
state,
connection_id,
database,
schema,
table,
db::ObjectSourceKind::View,
None,
None,
)
.await?;
let database_type = connection_config(state, connection_id).await.map(|config| config.db_type);
return Ok(crate::object_source_sql::build_view_ddl_sql(crate::object_source_sql::BuildViewDdlInput {
database_type,
@ -5393,6 +5401,7 @@ pub async fn get_table_ddl_core(
table,
db::ObjectSourceKind::MaterializedView,
None,
None,
)
.await?;
return Ok(source.source);
@ -5675,6 +5684,24 @@ pub fn postgres_object_source_sql(
postgres_object_source_sql_inner(schema, name, kind, signature, true, false)
}
fn postgres_trigger_object_source_sql(schema: &str, name: &str, relation_name: Option<&str>) -> String {
let relation_filter = relation_name
.filter(|value| !value.trim().is_empty())
.map(|value| format!(" AND c.relname = {}", sql_string(value)))
.unwrap_or_default();
format!(
"SELECT pg_catalog.pg_get_triggerdef(t.oid, true) \
FROM pg_catalog.pg_trigger t \
JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = {} AND t.tgname = {} AND NOT t.tgisinternal{} \
ORDER BY t.oid LIMIT 1",
sql_string(schema),
sql_string(name),
relation_filter
)
}
fn opengauss_object_source_sql(
schema: &str,
name: &str,
@ -5929,9 +5956,19 @@ pub async fn get_object_source_core(
name: &str,
object_type: db::ObjectSourceKind,
signature: Option<&str>,
relation_name: Option<&str>,
) -> Result<db::ObjectSource, String> {
retry_metadata_connection(state, connection_id, Some(database), || {
get_object_source_once(state, connection_id, database, schema, name, object_type.clone(), signature)
get_object_source_once(
state,
connection_id,
database,
schema,
name,
object_type.clone(),
signature,
relation_name,
)
})
.await
}
@ -5944,6 +5981,7 @@ async fn get_object_source_once(
name: &str,
object_type: db::ObjectSourceKind,
signature: Option<&str>,
relation_name: Option<&str>,
) -> Result<db::ObjectSource, String> {
let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?;
let db_config = connection_config(state, connection_id).await;
@ -6015,7 +6053,16 @@ async fn get_object_source_once(
}
PoolKind::Postgres(pool) => {
let unwrap_opengauss_record = db_config.as_ref().is_some_and(is_opengauss_family_config);
postgres_object_source(pool, schema, name, &object_type, signature, unwrap_opengauss_record).await?
postgres_object_source(
pool,
schema,
name,
&object_type,
signature,
relation_name,
unwrap_opengauss_record,
)
.await?
}
PoolKind::Sqlite(pool) => first_string_cell(
db::sqlite::execute_query(pool, &sqlite_object_source_sql(schema, name, &object_type)).await?,
@ -6062,12 +6109,32 @@ async fn get_object_source_once(
}
};
let editable = if matches!(object_type, db::ObjectSourceKind::Trigger)
&& db_config.as_ref().is_some_and(|config| {
matches!(
config.db_type,
DatabaseType::Postgres
| DatabaseType::Redshift
| DatabaseType::Gaussdb
| DatabaseType::Kwdb
| DatabaseType::OpenGauss
| DatabaseType::Questdb
| DatabaseType::Kingbase
| DatabaseType::Highgo
| DatabaseType::Vastbase
)
}) {
Some(false)
} else {
None
};
Ok(db::ObjectSource {
name: name.to_string(),
object_type,
schema: if schema.is_empty() { None } else { Some(schema.to_string()) },
source,
editable: None,
editable,
})
}
@ -6370,9 +6437,12 @@ async fn postgres_object_source(
name: &str,
object_type: &db::ObjectSourceKind,
signature: Option<&str>,
relation_name: Option<&str>,
unwrap_opengauss_record: bool,
) -> Result<String, String> {
let sql = if unwrap_opengauss_record {
let sql = if matches!(object_type, db::ObjectSourceKind::Trigger) {
postgres_trigger_object_source_sql(schema, name, relation_name)
} else if unwrap_opengauss_record {
opengauss_object_source_sql(schema, name, object_type, signature)
} else {
postgres_object_source_sql(schema, name, object_type, signature)
@ -6475,6 +6545,17 @@ mod object_source_tests {
);
}
#[test]
fn builds_postgres_object_source_sql_for_table_trigger() {
let sql = postgres_trigger_object_source_sql("audit", "trg_orders_update", Some("orders"));
assert!(sql.contains("pg_get_triggerdef(t.oid, true)"));
assert!(sql.contains("n.nspname = 'audit'"));
assert!(sql.contains("c.relname = 'orders'"));
assert!(sql.contains("t.tgname = 'trg_orders_update'"));
assert!(sql.contains("NOT t.tgisinternal"));
}
#[test]
fn builds_postgres_object_source_sql_without_relispopulated_for_legacy_catalogs() {
let sql = postgres_object_source_sql_without_relispopulated(

View File

@ -20,6 +20,7 @@ pub struct SchemaQuery {
pub offset: Option<usize>,
pub object_type: Option<dbx_core::db::ObjectSourceKind>,
pub signature: Option<String>,
pub relation_name: Option<String>,
pub object_types: Option<String>,
pub apply_visible_filter: Option<bool>,
pub client_session_id: Option<String>,
@ -302,6 +303,7 @@ pub async fn get_object_source(
name,
object_type,
q.signature.as_deref(),
q.relation_name.as_deref(),
)
.await
.map_err(AppError::from)?;

View File

@ -7,6 +7,12 @@ test("table and view rows open data without toggling structure groups", () => {
assert.equal(treeNodeRowAction("view", true), "open-data");
});
test("single click navigation mode opens source-capable rows", () => {
assert.equal(treeNodeRowAction("procedure", false), "open-source");
assert.equal(treeNodeRowAction("trigger", false), "open-source");
assert.equal(treeNodeRowAction("sequence", false), "open-source");
});
test("double click navigation mode selects rows on single click", () => {
assert.equal(treeNodeRowAction("table", true, "double"), "none");
assert.equal(treeNodeRowAction("view", true, "double"), "none");
@ -65,6 +71,8 @@ test("double-click follow-up clicks do not run row actions", () => {
assert.equal(shouldRunTreeNodeRowAction("toggle", 3), false);
assert.equal(shouldRunTreeNodeRowAction("open-data", 1), true);
assert.equal(shouldRunTreeNodeRowAction("open-data", 2), false);
assert.equal(shouldRunTreeNodeRowAction("open-source", 1), true);
assert.equal(shouldRunTreeNodeRowAction("open-source", 2), false);
assert.equal(shouldRunTreeNodeRowAction("none", 1), false);
});
@ -77,6 +85,7 @@ test("maps source-capable sidebar nodes to object source kinds", () => {
assert.equal(objectSourceKindForTreeNode("view"), "VIEW");
assert.equal(objectSourceKindForTreeNode("procedure"), "PROCEDURE");
assert.equal(objectSourceKindForTreeNode("function"), "FUNCTION");
assert.equal(objectSourceKindForTreeNode("trigger"), "TRIGGER");
assert.equal(objectSourceKindForTreeNode("sequence"), "SEQUENCE");
assert.equal(objectSourceKindForTreeNode("package"), "PACKAGE");
assert.equal(objectSourceKindForTreeNode("package-body"), "PACKAGE_BODY");

View File

@ -280,6 +280,7 @@ pub async fn get_object_source(
name: String,
object_type: db::ObjectSourceKind,
signature: Option<String>,
relation_name: Option<String>,
) -> Result<db::ObjectSource, String> {
dbx_core::schema::get_object_source_core(
&state,
@ -289,6 +290,7 @@ pub async fn get_object_source(
&name,
object_type,
signature.as_deref(),
relation_name.as_deref(),
)
.await
}