mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(model): generate models from table metadata
This commit is contained in:
@@ -779,6 +779,7 @@ const editRegexMaxMatchCount = ref(settingsStore.editorSettings.regexMaxMatchCou
|
||||
const editAutoCalculateTotalRows = ref(settingsStore.editorSettings.autoCalculateTotalRows);
|
||||
const editFlatteningMultiLineText = ref(settingsStore.editorSettings.flatteningMultiLineText);
|
||||
const editDataGridShowWhitespace = ref(settingsStore.editorSettings.dataGridShowWhitespace);
|
||||
const editModelGenerationTemplates = ref(settingsStore.editorSettings.modelGenerationTemplates.map((template) => ({ ...template })));
|
||||
const editTableColumnTemplateRows = ref<TableColumnTemplateGridRow[]>(tableColumnTemplateRowsFromSettings(settingsStore.editorSettings.tableColumnTemplateFields));
|
||||
const editTableColumnTemplateDatabaseType = ref<DatabaseType>(TABLE_COLUMN_TEMPLATE_DATABASE_TYPES[0] ?? "mysql");
|
||||
const editSqlVariableSubstitutionEnabled = ref(settingsStore.editorSettings.sqlVariableSubstitutionEnabled);
|
||||
@@ -1134,6 +1135,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
|
||||
dataGridZebraRowBg: editDataGridZebraRowBg.value,
|
||||
flatteningMultiLineText: editFlatteningMultiLineText.value,
|
||||
dataGridShowWhitespace: editDataGridShowWhitespace.value,
|
||||
modelGenerationTemplates: editModelGenerationTemplates.value,
|
||||
pageSize: editPageSize.value,
|
||||
tableOpenPageSize: editTableOpenPageSize.value,
|
||||
tableOpenSortMode: editTableOpenSortMode.value,
|
||||
@@ -1815,6 +1817,7 @@ function syncEditorSettingsDraftFromStore() {
|
||||
editDataGridZebraRowBg.value = settingsStore.editorSettings.dataGridZebraRowBg;
|
||||
editFlatteningMultiLineText.value = settingsStore.editorSettings.flatteningMultiLineText;
|
||||
editDataGridShowWhitespace.value = settingsStore.editorSettings.dataGridShowWhitespace;
|
||||
editModelGenerationTemplates.value = settingsStore.editorSettings.modelGenerationTemplates.map((template) => ({ ...template }));
|
||||
editPageSize.value = settingsStore.editorSettings.pageSize;
|
||||
editTableOpenPageSize.value = settingsStore.editorSettings.tableOpenPageSize;
|
||||
editTableOpenSortMode.value = settingsStore.editorSettings.tableOpenSortMode;
|
||||
@@ -2046,6 +2049,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = {
|
||||
defaultTransactionMode: editDefaultTransactionMode,
|
||||
keepExplicitTransactionInAutoCommit: editKeepExplicitTransactionInAutoCommit,
|
||||
tableColumnTemplateFields: editTableColumnTemplateRows,
|
||||
modelGenerationTemplates: editModelGenerationTemplates,
|
||||
};
|
||||
|
||||
function applyEditorSettingsKeysToRefs(draft: EditorSettingsDraft, keys: readonly EditorSettingsDraftKey[]) {
|
||||
@@ -2053,6 +2057,7 @@ function applyEditorSettingsKeysToRefs(draft: EditorSettingsDraft, keys: readonl
|
||||
customThemes: (value) => [...(value as CustomTheme[])],
|
||||
dataGridTypeColorSchemes: (value) => cloneDataGridTypeColorSchemes(value as DataGridTypeColorScheme[]),
|
||||
tableColumnTemplateFields: (value) => tableColumnTemplateRowsFromSettings(value as string[]),
|
||||
modelGenerationTemplates: (value) => (value as EditorSettings["modelGenerationTemplates"]).map((template) => ({ ...template })),
|
||||
shortcuts: (value) => normalizeShortcutSettings(value as Parameters<typeof normalizeShortcutSettings>[0]),
|
||||
sqlFormatter: (value) => normalizeSqlFormatterSettings(value as SqlFormatterSettings),
|
||||
sidebarHiddenTablePrefixes: (value) => (value as string[]).join("\n"),
|
||||
@@ -2564,6 +2569,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
||||
editDataGridZebraRowBg.value = DEFAULT_EDITOR_SETTINGS.dataGridZebraRowBg;
|
||||
editFlatteningMultiLineText.value = DEFAULT_EDITOR_SETTINGS.flatteningMultiLineText;
|
||||
editDataGridShowWhitespace.value = DEFAULT_EDITOR_SETTINGS.dataGridShowWhitespace;
|
||||
editModelGenerationTemplates.value = DEFAULT_EDITOR_SETTINGS.modelGenerationTemplates.map((template) => ({ ...template }));
|
||||
editPageSize.value = DEFAULT_EDITOR_SETTINGS.pageSize;
|
||||
editTableOpenPageSize.value = DEFAULT_EDITOR_SETTINGS.tableOpenPageSize;
|
||||
editTableOpenSortMode.value = DEFAULT_EDITOR_SETTINGS.tableOpenSortMode;
|
||||
@@ -2687,6 +2693,7 @@ function resetAllDefaults() {
|
||||
editDataGridZebraRowBg.value = DEFAULT_EDITOR_SETTINGS.dataGridZebraRowBg;
|
||||
editFlatteningMultiLineText.value = DEFAULT_EDITOR_SETTINGS.flatteningMultiLineText;
|
||||
editDataGridShowWhitespace.value = DEFAULT_EDITOR_SETTINGS.dataGridShowWhitespace;
|
||||
editModelGenerationTemplates.value = DEFAULT_EDITOR_SETTINGS.modelGenerationTemplates.map((template) => ({ ...template }));
|
||||
editPageSize.value = DEFAULT_EDITOR_SETTINGS.pageSize;
|
||||
editTableOpenPageSize.value = DEFAULT_EDITOR_SETTINGS.tableOpenPageSize;
|
||||
editTableOpenSortMode.value = DEFAULT_EDITOR_SETTINGS.tableOpenSortMode;
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search } from "@lucide/vue";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
import { MODEL_RENDERERS, generateModel, modelRenderer, type ModelFormatId } from "@/lib/model/modelRendererRegistry";
|
||||
import { renderModelTemplate, type ModelTemplate } from "@/lib/model/modelTemplates";
|
||||
import { pascalCase } from "@/lib/model/modelShape";
|
||||
import { useModelGenerationRuntime } from "@/composables/useModelGenerationRuntime";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { promptExportSavePath } from "@/lib/export/exportPath";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { createModelCodeHighlighter } from "@/lib/model/modelCodeHighlighter";
|
||||
|
||||
const props = defineProps<{ target: TreeNode }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useI18n();
|
||||
const { isDark } = useTheme();
|
||||
const settings = useSettingsStore();
|
||||
const { toast } = useToast();
|
||||
const { shape, loading, error, load } = useModelGenerationRuntime();
|
||||
const selected = ref("typescript");
|
||||
const search = ref("");
|
||||
const showHeaderComments = ref(false);
|
||||
const editing = ref(false);
|
||||
const saving = ref(false);
|
||||
const draft = ref<ModelTemplate>({ id: "", name: "", extension: "txt", body: "{{table.name}}\n{{#columns}}{{column.name}}: {{column.type}}\n{{/columns}}" });
|
||||
watch(
|
||||
() => props.target,
|
||||
(target) => void load({ ...target }),
|
||||
{ immediate: true },
|
||||
);
|
||||
const templates = computed(() => settings.editorSettings.modelGenerationTemplates ?? []);
|
||||
const custom = computed(() => templates.value.find((item) => item.id === selected.value));
|
||||
const priority = ["rust-struct", "go-struct", "java", "typescript", "pydantic", "python-dataclass", "php"];
|
||||
const orderedRenderers = computed(() =>
|
||||
[...MODEL_RENDERERS].sort((a, b) => {
|
||||
const ai = priority.indexOf(a.id);
|
||||
const bi = priority.indexOf(b.id);
|
||||
return (ai === -1 ? priority.length : ai) - (bi === -1 ? priority.length : bi);
|
||||
}),
|
||||
);
|
||||
const searchTerm = computed(() => search.value.trim().toLowerCase());
|
||||
const filteredRenderers = computed(() => orderedRenderers.value.filter((format) => !searchTerm.value || format.label.toLowerCase().includes(searchTerm.value)));
|
||||
const filteredTemplates = computed(() => templates.value.filter((item) => !searchTerm.value || item.name.toLowerCase().includes(searchTerm.value)));
|
||||
const selectedLabel = computed(() => custom.value?.name ?? modelRenderer(selected.value as ModelFormatId)?.label ?? selected.value);
|
||||
const highlightedCode = ref("");
|
||||
let highlightRequest = 0;
|
||||
const rendered = computed(() => {
|
||||
if (!shape.value) return { code: "", error: "" };
|
||||
try {
|
||||
const code = editing.value ? renderModelTemplate(draft.value.body, shape.value) : custom.value ? renderModelTemplate(custom.value.body, shape.value) : generateModel(shape.value, selected.value as ModelFormatId, { includeHeaderComments: showHeaderComments.value });
|
||||
return { code, error: "" };
|
||||
} catch (reason) {
|
||||
return { code: "", error: String(reason) };
|
||||
}
|
||||
});
|
||||
watch(
|
||||
[() => rendered.value.code, () => selected.value, () => isDark.value],
|
||||
async ([code, format, dark]) => {
|
||||
const request = ++highlightRequest;
|
||||
if (!code) {
|
||||
highlightedCode.value = "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const highlighter = await createModelCodeHighlighter({ appearance: () => (isDark.value ? "dark" : "light") });
|
||||
if (request === highlightRequest) highlightedCode.value = highlighter(code, format as ModelFormatId, dark ? "dark" : "light");
|
||||
} catch {
|
||||
if (request === highlightRequest) highlightedCode.value = "";
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const extension = computed(() => (editing.value ? draft.value.extension : (custom.value?.extension ?? modelRenderer(selected.value as ModelFormatId)?.extension ?? "txt")));
|
||||
const fileName = computed(() => `${pascalCase(props.target.label)}.${extension.value}`);
|
||||
function editTemplate() {
|
||||
draft.value = custom.value ? { ...custom.value } : { id: uuid(), name: "", extension: "txt", body: "{{table.name}}\n{{#columns}}{{column.name}}: {{column.type}}\n{{/columns}}" };
|
||||
editing.value = true;
|
||||
}
|
||||
async function saveTemplate(remove = false) {
|
||||
saving.value = true;
|
||||
try {
|
||||
const id = remove ? custom.value?.id : draft.value.id;
|
||||
const next = templates.value.filter((item) => item.id !== id);
|
||||
if (!remove) next.push({ ...draft.value, name: draft.value.name.trim() });
|
||||
await settings.updateEditorSettingsAndPersist({ modelGenerationTemplates: next });
|
||||
selected.value = remove ? "typescript" : draft.value.id;
|
||||
editing.value = false;
|
||||
} catch (reason) {
|
||||
toast(String(reason), 5000);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
async function copy() {
|
||||
try {
|
||||
await copyToClipboard(rendered.value.code);
|
||||
toast(t("connection.copied"));
|
||||
} catch (reason) {
|
||||
toast(t("grid.copyFailed", { message: String(reason) }), 5000);
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
const code = rendered.value.code;
|
||||
const name = fileName.value;
|
||||
const ext = extension.value;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isTauriRuntime()) {
|
||||
const path = await promptExportSavePath({ defaultFileName: name, filters: [{ name: ext, extensions: [ext] }], preferredPath: settings.editorSettings.preferredExportPath });
|
||||
if (!path) return;
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
await writeTextFile(path, code);
|
||||
} else {
|
||||
const url = URL.createObjectURL(new Blob([code], { type: "text/plain;charset=utf-8" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = name;
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
toast(t("grid.exported"));
|
||||
} catch (reason) {
|
||||
toast(t("grid.exportFailed", { message: String(reason) }), 5000);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:open="true"
|
||||
@update:open="
|
||||
(open) => {
|
||||
if (!open) emit('close');
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogContent class="flex h-[min(780px,90vh)] max-w-[min(1180px,95vw)] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader class="shrink-0 border-b px-6 py-4">
|
||||
<DialogTitle>{{ t("modelGeneration.title") }} · {{ target.label }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<aside class="flex w-full shrink-0 flex-col border-b bg-muted/20 md:w-64 md:border-b-0 md:border-r">
|
||||
<div class="border-b p-3">
|
||||
<div class="relative">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input v-model="search" class="pl-8" :placeholder="t('modelGeneration.search')" :aria-label="t('modelGeneration.search')" />
|
||||
</div>
|
||||
</div>
|
||||
<nav class="min-h-0 flex-1 overflow-y-auto p-2" :aria-label="t('modelGeneration.targets')">
|
||||
<div class="mb-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{{ t("modelGeneration.languages") }}</div>
|
||||
<button
|
||||
v-for="format in filteredRenderers"
|
||||
:key="format.id"
|
||||
type="button"
|
||||
class="mb-1 flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
|
||||
:class="selected === format.id && !editing ? 'bg-primary/10 font-medium text-primary' : 'text-foreground'"
|
||||
:aria-current="selected === format.id && !editing ? 'true' : undefined"
|
||||
@click="
|
||||
selected = format.id;
|
||||
editing = false;
|
||||
"
|
||||
>
|
||||
<span class="truncate">{{ format.label }}</span>
|
||||
</button>
|
||||
<div v-if="filteredTemplates.length" class="mb-1 mt-4 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{{ t("modelGeneration.customTemplates") }}</div>
|
||||
<button
|
||||
v-for="item in filteredTemplates"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="mb-1 flex w-full items-center rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
|
||||
:class="selected === item.id && !editing ? 'bg-primary/10 font-medium text-primary' : 'text-foreground'"
|
||||
:aria-current="selected === item.id && !editing ? 'true' : undefined"
|
||||
@click="
|
||||
selected = item.id;
|
||||
editing = false;
|
||||
"
|
||||
>
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</button>
|
||||
<p v-if="!filteredRenderers.length && !filteredTemplates.length" class="px-3 py-6 text-center text-xs text-muted-foreground">{{ t("modelGeneration.noMatches") }}</p>
|
||||
</nav>
|
||||
<div class="flex shrink-0 flex-wrap gap-2 border-t p-3">
|
||||
<Button size="sm" variant="outline" @click="editTemplate">{{ t(custom ? "modelGeneration.editTemplate" : "modelGeneration.addTemplate") }}</Button>
|
||||
<Button v-if="custom && !editing" size="sm" variant="outline" :disabled="saving" @click="saveTemplate(true)">{{ t("modelGeneration.deleteTemplate") }}</Button>
|
||||
<Button size="sm" variant="outline" :disabled="loading" @click="load({ ...target })">{{ t("contextMenu.refreshChildren") }}</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="flex min-h-0 min-w-0 flex-1 flex-col p-4 md:p-5">
|
||||
<div class="mb-3 flex shrink-0 items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">{{ t("modelGeneration.outputFormat") }}</p>
|
||||
<h2 class="text-lg font-semibold">{{ editing ? t("modelGeneration.editTemplate") : selectedLabel }}</h2>
|
||||
</div>
|
||||
<label v-if="!editing && !custom" class="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<input v-model="showHeaderComments" type="checkbox" class="size-3.5 accent-primary" :aria-label="t('modelGeneration.showHeaderComments')" />
|
||||
{{ t("modelGeneration.showHeaderComments") }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="editing" class="mb-3 grid shrink-0 gap-2 rounded-lg border bg-muted/20 p-3 sm:grid-cols-2">
|
||||
<label class="text-sm">{{ t("modelGeneration.templateName") }}<Input v-model="draft.name" class="mt-1" /></label>
|
||||
<label class="text-sm">{{ t("modelGeneration.extension") }}<Input v-model="draft.extension" class="mt-1" /></label>
|
||||
<p class="text-xs text-muted-foreground sm:col-span-2">{{ t("modelGeneration.templateHelp") }}</p>
|
||||
<textarea v-model="draft.body" :aria-label="t('modelGeneration.templateBody')" spellcheck="false" class="h-28 rounded border bg-background p-3 font-mono text-xs sm:col-span-2" />
|
||||
<Button class="sm:col-span-2 sm:w-fit" :disabled="saving || !draft.name.trim() || !/^[a-zA-Z0-9]+$/.test(draft.extension) || !!rendered.error" @click="saveTemplate()">{{ t("common.save") }}</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="py-6 text-sm text-muted-foreground">{{ t("contextMenu.exportStructureLoading") }}</p>
|
||||
<p v-else-if="error" class="py-6 text-sm text-destructive">{{ translateBackendError(t, error) }}</p>
|
||||
<p v-else-if="rendered.error" class="py-6 text-sm text-destructive">{{ rendered.error }}</p>
|
||||
<template v-else>
|
||||
<p v-if="shape?.columns.some((column) => column.normalizedType === 'unknown')" class="mb-2 shrink-0 text-xs text-muted-foreground">{{ t("modelGeneration.unknownTypes") }}</p>
|
||||
<div v-if="highlightedCode" :aria-label="t('modelGeneration.preview')" class="model-code-preview min-h-0 w-full flex-1 overflow-auto rounded-lg border bg-muted/30 text-xs leading-5" v-html="highlightedCode" />
|
||||
<pre v-else :aria-label="t('modelGeneration.preview')" class="min-h-0 w-full flex-1 overflow-auto rounded-lg border bg-muted/30 p-4 font-mono text-xs leading-5 whitespace-pre-wrap">{{ rendered.code }}</pre>
|
||||
</template>
|
||||
|
||||
<div class="mt-3 flex shrink-0 justify-end gap-2">
|
||||
<Button variant="outline" :disabled="loading || !rendered.code" @click="copy">{{ t("modelGeneration.copy") }}</Button>
|
||||
<Button :disabled="loading || saving || !rendered.code || !/^[a-zA-Z0-9]+$/.test(extension)" @click="save">{{ t("modelGeneration.save") }} · {{ fileName }}</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.model-code-preview :deep(pre) {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
padding: 1rem;
|
||||
background: transparent !important;
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.55;
|
||||
tab-size: 4;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.model-code-preview :deep(code) {
|
||||
display: block;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.model-code-preview :deep(.line) {
|
||||
display: block;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.model-code-preview :deep(.line:empty) {
|
||||
min-height: 1.55em;
|
||||
}
|
||||
|
||||
.model-code-preview {
|
||||
padding: 1rem;
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.55;
|
||||
tab-size: 4;
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, watch, onBeforeUnmount, onScopeDispose, inject, reactive, ref, shallowRef } from "vue";
|
||||
import { computed, defineAsyncComponent, nextTick, watch, onBeforeUnmount, onScopeDispose, inject, reactive, ref, shallowRef } from "vue";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import PluginWorkbenchHost from "@/components/plugins/PluginWorkbenchHost.vue";
|
||||
import type { PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
|
||||
@@ -425,6 +425,11 @@ const props = defineProps<{
|
||||
highlighted?: boolean;
|
||||
}>();
|
||||
|
||||
const ModelGenerateDialog = defineAsyncComponent(() => import("@/components/generate/ModelGenerateDialog.vue"));
|
||||
const modelGenerationTarget = shallowRef<TreeNode | null>(null);
|
||||
function openModelGeneration() {
|
||||
modelGenerationTarget.value = { ...activeNode.value };
|
||||
}
|
||||
const activeNode = shallowRef<TreeNode>(props.node);
|
||||
let acceptedSelectionIds: readonly string[] | null = null;
|
||||
let latestNavigationRequestId = 0;
|
||||
@@ -6507,6 +6512,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
variant: "destructive" as const,
|
||||
});
|
||||
}
|
||||
if (node.type === "table") items.push({ label: t("modelGeneration.title"), action: openModelGeneration, icon: Code2 });
|
||||
items.push({
|
||||
label: t("contextMenu.generateSql"),
|
||||
icon: FilePlus,
|
||||
@@ -7280,6 +7286,7 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModelGenerateDialog v-if="modelGenerationTarget" :key="modelGenerationTarget.id" :target="modelGenerationTarget" @close="modelGenerationTarget = null" />
|
||||
<Dialog
|
||||
:open="!!pluginDialog"
|
||||
@update:open="
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ref, shallowRef, onScopeDispose } from "vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { modelShapeFromTable, type DatabaseModelShape } from "@/lib/model/modelShape";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
|
||||
export function useModelGenerationRuntime() {
|
||||
const store = useConnectionStore();
|
||||
const shape = shallowRef<DatabaseModelShape>();
|
||||
const loading = ref(false);
|
||||
const error = ref<unknown>();
|
||||
let request = 0;
|
||||
function cancel() {
|
||||
request++;
|
||||
loading.value = false;
|
||||
}
|
||||
onScopeDispose(cancel);
|
||||
async function load(target: TreeNode) {
|
||||
const current = ++request;
|
||||
loading.value = true;
|
||||
error.value = undefined;
|
||||
shape.value = undefined;
|
||||
try {
|
||||
if (!target.connectionId || target.database == null) throw new Error("Missing table connection");
|
||||
await store.ensureConnected(target.connectionId);
|
||||
if (current !== request) return;
|
||||
const config = store.getConfig(target.connectionId);
|
||||
const schema = connectionObjectTreeQuerySchema(config, target.database, target.schema);
|
||||
const columns = await api.getColumns(target.connectionId, target.database, schema, target.tableName || target.label, target.catalog);
|
||||
if (current === request) shape.value = modelShapeFromTable(target, columns, effectiveDatabaseTypeForConnection(config));
|
||||
} catch (reason) {
|
||||
if (current === request) error.value = reason;
|
||||
} finally {
|
||||
if (current === request) loading.value = false;
|
||||
}
|
||||
}
|
||||
return { shape, loading, error, load, cancel };
|
||||
}
|
||||
@@ -61,6 +61,28 @@ const consul = {
|
||||
};
|
||||
|
||||
export default {
|
||||
modelGeneration: {
|
||||
title: "Generate model",
|
||||
editTemplate: "Edit template",
|
||||
addTemplate: "Add template",
|
||||
deleteTemplate: "Delete template",
|
||||
templateName: "Template name",
|
||||
extension: "File extension",
|
||||
templateBody: "Template source",
|
||||
search: "Search languages",
|
||||
targets: "Model targets",
|
||||
languages: "Languages",
|
||||
customTemplates: "Custom templates",
|
||||
noMatches: "No matching languages or templates",
|
||||
outputFormat: "Output format",
|
||||
showHeaderComments: "Show header comments",
|
||||
preview: "Generated code",
|
||||
copy: "Copy code",
|
||||
save: "Save code",
|
||||
unknownTypes: "Some database types are unknown. Review their generated types before use.",
|
||||
templateHelp:
|
||||
"Variables: table.name, table.schema, table.comment, class.name; inside a columns section: column.name, column.type, column.originalType, column.comment, column.defaultValue, column.nullable, column.primaryKey. Use double braces for variables, #columns and /columns for loops, #column.nullable and /column.nullable for conditional sections.",
|
||||
},
|
||||
cellTransform: {
|
||||
title: "Convert preview",
|
||||
description: "Convert the current value for viewing or copying. The source value and editor draft stay unchanged.",
|
||||
|
||||
@@ -63,6 +63,28 @@ const consul = {
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
modelGeneration: {
|
||||
title: "Generar modelo",
|
||||
editTemplate: "Editar plantilla",
|
||||
addTemplate: "Añadir plantilla",
|
||||
deleteTemplate: "Eliminar plantilla",
|
||||
templateName: "Nombre de la plantilla",
|
||||
extension: "Extensión del archivo",
|
||||
templateBody: "Código de la plantilla",
|
||||
search: "Buscar lenguajes",
|
||||
targets: "Destinos del modelo",
|
||||
languages: "Lenguajes",
|
||||
customTemplates: "Plantillas personalizadas",
|
||||
noMatches: "No hay lenguajes ni plantillas coincidentes",
|
||||
outputFormat: "Formato de salida",
|
||||
showHeaderComments: "Mostrar comentarios del encabezado",
|
||||
preview: "Vista previa del código generado",
|
||||
copy: "Copiar código",
|
||||
save: "Guardar código",
|
||||
unknownTypes: "Algunos tipos de base de datos no se reconocen. Revisa los tipos generados antes de usarlos.",
|
||||
templateHelp:
|
||||
"Variables: table.name, table.schema, table.comment, class.name; dentro de una sección de columnas: column.name, column.type, column.originalType, column.comment, column.defaultValue, column.nullable, column.primaryKey. Usa llaves dobles para las variables, #columns y /columns para los bucles, y #column.nullable y /column.nullable para las condiciones.",
|
||||
},
|
||||
mongoDump,
|
||||
cellTransform: {
|
||||
title: "Vista previa de conversión",
|
||||
|
||||
@@ -61,6 +61,28 @@ const consul = {
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
modelGeneration: {
|
||||
title: "Genera modello",
|
||||
editTemplate: "Modifica modello",
|
||||
addTemplate: "Aggiungi modello",
|
||||
deleteTemplate: "Elimina modello",
|
||||
templateName: "Nome del modello",
|
||||
extension: "Estensione del file",
|
||||
templateBody: "Sorgente del modello",
|
||||
search: "Cerca linguaggi",
|
||||
targets: "Destinazioni del modello",
|
||||
languages: "Linguaggi",
|
||||
customTemplates: "Modelli personalizzati",
|
||||
noMatches: "Nessun linguaggio o modello corrispondente",
|
||||
outputFormat: "Formato di output",
|
||||
showHeaderComments: "Mostra commenti di intestazione",
|
||||
preview: "Anteprima del codice generato",
|
||||
copy: "Copia codice",
|
||||
save: "Salva codice",
|
||||
unknownTypes: "Alcuni tipi di database non sono riconosciuti. Controlla i tipi generati prima dell'uso.",
|
||||
templateHelp:
|
||||
"Variabili: table.name, table.schema, table.comment, class.name; all'interno di una sezione columns: column.name, column.type, column.originalType, column.comment, column.defaultValue, column.nullable, column.primaryKey. Usa doppie parentesi graffe per le variabili, #columns e /columns per i cicli, #column.nullable e /column.nullable per le condizioni.",
|
||||
},
|
||||
mongoDump,
|
||||
cellTransform: {
|
||||
title: "Anteprima conversione",
|
||||
|
||||
@@ -62,6 +62,28 @@ const consul = {
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
modelGeneration: {
|
||||
title: "モデルを生成",
|
||||
editTemplate: "テンプレートを編集",
|
||||
addTemplate: "テンプレートを追加",
|
||||
deleteTemplate: "テンプレートを削除",
|
||||
templateName: "テンプレート名",
|
||||
extension: "ファイル拡張子",
|
||||
templateBody: "テンプレートソース",
|
||||
search: "言語を検索",
|
||||
targets: "モデル出力先",
|
||||
languages: "言語",
|
||||
customTemplates: "カスタムテンプレート",
|
||||
noMatches: "一致する言語またはテンプレートがありません",
|
||||
outputFormat: "出力形式",
|
||||
showHeaderComments: "ヘッダーコメントを表示",
|
||||
preview: "生成コードのプレビュー",
|
||||
copy: "コードをコピー",
|
||||
save: "コードを保存",
|
||||
unknownTypes: "認識できないデータベース型があります。生成された型を確認してから使用してください。",
|
||||
templateHelp:
|
||||
"変数: table.name、table.schema、table.comment、class.name。columns セクション内では column.name、column.type、column.originalType、column.comment、column.defaultValue、column.nullable、column.primaryKey を使用できます。変数は二重中括弧で囲み、#columns と /columns はループ、#column.nullable と /column.nullable は条件ブロックを表します。",
|
||||
},
|
||||
mongoDump,
|
||||
cellTransform: {
|
||||
title: "変換プレビュー",
|
||||
|
||||
@@ -61,6 +61,28 @@ const consul = {
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
modelGeneration: {
|
||||
title: "모델 생성",
|
||||
editTemplate: "템플릿 편집",
|
||||
addTemplate: "템플릿 추가",
|
||||
deleteTemplate: "템플릿 삭제",
|
||||
templateName: "템플릿 이름",
|
||||
extension: "파일 확장자",
|
||||
templateBody: "템플릿 소스",
|
||||
search: "언어 검색",
|
||||
targets: "모델 대상",
|
||||
languages: "언어",
|
||||
customTemplates: "사용자 지정 템플릿",
|
||||
noMatches: "일치하는 언어나 템플릿이 없습니다",
|
||||
outputFormat: "출력 형식",
|
||||
showHeaderComments: "헤더 주석 표시",
|
||||
preview: "생성 코드 미리보기",
|
||||
copy: "코드 복사",
|
||||
save: "코드 저장",
|
||||
unknownTypes: "일부 데이터베이스 유형을 인식할 수 없습니다. 생성된 유형을 사용하기 전에 확인하세요.",
|
||||
templateHelp:
|
||||
"변수: table.name, table.schema, table.comment, class.name. columns 섹션 안에서는 column.name, column.type, column.originalType, column.comment, column.defaultValue, column.nullable, column.primaryKey를 사용할 수 있습니다. 변수는 중괄호 두 개로 감싸고, #columns와 /columns는 반복, #column.nullable와 /column.nullable는 조건 블록을 나타냅니다.",
|
||||
},
|
||||
mongoDump,
|
||||
cellTransform: {
|
||||
title: "변환 미리보기",
|
||||
|
||||
@@ -62,6 +62,28 @@ const consul = {
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
modelGeneration: {
|
||||
title: "Gerar modelo",
|
||||
editTemplate: "Editar modelo",
|
||||
addTemplate: "Adicionar modelo",
|
||||
deleteTemplate: "Excluir modelo",
|
||||
templateName: "Nome do modelo",
|
||||
extension: "Extensão do arquivo",
|
||||
templateBody: "Código do modelo",
|
||||
search: "Pesquisar linguagens",
|
||||
targets: "Destinos do modelo",
|
||||
languages: "Linguagens",
|
||||
customTemplates: "Modelos personalizados",
|
||||
noMatches: "Nenhuma linguagem ou modelo correspondente",
|
||||
outputFormat: "Formato de saída",
|
||||
showHeaderComments: "Mostrar comentários do cabeçalho",
|
||||
preview: "Prévia do código gerado",
|
||||
copy: "Copiar código",
|
||||
save: "Salvar código",
|
||||
unknownTypes: "Alguns tipos de banco de dados não foram reconhecidos. Revise os tipos gerados antes de usá-los.",
|
||||
templateHelp:
|
||||
"Variáveis: table.name, table.schema, table.comment, class.name; dentro de uma seção columns: column.name, column.type, column.originalType, column.comment, column.defaultValue, column.nullable, column.primaryKey. Use chaves duplas para variáveis, #columns e /columns para loops, e #column.nullable e /column.nullable para blocos condicionais.",
|
||||
},
|
||||
mongoDump,
|
||||
cellTransform: {
|
||||
title: "Prévia da conversão",
|
||||
|
||||
@@ -5,6 +5,28 @@ import { meilisearchManagementRu } from "./meilisearchManagement";
|
||||
|
||||
// Russian locale. Entries omitted here fall back to English at runtime.
|
||||
export default withEnglishFallback({
|
||||
modelGeneration: {
|
||||
title: "Создать модель",
|
||||
editTemplate: "Изменить шаблон",
|
||||
addTemplate: "Добавить шаблон",
|
||||
deleteTemplate: "Удалить шаблон",
|
||||
templateName: "Название шаблона",
|
||||
extension: "Расширение файла",
|
||||
templateBody: "Исходный текст шаблона",
|
||||
search: "Поиск языков",
|
||||
targets: "Цели модели",
|
||||
languages: "Языки",
|
||||
customTemplates: "Пользовательские шаблоны",
|
||||
noMatches: "Подходящие языки или шаблоны не найдены",
|
||||
outputFormat: "Формат вывода",
|
||||
showHeaderComments: "Показывать комментарии заголовка",
|
||||
preview: "Предпросмотр сгенерированного кода",
|
||||
copy: "Копировать код",
|
||||
save: "Сохранить код",
|
||||
unknownTypes: "Некоторые типы базы данных не распознаны. Проверьте сгенерированные типы перед использованием.",
|
||||
templateHelp:
|
||||
"Переменные: table.name, table.schema, table.comment, class.name; внутри секции columns доступны column.name, column.type, column.originalType, column.comment, column.defaultValue, column.nullable, column.primaryKey. Используйте двойные фигурные скобки для переменных, #columns и /columns для циклов, а #column.nullable и /column.nullable для условных блоков.",
|
||||
},
|
||||
docs,
|
||||
dataDictionary,
|
||||
cellTransform: {
|
||||
|
||||
@@ -7,6 +7,28 @@ import { meilisearchManagementZhCN } from "./meilisearchManagement";
|
||||
import { mongodbDatabaseDumpZhCN as mongoDump } from "./mongodbDatabaseDump";
|
||||
|
||||
export default withEnglishFallback({
|
||||
modelGeneration: {
|
||||
title: "生成模型",
|
||||
editTemplate: "编辑模板",
|
||||
addTemplate: "添加模板",
|
||||
deleteTemplate: "删除模板",
|
||||
templateName: "模板名称",
|
||||
extension: "文件扩展名",
|
||||
templateBody: "模板内容",
|
||||
search: "搜索语言",
|
||||
targets: "模型目标",
|
||||
languages: "语言",
|
||||
customTemplates: "自定义模板",
|
||||
noMatches: "没有匹配的语言或模板",
|
||||
outputFormat: "输出格式",
|
||||
showHeaderComments: "显示顶部注释",
|
||||
preview: "生成代码预览",
|
||||
copy: "复制代码",
|
||||
save: "保存代码",
|
||||
unknownTypes: "部分数据库类型未识别,请检查生成的字段类型。",
|
||||
templateHelp:
|
||||
"变量:table.name、table.schema、table.comment、class.name;字段循环内可用 column.name、column.type、column.originalType、column.comment、column.defaultValue、column.nullable、column.primaryKey。变量用双花括号包围,#columns 与 /columns 表示循环,#column.nullable 与 /column.nullable 表示条件块。",
|
||||
},
|
||||
cellTransform: {
|
||||
title: "转换预览",
|
||||
description: "将当前内容转换后查看或复制,不改变原值和编辑草稿。",
|
||||
|
||||
@@ -63,6 +63,28 @@ const consul = {
|
||||
};
|
||||
|
||||
export default withEnglishFallback({
|
||||
modelGeneration: {
|
||||
title: "產生模型",
|
||||
editTemplate: "編輯範本",
|
||||
addTemplate: "新增範本",
|
||||
deleteTemplate: "刪除範本",
|
||||
templateName: "範本名稱",
|
||||
extension: "檔案副檔名",
|
||||
templateBody: "範本內容",
|
||||
search: "搜尋語言",
|
||||
targets: "模型目標",
|
||||
languages: "語言",
|
||||
customTemplates: "自訂範本",
|
||||
noMatches: "沒有符合的語言或範本",
|
||||
outputFormat: "輸出格式",
|
||||
showHeaderComments: "顯示頂部註解",
|
||||
preview: "產生程式碼預覽",
|
||||
copy: "複製程式碼",
|
||||
save: "儲存程式碼",
|
||||
unknownTypes: "部分資料庫類型未識別,請檢查產生的欄位類型。",
|
||||
templateHelp:
|
||||
"變數:table.name、table.schema、table.comment、class.name;欄位迴圈內可用 column.name、column.type、column.originalType、column.comment、column.defaultValue、column.nullable、column.primaryKey。變數使用雙大括號,#columns 與 /columns 表示迴圈,#column.nullable 與 /column.nullable 表示條件區塊。",
|
||||
},
|
||||
mongoDump,
|
||||
cellTransform: {
|
||||
title: "轉換預覽",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AppThemeAppearance } from "@/lib/app/appTheme";
|
||||
import type { ModelFormatId } from "./modelRendererRegistry";
|
||||
import { supportsRegExpLookbehind } from "@/lib/ui/legacyWebView";
|
||||
|
||||
type ModelCodeHighlighter = (content: string, format: ModelFormatId, appearance?: AppThemeAppearance) => string;
|
||||
type ShikiHighlighter = Awaited<ReturnType<typeof import("shiki/core").createHighlighterCore>>;
|
||||
|
||||
const themes = { dark: "github-dark", light: "github-light" } as const;
|
||||
const languages = {
|
||||
typescript: "typescript",
|
||||
zod: "typescript",
|
||||
yup: "typescript",
|
||||
joi: "typescript",
|
||||
pydantic: "python",
|
||||
"python-dataclass": "python",
|
||||
"go-struct": "go",
|
||||
"rust-struct": "rust",
|
||||
kotlin: "kotlin",
|
||||
swift: "swift",
|
||||
dart: "dart",
|
||||
java: "java",
|
||||
csharp: "csharp",
|
||||
php: "php",
|
||||
ruby: "ruby",
|
||||
} satisfies Record<ModelFormatId, string>;
|
||||
|
||||
let highlighterPromise: Promise<ShikiHighlighter> | undefined;
|
||||
|
||||
export async function createModelCodeHighlighter(options: { appearance: () => AppThemeAppearance }): Promise<ModelCodeHighlighter> {
|
||||
const highlighter = await getHighlighter();
|
||||
return (content, format, appearance = options.appearance()) => {
|
||||
const html = highlighter.codeToHtml(content.replace(/\t/g, " "), {
|
||||
lang: languages[format],
|
||||
structure: "classic",
|
||||
theme: themes[appearance],
|
||||
});
|
||||
const match = /<code[^>]*>([\s\S]*)<\/code>/.exec(html);
|
||||
return (match ? match[1] : html).replace(/<\/span>\n/g, "</span>");
|
||||
};
|
||||
}
|
||||
|
||||
function getHighlighter(): Promise<ShikiHighlighter> {
|
||||
highlighterPromise ??= loadHighlighter();
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
async function loadHighlighter(): Promise<ShikiHighlighter> {
|
||||
const [{ createHighlighterCore }, { createJavaScriptRegexEngine }, oniguruma, githubDark, githubLight, dart, go, csharp, java, javascript, json, kotlin, php, python, ruby, rust, swift, typescript] = await Promise.all([
|
||||
import("shiki/core"),
|
||||
import("shiki/engine/javascript"),
|
||||
supportsRegExpLookbehind() ? Promise.resolve(null) : Promise.all([import("shiki/engine/oniguruma"), import("shiki/wasm")]),
|
||||
import("shiki/themes/github-dark.mjs"),
|
||||
import("shiki/themes/github-light.mjs"),
|
||||
import("shiki/langs/dart.mjs"),
|
||||
import("shiki/langs/go.mjs"),
|
||||
import("shiki/langs/csharp.mjs"),
|
||||
import("shiki/langs/java.mjs"),
|
||||
import("shiki/langs/javascript.mjs"),
|
||||
import("shiki/langs/json.mjs"),
|
||||
import("shiki/langs/kotlin.mjs"),
|
||||
import("shiki/langs/php.mjs"),
|
||||
import("shiki/langs/python.mjs"),
|
||||
import("shiki/langs/ruby.mjs"),
|
||||
import("shiki/langs/rust.mjs"),
|
||||
import("shiki/langs/swift.mjs"),
|
||||
import("shiki/langs/typescript.mjs"),
|
||||
]);
|
||||
|
||||
return createHighlighterCore({
|
||||
engine: oniguruma ? await oniguruma[0].createOnigurumaEngine(oniguruma[1]) : createJavaScriptRegexEngine(),
|
||||
langs: [dart.default, go.default, csharp.default, java.default, javascript.default, json.default, kotlin.default, php.default, python.default, ruby.default, rust.default, swift.default, typescript.default],
|
||||
themes: [githubDark.default, githubLight.default],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateModel, MODEL_RENDERERS } from "./modelRendererRegistry";
|
||||
import { modelShapeFromTable } from "./modelShape";
|
||||
import { renderModelTemplate } from "./modelTemplates";
|
||||
|
||||
const node = { id: "table-1", label: "user_accounts", type: "table", connectionId: "c1", database: "app", schema: "public", comment: "Accounts" } as const;
|
||||
const columns = [
|
||||
{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: "Identifier" },
|
||||
{ name: "display_name", data_type: "varchar(80)", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: "Visible name" },
|
||||
];
|
||||
|
||||
describe("model generation", () => {
|
||||
it("registers the fifteen Schemato-compatible SQL targets", () => {
|
||||
expect(MODEL_RENDERERS.map((item) => item.id)).toEqual(["typescript", "zod", "yup", "joi", "pydantic", "python-dataclass", "go-struct", "rust-struct", "kotlin", "swift", "dart", "java", "csharp", "php", "ruby"]);
|
||||
});
|
||||
|
||||
it("preserves metadata while rendering a target", () => {
|
||||
const shape = modelShapeFromTable(node, columns, "postgres");
|
||||
const code = generateModel(shape, "csharp", { includeHeaderComments: true });
|
||||
expect(code).toContain("UserAccounts");
|
||||
expect(code).toContain("Identifier");
|
||||
expect(code).toContain("long Id");
|
||||
expect(code).toContain("string? DisplayName");
|
||||
});
|
||||
|
||||
it("hides generated schema comments by default", () => {
|
||||
const shape = modelShapeFromTable(node, columns, "postgres");
|
||||
const code = generateModel(shape, "go-struct");
|
||||
expect(code).not.toContain("// id: bigint");
|
||||
expect(generateModel(shape, "go-struct", { includeHeaderComments: true })).toContain("// id: bigint");
|
||||
});
|
||||
|
||||
it("supports safe custom templates with column loops and conditions", () => {
|
||||
const shape = modelShapeFromTable(node, columns);
|
||||
expect(renderModelTemplate("{{class.name}}\n{{#columns}}{{column.name}} {{#column.primaryKey}}PK{{/column.primaryKey}}\n{{/columns}}", shape)).toBe("UserAccounts\nid PK\ndisplay_name \n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { DatabaseModelColumn, DatabaseModelShape, ModelScalarType } from "./modelShape";
|
||||
import { camelCase, commentLines, pascalCase, safeIdentifier, snakeCase } from "./modelShape";
|
||||
|
||||
export type ModelFormatId = "typescript" | "zod" | "yup" | "joi" | "pydantic" | "python-dataclass" | "go-struct" | "rust-struct" | "kotlin" | "swift" | "dart" | "java" | "csharp" | "php" | "ruby";
|
||||
|
||||
export interface ModelRendererMeta {
|
||||
id: ModelFormatId;
|
||||
label: string;
|
||||
extension: string;
|
||||
render: (shape: DatabaseModelShape) => string;
|
||||
}
|
||||
|
||||
export interface ModelGenerationOptions {
|
||||
includeHeaderComments?: boolean;
|
||||
}
|
||||
|
||||
const scalar: Record<ModelScalarType, string> = {
|
||||
string: "string",
|
||||
integer: "number",
|
||||
number: "number",
|
||||
boolean: "boolean",
|
||||
date: "string",
|
||||
time: "string",
|
||||
datetime: "string",
|
||||
binary: "Uint8Array",
|
||||
json: "unknown",
|
||||
uuid: "string",
|
||||
unknown: "unknown",
|
||||
};
|
||||
|
||||
function tsName(column: DatabaseModelColumn): string {
|
||||
return safeIdentifier(camelCase(column.name));
|
||||
}
|
||||
function className(shape: DatabaseModelShape): string {
|
||||
return safeIdentifier(pascalCase(shape.tableName), "Model");
|
||||
}
|
||||
function tsType(column: DatabaseModelColumn): string {
|
||||
return scalar[column.normalizedType] + (column.nullable ? " | null" : "");
|
||||
}
|
||||
function jsDoc(value: string | null | undefined): string[] {
|
||||
return commentLines(value, " * ");
|
||||
}
|
||||
function jsDocBlock(value: string | null | undefined): string[] {
|
||||
const lines = jsDoc(value);
|
||||
return lines.length ? ["/**", ...lines, " */"] : [];
|
||||
}
|
||||
|
||||
function renderTypeScript(shape: DatabaseModelShape): string {
|
||||
const lines = [...jsDocBlock(shape.tableComment), `export interface ${className(shape)} {`];
|
||||
for (const column of shape.columns) lines.push(...jsDocBlock(column.comment).map((line) => ` ${line}`), ` ${tsName(column)}: ${tsType(column)};`);
|
||||
lines.push("}");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderZod(shape: DatabaseModelShape): string {
|
||||
const lines = [`import { z } from "zod";`, "", `export const ${className(shape)}Schema = z.object({`];
|
||||
for (const column of shape.columns) {
|
||||
const base = { string: "z.string()", integer: "z.number().int()", number: "z.number()", boolean: "z.boolean()", date: "z.coerce.date()", time: "z.string()", datetime: "z.coerce.date()", binary: "z.instanceof(Uint8Array)", json: "z.unknown()", uuid: "z.string().uuid()", unknown: "z.unknown()" }[
|
||||
column.normalizedType
|
||||
];
|
||||
lines.push(` ${JSON.stringify(tsName(column))}: ${base}${column.nullable ? ".nullable()" : ""},`);
|
||||
}
|
||||
lines.push("});", "", `export type ${className(shape)} = z.infer<typeof ${className(shape)}Schema>;`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderYup(shape: DatabaseModelShape): string {
|
||||
const lines = [`import * as yup from "yup";`, "", `export const ${className(shape)}Schema = yup.object({`];
|
||||
for (const column of shape.columns) {
|
||||
const base = { string: "yup.string()", integer: "yup.number().integer()", number: "yup.number()", boolean: "yup.boolean()", date: "yup.date()", time: "yup.string()", datetime: "yup.date()", binary: "yup.mixed<Uint8Array>()", json: "yup.mixed()", uuid: "yup.string()", unknown: "yup.mixed()" }[
|
||||
column.normalizedType
|
||||
];
|
||||
lines.push(` ${JSON.stringify(tsName(column))}: ${base}${column.nullable ? ".nullable()" : ".required()"},`);
|
||||
}
|
||||
lines.push("});");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderJoi(shape: DatabaseModelShape): string {
|
||||
const lines = [`import Joi from "joi";`, "", `export const ${className(shape)}Schema = Joi.object({`];
|
||||
for (const column of shape.columns) {
|
||||
const base = { string: "Joi.string()", integer: "Joi.number().integer()", number: "Joi.number()", boolean: "Joi.boolean()", date: "Joi.date()", time: "Joi.string()", datetime: "Joi.date()", binary: "Joi.binary()", json: "Joi.any()", uuid: "Joi.string().guid()", unknown: "Joi.any()" }[
|
||||
column.normalizedType
|
||||
];
|
||||
lines.push(` ${JSON.stringify(tsName(column))}: ${base}${column.nullable ? ".allow(null)" : ".required()"},`);
|
||||
}
|
||||
lines.push("});");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function pythonType(column: DatabaseModelColumn): string {
|
||||
return { string: "str", integer: "int", number: "float", boolean: "bool", date: "date", time: "time", datetime: "datetime", binary: "bytes", json: "Any", uuid: "UUID", unknown: "Any" }[column.normalizedType];
|
||||
}
|
||||
function renderPython(shape: DatabaseModelShape, pydantic: boolean): string {
|
||||
const imports = pydantic
|
||||
? ["from typing import Any, Optional", "from datetime import date, datetime, time", "from uuid import UUID", "from pydantic import BaseModel"]
|
||||
: ["from dataclasses import dataclass", "from typing import Any, Optional", "from datetime import date, datetime, time", "from uuid import UUID"];
|
||||
const lines = [...imports, "", ...(pydantic ? [] : ["@dataclass(kw_only=True)"]), `class ${className(shape)}(${pydantic ? "BaseModel" : "object"}):`];
|
||||
if (!shape.columns.length) lines.push(" pass");
|
||||
for (const column of shape.columns) {
|
||||
lines.push(...commentLines(column.comment, " # ").map((line) => line), ` ${safeIdentifier(snakeCase(column.name))}: ${column.nullable ? `Optional[${pythonType(column)}]` : pythonType(column)}${column.nullable ? " = None" : ""}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function goType(column: DatabaseModelColumn): string {
|
||||
return { string: "string", integer: "int64", number: "float64", boolean: "bool", date: "time.Time", time: "time.Time", datetime: "time.Time", binary: "[]byte", json: "any", uuid: "string", unknown: "any" }[column.normalizedType];
|
||||
}
|
||||
function renderGo(shape: DatabaseModelShape): string {
|
||||
const lines = ["package models", "", ...(shape.columns.some((column) => ["date", "time", "datetime"].includes(column.normalizedType)) ? ['import "time"', ""] : []), `type ${className(shape)} struct {`];
|
||||
for (const column of shape.columns) lines.push(...commentLines(column.comment, "\t// "), `\t${pascalCase(column.name)} ${column.nullable ? "*" : ""}${goType(column)} \`json:"${column.name}"\``);
|
||||
lines.push("}");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderRust(shape: DatabaseModelShape): string {
|
||||
const map: Record<ModelScalarType, string> = { string: "String", integer: "i64", number: "f64", boolean: "bool", date: "String", time: "String", datetime: "String", binary: "Vec<u8>", json: "serde_json::Value", uuid: "String", unknown: "serde_json::Value" };
|
||||
const lines = ["use serde::{Deserialize, Serialize};", "", "#[derive(Debug, Clone, Serialize, Deserialize)]", `pub struct ${className(shape)} {`];
|
||||
for (const column of shape.columns) lines.push(...commentLines(column.comment, " /// "), ` #[serde(rename = ${JSON.stringify(column.name)})]`, ` pub ${safeIdentifier(snakeCase(column.name))}: ${column.nullable ? `Option<${map[column.normalizedType]}>` : map[column.normalizedType]},`);
|
||||
lines.push("}");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderKotlin(shape: DatabaseModelShape): string {
|
||||
const map: Record<ModelScalarType, string> = { string: "String", integer: "Long", number: "Double", boolean: "Boolean", date: "String", time: "String", datetime: "String", binary: "ByteArray", json: "String", uuid: "String", unknown: "Any" };
|
||||
const lines = [`data class ${className(shape)}(`];
|
||||
shape.columns.forEach((column, index) => lines.push(` val ${safeIdentifier(camelCase(column.name))}: ${map[column.normalizedType]}${column.nullable ? "?" : ""}${index === shape.columns.length - 1 ? "" : ","}`));
|
||||
lines.push(");");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderSwift(shape: DatabaseModelShape): string {
|
||||
const map: Record<ModelScalarType, string> = { string: "String", integer: "Int", number: "Double", boolean: "Bool", date: "Date", time: "String", datetime: "Date", binary: "Data", json: "String", uuid: "UUID", unknown: "String" };
|
||||
const lines = ["import Foundation", "", `struct ${className(shape)}: Codable {`];
|
||||
for (const column of shape.columns) lines.push(` let ${safeIdentifier(camelCase(column.name))}: ${map[column.normalizedType]}${column.nullable ? "?" : ""}`);
|
||||
lines.push("}");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderDart(shape: DatabaseModelShape): string {
|
||||
const map: Record<ModelScalarType, string> = { string: "String", integer: "int", number: "double", boolean: "bool", date: "DateTime", time: "String", datetime: "DateTime", binary: "List<int>", json: "dynamic", uuid: "String", unknown: "dynamic" };
|
||||
const lines = [`class ${className(shape)} {`];
|
||||
for (const column of shape.columns) lines.push(` final ${map[column.normalizedType]}${column.nullable ? "?" : ""} ${safeIdentifier(camelCase(column.name))};`);
|
||||
lines.push("", ` ${className(shape)}({`);
|
||||
for (const column of shape.columns) lines.push(` required this.${safeIdentifier(camelCase(column.name))},`);
|
||||
lines.push(" });", "}");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderJava(shape: DatabaseModelShape): string {
|
||||
const map: Record<ModelScalarType, string> = { string: "String", integer: "Long", number: "BigDecimal", boolean: "Boolean", date: "LocalDate", time: "LocalTime", datetime: "LocalDateTime", binary: "byte[]", json: "Object", uuid: "UUID", unknown: "Object" };
|
||||
const lines = ["import java.math.BigDecimal;", "import java.time.*;", "import java.util.UUID;", "", `public class ${className(shape)} {`];
|
||||
for (const column of shape.columns) {
|
||||
const name = safeIdentifier(camelCase(column.name));
|
||||
const type = map[column.normalizedType];
|
||||
lines.push(` private ${type} ${name};`, ` public ${type} get${pascalCase(name)}() { return ${name}; }`, ` public void set${pascalCase(name)}(${type} value) { this.${name} = value; }`);
|
||||
}
|
||||
lines.push("}");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderCsharp(shape: DatabaseModelShape): string {
|
||||
const map: Record<ModelScalarType, string> = { string: "string", integer: "long", number: "decimal", boolean: "bool", date: "DateOnly", time: "TimeOnly", datetime: "DateTime", binary: "byte[]", json: "JsonElement", uuid: "Guid", unknown: "object" };
|
||||
const lines = ["#nullable enable", "using System;", "using System.Text.Json;", "", `public sealed record ${className(shape)}(`];
|
||||
shape.columns.forEach((column, index) => lines.push(` ${map[column.normalizedType]}${column.nullable ? "?" : ""} ${pascalCase(column.name)}${index === shape.columns.length - 1 ? "" : ","}`));
|
||||
lines.push(");");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderPhp(shape: DatabaseModelShape): string {
|
||||
const map: Record<ModelScalarType, string> = { string: "string", integer: "int", number: "float", boolean: "bool", date: "string", time: "string", datetime: "DateTimeImmutable", binary: "string", json: "mixed", uuid: "string", unknown: "mixed" };
|
||||
const lines = ["<?php", "", `final class ${className(shape)}`, "{", " public function __construct("];
|
||||
shape.columns.forEach((column, index) => lines.push(` public ${column.nullable && map[column.normalizedType] !== "mixed" ? "?" : ""}${map[column.normalizedType]} $${safeIdentifier(camelCase(column.name))}${index === shape.columns.length - 1 ? "" : ","}`));
|
||||
lines.push(" ) {}", "}");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderRuby(shape: DatabaseModelShape): string {
|
||||
const map: Record<ModelScalarType, string> = { string: "String", integer: "Integer", number: "Numeric", boolean: "TrueClass", date: "Date", time: "String", datetime: "Time", binary: "String", json: "Object", uuid: "String", unknown: "Object" };
|
||||
const lines = [`class ${className(shape)}`, ` attr_accessor ${shape.columns.map((column) => `:${safeIdentifier(snakeCase(column.name))}`).join(", ")}`, "", ` def initialize(`];
|
||||
lines.push(shape.columns.map((column) => `${safeIdentifier(snakeCase(column.name))}: nil`).join(", "), " )");
|
||||
for (const column of shape.columns) lines.push(` @${safeIdentifier(snakeCase(column.name))} = ${safeIdentifier(snakeCase(column.name))} # ${map[column.normalizedType]}`);
|
||||
lines.push(" end", "end");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export const MODEL_RENDERERS: readonly ModelRendererMeta[] = [
|
||||
{ id: "typescript", label: "TypeScript", extension: "ts", render: renderTypeScript },
|
||||
{ id: "zod", label: "Zod", extension: "ts", render: renderZod },
|
||||
{ id: "yup", label: "Yup", extension: "ts", render: renderYup },
|
||||
{ id: "joi", label: "Joi", extension: "ts", render: renderJoi },
|
||||
{ id: "pydantic", label: "Pydantic", extension: "py", render: (shape) => renderPython(shape, true) },
|
||||
{ id: "python-dataclass", label: "Python dataclass", extension: "py", render: (shape) => renderPython(shape, false) },
|
||||
{ id: "go-struct", label: "Go struct", extension: "go", render: renderGo },
|
||||
{ id: "rust-struct", label: "Rust struct", extension: "rs", render: renderRust },
|
||||
{ id: "kotlin", label: "Kotlin data class", extension: "kt", render: renderKotlin },
|
||||
{ id: "swift", label: "Swift struct", extension: "swift", render: renderSwift },
|
||||
{ id: "dart", label: "Dart class", extension: "dart", render: renderDart },
|
||||
{ id: "java", label: "Java POJO", extension: "java", render: renderJava },
|
||||
{ id: "csharp", label: "C# record", extension: "cs", render: renderCsharp },
|
||||
{ id: "php", label: "PHP class", extension: "php", render: renderPhp },
|
||||
{ id: "ruby", label: "Ruby class", extension: "rb", render: renderRuby },
|
||||
];
|
||||
|
||||
export function modelRenderer(id: ModelFormatId): ModelRendererMeta | undefined {
|
||||
return MODEL_RENDERERS.find((renderer) => renderer.id === id);
|
||||
}
|
||||
|
||||
export function generateModel(shape: DatabaseModelShape, id: ModelFormatId, options: ModelGenerationOptions = {}): string {
|
||||
const renderer = modelRenderer(id);
|
||||
if (!renderer) throw new Error("Unknown model format");
|
||||
const prefix = ["python-dataclass", "pydantic", "ruby"].includes(id) ? "# " : "// ";
|
||||
const comments = [
|
||||
shape.tableComment,
|
||||
...shape.columns.map((column) => `${column.name}: ${column.originalType}${column.primaryKey ? "; PRIMARY KEY" : ""}${column.nullable ? "; NULL" : "; NOT NULL"}${column.defaultValue ? `; DEFAULT ${column.defaultValue}` : ""}${column.comment ? `; ${column.comment}` : ""}`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.flatMap((value) => commentLines(value, prefix))
|
||||
.join("\n");
|
||||
const used = new Set<string>();
|
||||
const named = {
|
||||
...shape,
|
||||
columns: shape.columns.map((column) => {
|
||||
const base = safeIdentifier(camelCase(column.name));
|
||||
let name = base;
|
||||
let suffix = 2;
|
||||
while (used.has(name.toLowerCase())) name = `${base}_${suffix++}`;
|
||||
used.add(name.toLowerCase());
|
||||
return { ...column, name };
|
||||
}),
|
||||
};
|
||||
let code = renderer.render(named);
|
||||
// Go and Rust must retain the original database field name in serialization tags.
|
||||
if (id === "go-struct" || id === "rust-struct") {
|
||||
named.columns.forEach((column, index) => {
|
||||
const original = shape.columns[index]!.name;
|
||||
if (id === "go-struct") code = code.replace(`json:"${column.name}"`, `json:"${original.replace(/["\\\x00-\x1f`]/g, "_")}"`);
|
||||
else code = code.replace(`rename = "${column.name}"`, `rename = ${JSON.stringify(original)}`);
|
||||
});
|
||||
}
|
||||
if (!options.includeHeaderComments) return `${code}\n`;
|
||||
return id === "php" ? code.replace("<?php", `<?php\n${comments}`) + "\n" : `${comments}\n\n${code}\n`;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ColumnInfo, DatabaseType, TreeNode } from "@/types/database";
|
||||
|
||||
export type ModelScalarType = "string" | "integer" | "number" | "boolean" | "date" | "time" | "datetime" | "binary" | "json" | "uuid" | "unknown";
|
||||
|
||||
export interface DatabaseModelColumn {
|
||||
name: string;
|
||||
originalType: string;
|
||||
normalizedType: ModelScalarType;
|
||||
nullable: boolean;
|
||||
primaryKey: boolean;
|
||||
defaultValue?: string | null;
|
||||
comment?: string | null;
|
||||
length?: number | null;
|
||||
precision?: number | null;
|
||||
scale?: number | null;
|
||||
}
|
||||
|
||||
export interface DatabaseModelShape {
|
||||
tableName: string;
|
||||
schema?: string;
|
||||
tableComment?: string | null;
|
||||
databaseType?: DatabaseType;
|
||||
columns: DatabaseModelColumn[];
|
||||
}
|
||||
|
||||
export function modelShapeFromTable(node: TreeNode, columns: ColumnInfo[], databaseType?: DatabaseType): DatabaseModelShape {
|
||||
return {
|
||||
tableName: node.label,
|
||||
schema: node.schema,
|
||||
tableComment: node.comment,
|
||||
databaseType,
|
||||
columns: columns.map((column) => ({
|
||||
name: column.name,
|
||||
originalType: column.data_type,
|
||||
normalizedType: normalizeModelScalarType(column.data_type),
|
||||
nullable: column.is_nullable,
|
||||
primaryKey: column.is_primary_key,
|
||||
defaultValue: column.column_default,
|
||||
comment: column.comment,
|
||||
length: column.character_maximum_length,
|
||||
precision: column.numeric_precision,
|
||||
scale: column.numeric_scale,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeModelScalarType(raw: string | null | undefined): ModelScalarType {
|
||||
const type = (raw ?? "").trim().toLowerCase().replace(/\s+/g, " ");
|
||||
const base = type.split("(")[0]?.trim() ?? type;
|
||||
if (/^(?:tiny|small|medium|big)?int(?:eger)?$/.test(base) || /^(?:serial|bigserial|smallserial)$/.test(base)) return "integer";
|
||||
if (/^(?:decimal|numeric|number|real|float|double|money|smallmoney)$/.test(base)) return "number";
|
||||
if (/^(?:bool|boolean|bit)$/.test(base)) return "boolean";
|
||||
if (/^(?:date)$/.test(base)) return "date";
|
||||
if (/^(?:time|timetz)$/.test(base)) return "time";
|
||||
if (/^(?:datetime|datetime2|smalldatetime|timestamp|timestamptz|timestamp with time zone|timestamp without time zone)$/.test(base)) return "datetime";
|
||||
if (/^(?:binary|varbinary|bytea|blob|tinyblob|mediumblob|longblob|image)$/.test(base)) return "binary";
|
||||
if (/^(?:json|jsonb|xml|hstore)$/.test(base)) return "json";
|
||||
if (/^(?:uuid|uniqueidentifier)$/.test(base)) return "uuid";
|
||||
if (/^(?:char|nchar|varchar|nvarchar|character|text|tinytext|mediumtext|longtext|citext|clob|nclob)$/.test(base)) return "string";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function pascalCase(value: string): string {
|
||||
const result = value
|
||||
.replace(/([a-z\d])([A-Z])/g, "$1 $2")
|
||||
.replace(/[^a-zA-Z0-9]+/g, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
return result || "GeneratedModel";
|
||||
}
|
||||
|
||||
export function camelCase(value: string): string {
|
||||
const pascal = pascalCase(value);
|
||||
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
||||
}
|
||||
|
||||
export function snakeCase(value: string): string {
|
||||
return (
|
||||
value
|
||||
.replace(/([a-z\d])([A-Z])/g, "$1_$2")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toLowerCase() || "field"
|
||||
);
|
||||
}
|
||||
|
||||
export function safeIdentifier(value: string, fallback = "field"): string {
|
||||
const normalized = value.replace(/[^a-zA-Z0-9_$]/g, "_");
|
||||
const words = new Set(
|
||||
"class public private protected internal record struct interface enum type string int long float double bool boolean char byte short void new return default switch case for while do if else try catch finally throw throws import export package namespace using from as in is def pass async await lambda with yield global nonlocal None True False null true false self super this static const let var val fun object data when match move ref mut pub use mod crate impl trait fn loop where dyn unsafe final extends implements native synchronized volatile transient abstract assert break continue goto instanceof typeof delete function required init deinit operator protocol extension associatedtype any mixed get set".split(
|
||||
" ",
|
||||
),
|
||||
);
|
||||
const candidate = /^[a-zA-Z_]/.test(normalized) ? normalized : `${fallback}_${normalized}`;
|
||||
return words.has(candidate) ? `${candidate}_field` : candidate;
|
||||
}
|
||||
|
||||
export function commentLines(value: string | null | undefined, prefix: string): string[] {
|
||||
if (!value?.trim()) return [];
|
||||
return value.split(/\r?\n/).map(
|
||||
(line) =>
|
||||
`${prefix}${line
|
||||
.trim()
|
||||
.replace(/\*\//g, "* /")
|
||||
.replace(/\\u/gi, "u")
|
||||
.replace(/[\u2028\u2029]/g, " ")}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { DatabaseModelShape } from "./modelShape";
|
||||
import { pascalCase } from "./modelShape";
|
||||
|
||||
export interface ModelTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
extension: string;
|
||||
body: string;
|
||||
}
|
||||
export function normalizeModelTemplates(value: unknown): ModelTemplate[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const ids = new Set<string>();
|
||||
return value
|
||||
.filter((item): item is ModelTemplate => {
|
||||
if (!item || typeof item.id !== "string" || !item.id || ids.has(item.id) || typeof item.name !== "string" || !item.name.trim() || typeof item.body !== "string" || typeof item.extension !== "string" || !/^[a-zA-Z0-9]+$/.test(item.extension)) return false;
|
||||
ids.add(item.id);
|
||||
return true;
|
||||
})
|
||||
.map(({ id, name, extension, body }) => ({ id, name, extension, body }));
|
||||
}
|
||||
|
||||
/** A data-only template interpreter: substitution, column iteration and boolean sections. */
|
||||
export function renderModelTemplate(template: string, shape: DatabaseModelShape): string {
|
||||
type Context = Record<string, unknown>;
|
||||
const base: Context = { "table.name": shape.tableName, "table.schema": shape.schema ?? "", "table.comment": shape.tableComment ?? "", "class.name": pascalCase(shape.tableName) };
|
||||
const tokens = template.split(/({{[\s\S]*?}})/g);
|
||||
type Node = string | { key: string; children?: Node[] };
|
||||
let position = 0;
|
||||
function parse(end?: string): Node[] {
|
||||
const nodes: Node[] = [];
|
||||
while (position < tokens.length) {
|
||||
const token = tokens[position++]!;
|
||||
if (!token.startsWith("{{")) {
|
||||
nodes.push(token);
|
||||
continue;
|
||||
}
|
||||
const key = token.slice(2, -2).trim();
|
||||
if (key.startsWith("/")) {
|
||||
if (key.slice(1) !== end) throw new Error(`Unexpected template section: ${key}`);
|
||||
return nodes;
|
||||
}
|
||||
if (key.startsWith("#")) nodes.push({ key: key.slice(1), children: parse(key.slice(1)) });
|
||||
else nodes.push({ key });
|
||||
}
|
||||
if (end) throw new Error(`Unclosed template section: ${end}`);
|
||||
return nodes;
|
||||
}
|
||||
function render(nodes: Node[], context: Context): string {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
if (typeof node === "string") return node;
|
||||
if (node.children && node.key === "columns")
|
||||
return shape.columns
|
||||
.map((column) =>
|
||||
render(node.children!, {
|
||||
...context,
|
||||
"column.name": column.name,
|
||||
"column.type": column.normalizedType,
|
||||
"column.originalType": column.originalType,
|
||||
"column.nullable": column.nullable,
|
||||
"column.primaryKey": column.primaryKey,
|
||||
"column.defaultValue": column.defaultValue ?? "",
|
||||
"column.comment": column.comment ?? "",
|
||||
}),
|
||||
)
|
||||
.join("");
|
||||
if (!Object.prototype.hasOwnProperty.call(context, node.key)) throw new Error(`Unknown template variable: ${node.key}`);
|
||||
return node.children ? (context[node.key] ? render(node.children, context) : "") : String(context[node.key]);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
return render(parse(), base);
|
||||
}
|
||||
@@ -59,6 +59,34 @@ describe("settingsTransfer", () => {
|
||||
expect(result.value.categories).toContain("shortcuts");
|
||||
});
|
||||
|
||||
it("round-trips custom model generation templates", () => {
|
||||
const settings: EditorSettings = {
|
||||
...DEFAULT_EDITOR_SETTINGS,
|
||||
modelGenerationTemplates: [{ id: "model-1", name: "GraphQL", extension: "graphql", body: "type {{class.name}} {}" }],
|
||||
};
|
||||
const result = parseSettingsTransferFile(serializeSettingsTransfer(settings));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.editorSettings.modelGenerationTemplates).toEqual(settings.modelGenerationTemplates);
|
||||
expect(result.value.categories).toContain("editor");
|
||||
});
|
||||
|
||||
it("rejects malformed model generation templates instead of dropping them", () => {
|
||||
const missingBody = parseSettingsTransferFile(fileWith({ modelGenerationTemplates: [{ id: "broken", name: "Broken", extension: "ts" }] }));
|
||||
expect(missingBody.ok).toBe(false);
|
||||
if (!missingBody.ok) expect(missingBody.error.detail).toContain("modelGenerationTemplates");
|
||||
|
||||
const duplicateIds = parseSettingsTransferFile(
|
||||
fileWith({
|
||||
modelGenerationTemplates: [
|
||||
{ id: "same", name: "One", extension: "ts", body: "one" },
|
||||
{ id: "same", name: "Two", extension: "ts", body: "two" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(duplicateIds.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps connection timeout ownership out of the payload", () => {
|
||||
const text = serializeSettingsTransfer(DEFAULT_EDITOR_SETTINGS);
|
||||
expect(text).not.toContain("connectTimeoutInheritConnectionIds");
|
||||
|
||||
@@ -94,6 +94,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
|
||||
"autoCalculateTotalRows",
|
||||
"flatteningMultiLineText",
|
||||
"dataGridShowWhitespace",
|
||||
"modelGenerationTemplates",
|
||||
"tableColumnTemplateFields",
|
||||
"shortcuts",
|
||||
"sqlFormatter",
|
||||
|
||||
@@ -186,6 +186,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readon
|
||||
"infiniteScroll",
|
||||
"regexMaxMatchCount",
|
||||
"autoCalculateTotalRows",
|
||||
"modelGenerationTemplates",
|
||||
"tableColumnTemplateFields",
|
||||
"redisKeyTemplates",
|
||||
"redisDatabaseDisplayLimit",
|
||||
@@ -397,6 +398,16 @@ function isSqlSnippetItem(value: unknown): boolean {
|
||||
return value.enabled === undefined || typeof value.enabled === "boolean";
|
||||
}
|
||||
|
||||
function isModelGenerationTemplatesShape(value: unknown): boolean {
|
||||
if (!Array.isArray(value)) return false;
|
||||
const ids = new Set<string>();
|
||||
return value.every((item) => {
|
||||
if (!isPlainObject(item) || typeof item.id !== "string" || !item.id || ids.has(item.id) || !isNonEmptyTrimmedString(item.name) || typeof item.extension !== "string" || !/^[a-zA-Z0-9]+$/.test(item.extension) || typeof item.body !== "string") return false;
|
||||
ids.add(item.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function isSqlShortcutActionItem(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
if (!isNonEmptyTrimmedString(value.id) || !isNonEmptyTrimmedString(value.label) || typeof value.shortcut !== "string" || typeof value.sql !== "string") return false;
|
||||
@@ -460,6 +471,7 @@ const NESTED_FIELD_VALIDATORS: Partial<Record<EditorSettingsDraftKey, (value: un
|
||||
sidebarHiddenTablePrefixes: isNonEmptyStringArray,
|
||||
redisKeyTemplates: isNonEmptyStringArray,
|
||||
snippets: (value) => isArrayOfShape(value, isSqlSnippetItem),
|
||||
modelGenerationTemplates: isModelGenerationTemplatesShape,
|
||||
sqlShortcuts: (value) => isArrayOfShape(value, isSqlShortcutActionItem),
|
||||
sqlVariableSyntaxOverrides: (value) => isSqlVariableSyntaxOverridesShape(value, (toggle) => toggle === false),
|
||||
};
|
||||
@@ -484,6 +496,7 @@ const RAW_STRUCTURED_FIELD_VALIDATORS: Partial<Record<EditorSettingsDraftKey, (v
|
||||
sidebarHiddenTablePrefixes: isStringArray,
|
||||
redisKeyTemplates: isStringArray,
|
||||
snippets: (value) => isArrayOfShape(value, isSqlSnippetItem),
|
||||
modelGenerationTemplates: isModelGenerationTemplatesShape,
|
||||
sqlShortcuts: (value) => isArrayOfShape(value, isSqlShortcutActionItem),
|
||||
sqlVariableSyntaxOverrides: (value) => isSqlVariableSyntaxOverridesShape(value, (toggle) => typeof toggle === "boolean"),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeModelTemplates, type ModelTemplate } from "@/lib/model/modelTemplates";
|
||||
import { normalizePluginShortcutSettings, type PluginShortcutSettings } from "@/lib/plugins/pluginShortcuts";
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
@@ -1067,6 +1068,7 @@ export interface EditorSettings {
|
||||
globalDateTimeImportFormat: string;
|
||||
snippets: SqlSnippet[];
|
||||
sqlShortcuts: SqlShortcutAction[];
|
||||
modelGenerationTemplates: ModelTemplate[];
|
||||
tableColumnTemplateFields: string[];
|
||||
exportBatchSize: number;
|
||||
preferredExportPath: string;
|
||||
@@ -1376,6 +1378,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
||||
globalDateTimeImportFormat: "",
|
||||
snippets: DEFAULT_SQL_SNIPPETS,
|
||||
sqlShortcuts: DEFAULT_SQL_SHORTCUTS,
|
||||
modelGenerationTemplates: [],
|
||||
tableColumnTemplateFields: [...DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS],
|
||||
exportBatchSize: 2000,
|
||||
preferredExportPath: "",
|
||||
@@ -2045,6 +2048,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
|
||||
globalDateTimeImportFormat: normalizeGlobalDateTimePattern(settings.globalDateTimeImportFormat),
|
||||
snippets: normalizeSqlSnippets(settings.snippets, existing?.snippets),
|
||||
sqlShortcuts: normalizeSqlShortcuts(settings.sqlShortcuts, existing?.sqlShortcuts),
|
||||
modelGenerationTemplates: normalizeModelTemplates(settings.modelGenerationTemplates),
|
||||
tableColumnTemplateFields: normalizeTableColumnTemplateFields(settings.tableColumnTemplateFields),
|
||||
exportBatchSize: typeof settings.exportBatchSize === "number" && settings.exportBatchSize >= 100 && settings.exportBatchSize <= 100000 ? Math.round(settings.exportBatchSize) : DEFAULT_EDITOR_SETTINGS.exportBatchSize,
|
||||
preferredExportPath: typeof settings.preferredExportPath === "string" ? settings.preferredExportPath.trim() : DEFAULT_EDITOR_SETTINGS.preferredExportPath,
|
||||
@@ -2876,6 +2880,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
if (partial.globalDateTimeImportFormat !== undefined) editorSettings.value.globalDateTimeImportFormat = normalizeGlobalDateTimePattern(partial.globalDateTimeImportFormat);
|
||||
if (partial.snippets !== undefined) editorSettings.value.snippets = normalizeSqlSnippets(partial.snippets);
|
||||
if (partial.sqlShortcuts !== undefined) editorSettings.value.sqlShortcuts = normalizeSqlShortcuts(partial.sqlShortcuts);
|
||||
if (partial.modelGenerationTemplates !== undefined) editorSettings.value.modelGenerationTemplates = normalizeModelTemplates(partial.modelGenerationTemplates);
|
||||
if (partial.tableColumnTemplateFields !== undefined) editorSettings.value.tableColumnTemplateFields = normalizeTableColumnTemplateFields(partial.tableColumnTemplateFields);
|
||||
if (partial.exportBatchSize !== undefined) editorSettings.value.exportBatchSize = Math.min(100000, Math.max(100, Math.round(partial.exportBatchSize)));
|
||||
if (partial.preferredExportPath !== undefined) editorSettings.value.preferredExportPath = typeof partial.preferredExportPath === "string" ? partial.preferredExportPath.trim() : "";
|
||||
|
||||
Reference in New Issue
Block a user