feat(xugu): expose routine parameter metadata

This commit is contained in:
Elias 2026-08-08 10:37:59 +08:00 committed by GitHub
parent bdf87ac255
commit 159e813c9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 633 additions and 23 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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);
});
});

View File

@ -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');");
});
});

View File

@ -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");
});
});

View File

@ -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);

View File

@ -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 =