feat(xugu): expose programmable object metadata

This commit is contained in:
Elias 2026-07-17 17:53:44 +08:00 committed by GitHub
parent d3887c9936
commit 08cd8903f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 432 additions and 63 deletions

View File

@ -201,6 +201,7 @@ type objectInfo struct {
ObjectType string `json:"object_type"`
Schema string `json:"schema"`
Comment *string `json:"comment"`
Valid *bool `json:"valid,omitempty"`
}
type metadataListConstraints struct {
@ -1258,10 +1259,15 @@ func (s *server) listObjects(schema string, constraints metadataListConstraints)
var result []objectInfo
for rows.Next() {
var item objectInfo
var valid any
item.Schema = schema
if err := rows.Scan(&item.Name, &item.ObjectType, &item.Comment); err != nil {
if err := rows.Scan(&item.Name, &item.ObjectType, &item.Comment, &valid); err != nil {
return nil, err
}
if valid != nil {
value := truthy(valid)
item.Valid = &value
}
result = append(result, item)
}
return emptyIfNil(result), rows.Err()
@ -1311,19 +1317,58 @@ WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)`,
func xuguListObjectsQuery(schema string, constraints metadataListConstraints) xuguMetadataListQuery {
return xuguConstrainedMetadataListQuery(
`
SELECT t.TABLE_NAME AS OBJECT_NAME, 'TABLE' AS OBJECT_TYPE, t.COMMENTS
SELECT t.TABLE_NAME AS OBJECT_NAME, 'TABLE' AS OBJECT_TYPE, t.COMMENTS, NULL AS VALID
FROM ALL_TABLES t
JOIN ALL_SCHEMAS s ON s.DB_ID = t.DB_ID AND s.SCHEMA_ID = t.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)
UNION ALL
SELECT v.VIEW_NAME AS OBJECT_NAME, 'VIEW' AS OBJECT_TYPE, v.COMMENTS
SELECT v.VIEW_NAME AS OBJECT_NAME, 'VIEW' AS OBJECT_TYPE, v.COMMENTS, NULL AS VALID
FROM ALL_VIEWS v
JOIN ALL_SCHEMAS s ON s.DB_ID = v.DB_ID AND s.SCHEMA_ID = v.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)`,
"OBJECT_NAME, OBJECT_TYPE, COMMENTS",
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)
UNION ALL
SELECT p.PROC_NAME AS OBJECT_NAME,
CASE WHEN p.RET_TYPE IS NULL THEN 'PROCEDURE' ELSE 'FUNCTION' END AS OBJECT_TYPE,
p.COMMENTS, p.VALID
FROM ALL_PROCEDURES p
JOIN ALL_SCHEMAS s ON s.DB_ID = p.DB_ID AND s.SCHEMA_ID = p.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)
UNION ALL
SELECT p.PACK_NAME AS OBJECT_NAME, 'PACKAGE' AS OBJECT_TYPE, p.COMMENTS, p.VALID
FROM ALL_PACKAGES p
JOIN ALL_SCHEMAS s ON s.DB_ID = p.DB_ID AND s.SCHEMA_ID = p.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)
UNION ALL
SELECT p.PACK_NAME AS OBJECT_NAME, 'PACKAGE_BODY' AS OBJECT_TYPE, p.COMMENTS, p.ALL_OK
FROM ALL_PACKAGES p
JOIN ALL_SCHEMAS s ON s.DB_ID = p.DB_ID AND s.SCHEMA_ID = p.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)
AND p.BODY IS NOT NULL
UNION ALL
SELECT tr.TRIG_NAME AS OBJECT_NAME, 'TRIGGER' AS OBJECT_TYPE, tr.COMMENTS, tr.VALID
FROM ALL_TRIGGERS tr
JOIN ALL_SCHEMAS s ON s.DB_ID = tr.DB_ID AND s.SCHEMA_ID = tr.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)
UNION ALL
SELECT q.SEQ_NAME AS OBJECT_NAME, 'SEQUENCE' AS OBJECT_TYPE, NULL AS COMMENTS, NULL AS VALID
FROM ALL_SEQUENCES q
JOIN ALL_SCHEMAS s ON s.DB_ID = q.DB_ID AND s.SCHEMA_ID = q.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)
UNION ALL
SELECT u.TYPE_NAME AS OBJECT_NAME, 'TYPE' AS OBJECT_TYPE, u.COMMENTS, u.VALID
FROM ALL_TYPES u
JOIN ALL_SCHEMAS s ON s.DB_ID = u.DB_ID AND s.SCHEMA_ID = u.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)
UNION ALL
SELECT u.TYPE_NAME AS OBJECT_NAME, 'TYPE_BODY' AS OBJECT_TYPE, u.COMMENTS, u.VALID
FROM ALL_TYPES u
JOIN ALL_SCHEMAS s ON s.DB_ID = u.DB_ID AND s.SCHEMA_ID = u.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?)
AND u.BODY IS NOT NULL`,
"OBJECT_NAME, OBJECT_TYPE, COMMENTS, VALID",
"OBJECT_NAME",
"OBJECT_TYPE",
[]any{schema, schema},
[]any{schema, schema, schema, schema, schema, schema, schema, schema, schema},
constraints,
)
}
@ -1388,6 +1433,8 @@ func normalizedXuguObjectTypes(values []string) []string {
normalized = "TABLE"
case "VIEW":
normalized = "VIEW"
case "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY":
// Already normalized.
default:
continue
}
@ -1653,7 +1700,13 @@ func (s *server) getObjectSource(schema, name, objectType string) (map[string]an
}
builder.WriteString(line)
}
return map[string]any{"name": name, "object_type": objectType, "schema": schema, "source": builder.String()}, rows.Err()
result := map[string]any{"name": name, "object_type": objectType, "schema": schema, "source": builder.String()}
normalizedType := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(objectType), "_", " "))
if normalizedType == "TYPE" || normalizedType == "TYPE BODY" {
// Type source is exposed as catalog SPEC/BODY text, but cannot be safely edited as DDL.
result["editable"] = false
}
return result, rows.Err()
}
func (s *server) getTableDDL(schema, table string) (string, error) {
@ -2121,12 +2174,31 @@ SELECT TO_CHAR(p.DEFINE)
FROM SYS_PROCEDURES p
JOIN SYS_SCHEMAS s ON s.DB_ID = p.DB_ID AND s.SCHEMA_ID = p.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND UPPER(p.PROC_NAME) = UPPER(?)`, []any{schema, name}, nil
case "PACKAGE", "PACKAGE BODY":
case "PACKAGE":
return `
SELECT COALESCE(TO_CHAR(k.SPEC), '') || COALESCE(TO_CHAR(k.BODY), '')
SELECT COALESCE(TO_CHAR(k.SPEC), '')
FROM SYS_PACKAGES k
JOIN SYS_SCHEMAS s ON s.DB_ID = k.DB_ID AND s.SCHEMA_ID = k.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND UPPER(k.PACK_NAME) = UPPER(?)`, []any{schema, name}, nil
case "PACKAGE BODY", "PACKAGE_BODY":
return `
SELECT COALESCE(TO_CHAR(k.BODY), '')
FROM SYS_PACKAGES k
JOIN SYS_SCHEMAS s ON s.DB_ID = k.DB_ID AND s.SCHEMA_ID = k.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND UPPER(k.PACK_NAME) = UPPER(?)`, []any{schema, name}, nil
case "TYPE":
return `
SELECT COALESCE(TO_CHAR(u.SPEC), '')
FROM ALL_TYPES u
JOIN ALL_SCHEMAS s ON s.DB_ID = u.DB_ID AND s.SCHEMA_ID = u.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND UPPER(u.TYPE_NAME) = UPPER(?)`, []any{schema, name}, nil
case "TYPE BODY", "TYPE_BODY":
return `
SELECT COALESCE(TO_CHAR(u.BODY), '')
FROM ALL_TYPES u
JOIN ALL_SCHEMAS s ON s.DB_ID = u.DB_ID AND s.SCHEMA_ID = u.SCHEMA_ID
WHERE UPPER(s.SCHEMA_NAME) = UPPER(?) AND UPPER(u.TYPE_NAME) = UPPER(?)
AND u.BODY IS NOT NULL`, []any{schema, name}, nil
default:
return "", nil, fmt.Errorf("object source is not supported for %s", objectType)
}

View File

@ -537,10 +537,61 @@ func TestXuguListObjectsQueryRejectsUnsupportedObjectTypes(t *testing.T) {
t.Fatalf("unsupported object type should produce empty-result predicate:\n%s", query.SQL)
}
wantArgs := []any{"APP", "APP", 10, 0}
wantArgs := []any{"APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", 10, 0}
assertArgs(t, query.Args, wantArgs)
}
func TestXuguListObjectsQueryIncludesProgrammableObjects(t *testing.T) {
query := xuguListObjectsQuery("APP", metadataListConstraints{
ObjectTypes: []string{"procedure", "function", "package", "package-body", "trigger", "sequence", "type", "type-body"},
})
for _, want := range []string{"ALL_PROCEDURES", "p.VALID", "ALL_PACKAGES", "p.BODY IS NOT NULL", "ALL_TRIGGERS", "ALL_SEQUENCES", "ALL_TYPES", "u.BODY IS NOT NULL", "OBJECT_NAME, OBJECT_TYPE, COMMENTS, VALID", "OBJECT_TYPE IN (?,?,?,?,?,?,?,?)"} {
if !strings.Contains(query.SQL, want) {
t.Fatalf("expected SQL to contain %q:\n%s", want, query.SQL)
}
}
wantArgs := []any{"APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "APP", "FUNCTION", "PACKAGE", "PACKAGE_BODY", "PROCEDURE", "SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"}
assertArgs(t, query.Args, wantArgs)
}
func TestXuguObjectSourceQuerySupportsSharedObjectKinds(t *testing.T) {
for _, objectType := range []string{"TRIGGER", "PACKAGE_BODY", "TYPE", "TYPE_BODY"} {
query, _, err := objectSourceQuery("APP", "demo", objectType)
if err != nil {
t.Fatalf("%s should support object source lookup: %v", objectType, err)
}
if strings.TrimSpace(query) == "" {
t.Fatalf("%s should produce source SQL", objectType)
}
}
packageBodyQuery, _, err := objectSourceQuery("APP", "demo", "PACKAGE_BODY")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(packageBodyQuery, "TO_CHAR(k.BODY)") || strings.Contains(packageBodyQuery, "k.SPEC") {
t.Fatalf("package body query must request only the body: %s", packageBodyQuery)
}
typeSpecQuery, _, err := objectSourceQuery("APP", "demo", "TYPE")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(typeSpecQuery, "ALL_TYPES") || !strings.Contains(typeSpecQuery, "TO_CHAR(u.SPEC)") {
t.Fatalf("type query must return catalog SPEC content: %s", typeSpecQuery)
}
typeBodyQuery, _, err := objectSourceQuery("APP", "demo", "TYPE_BODY")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(typeBodyQuery, "ALL_TYPES") || !strings.Contains(typeBodyQuery, "TO_CHAR(u.BODY)") || !strings.Contains(typeBodyQuery, "u.BODY IS NOT NULL") {
t.Fatalf("type body query must return catalog BODY content: %s", typeBodyQuery)
}
}
func TestMetadataListConstraintsFromParams(t *testing.T) {
params := map[string]json.RawMessage{
"filter": json.RawMessage(`"tab"`),
@ -648,7 +699,6 @@ func contains(values []string, target string) bool {
return false
}
// -- fake drivers for timeout tests --
func init() {
@ -675,13 +725,13 @@ type xuguBlockingConn struct{}
func (c *xuguBlockingConn) Prepare(query string) (driver.Stmt, error) {
return &xuguBlockingStmt{}, nil
}
func (c *xuguBlockingConn) Close() error { return nil }
func (c *xuguBlockingConn) Close() error { return nil }
func (c *xuguBlockingConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") }
type xuguBlockingStmt struct{}
func (s *xuguBlockingStmt) Close() error { return nil }
func (s *xuguBlockingStmt) NumInput() int { return -1 }
func (s *xuguBlockingStmt) Close() error { return nil }
func (s *xuguBlockingStmt) NumInput() int { return -1 }
func (s *xuguBlockingStmt) Exec(args []driver.Value) (driver.Result, error) {
<-xuguBlockingUnblock
return nil, errors.New("killed")
@ -702,13 +752,13 @@ type xuguFastConn struct{}
func (c *xuguFastConn) Prepare(query string) (driver.Stmt, error) {
return &xuguFastStmt{}, nil
}
func (c *xuguFastConn) Close() error { return nil }
func (c *xuguFastConn) Close() error { return nil }
func (c *xuguFastConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") }
type xuguFastStmt struct{}
func (s *xuguFastStmt) Close() error { return nil }
func (s *xuguFastStmt) NumInput() int { return -1 }
func (s *xuguFastStmt) Close() error { return nil }
func (s *xuguFastStmt) NumInput() int { return -1 }
func (s *xuguFastStmt) Exec(args []driver.Value) (driver.Result, error) {
return driver.ResultNoRows, nil
}

View File

@ -1609,14 +1609,17 @@ async function handleQuickOpenSelect(item: any) {
tableName: item.objectName || item.tableName,
tableType: item.type === "view" ? "VIEW" : item.type === "materialized_view" ? "MATERIALIZED_VIEW" : "TABLE",
});
} else if (item.type === "procedure" || item.type === "function" || item.type === "sequence" || item.type === "package" || item.type === "package-body") {
} else if (item.type === "procedure" || item.type === "function" || item.type === "trigger" || item.type === "sequence" || item.type === "package" || item.type === "package-body" || item.type === "type" || item.type === "type-body") {
// Open the object source in a source tab
const objectTypeMap: Record<string, ObjectSourceKind> = {
procedure: "PROCEDURE",
function: "FUNCTION",
trigger: "TRIGGER",
sequence: "SEQUENCE",
package: "PACKAGE",
"package-body": "PACKAGE_BODY",
type: "TYPE",
"type-body": "TYPE_BODY",
};
const objectType = objectTypeMap[item.type];
@ -1627,7 +1630,7 @@ async function handleQuickOpenSelect(item: any) {
const result = await api.getObjectSource(item.connectionId, item.database, schema, item.objectName || item.tableName, objectType);
const tabId = queryStore.createTab(item.connectionId, item.database, `Source - ${item.objectName || item.tableName}`);
queryStore.updateSql(tabId, result.source);
if (item.type !== "sequence") {
if (item.type !== "sequence" && item.type !== "trigger" && item.type !== "type" && item.type !== "type-body") {
queryStore.setObjectSource(tabId, {
schema,
name: item.objectName || item.tableName,

View File

@ -93,13 +93,13 @@ import {
type ObjectBrowserSortDirection,
type ObjectBrowserSortKey,
} from "@/lib/table/objectBrowserRows";
import { resolveRowClickAction, shouldDeferSingleClick, type ObjectBrowserRowAction } from "@/lib/table/objectBrowserRowAction";
import { isSourceOnlyObjectBrowserRow, resolveRowClickAction, shouldDeferSingleClick, type ObjectBrowserRowAction } from "@/lib/table/objectBrowserRowAction";
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
type ObjectFilter = "all" | "tables" | "views" | "materializedViews" | "procedures" | "functions" | "sequences" | "packages";
type ObjectFilter = "all" | "tables" | "views" | "materializedViews" | "procedures" | "functions" | "triggers" | "sequences" | "packages" | "types";
type ObjectBrowserColumnKey = "select" | "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment";
const props = defineProps<{
@ -236,8 +236,10 @@ const viewCount = computed(() => rows.value.filter((row) => row.type === "VIEW")
const materializedViewCount = computed(() => rows.value.filter((row) => row.type === "MATERIALIZED_VIEW").length);
const procedureCount = computed(() => rows.value.filter((row) => row.type === "PROCEDURE").length);
const functionCount = computed(() => rows.value.filter((row) => row.type === "FUNCTION").length);
const triggerCount = computed(() => rows.value.filter((row) => row.type === "TRIGGER").length);
const sequenceCount = computed(() => rows.value.filter((row) => row.type === "SEQUENCE").length);
const packageCount = computed(() => rows.value.filter((row) => row.type === "PACKAGE" || row.type === "PACKAGE_BODY").length);
const typeCount = computed(() => rows.value.filter((row) => row.type === "TYPE" || row.type === "TYPE_BODY").length);
const canOpenStructureEditor = computed(() => supportsTableStructureEditing(tableStructureDatabaseType.value));
const canOpenDiagram = computed(() => !!props.database && supportsSchemaDiagram(effectiveDatabaseType.value));
const canOpenTableImport = computed(() => !!props.database && supportsTableImport(effectiveDatabaseType.value));
@ -253,8 +255,10 @@ const objectFilters = computed<ObjectFilter[]>(() =>
["materializedViews", materializedViewCount.value],
["procedures", procedureCount.value],
["functions", functionCount.value],
["triggers", triggerCount.value],
["sequences", sequenceCount.value],
["packages", packageCount.value],
["types", typeCount.value],
] as Array<[ObjectFilter, number]>
)
.filter(([filter, count]) => filter === "all" || count > 0)
@ -526,8 +530,10 @@ function iconFor(row: ObjectBrowserRow) {
if (row.type === "VIEW" || row.type === "MATERIALIZED_VIEW") return Eye;
if (row.type === "PROCEDURE") return ScrollText;
if (row.type === "FUNCTION") return Braces;
if (row.type === "TRIGGER") return RotateCcw;
if (row.type === "SEQUENCE") return ListTree;
if (row.type === "PACKAGE" || row.type === "PACKAGE_BODY") return Package;
if (row.type === "TYPE" || row.type === "TYPE_BODY") return Braces;
return Table2;
}
@ -536,9 +542,12 @@ function typeLabel(type: ObjectBrowserRow["type"]) {
if (type === "VIEW") return t("objects.view");
if (type === "PROCEDURE") return t("objects.procedure");
if (type === "FUNCTION") return t("objects.function");
if (type === "TRIGGER") return t("objects.trigger");
if (type === "SEQUENCE") return t("objects.sequence");
if (type === "PACKAGE") return t("objects.package");
if (type === "PACKAGE_BODY") return t("objects.packageBody");
if (type === "TYPE") return t("objects.typeDefinition");
if (type === "TYPE_BODY") return t("objects.typeBody");
return t("objects.table");
}
@ -635,8 +644,10 @@ function rowMatchesObjectFilter(row: ObjectBrowserRow) {
if (objectFilter.value === "materializedViews") return row.type === "MATERIALIZED_VIEW";
if (objectFilter.value === "procedures") return row.type === "PROCEDURE";
if (objectFilter.value === "functions") return row.type === "FUNCTION";
if (objectFilter.value === "triggers") return row.type === "TRIGGER";
if (objectFilter.value === "sequences") return row.type === "SEQUENCE";
if (objectFilter.value === "packages") return row.type === "PACKAGE" || row.type === "PACKAGE_BODY";
if (objectFilter.value === "types") return row.type === "TYPE" || row.type === "TYPE_BODY";
return true;
}
@ -673,8 +684,10 @@ function iconClass(type: ObjectBrowserRow["type"]) {
if (type === "VIEW" || type === "MATERIALIZED_VIEW") return "text-purple-500";
if (type === "PROCEDURE") return "text-blue-500";
if (type === "FUNCTION") return "text-amber-500";
if (type === "TRIGGER") return "text-rose-500";
if (type === "SEQUENCE") return "text-emerald-500";
if (type === "PACKAGE" || type === "PACKAGE_BODY") return "text-cyan-500";
if (type === "TYPE" || type === "TYPE_BODY") return "text-violet-500";
return "text-green-500";
}
@ -682,8 +695,10 @@ function iconBgClass(type: ObjectBrowserRow["type"]) {
if (type === "VIEW" || type === "MATERIALIZED_VIEW") return "object-browser-icon-bg object-browser-icon-bg-view";
if (type === "PROCEDURE") return "object-browser-icon-bg object-browser-icon-bg-procedure";
if (type === "FUNCTION") return "object-browser-icon-bg object-browser-icon-bg-function";
if (type === "TRIGGER") return "object-browser-icon-bg object-browser-icon-bg-procedure";
if (type === "SEQUENCE") return "object-browser-icon-bg object-browser-icon-bg-sequence";
if (type === "PACKAGE" || type === "PACKAGE_BODY") return "object-browser-icon-bg object-browser-icon-bg-package";
if (type === "TYPE" || type === "TYPE_BODY") return "object-browser-icon-bg object-browser-icon-bg-function";
return "object-browser-icon-bg object-browser-icon-bg-table";
}
@ -1013,14 +1028,16 @@ async function openSource(row: ObjectBrowserRow) {
try {
const result = await api.getObjectSource(connectionId, database, schema, row.name, row.type as ObjectSourceKind);
if (sidePanelGuard.isStale(epoch)) return;
sourceCanEdit.value = result.editable !== false && row.type !== "SEQUENCE";
const editable = await api.buildEditableObjectSource({
databaseType: effectiveDatabaseType.value,
objectType: row.type as ObjectSourceKind,
schema,
name: row.name,
source: result.source,
});
sourceCanEdit.value = result.editable !== false && !["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"].includes(row.type);
const editable = sourceCanEdit.value
? await api.buildEditableObjectSource({
databaseType: effectiveDatabaseType.value,
objectType: row.type as ObjectSourceKind,
schema,
name: row.name,
source: result.source,
})
: result.source;
if (sidePanelGuard.isStale(epoch)) return;
// Viewing database source must preserve its original whitespace and comments;
// formatting remains an explicit editor action instead of altering it on open.
@ -2129,8 +2146,10 @@ function filterCount(filter: ObjectFilter) {
if (filter === "materializedViews") return materializedViewCount.value;
if (filter === "procedures") return procedureCount.value;
if (filter === "functions") return functionCount.value;
if (filter === "triggers") return triggerCount.value;
if (filter === "sequences") return sequenceCount.value;
if (filter === "packages") return packageCount.value;
if (filter === "types") return typeCount.value;
return rows.value.length;
}
@ -2146,11 +2165,15 @@ function filterLabel(filter: ObjectFilter) {
? "objects.procedures"
: filter === "functions"
? "objects.functions"
: filter === "sequences"
? "objects.sequences"
: filter === "packages"
? "objects.packages"
: "objects.all";
: filter === "triggers"
? "tree.triggers"
: filter === "sequences"
? "objects.sequences"
: filter === "packages"
? "objects.packages"
: filter === "types"
? "tree.types"
: "objects.all";
return `${t(key)} ${filterCount(filter)}`;
}
@ -2334,8 +2357,7 @@ function getPackageMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
if (item.type === "TABLE") return getTableMenuItems(item);
if (item.type === "VIEW" || item.type === "MATERIALIZED_VIEW") return getViewMenuItems(item);
if (item.type === "SEQUENCE") return getPackageMenuItems(item);
if (item.type === "PACKAGE" || item.type === "PACKAGE_BODY") return getPackageMenuItems(item);
if (isSourceOnlyObjectBrowserRow(item)) return getPackageMenuItems(item);
return getProcFuncMenuItems(item);
}
</script>

View File

@ -154,9 +154,9 @@ watch(deferredSearchQuery, (newQuery, oldQuery) => {
.catch(() => {});
});
const searchableObjectGroupTypes = new Set<TreeNodeType>(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages"]);
const searchableObjectGroupTypes = new Set<TreeNodeType>(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-sequences", "group-packages", "group-types"]);
const simpleObjectParentTypes = new Set<TreeNodeType>(["database", "schema", "linked-server-schema"]);
const simpleObjectChildTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "procedure", "function", "sequence", "package", "package-body", "load-more"]);
const simpleObjectChildTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "procedure", "function", "trigger", "sequence", "package", "package-body", "type", "type-body", "load-more"]);
function isSimpleObjectSearchParent(node: TreeNode): boolean {
return settingsStore.editorSettings.sidebarObjectDisplay === "simple" && simpleObjectParentTypes.has(node.type) && node.isExpanded === true && (!!node.children?.some((child) => simpleObjectChildTypes.has(child.type)) || !!store.sidebarTableSearchQueries[node.id]?.trim());

View File

@ -445,7 +445,22 @@ function hasNodeDatabaseContext(node: TreeNode): node is TreeNode & { connection
return !!node.connectionId && hasTreeNodeDatabaseContext(node);
}
const groupTypes: Set<TreeNodeType> = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages", "group-partitions", "group-extensions"]);
const groupTypes: Set<TreeNodeType> = new Set([
"group-columns",
"group-indexes",
"group-fkeys",
"group-triggers",
"group-tables",
"group-views",
"group-materialized-views",
"group-procedures",
"group-functions",
"group-sequences",
"group-packages",
"group-types",
"group-partitions",
"group-extensions",
]);
function isGroupLabel(node: TreeNode): boolean {
return groupTypes.has(node.type);
@ -642,7 +657,7 @@ function runRowClickAction(clickDetail: number) {
scheduleOpenData(node);
} else if (isDocumentBrowserTreeNode(node.type)) {
openMongoTreeData(node);
} else if (node.type === "procedure" || node.type === "function" || node.type === "sequence" || node.type === "package" || node.type === "package-body") {
} 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();
@ -2833,7 +2848,7 @@ const canOpenFieldLineage = computed(() => {
const hasTypeMenu = computed(() => {
const t = activeNode.value.type;
return t === "connection" || t === "database" || t === "schema" || t === "table" || t === "view" || t === "column" || t === "procedure" || t === "function" || t === "package" || t === "package-body" || isGroupLabel(activeNode.value);
return t === "connection" || t === "database" || t === "schema" || t === "table" || t === "view" || t === "column" || t === "procedure" || t === "function" || t === "trigger" || t === "package" || t === "package-body" || t === "type" || t === "type-body" || isGroupLabel(activeNode.value);
});
const isSelected = computed(() => connectionStore.selectedTreeNodeId === activeNode.value.id);
@ -3871,7 +3886,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
return true;
}
if (node.type === "index" || node.type === "fkey" || node.type === "trigger") {
if (node.type === "index" || node.type === "fkey" || (node.type === "trigger" && !!node.tableName)) {
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
if (node.type === "index" && canOpenStructureEditor.value) {
items.push({ label: "", separator: true });
@ -3931,7 +3946,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
return true;
}
if (node.type === "package" || node.type === "package-body") {
if (node.type === "trigger" || node.type === "package" || node.type === "package-body" || node.type === "type" || node.type === "type-body") {
items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 });
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });

View File

@ -261,6 +261,10 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: Package, colorClass: "text-cyan-500" };
case "package-body":
return { icon: FileCode, colorClass: "text-cyan-400" };
case "type":
return { icon: Braces, colorClass: "text-violet-500" };
case "type-body":
return { icon: FileCode, colorClass: "text-violet-400" };
case "group-tables":
return { icon: Table, colorClass: "text-green-500" };
case "group-views":
@ -275,6 +279,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: ListTree, colorClass: "text-emerald-500" };
case "group-packages":
return { icon: Package, colorClass: "text-cyan-500" };
case "group-types":
return { icon: Braces, colorClass: "text-violet-500" };
case "group-partitions":
return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-green-400" };
case "group-extensions":
@ -288,7 +294,22 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
}
}
const groupTypes: Set<TreeNodeType> = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages", "group-partitions", "group-extensions"]);
const groupTypes: Set<TreeNodeType> = new Set([
"group-columns",
"group-indexes",
"group-fkeys",
"group-triggers",
"group-tables",
"group-views",
"group-materialized-views",
"group-procedures",
"group-functions",
"group-sequences",
"group-packages",
"group-types",
"group-partitions",
"group-extensions",
]);
function isGroupLabel(node: TreeNode): boolean {
return groupTypes.has(node.type);
@ -304,10 +325,11 @@ function displayLabel(node: TreeNode): string {
}
function visibleLabel(node: TreeNode): string {
const withValidity = (label: string) => (node.valid === false ? `${label} · INVALID` : label);
if (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "vector-collection" || node.type === "elasticsearch-index") {
return sidebarDisplayTableName(node.label, settingsStore.editorSettings.sidebarHiddenTablePrefixes);
return withValidity(sidebarDisplayTableName(node.label, settingsStore.editorSettings.sidebarHiddenTablePrefixes));
}
return displayLabel(node);
return withValidity(displayLabel(node));
}
type DetailTooltipRow = {

View File

@ -1855,6 +1855,7 @@ export default {
functions: "Functions",
sequences: "Sequences",
packages: "Packages",
types: "Types",
gridfs: "GridFS",
buckets: "Buckets",
partitions: "Partitions",
@ -2092,9 +2093,12 @@ export default {
view: "View",
procedure: "Procedure",
function: "Function",
trigger: "Trigger",
sequence: "Sequence",
package: "Package",
packageBody: "Package Body",
typeDefinition: "Type",
typeBody: "Type Body",
name: "Name",
type: "Type",
rows: "Rows",

View File

@ -1802,6 +1802,7 @@ export default withEnglishFallback({
loadMore: "Cargar más...",
objectBrowser: "Explorar en el navegador de objetos ({count})",
extensions: "Extensiones",
types: "tipo",
},
userAdmin: {
title: "Usuarios y Privilegios",
@ -1988,6 +1989,9 @@ export default withEnglishFallback({
sortAsc: "ascendente",
sortDesc: "descendente",
sortBy: "ordenar por",
trigger: "disparador",
typeDefinition: "definición de tipo",
typeBody: "cuerpo de tipo",
},
structureEditor: {
mysqlDataTypeHelp: {

View File

@ -1800,6 +1800,7 @@ export default withEnglishFallback({
loadMore: "Carica altro...",
objectBrowser: "Sfoglia in Esplora Oggetti ({count})",
extensions: "Estensioni",
types: "Tipi",
},
userAdmin: {
title: "Utenti e Privilegi",
@ -1986,6 +1987,9 @@ export default withEnglishFallback({
sortAsc: "Crescente",
sortDesc: "Decrescente",
sortBy: "Ordina per",
trigger: "Trigger",
typeDefinition: "Definizione del tipo",
typeBody: "Corpo del tipo",
},
structureEditor: {
mysqlDataTypeHelp: {

View File

@ -1801,6 +1801,7 @@ export default withEnglishFallback({
linkedServers: "リンクサーバー",
materializedViews: "マテリアライズドビュー",
extensions: "拡張機能",
types: "タイプ",
},
zookeeper: {
prefixPlaceholder: "パスプレフィックス(例: /app/",
@ -2021,6 +2022,9 @@ export default withEnglishFallback({
sortAsc: "昇順",
sortDesc: "降順",
sortBy: "並べ替え",
trigger: "トリガー",
typeDefinition: "タイプ",
typeBody: "タイプ本体",
},
structureEditor: {
mysqlDataTypeHelp: {

View File

@ -1802,6 +1802,7 @@ export default withEnglishFallback({
loadMore: "Carregar mais...",
objectBrowser: "Navegar no Navegador de Objetos ({count})",
extensions: "Extensões",
types: "Tipos",
},
userAdmin: {
title: "Usuários e Privilégios",
@ -1988,6 +1989,9 @@ export default withEnglishFallback({
sortAsc: "Crescente",
sortDesc: "Decrescente",
sortBy: "Ordenar por",
trigger: "Gatilho",
typeDefinition: "Definição de tipo",
typeBody: "Corpo do tipo",
},
structureEditor: {
mysqlDataTypeHelp: {

View File

@ -1855,6 +1855,7 @@ export default withEnglishFallback({
functions: "函数",
sequences: "序列",
packages: "包",
types: "类型",
partitions: "分区",
childTables: "子表",
loadMore: "加载更多...",
@ -2062,9 +2063,12 @@ export default withEnglishFallback({
view: "视图",
procedure: "存储过程",
function: "函数",
trigger: "触发器",
sequence: "序列",
package: "包",
packageBody: "包体",
typeDefinition: "类型",
typeBody: "类型体",
name: "名称",
type: "类型",
rows: "行数",

View File

@ -1802,6 +1802,7 @@ export default withEnglishFallback({
loadMore: "載入更多...",
objectBrowser: "在物件瀏覽器中檢視 ({count})",
extensions: "擴展",
types: "類型",
},
objects: {
all: "全部",
@ -1858,6 +1859,9 @@ export default withEnglishFallback({
sortAsc: "升序",
sortDesc: "降序",
sortBy: "排序方式",
trigger: "觸發器",
typeDefinition: "類型",
typeBody: "類型體",
},
structureEditor: {
mysqlDataTypeHelp: {

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { doubleClickRowAction, resolveRowClickAction, shouldDeferSingleClick, singleClickRowAction } from "@/lib/table/objectBrowserRowAction";
import { doubleClickRowAction, isSourceOnlyObjectBrowserRow, resolveRowClickAction, shouldDeferSingleClick, singleClickRowAction } from "@/lib/table/objectBrowserRowAction";
import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
function row(type: ObjectBrowserRow["type"], name = "test"): ObjectBrowserRow {
@ -39,6 +39,10 @@ describe("singleClickRowAction", () => {
expect(singleClickRowAction(row("PACKAGE_BODY", "pkg_body_test"))).toBe("open-source");
});
it.each(["TRIGGER", "TYPE", "TYPE_BODY"] as const)("returns open-source for %s", (type) => {
expect(singleClickRowAction(row(type, "programmable_test"))).toBe("open-source");
});
it("returns none for null/undefined", () => {
expect(singleClickRowAction(null)).toBe("none");
expect(singleClickRowAction(undefined)).toBe("none");
@ -63,6 +67,17 @@ describe("doubleClickRowAction", () => {
});
});
describe("isSourceOnlyObjectBrowserRow", () => {
it.each(["TRIGGER", "TYPE", "TYPE_BODY"] as const)("marks %s as source-only", (type) => {
expect(isSourceOnlyObjectBrowserRow(row(type))).toBe(true);
});
it("keeps procedure and function mutation menus separate", () => {
expect(isSourceOnlyObjectBrowserRow(row("PROCEDURE"))).toBe(false);
expect(isSourceOnlyObjectBrowserRow(row("FUNCTION"))).toBe(false);
});
});
describe("resolveRowClickAction", () => {
const tableRow = row("TABLE", "users");
const viewRow = row("VIEW", "v_users");

View File

@ -29,6 +29,22 @@ describe("PostgreSQL overloaded routines", () => {
});
});
describe("programmable database objects", () => {
it("keeps Xugu trigger/type nodes distinct and preserves an invalid status", () => {
const objects: ObjectInfo[] = [
{ name: "TRG_AUDIT", object_type: "TRIGGER", schema: "APP", valid: false },
{ name: "ADDRESS_T", object_type: "TYPE", schema: "APP", valid: true },
{ name: "ADDRESS_T", object_type: "TYPE_BODY", schema: "APP", valid: true },
];
const nodes = buildSimpleObjectTreeNodes({ ...context, schema: "APP", objects });
expect(nodes).toEqual(
expect.arrayContaining([expect.objectContaining({ type: "trigger", objectName: "TRG_AUDIT", valid: false }), expect.objectContaining({ type: "type", objectName: "ADDRESS_T", valid: true }), expect.objectContaining({ type: "type-body", objectName: "ADDRESS_T", valid: true })]),
);
});
});
describe("PostgreSQL table hierarchy", () => {
it("keeps schema pagination visible at the table-group root when a page ends inside nested partitions", () => {
const nodes = buildTableTreeNodes({

View File

@ -1,6 +1,6 @@
import type { DatabaseType } from "@/types/database";
export type SidebarObjectKind = "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY";
export type SidebarObjectKind = "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY";
export interface DatabaseObjectCapabilities {
sidebarObjects: SidebarObjectKind[];
@ -15,6 +15,7 @@ const ROUTINE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUN
const POSTGRES_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE"];
const POSTGRES_LIKE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION"];
const ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY"];
const XUGU_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY"];
const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
// postgres
@ -31,6 +32,7 @@ const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
["oracle", ORACLE_OBJECTS],
["dameng", ORACLE_OBJECTS],
["oceanbase-oracle", ORACLE_OBJECTS],
["xugu", XUGU_OBJECTS],
// table and view
["sqlite", TABLE_VIEW_OBJECTS],
["rqlite", TABLE_VIEW_OBJECTS],
@ -74,7 +76,10 @@ export function normalizeSidebarObjectKind(type: string): SidebarObjectKind {
const value = type.toUpperCase();
const normalized = value.replace(/[\s-]+/g, "_");
if (normalized.includes("PACKAGE_BODY")) return "PACKAGE_BODY";
if (normalized.includes("TYPE_BODY")) return "TYPE_BODY";
if (normalized.includes("PACKAGE")) return "PACKAGE";
if (normalized.includes("TRIGGER")) return "TRIGGER";
if (normalized.includes("TYPE")) return "TYPE";
if (normalized.includes("MATERIALIZED_VIEW")) return "MATERIALIZED_VIEW";
if (value.includes("VIEW")) return "VIEW";
if (value.includes("SEQ")) return "SEQUENCE";

View File

@ -9,6 +9,8 @@ const leafTypes: Set<TreeNodeType> = new Set([
"function",
"package",
"package-body",
"type",
"type-body",
"object-browser",
"redis-db",
"mq-tenant",

View File

@ -10,19 +10,22 @@ const dataNodeTypes = new Set<TreeNodeType>(["table", "view", "materialized_view
const documentBrowserNodeTypes = new Set<TreeNodeType>(["mongo-collection", "mongo-bucket"]);
const toggleLeafNodeTypes = new Set<TreeNodeType>(["redis-db", "mq-tenant", "etcd-root", "zookeeper-root", "mongo-gridfs", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index", "user-admin"]);
const objectBrowserNodeTypes = new Set<TreeNodeType>(["database", "schema", "object-browser"]);
const sourceNodeTypes = new Set<TreeNodeType>(["materialized_view", "procedure", "function", "sequence", "package", "package-body"]);
const sourceNodeTypes = new Set<TreeNodeType>(["materialized_view", "procedure", "function", "trigger", "sequence", "package", "package-body", "type", "type-body"]);
const savedSqlNodeTypes = new Set<TreeNodeType>(["saved-sql-file"]);
const tableChildGroupNodeTypes = new Set<TreeNodeType>(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-partitions"]);
const databaseChildGroupNodeTypes = new Set<TreeNodeType>(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages"]);
const databaseChildGroupNodeTypes = new Set<TreeNodeType>(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-sequences", "group-packages", "group-types"]);
export function objectSourceKindForTreeNode(type: TreeNodeType): ObjectSourceKind | null {
if (type === "view") return "VIEW";
if (type === "materialized_view") return "MATERIALIZED_VIEW";
if (type === "procedure") return "PROCEDURE";
if (type === "function") return "FUNCTION";
if (type === "trigger") return "TRIGGER";
if (type === "sequence") return "SEQUENCE";
if (type === "package") return "PACKAGE";
if (type === "package-body") return "PACKAGE_BODY";
if (type === "type") return "TYPE";
if (type === "type-body") return "TYPE_BODY";
return null;
}

View File

@ -5,7 +5,7 @@ export type ObjectBrowserRowAction = "table-info" | "open-table" | "open-source"
/**
* Determine the action for a single click on an object browser row.
* - TABLE table-info (show table properties panel)
* - VIEW/MATERIALIZED_VIEW/PROCEDURE/FUNCTION/SEQUENCE/PACKAGE/PACKAGE_BODY open-source
* - VIEW/MATERIALIZED_VIEW/PROCEDURE/FUNCTION/TRIGGER/SEQUENCE/PACKAGE/PACKAGE_BODY/TYPE/TYPE_BODY open-source
* - otherwise none
*/
export function singleClickRowAction(row: ObjectBrowserRow | null | undefined): ObjectBrowserRowAction {
@ -18,7 +18,7 @@ export function singleClickRowAction(row: ObjectBrowserRow | null | undefined):
/**
* Determine the action for a double click on an object browser row.
* - TABLE open-table (open table data tab)
* - VIEW/MATERIALIZED_VIEW/PROCEDURE/FUNCTION/SEQUENCE/PACKAGE/PACKAGE_BODY open-source
* - VIEW/MATERIALIZED_VIEW/PROCEDURE/FUNCTION/TRIGGER/SEQUENCE/PACKAGE/PACKAGE_BODY/TYPE/TYPE_BODY open-source
* - otherwise none
*/
export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined): ObjectBrowserRowAction {
@ -62,6 +62,14 @@ export function shouldDeferSingleClick(row: ObjectBrowserRow | null | undefined,
return single !== double && action === single;
}
function canOpenSource(row: ObjectBrowserRow): boolean {
return row.type === "VIEW" || row.type === "MATERIALIZED_VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY";
/**
* Objects with source metadata but no supported object-browser mutation API.
* Their menu intentionally exposes only source viewing and copying.
*/
export function isSourceOnlyObjectBrowserRow(row: ObjectBrowserRow): boolean {
return row.type === "TRIGGER" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY" || row.type === "TYPE" || row.type === "TYPE_BODY";
}
function canOpenSource(row: ObjectBrowserRow): boolean {
return row.type === "VIEW" || row.type === "MATERIALIZED_VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION" || row.type === "TRIGGER" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY" || row.type === "TYPE" || row.type === "TYPE_BODY";
}

View File

@ -7,7 +7,8 @@ export type ObjectBrowserRow = {
name: string;
displayName: string;
schema?: string;
type: "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY";
type: "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY";
valid?: boolean | null;
signature?: string | null;
comment?: string | null;
created_at?: string | null;
@ -27,7 +28,10 @@ export function normalizeObjectBrowserType(type: string): ObjectBrowserRow["type
const value = type.toUpperCase();
const normalized = value.replace(/[\s-]+/g, "_");
if (normalized.includes("PACKAGE_BODY")) return "PACKAGE_BODY";
if (normalized.includes("TYPE_BODY")) return "TYPE_BODY";
if (normalized.includes("PACKAGE")) return "PACKAGE";
if (normalized.includes("TRIGGER")) return "TRIGGER";
if (normalized.includes("TYPE")) return "TYPE";
if (normalized.includes("MATERIALIZED_VIEW")) return "MATERIALIZED_VIEW";
if (value.includes("VIEW")) return "VIEW";
if (value.includes("SEQ")) return "SEQUENCE";
@ -57,6 +61,7 @@ export function buildObjectBrowserRows(options: { objects: ObjectInfo[]; databas
displayName,
schema,
type,
valid: object.valid,
signature,
comment: object.comment,
created_at: object.created_at,

View File

@ -504,7 +504,7 @@ export function buildSimpleObjectTreeNodes({ nodeId, connectionId, database, sch
for (const obj of objects) {
const objectType = normalizeObjectType(obj.object_type);
if (!["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE", "PACKAGE", "PACKAGE_BODY"].includes(objectType)) {
if (!["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY"].includes(objectType)) {
continue;
}
@ -540,6 +540,7 @@ export function buildSimpleObjectTreeNodes({ nodeId, connectionId, database, sch
objectName: name,
signature: signature || undefined,
comment: obj.comment,
valid: obj.valid ?? undefined,
connectionId,
database,
schema: childSchema,
@ -557,9 +558,12 @@ function simpleObjectNodeType(objectType: DatabaseObjectTreeKind): TreeNodeType
if (objectType === "MATERIALIZED_VIEW") return "materialized_view";
if (objectType === "PROCEDURE") return "procedure";
if (objectType === "FUNCTION") return "function";
if (objectType === "TRIGGER") return "trigger";
if (objectType === "SEQUENCE") return "sequence";
if (objectType === "PACKAGE_BODY") return "package-body";
if (objectType === "PACKAGE") return "package";
if (objectType === "TYPE_BODY") return "type-body";
if (objectType === "TYPE") return "type";
return "table";
}
@ -597,6 +601,13 @@ const groupDefs: Array<{
nodeType: "group-functions",
childType: "function",
},
{
key: "__triggers",
label: "tree.triggers",
objectTypes: ["TRIGGER"],
nodeType: "group-triggers",
childType: "trigger",
},
{
key: "__sequences",
label: "tree.sequences",
@ -611,9 +622,16 @@ const groupDefs: Array<{
nodeType: "group-packages",
childType: (objectType) => (objectType === "PACKAGE_BODY" ? "package-body" : "package"),
},
{
key: "__types",
label: "tree.types",
objectTypes: ["TYPE", "TYPE_BODY"],
nodeType: "group-types",
childType: (objectType) => (objectType === "TYPE_BODY" ? "type-body" : "type"),
},
];
const objectGroupNodeTypes = new Set<TreeNodeType>(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages"]);
const objectGroupNodeTypes = new Set<TreeNodeType>(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-sequences", "group-packages", "group-types"]);
export function buildObjectGroupPlaceholderNodes({ nodeId, connectionId, database, schema, objectTypes }: { nodeId: string; connectionId: string; database: string; schema?: string; objectTypes: DatabaseObjectTreeKind[] }): TreeNode[] {
const supported = new Set(objectTypes);
@ -676,12 +694,13 @@ export function buildGroupedObjectTreeNodes({ nodeId, connectionId, database, sc
const childSchema = obj.schema ? normalizeDatabaseObjectName(obj.schema) : schema;
const objectType = normalizeObjectType(obj.object_type);
const childType = typeof def.childType === "function" ? def.childType(objectType) : def.childType;
const objectTypeSuffix = objectType === "PACKAGE" || objectType === "PACKAGE_BODY" ? `:${objectType}` : "";
const objectTypeSuffix = objectType === "PACKAGE" || objectType === "PACKAGE_BODY" || objectType === "TYPE" || objectType === "TYPE_BODY" ? `:${objectType}` : "";
return {
id: `${nodeId}:${def.key}:${childSchema ? `${childSchema}:` : ""}${obj.name}${objectTypeSuffix}`,
label: obj.name,
type: childType,
comment: obj.comment,
valid: obj.valid ?? undefined,
connectionId,
database,
schema: childSchema,

View File

@ -377,12 +377,13 @@ export interface TableInfo {
parent_name?: string | null;
}
export type DatabaseObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY";
export type DatabaseObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY";
export interface ObjectInfo {
name: string;
object_type: DatabaseObjectType | string;
schema?: string | null;
valid?: boolean | null;
signature?: string | null;
comment?: string | null;
created_at?: string | null;
@ -398,7 +399,7 @@ export interface ObjectStatistics {
total_bytes?: number | null;
}
export type ObjectSourceKind = "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY";
export type ObjectSourceKind = "VIEW" | "MATERIALIZED_VIEW" | "PROCEDURE" | "FUNCTION" | "TRIGGER" | "SEQUENCE" | "PACKAGE" | "PACKAGE_BODY" | "TYPE" | "TYPE_BODY";
export interface ObjectSource {
name: string;
@ -616,6 +617,8 @@ export type TreeNodeType =
| "materialized_view"
| "procedure"
| "function"
| "type"
| "type-body"
| "sequence"
| "package"
| "package-body"
@ -628,6 +631,7 @@ export type TreeNodeType =
| "group-materialized-views"
| "group-procedures"
| "group-functions"
| "group-types"
| "group-sequences"
| "group-packages"
| "group-partitions"
@ -697,6 +701,7 @@ export interface TreeNode {
signature?: string;
tableType?: string;
comment?: string | null;
valid?: boolean | null;
objectCount?: number;
loadedKeyCount?: number;
totalKeyCount?: number;

View File

@ -40,6 +40,7 @@ pub async fn list_objects(pool: &MySqlPool, database: &str) -> Result<Vec<Object
name: table.name,
object_type: "TABLE".to_string(),
schema: Some(database.to_string()),
valid: None,
signature: None,
comment: table.comment,
created_at: None,
@ -100,6 +101,7 @@ fn plugin_object(
name: name.to_string(),
object_type: "FUNCTION".to_string(),
schema: Some(database.to_string()),
valid: None,
signature: None,
comment: if comment_parts.is_empty() { None } else { Some(comment_parts.join(", ")) },
created_at: None,

View File

@ -2148,6 +2148,7 @@ fn row_to_object(row: &mysql_async::Row, database: &str) -> ObjectInfo {
name: get_str_by_name(row, "object_name"),
object_type: get_str_by_name(row, "object_type"),
schema: Some(database.to_string()),
valid: None,
signature: None,
comment: get_opt_str(row, "object_comment")
.map(|s| fix_potential_double_encoding(&s))
@ -2283,6 +2284,7 @@ pub async fn list_table_objects_show(pool: &MySqlPool, database: &str) -> Result
name: table.name,
object_type: if table.table_type.eq_ignore_ascii_case("VIEW") { "VIEW" } else { "TABLE" }.to_string(),
schema: Some(database.to_string()),
valid: None,
signature: None,
comment: table.comment,
created_at: meta.and_then(|meta| meta.created_at.clone()),

View File

@ -104,6 +104,7 @@ pub async fn list_objects(pool: &mysql_async::Pool, schema: &str) -> Result<Vec<
name: get_str(row, 0),
object_type: get_str(row, 1),
schema: Some(schema.to_string()),
valid: None,
signature: None,
comment: None,
created_at: None,

View File

@ -2125,6 +2125,7 @@ pub async fn list_objects(pool: &Pool, schema: &str) -> Result<Vec<ObjectInfo>,
name: pg_row_try_string(row, 0),
object_type: pg_row_try_string(row, 1),
schema: Some(schema.to_string()),
valid: None,
comment: row.try_get::<_, Option<String>>(2).ok().flatten().filter(|s| !s.is_empty()),
created_at: row.try_get::<_, Option<String>>(3).ok().flatten().filter(|s| !s.is_empty()),
updated_at: row.try_get::<_, Option<String>>(4).ok().flatten().filter(|s| !s.is_empty()),

View File

@ -12,6 +12,7 @@ pub async fn list_objects(pool: &Pool, schema: &str) -> Result<Vec<ObjectInfo>,
name: t.name.clone(),
object_type: t.table_type.clone(),
schema: None,
valid: None,
signature: None,
comment: t.comment.clone(),
created_at: None,

View File

@ -1330,6 +1330,7 @@ pub async fn list_objects(client: &mut SqlServerClient, schema: &str) -> Result<
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
object_type: row.get::<&str, _>(1).unwrap_or("TABLE").to_string(),
schema: Some(schema.to_string()),
valid: None,
signature: None,
comment: row.get::<&str, _>(4).filter(|s: &&str| !s.is_empty()).map(|s: &str| s.to_string()),
created_at: row.get::<chrono::NaiveDateTime, _>(2).map(|value| value.to_string()),

View File

@ -320,9 +320,12 @@ fn object_type_keyword(object_type: &ObjectSourceKind) -> &'static str {
ObjectSourceKind::MaterializedView => "MATERIALIZED_VIEW",
ObjectSourceKind::Procedure => "PROCEDURE",
ObjectSourceKind::Function => "FUNCTION",
ObjectSourceKind::Trigger => "TRIGGER",
ObjectSourceKind::Sequence => "SEQUENCE",
ObjectSourceKind::Package => "PACKAGE",
ObjectSourceKind::PackageBody => "PACKAGE BODY",
ObjectSourceKind::Type => "TYPE",
ObjectSourceKind::TypeBody => "TYPE BODY",
}
}
@ -819,12 +822,18 @@ fn parse_object_source_kind(value: &str) -> Option<ObjectSourceKind> {
Some(ObjectSourceKind::Procedure)
} else if value.eq_ignore_ascii_case("FUNCTION") {
Some(ObjectSourceKind::Function)
} else if value.eq_ignore_ascii_case("TRIGGER") {
Some(ObjectSourceKind::Trigger)
} else if value.eq_ignore_ascii_case("SEQUENCE") {
Some(ObjectSourceKind::Sequence)
} else if value.eq_ignore_ascii_case("PACKAGE") {
Some(ObjectSourceKind::Package)
} else if value.eq_ignore_ascii_case("PACKAGE BODY") || value.eq_ignore_ascii_case("PACKAGE_BODY") {
Some(ObjectSourceKind::PackageBody)
} else if value.eq_ignore_ascii_case("TYPE") {
Some(ObjectSourceKind::Type)
} else if value.eq_ignore_ascii_case("TYPE BODY") || value.eq_ignore_ascii_case("TYPE_BODY") {
Some(ObjectSourceKind::TypeBody)
} else {
None
}
@ -1251,6 +1260,14 @@ mod tests {
assert_eq!(sql, "CREATE OR REPLACE PACKAGE BODY PAYROLL AS\nEND PAYROLL;");
}
#[test]
fn parses_programmable_metadata_object_kinds() {
assert_eq!(parse_object_source_kind("TRIGGER"), Some(ObjectSourceKind::Trigger));
assert_eq!(parse_object_source_kind("TYPE"), Some(ObjectSourceKind::Type));
assert_eq!(parse_object_source_kind("TYPE_BODY"), Some(ObjectSourceKind::TypeBody));
assert_eq!(parse_object_source_kind("PACKAGE BODY"), Some(ObjectSourceKind::PackageBody));
}
#[test]
fn postgres_procedure_rename_adds_drop_cleanup() {
let statements = build_executable_object_source_statements(input(

View File

@ -2219,6 +2219,7 @@ async fn external_driver_presto_like_objects(
name: table.name,
object_type: table.table_type,
schema: Some(schema.to_string()),
valid: None,
signature: None,
comment: table.comment,
created_at: None,
@ -2601,6 +2602,7 @@ mod tests {
name: name.to_string(),
object_type: object_type.to_string(),
schema: Some("app".to_string()),
valid: None,
signature: None,
comment: None,
created_at: None,
@ -3318,6 +3320,7 @@ mod tests {
name: "ORDERS".to_string(),
object_type: "TABLE".to_string(),
schema: Some("DBX_TEST".to_string()),
valid: None,
signature: None,
comment: None,
created_at: None,
@ -3329,6 +3332,7 @@ mod tests {
name: "ORDERS_VIEW".to_string(),
object_type: "VIEW".to_string(),
schema: Some("DBX_TEST".to_string()),
valid: None,
signature: None,
comment: None,
created_at: None,
@ -3340,6 +3344,7 @@ mod tests {
name: "REFRESH_ORDERS".to_string(),
object_type: "PROCEDURE".to_string(),
schema: Some("DBX_TEST".to_string()),
valid: None,
signature: None,
comment: None,
created_at: None,
@ -3763,6 +3768,7 @@ async fn list_objects_once(
name: table.name,
object_type: table.table_type,
schema: None,
valid: None,
signature: None,
comment: table.comment,
created_at: None,
@ -3940,6 +3946,7 @@ async fn list_objects_once(
name: table.name,
object_type: table.table_type,
schema: if schema.is_empty() { None } else { Some(schema.to_string()) },
valid: None,
signature: None,
comment: table.comment,
created_at: None,
@ -4920,9 +4927,12 @@ fn sqlite_object_type(kind: &db::ObjectSourceKind) -> &'static str {
db::ObjectSourceKind::View | db::ObjectSourceKind::MaterializedView => "view",
db::ObjectSourceKind::Procedure
| db::ObjectSourceKind::Function
| db::ObjectSourceKind::Trigger
| db::ObjectSourceKind::Sequence
| db::ObjectSourceKind::Package
| db::ObjectSourceKind::PackageBody => "routine",
| db::ObjectSourceKind::PackageBody
| db::ObjectSourceKind::Type
| db::ObjectSourceKind::TypeBody => "routine",
}
}
@ -4931,9 +4941,12 @@ fn sqlserver_object_type_filter(kind: &db::ObjectSourceKind) -> &'static str {
db::ObjectSourceKind::View => "'V'",
db::ObjectSourceKind::Procedure => "'P'",
db::ObjectSourceKind::Function => "'FN','IF','TF','FS','FT'",
db::ObjectSourceKind::Trigger => "'TR'",
db::ObjectSourceKind::Sequence
| db::ObjectSourceKind::Package
| db::ObjectSourceKind::PackageBody
| db::ObjectSourceKind::Type
| db::ObjectSourceKind::TypeBody
| db::ObjectSourceKind::MaterializedView => "''",
}
}
@ -5055,7 +5068,11 @@ fn postgres_object_source_sql_inner(
sql_string(name)
)
}
db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody => "SELECT NULL WHERE FALSE".to_string(),
db::ObjectSourceKind::Trigger
| db::ObjectSourceKind::Package
| db::ObjectSourceKind::PackageBody
| db::ObjectSourceKind::Type
| db::ObjectSourceKind::TypeBody => "SELECT NULL WHERE FALSE".to_string(),
}
}
@ -5065,9 +5082,12 @@ pub fn oracle_object_source_sql(schema: &str, name: &str, kind: &db::ObjectSourc
db::ObjectSourceKind::MaterializedView => "MATERIALIZED_VIEW",
db::ObjectSourceKind::Procedure => "PROCEDURE",
db::ObjectSourceKind::Function => "FUNCTION",
db::ObjectSourceKind::Trigger => "TRIGGER",
db::ObjectSourceKind::Sequence => "SEQUENCE",
db::ObjectSourceKind::Package => "PACKAGE",
db::ObjectSourceKind::PackageBody => "PACKAGE_BODY",
db::ObjectSourceKind::Type => "TYPE",
db::ObjectSourceKind::TypeBody => "TYPE_BODY",
};
if schema.trim().is_empty() {
format!("SELECT DBMS_METADATA.GET_DDL({}, {}) FROM DUAL", sql_string(object_type), sql_string(name))
@ -5095,9 +5115,12 @@ pub fn mysql_object_source_sql(database: &str, name: &str, kind: &db::ObjectSour
db::ObjectSourceKind::View => format!("SHOW CREATE VIEW {qualified_name}"),
db::ObjectSourceKind::Procedure => format!("SHOW CREATE PROCEDURE {qualified_name}"),
db::ObjectSourceKind::Function => format!("SHOW CREATE FUNCTION {qualified_name}"),
db::ObjectSourceKind::Sequence
db::ObjectSourceKind::Trigger
| db::ObjectSourceKind::Sequence
| db::ObjectSourceKind::Package
| db::ObjectSourceKind::PackageBody
| db::ObjectSourceKind::Type
| db::ObjectSourceKind::TypeBody
| db::ObjectSourceKind::MaterializedView => String::new(),
}
}
@ -5313,6 +5336,7 @@ async fn oracle_agent_list_objects(
name,
object_type,
schema,
valid: None,
signature: None,
comment: None,
created_at: None,

View File

@ -235,6 +235,7 @@ mod tests {
name: "orders".to_string(),
object_type: "BASE TABLE".to_string(),
schema: None,
valid: None,
signature: None,
comment: None,
created_at: None,
@ -246,6 +247,7 @@ mod tests {
name: "active_orders".to_string(),
object_type: "MATERIALIZED_VIEW".to_string(),
schema: None,
valid: None,
signature: None,
comment: None,
created_at: None,
@ -257,6 +259,7 @@ mod tests {
name: "payroll".to_string(),
object_type: "PACKAGE BODY".to_string(),
schema: None,
valid: None,
signature: None,
comment: None,
created_at: None,
@ -308,6 +311,7 @@ mod tests {
name: "ORDERS".to_string(),
object_type: "TABLE".to_string(),
schema: Some("HR".to_string()),
valid: None,
signature: None,
comment: None,
created_at: None,
@ -319,6 +323,7 @@ mod tests {
name: "bin$deleted".to_string(),
object_type: "TABLE".to_string(),
schema: Some("HR".to_string()),
valid: None,
signature: None,
comment: None,
created_at: None,

View File

@ -4594,6 +4594,9 @@ where
db::ObjectSourceKind::Sequence | db::ObjectSourceKind::Package | db::ObjectSourceKind::PackageBody => {
object.source.clone()
}
db::ObjectSourceKind::Trigger | db::ObjectSourceKind::Type | db::ObjectSourceKind::TypeBody => {
object.source.clone()
}
};
let statements = build_executable_object_source_statements(EditableObjectSourceSqlInput {
database_type: DatabaseType::Postgres,

View File

@ -62,6 +62,8 @@ pub struct ObjectInfo {
pub object_type: String,
pub schema: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
pub comment: Option<String>,
pub created_at: Option<String>,
@ -93,9 +95,12 @@ pub enum ObjectSourceKind {
MaterializedView,
Procedure,
Function,
Trigger,
Sequence,
Package,
PackageBody,
Type,
TypeBody,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -310,3 +315,18 @@ pub struct OwnerInfo {
pub object_type: String,
pub owner: String,
}
#[cfg(test)]
mod tests {
use super::ObjectInfo;
#[test]
fn list_objects_payload_preserves_optional_validity() {
let objects: Vec<ObjectInfo> =
serde_json::from_str(r#"[{"name":"TRG_AUDIT","object_type":"TRIGGER","schema":"APP","valid":false}]"#)
.unwrap();
assert_eq!(objects[0].valid, Some(false));
assert_eq!(objects[0].object_type, "TRIGGER");
}
}

View File

@ -194,6 +194,7 @@ pub async fn list_objects(
name: table.name,
object_type: table.table_type,
schema: Some(database.to_string()),
valid: None,
signature: None,
comment: table.comment,
created_at: None,

View File

@ -211,6 +211,7 @@ pub async fn list_objects(
name: table.name,
object_type: table.table_type,
schema: Some(database.clone()),
valid: None,
signature: None,
comment: table.comment,
created_at: None,