feat(xugu): expose routine parameter metadata
This commit is contained in:
parent
bdf87ac255
commit
159e813c9c
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildProcedureExecutionSqlFromValues } from "@/lib/table/routineExecutionSql";
|
||||
import { routineParametersFromResult, routineParametersQuery } from "@/lib/table/routineParameters";
|
||||
import { routineParametersFromResult, routineParametersQuery, supportsRoutineParameterMetadata, xuguRoutineMetadataFromDefinition } from "@/lib/table/routineParameters";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
function queryResult(columns: string[], rows: unknown[][]): QueryResult {
|
||||
|
|
@ -153,3 +153,128 @@ describe("SQL Server routine execution SQL", () => {
|
|||
expect(metadataSql).toContain("p.scale AS scale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("XuguDB routine parameter metadata", () => {
|
||||
it("parses modes, nested types, defaults, comments, and quoted identifiers", () => {
|
||||
const metadata = xuguRoutineMetadataFromDefinition(`
|
||||
CREATE OR REPLACE PROCEDURE "AppSchema"."MixedCaseProcedure" (
|
||||
"p_required" IN INTEGER,
|
||||
p_text VARCHAR(50) DEFAULT 'a,b -- literal',
|
||||
p_amount IN /* mode separator */ OUT NUMERIC(12, 3) := -1.250,
|
||||
p_result OUT VARCHAR(100),
|
||||
p_comment IN VARCHAR(40) DEFAULT '/* literal */'
|
||||
) AS
|
||||
BEGIN
|
||||
NULL;
|
||||
END;`);
|
||||
|
||||
expect(metadata.kind).toBe("PROCEDURE");
|
||||
expect(metadata.returnType).toBeUndefined();
|
||||
expect(metadata.parameters).toEqual([
|
||||
{ name: "p_required", dataType: "INTEGER", mode: "IN", ordinal: 1, hasDefault: false, defaultValue: undefined },
|
||||
{ name: "p_text", dataType: "VARCHAR(50)", mode: "IN", ordinal: 2, hasDefault: true, defaultValue: "'a,b -- literal'" },
|
||||
{ name: "p_amount", dataType: "NUMERIC(12, 3)", mode: "INOUT", ordinal: 3, hasDefault: true, defaultValue: "-1.250" },
|
||||
{ name: "p_result", dataType: "VARCHAR(100)", mode: "OUT", ordinal: 4, hasDefault: false, defaultValue: undefined },
|
||||
{ name: "p_comment", dataType: "VARCHAR(40)", mode: "IN", ordinal: 5, hasDefault: true, defaultValue: "'/* literal */'" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses function parameters and its return type", () => {
|
||||
const metadata = xuguRoutineMetadataFromDefinition(`
|
||||
CREATE OR REPLACE FUNCTION calculate_total(
|
||||
p_amount IN NUMERIC(10,2),
|
||||
p_rate NUMERIC(5,2) DEFAULT 0.10
|
||||
) RETURN NUMERIC(12,3)
|
||||
AS
|
||||
BEGIN
|
||||
RETURN p_amount * p_rate;
|
||||
END;`);
|
||||
|
||||
expect(metadata).toEqual({
|
||||
kind: "FUNCTION",
|
||||
returnType: "NUMERIC(12,3)",
|
||||
parameters: [
|
||||
{ name: "p_amount", dataType: "NUMERIC(10,2)", mode: "IN", ordinal: 1, hasDefault: false, defaultValue: undefined },
|
||||
{ name: "p_rate", dataType: "NUMERIC(5,2)", mode: "IN", ordinal: 2, hasDefault: true, defaultValue: "0.10" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles routines without parentheses and fails closed for malformed headers", () => {
|
||||
expect(xuguRoutineMetadataFromDefinition("CREATE FUNCTION ping RETURN INTEGER AS BEGIN RETURN 1; END;")).toEqual({ kind: "FUNCTION", parameters: [], returnType: "INTEGER" });
|
||||
expect(xuguRoutineMetadataFromDefinition("CREATE PROCEDURE broken(p_value IN INTEGER AS BEGIN NULL; END;")).toEqual({ kind: "PROCEDURE", parameters: [] });
|
||||
expect(xuguRoutineMetadataFromDefinition("CREATE FUNCTION broken(p_value INTEGER) AS BEGIN RETURN p_value; END;")).toEqual({
|
||||
kind: "FUNCTION",
|
||||
parameters: [{ name: "p_value", dataType: "INTEGER", mode: "IN", ordinal: 1, hasDefault: false, defaultValue: undefined }],
|
||||
returnType: undefined,
|
||||
});
|
||||
expect(xuguRoutineMetadataFromDefinition("CREATE PROCEDURE broken(p_value VARCHAR DEFAULT 'unterminated) AS BEGIN NULL; END;")).toEqual({ parameters: [] });
|
||||
expect(xuguRoutineMetadataFromDefinition("CREATE PROCEDURE broken(/* unterminated")).toEqual({ parameters: [] });
|
||||
expect(xuguRoutineMetadataFromDefinition("SELECT 'PROCEDURE fake(p INT)' FROM dual")).toEqual({ parameters: [] });
|
||||
});
|
||||
|
||||
it("supports INOUT spelling, escaped names, and equals defaults", () => {
|
||||
expect(xuguRoutineMetadataFromDefinition('CREATE PROCEDURE p("p""name" INOUT DECIMAL(9,2) = COALESCE(-1, 0)) AS BEGIN NULL; END;').parameters).toEqual([
|
||||
{
|
||||
name: 'p"name',
|
||||
dataType: "DECIMAL(9,2)",
|
||||
mode: "INOUT",
|
||||
ordinal: 1,
|
||||
hasDefault: true,
|
||||
defaultValue: "COALESCE(-1, 0)",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("enables source-backed metadata without generating an ALL_ARGUMENTS query", () => {
|
||||
expect(supportsRoutineParameterMetadata("xugu")).toBe(true);
|
||||
expect(routineParametersQuery({ database: "sample", databaseType: "xugu", schema: "app", routineName: "save_value" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("XuguDB procedure execution SQL", () => {
|
||||
it("supplies OUT arguments and preserves INOUT input values", () => {
|
||||
const sql = buildProcedureExecutionSqlFromValues({
|
||||
databaseType: "xugu",
|
||||
schema: "AppSchema",
|
||||
routineName: "adjust_amount",
|
||||
parameters: [
|
||||
{ name: "p_input", dataType: "INTEGER", mode: "IN", ordinal: 1, value: "5" },
|
||||
{ name: "p_amount", dataType: "NUMERIC(12,3)", mode: "INOUT", ordinal: 2, value: "2.500" },
|
||||
{ name: "p_status", dataType: "VARCHAR(20)", mode: "OUT", ordinal: 3, value: "ignored" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(sql).toBe('CALL "AppSchema"."adjust_amount"(5, 2.500, NULL);');
|
||||
});
|
||||
|
||||
it("uses named notation when a middle default is omitted", () => {
|
||||
const sql = buildProcedureExecutionSqlFromValues({
|
||||
databaseType: "xugu",
|
||||
schema: "AppSchema",
|
||||
routineName: "save_value",
|
||||
parameters: [
|
||||
{ name: "p_required", dataType: "INTEGER", mode: "IN", ordinal: 1, value: "1" },
|
||||
{ name: "p_label", dataType: "VARCHAR(20)", mode: "IN", ordinal: 2, value: "unused", hasDefault: true, useDefault: true },
|
||||
{ name: "p_amount", dataType: "NUMERIC(12,3)", mode: "INOUT", ordinal: 3, value: "2.500" },
|
||||
{ name: "p_result", dataType: "VARCHAR(20)", mode: "OUT", ordinal: 4, value: "" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(sql).toBe('CALL "AppSchema"."save_value"("p_required" => 1, "p_amount" => 2.500, "p_result" => NULL);');
|
||||
});
|
||||
|
||||
it("omits trailing defaults positionally and escapes string values", () => {
|
||||
const sql = buildProcedureExecutionSqlFromValues({
|
||||
databaseType: "xugu",
|
||||
schema: "AppSchema",
|
||||
routineName: "save_label",
|
||||
parameters: [
|
||||
{ name: "p_label", dataType: "VARCHAR(30)", mode: "IN", ordinal: 1, value: "O'Reilly" },
|
||||
{ name: "p_enabled", dataType: "BOOLEAN", mode: "IN", ordinal: 2, value: "true", hasDefault: true, useDefault: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect(sql).toBe("CALL \"AppSchema\".\"save_label\"('O''Reilly');");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
executeQuery: vi.fn(),
|
||||
getObjectSource: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => apiMocks);
|
||||
|
||||
import { loadRoutineParameters } from "@/lib/table/routineParameters";
|
||||
|
||||
describe("XuguDB routine parameter loading", () => {
|
||||
beforeEach(() => {
|
||||
apiMocks.executeQuery.mockReset();
|
||||
apiMocks.getObjectSource.mockReset();
|
||||
});
|
||||
|
||||
it("loads the procedure definition on demand and parses its parameters", async () => {
|
||||
apiMocks.getObjectSource.mockResolvedValue({
|
||||
name: "save_value",
|
||||
object_type: "PROCEDURE",
|
||||
schema: "AppSchema",
|
||||
source: "CREATE PROCEDURE save_value(p_id IN INTEGER, p_message OUT VARCHAR(100)) AS BEGIN NULL; END;",
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadRoutineParameters({
|
||||
connectionId: "connection-1",
|
||||
database: "sample",
|
||||
databaseType: "xugu",
|
||||
schema: "AppSchema",
|
||||
routineName: "save_value",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{ name: "p_id", dataType: "INTEGER", mode: "IN", ordinal: 1, hasDefault: false, defaultValue: undefined },
|
||||
{ name: "p_message", dataType: "VARCHAR(100)", mode: "OUT", ordinal: 2, hasDefault: false, defaultValue: undefined },
|
||||
]);
|
||||
|
||||
expect(apiMocks.getObjectSource).toHaveBeenCalledWith("connection-1", "sample", "AppSchema", "save_value", "PROCEDURE");
|
||||
expect(apiMocks.executeQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the selected database as the schema fallback", async () => {
|
||||
apiMocks.getObjectSource.mockResolvedValue({ name: "ping", object_type: "PROCEDURE", source: "CREATE PROCEDURE ping AS BEGIN NULL; END;" });
|
||||
|
||||
await loadRoutineParameters({ connectionId: "connection-1", database: "sample", databaseType: "xugu", routineName: "ping" });
|
||||
|
||||
expect(apiMocks.getObjectSource).toHaveBeenCalledWith("connection-1", "sample", "sample", "ping", "PROCEDURE");
|
||||
});
|
||||
});
|
||||
|
|
@ -47,6 +47,9 @@ export function buildProcedureExecutionSqlFromValues(options: BuildRoutineExecut
|
|||
if (options.databaseType === "mysql") {
|
||||
return buildMySqlProcedureExecutionSql(routine, sortedParameters);
|
||||
}
|
||||
if (options.databaseType === "xugu") {
|
||||
return buildXuguProcedureExecutionSql(routine, sortedParameters);
|
||||
}
|
||||
const values = sortedParameters.filter((parameter) => shouldIncludeParameter(parameter));
|
||||
const useNamedArguments = shouldUseNamedArguments(options.databaseType, sortedParameters);
|
||||
if (options.databaseType === "oracle" || options.databaseType === "dameng" || options.databaseType === "oceanbase-oracle") {
|
||||
|
|
@ -58,6 +61,30 @@ export function buildProcedureExecutionSqlFromValues(options: BuildRoutineExecut
|
|||
return `CALL ${routine}(${values.map((parameter) => routineArgumentSql(options.databaseType, parameter, useNamedArguments)).join(", ")});`;
|
||||
}
|
||||
|
||||
function buildXuguProcedureExecutionSql(routine: string, sortedParameters: RoutineParameterValue[]): string {
|
||||
const callableParameters = sortedParameters.filter((parameter) => parameter.mode !== "RETURN");
|
||||
const useNamedArguments = shouldUseXuguNamedArguments(callableParameters);
|
||||
const args = callableParameters.flatMap((parameter) => {
|
||||
if (parameter.useDefault && parameter.hasDefault && acceptsRoutineInput(parameter)) return [];
|
||||
const value = parameter.mode === "OUT" ? "NULL" : routineParameterSqlValue("xugu", parameter);
|
||||
if (!useNamedArguments) return [value];
|
||||
return [`${quoteTableIdentifier("xugu", parameter.name)} => ${value}`];
|
||||
});
|
||||
return `CALL ${routine}(${args.join(", ")});`;
|
||||
}
|
||||
|
||||
function shouldUseXuguNamedArguments(parameters: RoutineParameterValue[]): boolean {
|
||||
let omittedDefault = false;
|
||||
for (const parameter of parameters) {
|
||||
if (parameter.useDefault && parameter.hasDefault && acceptsRoutineInput(parameter)) {
|
||||
omittedDefault = true;
|
||||
continue;
|
||||
}
|
||||
if (omittedDefault) return parameters.every((item) => !!item.name);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldIncludeParameter(parameter: RoutineParameterValue): boolean {
|
||||
if (parameter.useDefault && parameter.hasDefault) return false;
|
||||
return acceptsRoutineInput(parameter);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ export interface LoadRoutineParametersOptions {
|
|||
}
|
||||
|
||||
export async function loadRoutineParameters(options: LoadRoutineParametersOptions): Promise<RoutineParameter[]> {
|
||||
if (options.databaseType === "xugu") {
|
||||
const source = await api.getObjectSource(options.connectionId, options.database, options.schema || options.database, options.routineName, "PROCEDURE");
|
||||
return xuguRoutineMetadataFromDefinition(source.source).parameters;
|
||||
}
|
||||
const sql = routineParametersQuery(options);
|
||||
if (!sql) return [];
|
||||
const result = await api.executeQuery(options.connectionId, options.database, sql, options.schema, undefined, {
|
||||
|
|
@ -21,7 +25,18 @@ export async function loadRoutineParameters(options: LoadRoutineParametersOption
|
|||
}
|
||||
|
||||
export function supportsRoutineParameterMetadata(databaseType?: DatabaseType): boolean {
|
||||
return databaseType === "postgres" || databaseType === "mysql" || databaseType === "doris" || databaseType === "starrocks" || databaseType === "sqlserver" || databaseType === "oracle" || databaseType === "dameng" || databaseType === "oceanbase-oracle" || databaseType === "databend";
|
||||
return (
|
||||
databaseType === "postgres" ||
|
||||
databaseType === "mysql" ||
|
||||
databaseType === "doris" ||
|
||||
databaseType === "starrocks" ||
|
||||
databaseType === "sqlserver" ||
|
||||
databaseType === "oracle" ||
|
||||
databaseType === "dameng" ||
|
||||
databaseType === "oceanbase-oracle" ||
|
||||
databaseType === "databend" ||
|
||||
databaseType === "xugu"
|
||||
);
|
||||
}
|
||||
|
||||
export function routineParametersQuery(options: Pick<LoadRoutineParametersOptions, "database" | "databaseType" | "schema" | "routineName">): string | null {
|
||||
|
|
@ -127,6 +142,279 @@ ORDER BY SEQUENCE;`.trim();
|
|||
return null;
|
||||
}
|
||||
|
||||
export interface XuguRoutineMetadata {
|
||||
kind?: "PROCEDURE" | "FUNCTION";
|
||||
parameters: RoutineParameter[];
|
||||
returnType?: string;
|
||||
}
|
||||
|
||||
interface XuguRoutineToken {
|
||||
kind: "word" | "quoted-identifier" | "string" | "symbol";
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* XuguDB does not expose ALL_ARGUMENTS on every supported server version.
|
||||
* Its DBeaver extension therefore parses ALL_PROCEDURES.DEFINE as well. Keep
|
||||
* this parser deliberately limited to the declaration header: the PL/SQL body
|
||||
* is never interpreted and malformed definitions fail closed with no metadata.
|
||||
*/
|
||||
export function xuguRoutineMetadataFromDefinition(definition: string): XuguRoutineMetadata {
|
||||
const tokens = tokenizeXuguRoutineDefinition(definition);
|
||||
if (!tokens) return { parameters: [] };
|
||||
const kindIndex = tokens.findIndex((token) => isWord(token, "PROCEDURE") || isWord(token, "FUNCTION"));
|
||||
if (kindIndex < 0) return { parameters: [] };
|
||||
|
||||
const kind = tokens[kindIndex].text.toUpperCase() as "PROCEDURE" | "FUNCTION";
|
||||
const nameEndIndex = xuguRoutineNameEndIndex(tokens, kindIndex + 1);
|
||||
if (nameEndIndex < 0) return { kind, parameters: [] };
|
||||
|
||||
let headerIndex = nameEndIndex;
|
||||
let parameterCloseIndex = -1;
|
||||
let parameters: RoutineParameter[] = [];
|
||||
if (tokens[headerIndex]?.text === "(") {
|
||||
parameterCloseIndex = matchingTokenParenIndex(tokens, headerIndex);
|
||||
if (parameterCloseIndex < 0) return { kind, parameters: [] };
|
||||
parameters = parseXuguRoutineParameters(definition, tokens, headerIndex + 1, parameterCloseIndex);
|
||||
headerIndex = parameterCloseIndex + 1;
|
||||
}
|
||||
|
||||
const returnType = kind === "FUNCTION" ? xuguFunctionReturnType(definition, tokens, headerIndex) : undefined;
|
||||
return { kind, parameters, returnType };
|
||||
}
|
||||
|
||||
function tokenizeXuguRoutineDefinition(definition: string): XuguRoutineToken[] | null {
|
||||
const tokens: XuguRoutineToken[] = [];
|
||||
let index = 0;
|
||||
while (index < definition.length) {
|
||||
const char = definition[index];
|
||||
if (/\s/.test(char)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "-" && definition[index + 1] === "-") {
|
||||
index += 2;
|
||||
while (index < definition.length && definition[index] !== "\n" && definition[index] !== "\r") index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && definition[index + 1] === "*") {
|
||||
const close = definition.indexOf("*/", index + 2);
|
||||
if (close < 0) return null;
|
||||
index = close + 2;
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"') {
|
||||
const start = index;
|
||||
const quote = char;
|
||||
let closed = false;
|
||||
index += 1;
|
||||
while (index < definition.length) {
|
||||
if (definition[index] !== quote) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (definition[index + 1] === quote) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if (!closed) return null;
|
||||
tokens.push({ kind: quote === "'" ? "string" : "quoted-identifier", text: definition.slice(start, index), start, end: index });
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_#$]/.test(char)) {
|
||||
const start = index;
|
||||
index += 1;
|
||||
while (index < definition.length && /[A-Za-z0-9_#$%]/.test(definition[index])) index += 1;
|
||||
tokens.push({ kind: "word", text: definition.slice(start, index), start, end: index });
|
||||
continue;
|
||||
}
|
||||
const start = index;
|
||||
if (char === ":" && definition[index + 1] === "=") index += 2;
|
||||
else index += 1;
|
||||
tokens.push({ kind: "symbol", text: definition.slice(start, index), start, end: index });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function xuguRoutineNameEndIndex(tokens: XuguRoutineToken[], startIndex: number): number {
|
||||
const first = tokens[startIndex];
|
||||
if (!isIdentifierToken(first)) return -1;
|
||||
let index = startIndex + 1;
|
||||
while (tokens[index]?.text === "." && isIdentifierToken(tokens[index + 1])) index += 2;
|
||||
return index;
|
||||
}
|
||||
|
||||
function isIdentifierToken(token?: XuguRoutineToken): boolean {
|
||||
return token?.kind === "word" || token?.kind === "quoted-identifier";
|
||||
}
|
||||
|
||||
function isWord(token: XuguRoutineToken | undefined, word: string): boolean {
|
||||
return token?.kind === "word" && token.text.toUpperCase() === word;
|
||||
}
|
||||
|
||||
function matchingTokenParenIndex(tokens: XuguRoutineToken[], openIndex: number): number {
|
||||
let depth = 0;
|
||||
for (let index = openIndex; index < tokens.length; index += 1) {
|
||||
if (tokens[index].text === "(") depth += 1;
|
||||
if (tokens[index].text === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseXuguRoutineParameters(definition: string, tokens: XuguRoutineToken[], startIndex: number, endIndex: number): RoutineParameter[] {
|
||||
const ranges: Array<[number, number]> = [];
|
||||
let depth = 0;
|
||||
let rangeStart = startIndex;
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
if (tokens[index].text === "(") depth += 1;
|
||||
if (tokens[index].text === ")") depth = Math.max(0, depth - 1);
|
||||
if (tokens[index].text === "," && depth === 0) {
|
||||
ranges.push([rangeStart, index]);
|
||||
rangeStart = index + 1;
|
||||
}
|
||||
}
|
||||
ranges.push([rangeStart, endIndex]);
|
||||
|
||||
return ranges.flatMap(([start, end], ordinalIndex) => {
|
||||
const parameter = parseXuguRoutineParameter(definition, tokens, start, end, ordinalIndex + 1);
|
||||
return parameter ? [parameter] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function parseXuguRoutineParameter(definition: string, tokens: XuguRoutineToken[], startIndex: number, endIndex: number, ordinal: number): RoutineParameter | null {
|
||||
if (startIndex >= endIndex || !isIdentifierToken(tokens[startIndex])) return null;
|
||||
const name = unquoteXuguIdentifier(tokens[startIndex].text);
|
||||
let typeStartIndex = startIndex + 1;
|
||||
let mode: RoutineParameterMode = "IN";
|
||||
if (isWord(tokens[typeStartIndex], "INOUT")) {
|
||||
mode = "INOUT";
|
||||
typeStartIndex += 1;
|
||||
} else if (isWord(tokens[typeStartIndex], "IN")) {
|
||||
if (isWord(tokens[typeStartIndex + 1], "OUT")) {
|
||||
mode = "INOUT";
|
||||
typeStartIndex += 2;
|
||||
} else {
|
||||
mode = "IN";
|
||||
typeStartIndex += 1;
|
||||
}
|
||||
} else if (isWord(tokens[typeStartIndex], "OUT")) {
|
||||
mode = "OUT";
|
||||
typeStartIndex += 1;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let defaultIndex = -1;
|
||||
for (let index = typeStartIndex; index < endIndex; index += 1) {
|
||||
if (tokens[index].text === "(") depth += 1;
|
||||
if (tokens[index].text === ")") depth = Math.max(0, depth - 1);
|
||||
if (depth === 0 && (isWord(tokens[index], "DEFAULT") || tokens[index].text === ":=" || tokens[index].text === "=")) {
|
||||
defaultIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const typeEndIndex = defaultIndex >= 0 ? defaultIndex : endIndex;
|
||||
if (typeStartIndex >= typeEndIndex) return null;
|
||||
const dataType = xuguTokenRangeText(definition, tokens, typeStartIndex, typeEndIndex).replace(/\s+/g, " ");
|
||||
if (!dataType) return null;
|
||||
const defaultValue = defaultIndex >= 0 ? xuguTokenRangeText(definition, tokens, defaultIndex + 1, endIndex) : undefined;
|
||||
if (defaultIndex >= 0 && !defaultValue) return null;
|
||||
return {
|
||||
name,
|
||||
dataType,
|
||||
mode,
|
||||
ordinal,
|
||||
hasDefault: defaultIndex >= 0,
|
||||
defaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
function xuguFunctionReturnType(definition: string, tokens: XuguRoutineToken[], startIndex: number): string | undefined {
|
||||
let returnIndex = -1;
|
||||
for (let index = startIndex; index < tokens.length; index += 1) {
|
||||
if (isWord(tokens[index], "AS") || isWord(tokens[index], "IS")) break;
|
||||
if (isWord(tokens[index], "RETURN")) {
|
||||
returnIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (returnIndex < 0) return undefined;
|
||||
let endIndex = returnIndex + 1;
|
||||
let depth = 0;
|
||||
while (endIndex < tokens.length) {
|
||||
const token = tokens[endIndex];
|
||||
if (token.text === "(") depth += 1;
|
||||
if (token.text === ")") depth = Math.max(0, depth - 1);
|
||||
if (depth === 0 && (isWord(token, "AS") || isWord(token, "IS") || isWord(token, "AUTHID") || isWord(token, "PIPELINED") || isWord(token, "DETERMINISTIC"))) break;
|
||||
endIndex += 1;
|
||||
}
|
||||
if (returnIndex + 1 >= endIndex) return undefined;
|
||||
return xuguTokenRangeText(definition, tokens, returnIndex + 1, endIndex).replace(/\s+/g, " ") || undefined;
|
||||
}
|
||||
|
||||
function xuguTokenRangeText(definition: string, tokens: XuguRoutineToken[], startIndex: number, endIndex: number): string {
|
||||
if (startIndex >= endIndex) return "";
|
||||
return stripXuguSqlComments(definition.slice(tokens[startIndex].start, tokens[endIndex - 1].end)).trim();
|
||||
}
|
||||
|
||||
function stripXuguSqlComments(value: string): string {
|
||||
let result = "";
|
||||
let index = 0;
|
||||
let quote = "";
|
||||
while (index < value.length) {
|
||||
const char = value[index];
|
||||
if (quote) {
|
||||
result += char;
|
||||
if (char === quote) {
|
||||
if (value[index + 1] === quote) {
|
||||
result += value[index + 1];
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
quote = "";
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"') {
|
||||
quote = char;
|
||||
result += char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "-" && value[index + 1] === "-") {
|
||||
result += " ";
|
||||
index += 2;
|
||||
while (index < value.length && value[index] !== "\n" && value[index] !== "\r") index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && value[index + 1] === "*") {
|
||||
result += " ";
|
||||
const close = value.indexOf("*/", index + 2);
|
||||
if (close < 0) break;
|
||||
index = close + 2;
|
||||
continue;
|
||||
}
|
||||
result += char;
|
||||
index += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function unquoteXuguIdentifier(value: string): string {
|
||||
if (!value.startsWith('"') || !value.endsWith('"')) return value;
|
||||
return value.slice(1, -1).replace(/""/g, '"');
|
||||
}
|
||||
|
||||
export function routineParametersFromResult(result: QueryResult, databaseType?: DatabaseType): RoutineParameter[] {
|
||||
if (databaseType === "databend") return databendRoutineParametersFromResult(result);
|
||||
const sqlServerMetadata =
|
||||
|
|
|
|||
Loading…
Reference in New Issue