feat(xugu): expose routine parameter metadata

This commit is contained in:
Elias authored and GitHub committed 2026-08-08 10:37:59 +08:00
1 parent bdf87ac255
commit 159e813c9c
8 files changed
+633 -23

No files matched your search

@@ -9,9 +9,11 @@ import { copyToClipboard } from "@/lib/common/clipboard";
import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { buildEditableObjectSource, buildExecutableObjectSourceStatements, executeObjectSourceSave, formatObjectSourceSaveError } from "@/lib/table/objectSourceEditor";
import { loadObjectSourceWithRoutineFallback } from "@/lib/table/objectSourceLoad";
import { xuguRoutineMetadataFromDefinition, type XuguRoutineMetadata } from "@/lib/table/routineParameters";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import * as api from "@/lib/backend/api";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import RoutineMetadataPanel from "@/components/objects/RoutineMetadataPanel.vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { DatabaseType, ObjectSourceKind } from "@/types/database";
@@ -55,12 +57,14 @@ const editing = ref(false);
const sourceEditable = ref(true);
const error = ref("");
const saveError = ref("");
const routineMetadata = ref<XuguRoutineMetadata | null>(null);
/** May differ from props.objectType after PROCEDURE/FUNCTION/PACKAGE fallback resolution. */
const resolvedObjectType = ref<ObjectSourceKind>(props.objectType);
let loadSerial = 0;
const canEdit = computed(() => sourceEditable.value && props.objectType !== "SEQUENCE");
const title = computed(() => `${editing.value ? t("contextMenu.editView") : t("contextMenu.viewSource")} - ${props.name}`);
const hasRoutineMetadata = computed(() => !!routineMetadata.value && (routineMetadata.value.parameters.length > 0 || !!routineMetadata.value.returnType));
watch(
() => [props.open, props.connectionId, props.database, props.schema, props.name, props.relationName, props.signature, props.objectType, props.initialEditing] as const,
@@ -77,6 +81,7 @@ async function loadSource(nextEditing = props.initialEditing && canEdit.value) {
draft.value = "";
error.value = "";
saveError.value = "";
routineMetadata.value = null;
sourceEditable.value = true;
editing.value = false;
loading.value = true;
@@ -94,6 +99,7 @@ async function loadSource(nextEditing = props.initialEditing && canEdit.value) {
});
if (serial !== loadSerial) return;
resolvedObjectType.value = resolvedType;
routineMetadata.value = props.databaseType === "xugu" && (resolvedType === "PROCEDURE" || resolvedType === "FUNCTION") ? xuguRoutineMetadataFromDefinition(result.source) : null;
sourceEditable.value = editableAllowed;
const formatted = await formatSqlForDisplay(editable, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
editableText.value = editable;
@@ -221,22 +227,24 @@ function closeDialog() {
{{ saveError }}
</div>
</div>
<QueryEditor
v-else
:key="`${props.connectionId}:${props.database}:${props.schema || ''}:${props.name}:${props.objectType}`"
:model-value="content"
class="object-source-dialog-editor min-h-0 overflow-hidden rounded border"
:connection-id="props.connectionId"
:database="props.database"
:schema="props.schema || props.database"
:database-type="props.databaseType"
:dialect="props.dialect"
:format-dialect="props.formatDialect"
force-word-wrap
read-only
hide-execution-controls
data-object-source-preview
/>
<div v-else class="flex min-h-0 flex-col gap-3 overflow-hidden">
<RoutineMetadataPanel v-if="hasRoutineMetadata && routineMetadata" :parameters="routineMetadata.parameters" :return-type="routineMetadata.returnType" />
<QueryEditor
:key="`${props.connectionId}:${props.database}:${props.schema || ''}:${props.name}:${props.objectType}`"
:model-value="content"
class="object-source-dialog-editor min-h-0 flex-1 overflow-hidden rounded border"
:connection-id="props.connectionId"
:database="props.database"
:schema="props.schema || props.database"
:database-type="props.databaseType"
:dialect="props.dialect"
:format-dialect="props.formatDialect"
force-word-wrap
read-only
hide-execution-controls
data-object-source-preview
/>
</div>
<DialogFooter>
<Button variant="outline" @click="closeDialog">{{ t("common.close") }}</Button>
@@ -52,7 +52,8 @@ const generatedSql = computed(() => {
});
const inputParameterCount = computed(() => parameters.value.filter(acceptsRoutineInput).length);
const outputParameterCount = computed(() => parameters.value.filter((parameter) => parameter.mode === "OUT").length);
const outputParameterCount = computed(() => parameters.value.filter((parameter) => parameter.mode === "OUT" || (props.databaseType === "xugu" && parameter.mode === "INOUT")).length);
const isXugu = computed(() => props.databaseType === "xugu");
watch(
() => [open.value, props.connectionId, props.database, props.databaseType, props.schema, props.routineName] as const,
@@ -134,6 +135,11 @@ function execute() {
function canEditParameter(parameter: RoutineParameterValue): boolean {
return acceptsRoutineInput(parameter);
}
function displayParameterDefault(parameter: RoutineParameterValue): string {
if (!parameter.hasDefault) return "-";
return parameter.defaultValue?.trim() || "DEFAULT";
}
</script>
<template>
@@ -169,8 +175,11 @@ function canEditParameter(parameter: RoutineParameterValue): boolean {
</div>
<div v-else-if="parameters.length" class="overflow-x-auto rounded-md border bg-background">
<div class="min-w-[650px]">
<div class="grid grid-cols-[minmax(120px,1.2fr)_minmax(96px,1fr)_72px_minmax(160px,1.5fr)_64px_86px] border-b bg-muted px-3 py-2 text-xs font-medium text-muted-foreground">
<div :class="isXugu ? 'min-w-[720px]' : 'min-w-[650px]'">
<div
class="grid border-b bg-muted px-3 py-2 text-xs font-medium text-muted-foreground"
:class="isXugu ? 'grid-cols-[minmax(120px,1.2fr)_minmax(96px,1fr)_72px_minmax(150px,1.4fr)_56px_minmax(130px,1.1fr)]' : 'grid-cols-[minmax(120px,1.2fr)_minmax(96px,1fr)_72px_minmax(160px,1.5fr)_64px_86px]'"
>
<div>{{ t("contextMenu.parameterName") }}</div>
<div>{{ t("contextMenu.parameterType") }}</div>
<div>{{ t("contextMenu.parameterMode") }}</div>
@@ -185,13 +194,22 @@ function canEditParameter(parameter: RoutineParameterValue): boolean {
</LightTooltip>
</div>
</div>
<div v-for="parameter in parameters" :key="`${parameter.ordinal}:${parameter.name}`" class="grid grid-cols-[minmax(120px,1.2fr)_minmax(96px,1fr)_72px_minmax(160px,1.5fr)_64px_86px] items-center gap-2 border-b px-3 py-2 text-sm last:border-b-0">
<div
v-for="parameter in parameters"
:key="`${parameter.ordinal}:${parameter.name}`"
class="grid items-center gap-2 border-b px-3 py-2 text-sm last:border-b-0"
:class="isXugu ? 'grid-cols-[minmax(120px,1.2fr)_minmax(96px,1fr)_72px_minmax(150px,1.4fr)_56px_minmax(130px,1.1fr)]' : 'grid-cols-[minmax(120px,1.2fr)_minmax(96px,1fr)_72px_minmax(160px,1.5fr)_64px_86px]'"
>
<div class="min-w-0 truncate font-medium">{{ parameter.name }}</div>
<div class="min-w-0 truncate text-muted-foreground">{{ parameter.dataType || "-" }}</div>
<div class="text-xs text-muted-foreground">{{ parameter.mode }}</div>
<Input v-model="parameter.value" class="h-8 bg-background font-mono text-xs" :disabled="!canEditParameter(parameter) || parameter.useNull || parameter.useDefault" :placeholder="canEditParameter(parameter) ? t('contextMenu.parameterValuePlaceholder') : t('contextMenu.outputOnly')" />
<input type="checkbox" class="h-4 w-4 accent-primary" :checked="!!parameter.useNull" :disabled="!canEditParameter(parameter) || parameter.useDefault" @change="(event: Event) => (parameter.useNull = (event.target as HTMLInputElement).checked)" />
<input type="checkbox" class="h-4 w-4 accent-primary" :checked="!!parameter.useDefault" :disabled="!canEditParameter(parameter) || !parameter.hasDefault || parameter.useNull" @change="(event: Event) => (parameter.useDefault = (event.target as HTMLInputElement).checked)" />
<label v-if="isXugu" class="flex min-w-0 items-center gap-2" :title="displayParameterDefault(parameter)">
<input type="checkbox" class="h-4 w-4 shrink-0 accent-primary" :checked="!!parameter.useDefault" :disabled="!canEditParameter(parameter) || !parameter.hasDefault || parameter.useNull" @change="(event: Event) => (parameter.useDefault = (event.target as HTMLInputElement).checked)" />
<span class="truncate font-mono text-xs text-muted-foreground">{{ displayParameterDefault(parameter) }}</span>
</label>
<input v-else type="checkbox" class="h-4 w-4 accent-primary" :checked="!!parameter.useDefault" :disabled="!canEditParameter(parameter) || !parameter.hasDefault || parameter.useNull" @change="(event: Event) => (parameter.useDefault = (event.target as HTMLInputElement).checked)" />
</div>
</div>
</div>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import type { RoutineParameter } from "@/lib/table/routineExecutionSql";
defineProps<{
parameters: RoutineParameter[];
returnType?: string;
}>();
const { t } = useI18n();
function defaultLabel(parameter: RoutineParameter): string {
if (!parameter.hasDefault) return "-";
return parameter.defaultValue?.trim() || "DEFAULT";
}
</script>
<template>
<div class="max-h-52 shrink-0 overflow-auto rounded border bg-background" data-routine-metadata>
<div v-if="returnType" class="flex items-center gap-3 border-b bg-muted/50 px-3 py-2 text-xs" data-routine-return-type>
<span class="font-semibold text-muted-foreground">RETURN</span>
<span class="font-mono">{{ returnType }}</span>
</div>
<div v-if="parameters.length" class="min-w-[620px]">
<div class="grid grid-cols-[minmax(140px,1.2fr)_minmax(150px,1.3fr)_80px_minmax(150px,1.3fr)] border-b bg-muted px-3 py-2 text-xs font-medium text-muted-foreground">
<div>{{ t("contextMenu.parameterName") }}</div>
<div>{{ t("contextMenu.parameterType") }}</div>
<div>{{ t("contextMenu.parameterMode") }}</div>
<div>{{ t("contextMenu.parameterDefault") }}</div>
</div>
<div v-for="parameter in parameters" :key="`${parameter.ordinal}:${parameter.name}`" class="grid grid-cols-[minmax(140px,1.2fr)_minmax(150px,1.3fr)_80px_minmax(150px,1.3fr)] gap-2 border-b px-3 py-2 text-xs last:border-b-0" data-routine-parameter>
<div class="truncate font-medium" :title="parameter.name">{{ parameter.name }}</div>
<div class="truncate font-mono text-muted-foreground" :title="parameter.dataType">{{ parameter.dataType }}</div>
<div class="text-muted-foreground">{{ parameter.mode }}</div>
<div class="truncate font-mono text-muted-foreground" :title="defaultLabel(parameter)">
{{ defaultLabel(parameter) }}
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { createApp, type App } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import i18n from "@/i18n";
import RoutineMetadataPanel from "@/components/objects/RoutineMetadataPanel.vue";
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
});
describe("RoutineMetadataPanel", () => {
it("renders parameter modes, defaults, and a function return type", () => {
const host = document.createElement("div");
document.body.append(host);
const app = createApp(RoutineMetadataPanel, {
returnType: "NUMERIC(12,3)",
parameters: [
{ name: "p_amount", dataType: "NUMERIC(10,2)", mode: "IN", ordinal: 1, hasDefault: false },
{ name: "p_rate", dataType: "NUMERIC(5,2)", mode: "INOUT", ordinal: 2, hasDefault: true, defaultValue: "0.10" },
{ name: "p_status", dataType: "VARCHAR(20)", mode: "OUT", ordinal: 3, hasDefault: false },
],
});
app.use(i18n);
app.mount(host);
mountedApps.push({ app, host });
expect(host.querySelector("[data-routine-return-type]")?.textContent).toContain("NUMERIC(12,3)");
const rows = [...host.querySelectorAll("[data-routine-parameter]")];
expect(rows).toHaveLength(3);
expect(rows[0].textContent).toContain("p_amount");
expect(rows[1].textContent).toContain("INOUT");
expect(rows[1].textContent).toContain("0.10");
expect(rows[2].textContent).toContain("OUT");
});
it("shows return metadata without rendering an empty parameter table", () => {
const host = document.createElement("div");
document.body.append(host);
const app = createApp(RoutineMetadataPanel, { returnType: "INTEGER", parameters: [] });
app.use(i18n);
app.mount(host);
mountedApps.push({ app, host });
expect(host.querySelector("[data-routine-return-type]")?.textContent).toContain("INTEGER");
expect(host.querySelectorAll("[data-routine-parameter]")).toHaveLength(0);
});
});