feat(xugu): expose routine parameter metadata
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user