feat(xugu): expose programmable object metadata

This commit is contained in:
Elias authored and GitHub committed 2026-07-17 17:53:44 +08:00
1 parent d3887c9936
commit 08cd8903f4
36 files changed
+431 -62

No files matched your search

@@ -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");
+16
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({
@@ -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";
@@ -9,6 +9,8 @@ const leafTypes: Set<TreeNodeType> = new Set([
"function",
"package",
"package-body",
"type",
"type-body",
"object-browser",
"redis-db",
"mq-tenant",
@@ -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;
}
@@ -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;
}
/**
* 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 === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY";
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";
}
@@ -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,
+22 -3
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,