feat: expand data-grid copy formats and metadata (#12777)

* feat: extend data grid copy formats

* fix(data-grid): address copy parity review findings

* test(sidebar): cover qualified multi-copy names

* fix(grid): retain row header option for TSV

* fix(i18n): remove unused extractor import

* fix(grid): satisfy clippy for unsigned types

* fix(grid): address copy parity review findings

* fix(grid): preserve exact PHP decimal values

* fix(grid): pass dialect into PHP extraction

* fix(grid): require known dialect for PHP floats

* style(grid): format metadata copy test

* Avoid lint warning in metadata formula guard

* Preserve standalone at-sign metadata values

* Align PHP date option with extractor wire contract

* Distinguish binary floats from binary data

* Preserve exact decimal precision across query results

* Cover CLI exact decimal formatting

* docs: clarify data grid copy entry points

* fix: satisfy Rust clippy for decimal serializer

* fix: preserve PHP single-quoted line breaks

* docs: add verified data grid copy example

* test: cover qualified sidebar object namespaces

* feat(grid): add temporary advanced copy workflow

* fix(grid): guard advanced copy preview lifecycle

* docs(grid): clarify copy format settings without stale preview

* fix(copy): preserve identifier and null-marker semantics

* test(copy): assert null-disabled marker collisions are allowed

* style(copy): match CI rustfmt ordering
This commit is contained in:
efeire authored and GitHub committed 2026-10-20 20:10:31 +08:00
1 parent 1e1467083c
commit 5518830440
50 files changed
+4190 -223

No files matched your search

+152 -2
View File
@@ -90,6 +90,7 @@ import DataGridToolbar from "@/components/grid/DataGridToolbar.vue";
import DataGridVirtualRowIdentifier from "@/components/grid/DataGridVirtualRowIdentifier.vue";
import DataGridExtractorDialog from "@/components/grid/DataGridExtractorDialog.vue";
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
import { formatDataGridColumnMetadataForCopy } from "@/lib/dataGrid/dataGridColumnMetadataCopy";
import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
import DataGridFilterModeOrderDialog from "@/components/grid/DataGridFilterModeOrderDialog.vue";
@@ -9155,6 +9156,7 @@ const {
columnComments: visibleColumnComments,
allColumnComments,
displayValue: (value, index) => formatCell(value, index, undefined, false),
editValue: (value, index) => cellEditorTextForValue(value, index),
hasColumnFormatter: (index: number) => Boolean(resolvedColumnFormatters.value[index]),
applyColumnFormattersOnExport: computed(() => settingsStore.editorSettings.applyColumnFormattersOnExport),
cellClipboardText: (value) => (usesMongoDocumentGridValues.value ? mongoDocumentGridClipboardText(value) : undefined),
@@ -9223,6 +9225,8 @@ function copyExtractorLabel(extractor: DataGridCopyExtractorId): string {
html: "HTML",
xml: "XML",
pretty: t("grid.copyExtractorPretty"),
dbunit: "DbUnit",
php: "PHP",
};
return labels[extractor];
}
@@ -9242,6 +9246,86 @@ function sqlInsertModeLabel(mode: DataGridPrimaryKeyMode): string {
const selectedCopyPreference = computed(() => settingsStore.editorSettings.dataGridCopyExtractor);
const defaultCopyPreferenceLabel = computed(() => copyPreferenceLabel(selectedCopyPreference.value));
const extractorConfigOpen = ref(false);
const advancedCopyOpen = ref(false);
let advancedCopySession = 0;
const advancedCopyGeneration = ref(0);
let advancedCopyBinding: ReturnType<typeof advancedCopySnapshot> | undefined;
const advancedCopySelectionKey = computed(() =>
JSON.stringify({
rows: [...selectedRowIds.value].sort((a, b) => a - b),
columns: [...selectedColumnIndexes.value].sort((a, b) => a - b),
cells: [...selectedCellKeys.value].sort(),
range: selectedRange.value,
anchor: selectionAnchor.value,
focus: selectionFocus.value,
}),
);
watch(
[() => props.result, advancedCopySelectionKey],
() => {
advancedCopyGeneration.value += 1;
},
{ flush: "sync" },
);
function advancedCopySnapshot() {
return {
result: props.result,
rows: props.result.rows,
columns: props.result.columns,
selection: advancedCopySelectionKey.value,
generation: advancedCopyGeneration.value,
};
}
function isAdvancedCopySessionValid(session: number, binding: ReturnType<typeof advancedCopySnapshot>): boolean {
const current = advancedCopySnapshot();
return advancedCopyOpen.value && session === advancedCopySession && current.result === binding.result && current.rows === binding.rows && current.columns === binding.columns && current.selection === binding.selection && current.generation === binding.generation;
}
function openAdvancedCopy() {
advancedCopySession += 1;
advancedCopyBinding = advancedCopySnapshot();
advancedCopyOpen.value = true;
}
watch(advancedCopyOpen, (open) => {
if (open) return;
advancedCopySession += 1;
advancedCopyBinding = undefined;
});
onUnmounted(() => {
advancedCopySession += 1;
advancedCopyOpen.value = false;
});
function canCopyAdvancedFormat(preference: DataGridCopyPreference): boolean {
if (preference === "smart") return canCopyWithExtractor("raw") || canCopyWithExtractor("tsv");
return canCopyWithExtractor(preference);
}
async function copyAdvancedFormat(preference: DataGridCopyPreference, extractorOptions: typeof settingsStore.editorSettings.dataGridExtractorOptions): Promise<boolean> {
const session = advancedCopySession;
const binding = advancedCopyBinding;
const isValid = () => !!binding && isAdvancedCopySessionValid(session, binding);
if (!isValid()) throw new Error(t("grid.copyExtractorSelectionChanged"));
const copied = await copyWithPreference(preference, extractorOptions, isValid);
if (!copied && !isValid()) throw new Error(t("grid.copyExtractorSelectionChanged"));
return copied;
}
async function previewAdvancedCopy(preference: DataGridCopyPreference, extractorOptions: typeof settingsStore.editorSettings.dataGridExtractorOptions) {
const session = advancedCopySession;
const binding = advancedCopyBinding;
const isValid = () => !!binding && isAdvancedCopySessionValid(session, binding);
if (!isValid()) throw new Error(t("grid.copyExtractorSelectionChanged"));
const preview = await previewWithPreference(preference, extractorOptions);
if (!isValid()) throw new Error(t("grid.copyExtractorSelectionChanged"));
return preview;
}
const copyPreferenceMenuItems = computed(() =>
DATA_GRID_DEFAULT_COPY_PREFERENCES.map((preference) => {
const extractor = preference === "smart" ? undefined : preference;
@@ -9253,6 +9337,13 @@ const copyPreferenceMenuItems = computed(() =>
};
}),
);
const visibleCopyExtractorItems = computed(() =>
DATA_GRID_COPY_EXTRACTOR_IDS.map((extractor) => ({
value: extractor,
label: copyExtractorLabel(extractor),
disabled: extractorUnavailableForDatabase(extractor, props.databaseType) || ((extractor === "sql-updates" || extractor === "sql-deletes-inserts") && !props.tableMeta?.primaryKeys.length),
})),
);
function openExtractorConfiguration() {
extractorConfigOpen.value = true;
}
@@ -9273,10 +9364,11 @@ function sqlInsertExtractorOptions(primaryKeyMode: DataGridPrimaryKeyMode) {
};
}
function saveExtractorConfiguration(value: { preference: DataGridCopyPreference; options: typeof settingsStore.editorSettings.dataGridExtractorOptions }) {
function saveExtractorConfiguration(value: { preference: DataGridCopyPreference; options: typeof settingsStore.editorSettings.dataGridExtractorOptions; visibleCopyExtractors: typeof settingsStore.editorSettings.dataGridVisibleCopyExtractors }) {
settingsStore.updateEditorSettings({
dataGridCopyExtractor: value.preference,
dataGridExtractorOptions: value.options,
dataGridVisibleCopyExtractors: value.visibleCopyExtractors,
});
// The dialog closes on save; without this the write is invisible and users
// report the save button as doing nothing (#9872).
@@ -12169,6 +12261,29 @@ function copyHeaderColumnOrSelected() {
void copyHeaderColumn();
}
function copyContextColumnMetadata() {
const visibleIndexes =
contextHeaderVisibleColIdx.value !== null && selectedColumnIndexes.value.has(contextHeaderVisibleColIdx.value) && selectedColumnIndexes.value.size > 1 ? [...selectedColumnIndexes.value].sort((a, b) => a - b) : contextHeaderVisibleColIdx.value !== null ? [contextHeaderVisibleColIdx.value] : [];
const rows = visibleIndexes
.map((visibleIndex) => {
const actualIndex = visibleColumnIndexes.value[visibleIndex];
const name = visibleColumns.value[visibleIndex];
if (actualIndex === undefined || name === undefined) return undefined;
const info = tableColumnForGridColumn(actualIndex);
const sourceName = props.sourceColumns?.[actualIndex] ?? info?.name;
return {
name,
type: allColumnTypes.value[actualIndex],
source: sourceName,
nullable: info ? info.is_nullable : undefined,
comment: resolvedColumnComment(name, actualIndex) || undefined,
};
})
.filter((row): row is NonNullable<typeof row> => row !== undefined);
if (rows.length === 0) return;
void copyText(formatDataGridColumnMetadataForCopy(rows));
}
// 「复制列注释」的目标列:显式多选时按选中列,否则用右键命中的那列。
function contextCommentActualColumnIndexes(): number[] {
if (contextHeaderColumnIndex.value === null) return [];
@@ -13914,6 +14029,7 @@ function buildExtractorContextItems(destination: "copy" | "export" = "copy"): Co
const items: ContextMenuItem[] = [];
let separatorPending = false;
for (const extractor of DATA_GRID_COPY_EXTRACTOR_IDS) {
if (destination === "copy" && !settingsStore.editorSettings.dataGridVisibleCopyExtractors.includes(extractor)) continue;
if (extractor === "raw" && !canCopyWithExtractor(extractor)) continue;
const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor];
if (descriptor.separatorBefore) {
@@ -13971,6 +14087,12 @@ function copySubmenu(): ContextMenuItem {
icon: Settings2,
action: openExtractorConfiguration,
},
{
label: t("grid.copyExtractorAdvancedCopy"),
icon: Copy,
action: openAdvancedCopy,
disabled: !copyPreferenceMenuItems.value.some((item) => !item.disabled && canCopyAdvancedFormat(item.value)),
},
defaultCopyPreferenceContextItem(),
{ label: "", separator: true },
];
@@ -14149,6 +14271,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
headerColumn: !!contextHeaderColumn.value,
contextColumn: !!contextColumn.value,
canCopyAlterSql: canCopyAlterColumnSql.value,
canCopyMetadata: true,
canCopyComment: contextCopyableColumnComments.value.length > 0,
canFilter: canUseWhereSearch.value,
hasSort: !!sortCol.value,
@@ -14168,6 +14291,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
count: selectedColumnNamesForCopy.value.length,
})
: t("grid.copyColumnName"),
copyMetadata: t("grid.copyColumnMetadata"),
copyComment: t("grid.copyColumnComment"),
copyNames: t("grid.copyColumnNames"),
details: t("grid.openColumnDetailsDialog"),
@@ -14201,6 +14325,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
copyNames: openCopyAllColumnNamesDialog,
details: openContextColumnDetailDialog,
copyAlterSql: copyAlterColumnSql,
copyMetadata: copyContextColumnMetadata,
sort: applyContextSort,
freezeToColumn: () => {
const idx = contextHeaderVisibleColIdx.value;
@@ -16609,7 +16734,32 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
</DialogContent>
</Dialog>
<DataGridExtractorDialog v-model:open="extractorConfigOpen" :preference="selectedCopyPreference" :options="settingsStore.editorSettings.dataGridExtractorOptions" :items="copyPreferenceMenuItems" :preview="previewWithPreference" @save="saveExtractorConfiguration" />
<DataGridExtractorDialog
v-model:open="extractorConfigOpen"
mode="configure"
:preference="selectedCopyPreference"
:source-table-name="props.tableMeta?.tableName"
:visible-copy-extractors="settingsStore.editorSettings.dataGridVisibleCopyExtractors"
:options="settingsStore.editorSettings.dataGridExtractorOptions"
:items="copyPreferenceMenuItems"
:visible-items="visibleCopyExtractorItems"
:preview="previewWithPreference"
@save="saveExtractorConfiguration"
/>
<DataGridExtractorDialog
v-model:open="advancedCopyOpen"
mode="copy"
:preference="selectedCopyPreference"
:source-table-name="props.tableMeta?.tableName"
:visible-copy-extractors="settingsStore.editorSettings.dataGridVisibleCopyExtractors"
:options="settingsStore.editorSettings.dataGridExtractorOptions"
:items="copyPreferenceMenuItems"
:visible-items="visibleCopyExtractorItems"
:preview="previewAdvancedCopy"
:preview-session-key="advancedCopyGeneration"
:can-copy="canCopyAdvancedFormat"
:copy="copyAdvancedFormat"
/>
<DataGridCopyColumnNamesDialog v-if="copyColumnNamesDialogMounted" v-model:open="copyColumnNamesDialogOpen" :column-names="copyColumnNamesDialogColumns" :database-type="resolvedDatabaseType" :column-comments="columnCommentMap" @copy="copyText" />
<GridSnapshotDialog v-model:open="gridSnapshotOpen" :source="gridSnapshotSource" />
<DataGridFilterModeOrderDialog v-model:open="filterModeOrderDialogOpen" :database-type="resolvedDatabaseType" :options="supportedFilterModeOptions" @saved="onFilterModeOrderSaved" />
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
@@ -8,6 +8,9 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS,
DATA_GRID_COPY_EXTRACTOR_IDS,
DATA_GRID_DEFAULT_VISIBLE_COPY_EXTRACTORS,
normalizeVisibleDataGridCopyExtractors,
normalizeDataGridExtractorOptions,
validateDataGridExtractorOptions,
type DataGridCopyExtractorId,
@@ -15,39 +18,62 @@ import {
type DataGridExtractorOptions,
type DataGridExtractPreview,
type DataGridExtractWarningCode,
type DataGridValueFormat,
} from "@/lib/dataGrid/dataGridCopyExtractor";
const props = defineProps<{
open: boolean;
preference: DataGridCopyPreference;
sourceTableName?: string;
visibleCopyExtractors: DataGridCopyExtractorId[];
options: DataGridExtractorOptions;
mode?: "configure" | "copy";
previewSessionKey?: number;
items: Array<{ value: DataGridCopyPreference; label: string; disabled?: boolean }>;
visibleItems: Array<{ value: DataGridCopyExtractorId; label: string; disabled?: boolean }>;
preview: (preference: DataGridCopyPreference, options: DataGridExtractorOptions) => Promise<DataGridExtractPreview>;
canCopy?: (preference: DataGridCopyPreference, options: DataGridExtractorOptions) => boolean;
copy?: (preference: DataGridCopyPreference, options: DataGridExtractorOptions) => Promise<boolean>;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
save: [value: { preference: DataGridCopyPreference; options: DataGridExtractorOptions }];
save: [value: { preference: DataGridCopyPreference; options: DataGridExtractorOptions; visibleCopyExtractors: DataGridCopyExtractorId[] }];
}>();
const { t } = useI18n();
const draftPreference = ref<DataGridCopyPreference>(props.preference);
const formatRadioGroupName = `grid-copy-format-${useId()}`;
const selectedFormat = ref<DataGridCopyPreference>(props.preference);
const draftDefaultPreference = ref<DataGridCopyPreference>(props.preference);
const draftOptions = ref<DataGridExtractorOptions>(normalizeDataGridExtractorOptions(props.options));
const draftVisibleCopyExtractors = ref<DataGridCopyExtractorId[]>(normalizeVisibleDataGridCopyExtractors(props.visibleCopyExtractors));
const previewText = ref("");
const previewError = ref("");
const copyError = ref("");
const previewWarnings = ref<string[]>([]);
const previewLoading = ref(false);
const previewTruncatedRows = ref(0);
const previewExpanded = ref(false);
const copyRunning = ref(false);
let previewSequence = 0;
let dialogSession = 0;
let previewTimer: ReturnType<typeof setTimeout> | undefined;
const draftExtractor = computed<DataGridCopyExtractorId | null>(() => (draftPreference.value === "smart" ? null : draftPreference.value));
const draftExtractor = computed<DataGridCopyExtractorId | null>(() => (selectedFormat.value === "smart" ? null : selectedFormat.value));
const extractorCategory = computed(() => (draftExtractor.value ? DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[draftExtractor.value].category : null));
const isDsv = computed(() => extractorCategory.value === "delimited");
const supportsCsvOptions = computed(() => draftExtractor.value === "csv" || draftExtractor.value === "csv-with-headers" || draftExtractor.value === "dsv");
const isSql = computed(() => draftExtractor.value === "sql-inserts" || draftExtractor.value === "sql-deletes-inserts" || draftExtractor.value === "sql-updates");
const isJson = computed(() => extractorCategory.value === "json");
const isDbunit = computed(() => draftExtractor.value === "dbunit");
const isPhp = computed(() => draftExtractor.value === "php");
const hasSpecificOptions = computed(() => isDsv.value || isSql.value || isJson.value || isDbunit.value || isPhp.value);
const canCopySelectedFormat = computed(() => props.mode !== "copy" || !!props.canCopy?.(selectedFormat.value, normalizeDataGridExtractorOptions(draftOptions.value)));
const optionsError = computed(() => {
if (!draftExtractor.value) return "";
if (draftExtractor.value === "dbunit" && !props.sourceTableName && !draftOptions.value.dbunit.tableName.trim()) return t("grid.copyDbunitTableNameRequired");
if (draftExtractor.value === "php" && draftOptions.value.php.variableName !== "" && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(draftOptions.value.php.variableName)) return t("grid.copyPhpVariableInvalid");
if (draftExtractor.value === "php" && draftOptions.value.php.rowDelimiter.length > 8) return t("grid.copyExtractorValidation.separator-too-long");
const code = validateDataGridExtractorOptions(draftExtractor.value, draftOptions.value);
if (!code) return "";
return t(`grid.copyExtractorValidation.${code}`);
@@ -73,7 +99,23 @@ function updateJson<K extends keyof DataGridExtractorOptions["json"]>(key: K, va
draftOptions.value = { ...draftOptions.value, json: { ...draftOptions.value.json, [key]: value } };
}
function updateDbunit<K extends keyof DataGridExtractorOptions["dbunit"]>(key: K, value: DataGridExtractorOptions["dbunit"][K]) {
draftOptions.value = { ...draftOptions.value, dbunit: { ...draftOptions.value.dbunit, [key]: value } };
}
function updatePhp<K extends keyof DataGridExtractorOptions["php"]>(key: K, value: DataGridExtractorOptions["php"][K]) {
draftOptions.value = { ...draftOptions.value, php: { ...draftOptions.value.php, [key]: value } };
}
function toggleVisibleCopyExtractor(extractor: DataGridCopyExtractorId, visible: boolean) {
const current = new Set(draftVisibleCopyExtractors.value);
if (visible) current.add(extractor);
else current.delete(extractor);
draftVisibleCopyExtractors.value = DATA_GRID_COPY_EXTRACTOR_IDS.filter((item) => current.has(item));
}
async function refreshPreview() {
if (!props.open || !previewExpanded.value) return;
const sequence = ++previewSequence;
previewLoading.value = true;
previewError.value = "";
@@ -85,7 +127,7 @@ async function refreshPreview() {
return;
}
try {
const result = await props.preview(draftPreference.value, normalizeDataGridExtractorOptions(draftOptions.value));
const result = await props.preview(selectedFormat.value, normalizeDataGridExtractorOptions(draftOptions.value));
if (sequence === previewSequence) {
previewText.value = result.text;
previewWarnings.value = (result.warnings ?? []).map((warning) => warningText(warning.code, result.omittedColumns));
@@ -102,40 +144,81 @@ async function refreshPreview() {
}
function warningText(code: DataGridExtractWarningCode, omittedColumns: string[] | undefined): string {
return code === "omitted-columns" ? t("grid.copyExtractorWarningOmittedColumns", { columns: omittedColumns?.join(", ") || "-" }) : t("grid.copyExtractorWarningDuplicateJsonColumns");
if (code === "omitted-columns") return t("grid.copyExtractorWarningOmittedColumns", { columns: omittedColumns?.join(", ") || "-" });
if (code === "php-date-not-iso") return t("grid.copyExtractorWarningPhpDateNotIso");
return t("grid.copyExtractorWarningDuplicateJsonColumns");
}
function schedulePreview() {
if (!props.open || !previewExpanded.value) return;
previewSequence += 1;
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(() => void refreshPreview(), 180);
}
watch(
() => props.open,
(open) => {
() => [props.open, props.mode] as const,
([open]) => {
if (!open) {
dialogSession += 1;
if (previewTimer) clearTimeout(previewTimer);
previewTimer = undefined;
previewSequence += 1;
previewLoading.value = false;
return;
}
draftPreference.value = props.preference;
dialogSession += 1;
selectedFormat.value = props.preference;
draftDefaultPreference.value = props.preference;
draftOptions.value = normalizeDataGridExtractorOptions(props.options);
schedulePreview();
draftVisibleCopyExtractors.value = normalizeVisibleDataGridCopyExtractors(props.visibleCopyExtractors);
previewExpanded.value = false;
copyRunning.value = false;
previewText.value = "";
previewError.value = "";
copyError.value = "";
},
{ immediate: true },
);
watch(
[draftPreference, draftOptions],
[selectedFormat, draftOptions],
() => {
if (props.open) schedulePreview();
copyError.value = "";
if (props.open && previewExpanded.value) schedulePreview();
},
{ deep: true },
);
watch(previewExpanded, (expanded) => {
if (expanded) {
schedulePreview();
return;
}
if (previewTimer) clearTimeout(previewTimer);
previewTimer = undefined;
previewSequence += 1;
previewLoading.value = false;
previewText.value = "";
previewError.value = "";
previewWarnings.value = [];
previewTruncatedRows.value = 0;
});
watch(
() => props.previewSessionKey,
() => {
if (props.mode !== "copy" || !props.open) return;
previewSequence += 1;
if (previewTimer) clearTimeout(previewTimer);
previewTimer = undefined;
previewLoading.value = false;
previewText.value = "";
previewWarnings.value = [];
previewError.value = t("grid.copyExtractorSelectionChanged");
},
);
onBeforeUnmount(() => {
if (previewTimer) clearTimeout(previewTimer);
previewSequence += 1;
@@ -143,23 +226,61 @@ onBeforeUnmount(() => {
function save() {
if (optionsError.value) return;
emit("save", { preference: draftPreference.value, options: normalizeDataGridExtractorOptions(draftOptions.value) });
emit("save", { preference: draftDefaultPreference.value, options: normalizeDataGridExtractorOptions(draftOptions.value), visibleCopyExtractors: [...draftVisibleCopyExtractors.value] });
emit("update:open", false);
}
async function performCopy() {
if (copyRunning.value || optionsError.value || !canCopySelectedFormat.value || !props.copy) return;
const session = dialogSession;
copyRunning.value = true;
copyError.value = "";
try {
const copied = await props.copy(selectedFormat.value, normalizeDataGridExtractorOptions(draftOptions.value));
if (session !== dialogSession || !props.open) return;
if (copied) emit("update:open", false);
else copyError.value = t("grid.copyExtractorCopyFailed");
} catch (error: unknown) {
if (session === dialogSession && props.open) copyError.value = error instanceof Error ? error.message : String(error);
} finally {
if (session === dialogSession) copyRunning.value = false;
}
}
function togglePreview() {
previewExpanded.value = !previewExpanded.value;
}
function handleFormatKeydown(event: KeyboardEvent, itemIndex: number) {
const keyOffsets: Record<string, number> = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 };
const enabledItems = props.items.map((item, index) => ({ item, index })).filter(({ item }) => !item.disabled);
const currentEnabledIndex = enabledItems.findIndex(({ index }) => index === itemIndex);
let targetEnabledIndex: number;
if (event.key === "Home") targetEnabledIndex = 0;
else if (event.key === "End") targetEnabledIndex = enabledItems.length - 1;
else if (event.key in keyOffsets) targetEnabledIndex = (currentEnabledIndex + keyOffsets[event.key]! + enabledItems.length) % enabledItems.length;
else return;
const target = enabledItems[targetEnabledIndex];
if (!target) return;
event.preventDefault();
selectedFormat.value = target.item.value;
void nextTick(() => document.getElementById(`${formatRadioGroupName}-${target.item.value}`)?.focus());
}
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="max-h-[85vh] overflow-y-auto sm:max-w-[760px]">
<DialogHeader
><DialogTitle>{{ t("grid.copyExtractorConfigure") }}</DialogTitle></DialogHeader
>
<DialogContent class="flex max-h-[90dvh] flex-col sm:max-w-[1040px]">
<DialogHeader>
<DialogTitle>{{ t(mode === "copy" ? "grid.copyExtractorAdvancedCopy" : "grid.copyExtractorConfigure") }}</DialogTitle>
<p class="text-sm text-muted-foreground">{{ t(mode === "copy" ? "grid.copyExtractorAdvancedCopyHint" : "grid.copyExtractorConfigureHint") }}</p>
</DialogHeader>
<div class="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)]">
<div class="space-y-3">
<div class="space-y-1.5">
<div class="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 overflow-hidden md:grid-cols-[210px_minmax(0,1fr)] md:grid-rows-1">
<aside class="flex min-h-0 flex-col gap-3">
<div v-if="mode !== 'copy'" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorDefaultFormat") }}</Label>
<Select v-model="draftPreference">
<Select v-model="draftDefaultPreference">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="item in items" :key="item.value" :value="item.value" :disabled="item.disabled">{{ item.label }}</SelectItem>
@@ -167,107 +288,273 @@ function save() {
</Select>
</div>
<p v-if="draftExtractor === 'raw'" class="text-xs text-muted-foreground">{{ t("grid.copyExtractorRawHint") }}</p>
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
<Label class="mb-1.5 block">{{ t("grid.copyExtractorFormatList") }}</Label>
<div role="radiogroup" :aria-label="t('grid.copyExtractorFormatList')" class="space-y-1">
<label v-for="(item, index) in items" :key="item.value" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50">
<input
:id="`${formatRadioGroupName}-${item.value}`"
v-model="selectedFormat"
type="radio"
:name="formatRadioGroupName"
:value="item.value"
:disabled="item.disabled"
class="accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@keydown="handleFormatKeydown($event, index)"
/>
<span>{{ item.label }}</span>
</label>
</div>
</div>
<template v-if="isDsv">
<div v-if="draftExtractor === 'dsv'" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorColumnSeparator") }}</Label>
<Input :model-value="visibleSeparator(draftOptions.dsv.columnSeparator)" @update:model-value="updateDsv('columnSeparator', separatorValue(String($event)))" />
<details v-if="mode !== 'copy'" class="max-h-[28vh] shrink-0 overflow-y-auto border-t pt-2">
<summary class="cursor-pointer text-sm font-medium">{{ t("grid.copyExtractorVisibleFormats") }}</summary>
<div class="flex items-center justify-between gap-2 py-2">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="draftVisibleCopyExtractors = [...DATA_GRID_DEFAULT_VISIBLE_COPY_EXTRACTORS]">{{ t("grid.copyExtractorRestoreVisibleFormats") }}</Button>
</div>
<div v-if="draftExtractor !== 'one-row'" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorRowSeparator") }}</Label>
<Input :model-value="visibleSeparator(draftOptions.dsv.rowSeparator)" @update:model-value="updateDsv('rowSeparator', separatorValue(String($event)))" />
<label v-for="item in visibleItems" :key="item.value" class="flex items-center gap-2 py-1 text-sm">
<input type="checkbox" :checked="draftVisibleCopyExtractors.includes(item.value)" @change="toggleVisibleCopyExtractor(item.value, ($event.target as HTMLInputElement).checked)" />{{ item.label }}
</label>
</details>
</aside>
<section class="flex min-h-0 min-w-0 flex-col gap-3">
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
<p v-if="selectedFormat === 'smart'" class="text-sm text-muted-foreground">{{ t("grid.copyExtractorSmart") }}</p>
<p v-else-if="draftExtractor === 'raw'" class="text-sm text-muted-foreground">{{ t("grid.copyExtractorRawHint") }}</p>
<div v-if="draftExtractor && draftExtractor !== 'raw' && !isSql" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorValueFormat") }}</Label>
<Select :model-value="draftOptions.valueFormat" @update:model-value="draftOptions = { ...draftOptions, valueFormat: $event as DataGridValueFormat }">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent
><SelectItem value="default">{{ t("grid.copyExtractorValueFormatDefault") }}</SelectItem
><SelectItem value="legacy">{{ t("grid.copyExtractorValueFormatLegacy") }}</SelectItem
><SelectItem value="display">{{ t("grid.copyExtractorValueFormatDisplay") }}</SelectItem
><SelectItem value="edit">{{ t("grid.copyExtractorValueFormatEdit") }}</SelectItem
><SelectItem value="native">{{ t("grid.copyExtractorValueFormatNative") }}</SelectItem></SelectContent
>
</Select>
</div>
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorNullText") }}</Label>
<Input :model-value="draftOptions.dsv.nullText" @update:model-value="updateDsv('nullText', String($event))" />
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorQuote") }}</Label>
<Input maxlength="1" :model-value="draftOptions.dsv.quote" @update:model-value="updateDsv('quote', String($event))" />
<p v-if="hasSpecificOptions" class="text-sm font-medium">{{ t("grid.copyExtractorFormatOptions") }}</p>
<template v-if="isDsv">
<div v-if="draftExtractor === 'dsv'" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorColumnSeparator") }}</Label>
<Input :model-value="visibleSeparator(draftOptions.dsv.columnSeparator)" @update:model-value="updateDsv('columnSeparator', separatorValue(String($event)))" />
</div>
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorQuotePolicy") }}</Label>
<Select :model-value="draftOptions.dsv.quotePolicy" @update:model-value="updateDsv('quotePolicy', $event as DataGridExtractorOptions['dsv']['quotePolicy'])">
<div v-if="draftExtractor !== 'one-row'" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorRowSeparator") }}</Label>
<Input :model-value="visibleSeparator(draftOptions.dsv.rowSeparator)" @update:model-value="updateDsv('rowSeparator', separatorValue(String($event)))" />
</div>
<div v-if="supportsCsvOptions" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorNullText") }}</Label>
<Input :model-value="draftOptions.dsv.nullText" @update:model-value="updateDsv('nullText', String($event))" />
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorQuote") }}</Label>
<Input maxlength="1" :model-value="draftOptions.dsv.quote" @update:model-value="updateDsv('quote', String($event))" />
</div>
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorQuotePolicy") }}</Label>
<Select :model-value="draftOptions.dsv.quotePolicy" @update:model-value="updateDsv('quotePolicy', $event as DataGridExtractorOptions['dsv']['quotePolicy'])">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="minimal">{{ t("grid.copyExtractorQuoteMinimal") }}</SelectItem>
<SelectItem value="always">{{ t("grid.copyExtractorQuoteAlways") }}</SelectItem>
<SelectItem value="never">{{ t("grid.copyExtractorQuoteNever") }}</SelectItem>
<SelectItem value="strings">{{ t("grid.copyExtractorQuoteStrings") }}</SelectItem>
<SelectItem value="all-but-numbers">{{ t("grid.copyExtractorQuoteAllButNumbers") }}</SelectItem>
<SelectItem value="all-including-nulls">{{ t("grid.copyExtractorQuoteAllIncludingNulls") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div v-if="supportsCsvOptions" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorHeaderPosition") }}</Label>
<Select :model-value="draftOptions.dsv.headerPosition" @update:model-value="updateDsv('headerPosition', $event as DataGridExtractorOptions['dsv']['headerPosition'])">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent
><SelectItem value="legacy">{{ t("grid.copyExtractorHeaderLegacy") }}</SelectItem
><SelectItem value="none">{{ t("grid.copyExtractorHeaderNone") }}</SelectItem
><SelectItem value="top">{{ t("grid.copyExtractorHeaderTop") }}</SelectItem
><SelectItem value="bottom">{{ t("grid.copyExtractorHeaderBottom") }}</SelectItem></SelectContent
>
</Select>
</div>
<div v-if="supportsCsvOptions && draftOptions.dsv.headerPosition !== 'none'" class="grid grid-cols-2 gap-2">
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorHeaderFormat") }}</Label
><Select :model-value="draftOptions.dsv.headerFormat" @update:model-value="updateDsv('headerFormat', $event as DataGridExtractorOptions['dsv']['headerFormat'])"
><SelectTrigger><SelectValue /></SelectTrigger
><SelectContent
><SelectItem value="name">{{ t("grid.copyExtractorHeaderName") }}</SelectItem
><SelectItem value="description">{{ t("grid.copyExtractorHeaderDescription") }}</SelectItem
><SelectItem value="both">{{ t("grid.copyExtractorHeaderBoth") }}</SelectItem></SelectContent
></Select
>
</div>
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorHeaderCase") }}</Label
><Select :model-value="draftOptions.dsv.headerCase" @update:model-value="updateDsv('headerCase', $event as DataGridExtractorOptions['dsv']['headerCase'])"
><SelectTrigger><SelectValue /></SelectTrigger
><SelectContent
><SelectItem value="preserve">{{ t("grid.copyExtractorHeaderPreserve") }}</SelectItem
><SelectItem value="upper">{{ t("grid.copyExtractorHeaderUpper") }}</SelectItem
><SelectItem value="lower">{{ t("grid.copyExtractorHeaderLower") }}</SelectItem></SelectContent
></Select
>
</div>
</div>
<details v-if="supportsCsvOptions" class="space-y-2 rounded-md border p-3">
<summary class="cursor-pointer text-sm font-medium">{{ t("grid.copyExtractorAdvancedOptions") }}</summary>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.dsv.quoteNumericStrings" @change="updateDsv('quoteNumericStrings', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorQuoteNumericStrings") }}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.dsv.escapeFormulas" @change="updateDsv('escapeFormulas', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorEscapeFormulas") }}</label>
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorLineFeedEscape") }}</Label
><Input :model-value="visibleSeparator(draftOptions.dsv.lineFeedEscapeString)" @update:model-value="updateDsv('lineFeedEscapeString', separatorValue(String($event)))" />
</div>
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorArrayFormat") }}</Label
><Select :model-value="draftOptions.dsv.arrayFormat" @update:model-value="updateDsv('arrayFormat', $event as DataGridExtractorOptions['dsv']['arrayFormat'])"
><SelectTrigger><SelectValue /></SelectTrigger
><SelectContent
><SelectItem value="preserve">{{ t("grid.copyExtractorArrayPreserve") }}</SelectItem
><SelectItem value="braces">{ }</SelectItem><SelectItem value="brackets">[ ]</SelectItem><SelectItem value="parentheses">( )</SelectItem></SelectContent
></Select
>
</div>
<div class="space-y-1.5">
<Label>{{ t("grid.copyExtractorFormatNumbers") }}</Label
><Select :model-value="draftOptions.dsv.formatNumbers" @update:model-value="updateDsv('formatNumbers', $event as DataGridExtractorOptions['dsv']['formatNumbers'])"
><SelectTrigger><SelectValue /></SelectTrigger
><SelectContent
><SelectItem value="inherit">{{ t("grid.copyExtractorFormatNumbersInherit") }}</SelectItem
><SelectItem value="native">{{ t("grid.copyExtractorFormatNumbersNative") }}</SelectItem
><SelectItem value="display">{{ t("grid.copyExtractorFormatNumbersDisplay") }}</SelectItem></SelectContent
></Select
>
</div>
</details>
</template>
<label v-if="isDsv && draftExtractor !== 'one-row'" class="flex items-center gap-2 text-sm"
><input type="checkbox" :checked="draftOptions.dsv.includeRowHeader" @change="updateDsv('includeRowHeader', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorIncludeRowHeader") }}</label
>
<template v-if="isSql">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.sql.skipComputedColumns" @change="updateSql('skipComputedColumns', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorSkipComputed") }}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.sql.skipGeneratedColumns" @change="updateSql('skipGeneratedColumns', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorSkipGenerated") }}</label>
<div v-if="draftExtractor === 'sql-inserts' || draftExtractor === 'sql-deletes-inserts'" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorExcludePrimaryKeys") }}</Label>
<Select :model-value="draftOptions.sql.primaryKeyMode" @update:model-value="updateSql('primaryKeyMode', $event as DataGridExtractorOptions['sql']['primaryKeyMode'])">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="minimal">{{ t("grid.copyExtractorQuoteMinimal") }}</SelectItem>
<SelectItem value="always">{{ t("grid.copyExtractorQuoteAlways") }}</SelectItem>
<SelectItem value="never">{{ t("grid.copyExtractorQuoteNever") }}</SelectItem>
<SelectItem value="include">{{ t("grid.copyExtractorSqlInsertsWithPrimaryKeys") }}</SelectItem>
<SelectItem value="exclude-generated">{{ t("grid.copyExtractorSqlInsertsWithoutPrimaryKeys") }}</SelectItem>
<SelectItem value="exclude-all">{{ t("grid.copyExtractorSqlInsertsWithoutAllPrimaryKeys") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<label v-if="draftExtractor === 'dsv'" class="flex items-center gap-2 text-sm"
><input type="checkbox" :checked="draftOptions.dsv.includeColumnHeader" @change="updateDsv('includeColumnHeader', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorIncludeHeader") }}</label
>
<label v-if="draftExtractor !== 'one-row'" class="flex items-center gap-2 text-sm"
><input type="checkbox" :checked="draftOptions.dsv.includeRowHeader" @change="updateDsv('includeRowHeader', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorIncludeRowHeader") }}</label
>
</template>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.sql.includeDatabaseName" @change="updateSql('includeDatabaseName', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorIncludeDatabaseName") }}</label>
<div v-if="draftExtractor === 'sql-inserts' || draftExtractor === 'sql-deletes-inserts'" class="space-y-1.5">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.sql.quoteIdentifiers" @change="updateSql('quoteIdentifiers', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorQuoteIdentifiers") }}</label>
<Label>{{ t("grid.copyExtractorTemporalFormat") }}</Label>
<Select :model-value="draftOptions.sql.temporalFormat" @update:model-value="updateSql('temporalFormat', $event as DataGridExtractorOptions['sql']['temporalFormat'])">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="native">{{ t("grid.copyExtractorTemporalNative") }}</SelectItem>
<SelectItem value="string">{{ t("grid.copyExtractorTemporalString") }}</SelectItem>
</SelectContent>
</Select>
<p v-if="draftOptions.sql.temporalFormat === 'string'" class="text-xs text-muted-foreground">{{ t("grid.copyExtractorTemporalStringHint") }}</p>
</div>
<div v-if="draftExtractor === 'sql-inserts' || draftExtractor === 'sql-deletes-inserts'" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorInsertMode") }}</Label>
<Select :model-value="draftOptions.sql.insertMode" @update:model-value="updateSql('insertMode', $event as DataGridExtractorOptions['sql']['insertMode'])">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="merged">{{ t("grid.copyExtractorInsertMerged") }}</SelectItem>
<SelectItem value="row-by-row">{{ t("grid.copyExtractorInsertRowByRow") }}</SelectItem>
</SelectContent>
</Select>
</div>
</template>
<template v-if="isSql">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.sql.skipComputedColumns" @change="updateSql('skipComputedColumns', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorSkipComputed") }}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.sql.skipGeneratedColumns" @change="updateSql('skipGeneratedColumns', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorSkipGenerated") }}</label>
<div v-if="draftExtractor === 'sql-inserts' || draftExtractor === 'sql-deletes-inserts'" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorExcludePrimaryKeys") }}</Label>
<Select :model-value="draftOptions.sql.primaryKeyMode" @update:model-value="updateSql('primaryKeyMode', $event as DataGridExtractorOptions['sql']['primaryKeyMode'])">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="include">{{ t("grid.copyExtractorSqlInsertsWithPrimaryKeys") }}</SelectItem>
<SelectItem value="exclude-generated">{{ t("grid.copyExtractorSqlInsertsWithoutPrimaryKeys") }}</SelectItem>
<SelectItem value="exclude-all">{{ t("grid.copyExtractorSqlInsertsWithoutAllPrimaryKeys") }}</SelectItem>
</SelectContent>
</Select>
</div>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.sql.includeDatabaseName" @change="updateSql('includeDatabaseName', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorIncludeDatabaseName") }}</label>
<div v-if="draftExtractor === 'sql-inserts' || draftExtractor === 'sql-deletes-inserts'" class="space-y-1.5">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.sql.quoteIdentifiers" @change="updateSql('quoteIdentifiers', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorQuoteIdentifiers") }}</label>
<Label>{{ t("grid.copyExtractorTemporalFormat") }}</Label>
<Select :model-value="draftOptions.sql.temporalFormat" @update:model-value="updateSql('temporalFormat', $event as DataGridExtractorOptions['sql']['temporalFormat'])">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="native">{{ t("grid.copyExtractorTemporalNative") }}</SelectItem>
<SelectItem value="string">{{ t("grid.copyExtractorTemporalString") }}</SelectItem>
</SelectContent>
</Select>
<p v-if="draftOptions.sql.temporalFormat === 'string'" class="text-xs text-muted-foreground">{{ t("grid.copyExtractorTemporalStringHint") }}</p>
</div>
<div v-if="draftExtractor === 'sql-inserts' || draftExtractor === 'sql-deletes-inserts'" class="space-y-1.5">
<Label>{{ t("grid.copyExtractorInsertMode") }}</Label>
<Select :model-value="draftOptions.sql.insertMode" @update:model-value="updateSql('insertMode', $event as DataGridExtractorOptions['sql']['insertMode'])">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="merged">{{ t("grid.copyExtractorInsertMerged") }}</SelectItem>
<SelectItem value="row-by-row">{{ t("grid.copyExtractorInsertRowByRow") }}</SelectItem>
</SelectContent>
</Select>
</div>
</template>
<template v-if="isJson">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.json.pretty" @change="updateJson('pretty', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorPrettyJson") }}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.json.camelCaseFieldNames" @change="updateJson('camelCaseFieldNames', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorCamelCaseJsonFields") }}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.json.valuesOnly" @change="updateJson('valuesOnly', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorValuesOnlyJsonArray") }}</label>
<p v-if="draftOptions.json.valuesOnly" class="text-xs text-muted-foreground">{{ t("grid.copyExtractorValuesOnlyJsonArrayHint") }}</p>
</template>
<template v-if="isJson">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.json.pretty" @change="updateJson('pretty', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorPrettyJson") }}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.json.camelCaseFieldNames" @change="updateJson('camelCaseFieldNames', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorCamelCaseJsonFields") }}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.json.valuesOnly" @change="updateJson('valuesOnly', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyExtractorValuesOnlyJsonArray") }}</label>
<p v-if="draftOptions.json.valuesOnly" class="text-xs text-muted-foreground">{{ t("grid.copyExtractorValuesOnlyJsonArrayHint") }}</p>
</template>
</div>
<template v-if="isDbunit">
<div class="space-y-1.5">
<Label>{{ t("grid.copyDbunitTableName") }}</Label>
<Input v-if="!sourceTableName" :model-value="draftOptions.dbunit.tableName" @update:model-value="updateDbunit('tableName', String($event))" />
<p v-else class="rounded-md border bg-muted/30 px-2 py-1.5 text-sm">{{ sourceTableName }}</p>
<p class="text-xs text-muted-foreground">{{ t("grid.copyDbunitTableNameHint") }}</p>
</div>
<div class="space-y-1.5">
<Label>{{ t("grid.copyDbunitNullValue") }}</Label>
<Input :model-value="draftOptions.dbunit.nullValueString" @update:model-value="updateDbunit('nullValueString', String($event))" />
</div>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.dbunit.upperCaseTableName" @change="updateDbunit('upperCaseTableName', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyDbunitUppercaseTable") }}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.dbunit.upperCaseColumnNames" @change="updateDbunit('upperCaseColumnNames', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyDbunitUppercaseColumns") }}</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.dbunit.includeNullValues" @change="updateDbunit('includeNullValues', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyDbunitIncludeNulls") }}</label>
</template>
<div class="min-w-0 space-y-1.5">
<Label>{{ t("grid.copyExtractorPreview") }}</Label>
<div class="relative min-h-72 rounded-md border bg-muted/30">
<pre class="max-h-[55vh] overflow-auto whitespace-pre p-3 font-mono text-xs">{{ previewError || previewText }}</pre>
<span v-if="previewLoading" class="absolute right-2 top-2 text-xs text-muted-foreground">{{ t("common.loading") }}</span>
<template v-if="isPhp">
<div class="space-y-1.5">
<Label>{{ t("grid.copyPhpVariable") }}</Label>
<Input :model-value="draftOptions.php.variableName || (sourceTableName && /^[A-Za-z_][A-Za-z0-9_]*$/.test(sourceTableName) ? sourceTableName : 'data')" @update:model-value="updatePhp('variableName', String($event))" />
</div>
<div class="space-y-1.5">
<Label>{{ t("grid.copyPhpArraySyntax") }}</Label>
<Select :model-value="draftOptions.php.arraySyntax" @update:model-value="updatePhp('arraySyntax', $event as DataGridExtractorOptions['php']['arraySyntax'])">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="legacy">array()</SelectItem><SelectItem value="short">[]</SelectItem></SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label>{{ t("grid.copyPhpQuote") }}</Label>
<Select :model-value="draftOptions.php.quoteChar" @update:model-value="updatePhp('quoteChar', $event as DataGridExtractorOptions['php']['quoteChar'])">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="'">'…'</SelectItem><SelectItem value='"'>"…"</SelectItem></SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label>{{ t("grid.copyPhpRowDelimiter") }}</Label>
<Input :model-value="visibleSeparator(draftOptions.php.rowDelimiter)" @update:model-value="updatePhp('rowDelimiter', separatorValue(String($event)))" />
</div>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" :checked="draftOptions.php.formatDateIso" @change="updatePhp('formatDateIso', ($event.target as HTMLInputElement).checked)" />{{ t("grid.copyPhpIsoDates") }}</label>
</template>
</div>
<p v-if="previewTruncatedRows" class="text-xs text-muted-foreground">{{ t("grid.copyExtractorPreviewTruncated", { count: previewTruncatedRows }) }}</p>
<p v-for="warning in previewWarnings" :key="warning" class="text-xs text-amber-700 dark:text-amber-300">{{ warning }}</p>
</div>
<div class="shrink-0 border-t pt-2">
<Button type="button" variant="ghost" class="w-full justify-between" :aria-expanded="previewExpanded" @click="togglePreview">
<span>{{ t("grid.copyExtractorPreview") }}</span
><span class="text-xs text-muted-foreground">{{ previewExpanded ? t("grid.copyExtractorCollapsePreview") : t("grid.copyExtractorPreviewOnDemand") }}</span>
</Button>
<div v-if="previewExpanded" class="relative mt-2 min-h-24 rounded-md border bg-muted/30">
<pre v-if="previewError" class="max-h-[32vh] overflow-y-auto whitespace-pre-wrap break-words p-3 text-sm">{{ previewError }}</pre>
<pre v-else-if="previewText" class="max-h-[32vh] overflow-auto whitespace-pre p-3 font-mono text-xs">{{ previewText }}</pre>
<p v-else class="p-3 text-sm text-muted-foreground">{{ previewLoading ? t("common.loading") : t("grid.copyExtractorPreviewOnDemand") }}</p>
<span v-if="previewLoading && previewText" class="absolute right-2 top-2 text-xs text-muted-foreground">{{ t("common.loading") }}</span>
</div>
<p v-if="previewTruncatedRows" class="mt-1 text-xs text-muted-foreground">{{ t("grid.copyExtractorPreviewTruncated", { count: previewTruncatedRows }) }}</p>
<p v-for="warning in previewWarnings" :key="warning" class="mt-1 text-xs text-amber-700 dark:text-amber-300">{{ warning }}</p>
<p v-if="copyError" role="alert" class="mt-1 text-sm text-destructive">{{ copyError }}</p>
</div>
</section>
</div>
<DialogFooter>
<DialogFooter class="shrink-0">
<Button variant="outline" @click="emit('update:open', false)">{{ t("common.cancel") }}</Button>
<Button :disabled="!!optionsError" @click="save">{{ t("common.save") }}</Button>
<Button v-if="mode !== 'copy'" :disabled="!!optionsError" @click="save">{{ t("common.save") }}</Button>
<Button v-else :disabled="!!optionsError || !canCopySelectedFormat || copyRunning" @click="performCopy">{{ t(copyRunning ? "grid.copyExtractorCopyRunning" : "grid.copy") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { DatabaseType, QueryResult } from "@/types/database";
import type { DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
import { DATA_GRID_DEFAULT_VISIBLE_COPY_EXTRACTORS, DATA_GRID_EXTRACTOR_PREVIEW_MAX_ROWS } from "@/lib/dataGrid/dataGridCopyExtractor";
import { TooltipProvider } from "@/components/ui/tooltip";
const mocks = vi.hoisted(() => ({
@@ -138,7 +139,7 @@ function pasteGridCell(host: HTMLElement, value: string) {
gridRoot.dispatchEvent(paste);
}
function mountGrid(initialResult = largeValueResult(), onReload?: () => void, databaseType: DatabaseType = "mysql", tableMeta?: DataGridTableMeta) {
function mountGrid(initialResult = largeValueResult(), onReload?: () => void, databaseType: DatabaseType = "mysql", tableMeta?: DataGridTableMeta, columnLayoutScopeKey?: string) {
const result = shallowRef(markRaw(initialResult));
const onExecuteSql = vi.fn().mockResolvedValue(undefined);
const pinia = createPinia();
@@ -161,6 +162,7 @@ function mountGrid(initialResult = largeValueResult(), onReload?: () => void, da
databaseType,
connectionId: "connection-1",
database: "dbx",
...(columnLayoutScopeKey ? { columnLayoutScopeKey } : {}),
context: "table-data",
editable: true,
tableMeta: tableMeta ?? {
@@ -190,6 +192,7 @@ function mountGrid(initialResult = largeValueResult(), onReload?: () => void, da
mountedApps.push(mounted);
return {
host,
settingsStore,
onExecuteSql,
replaceResult: (nextResult: QueryResult) => (result.value = markRaw(nextResult)),
unmount: () => {
@@ -532,6 +535,242 @@ describe("DataGrid context menu target lifecycle", () => {
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith("payload"));
});
it("copies column metadata from the header without reading row values", async () => {
const { host } = mountGrid();
openContextMenu(columnHeader(host, 1));
await settle();
contextMenuButton("Copy Column Metadata").click();
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith("Name\tType\tSource\tNullable\tComment\npayload\ttext\tpayload\t\t"));
expect(mocks.executeMulti).not.toHaveBeenCalled();
});
it("copies selected duplicate result aliases from a zero-row query without fetching data", async () => {
const layoutScopeKey = "column-metadata-reordered-test";
localStorage.setItem(`dbx-data-grid-column-layout:${layoutScopeKey}`, JSON.stringify({ version: 1, orderKeys: ["expression\u00000", "payload\u00001", "payload\u00000"], hiddenKeys: [] }));
const result: QueryResult = {
columns: ["id", "payload", "payload", "expression"],
column_types: ["int", "text", "jsonb"],
rows: [],
affected_rows: 0,
execution_time_ms: 0,
hidden_column_indexes: [0],
};
const { host } = mountGrid(
result,
undefined,
"postgres",
{
tableName: "events",
primaryKeys: [],
columns: [{ name: "payload", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: "payload source" }],
},
layoutScopeKey,
);
await settle();
const headers = [...host.querySelectorAll<HTMLElement>("[data-grid-column-index]")];
expect(headers.map((header) => header.dataset.gridColumnIndex)).toEqual(["3", "2", "1"]);
headers.forEach((header) => header.dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true })));
openContextMenu(headers.at(-1)!);
await settle();
contextMenuButton("Copy Column Metadata").click();
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith("Name\tType\tSource\tNullable\tComment\nexpression\t\t\t\t\npayload\tjsonb\tpayload\tYES\tpayload source\npayload\ttext\tpayload\tYES\tpayload source"));
expect(mocks.executeMulti).not.toHaveBeenCalled();
});
it("keeps SQL Select available in Copy as by default and restores it after hiding it", async () => {
const { host, settingsStore } = mountGrid(hydratedResult(1, "value"));
await settle();
columnHeader(host, 1).click();
openContextMenu(gridCell(host));
await settle();
contextMenuButton("Copy").dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await settle();
expect(contextMenuLabels()).toContain("SQL Select");
settingsStore.updateEditorSettings({ dataGridVisibleCopyExtractors: DATA_GRID_DEFAULT_VISIBLE_COPY_EXTRACTORS.filter((item) => item !== "sql-select") });
document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
await settle();
openContextMenu(gridCell(host));
await settle();
contextMenuButton("Copy").dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await settle();
expect(contextMenuLabels()).not.toContain("SQL Select");
contextMenuButton("Configure Data Extractors…").click();
await settle();
const restore = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Restore defaults");
expect(restore).toBeTruthy();
restore!.click();
await settle();
const save = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Save");
expect(save).toBeTruthy();
save!.click();
await settle();
expect(settingsStore.editorSettings.dataGridVisibleCopyExtractors).toContain("sql-select");
document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
await settle();
openContextMenu(gridCell(host));
await settle();
contextMenuButton("Copy").dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await settle();
expect(contextMenuLabels()).toContain("SQL Select");
});
it("copies the full current result using temporary advanced-copy options without saving settings", async () => {
const rows = Array.from({ length: DATA_GRID_EXTRACTOR_PREVIEW_MAX_ROWS + 2 }, (_, index) => [index + 1, `value-${index + 1}`]);
const { host, settingsStore } = mountGrid({ columns: ["id", "payload"], rows, affected_rows: 0, execution_time_ms: 0 });
await settle();
selectAllHeader(host).click();
openContextMenu(selectAllHeader(host));
await settle();
contextMenuButton("Copy").dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await settle();
contextMenuButton("Advanced copy…").click();
await settle();
expect(mocks.extractDataGridSelection).not.toHaveBeenCalled();
const php = document.querySelector<HTMLInputElement>('input[type="radio"][value="php"]');
expect(php).toBeTruthy();
php!.click();
await settle();
mocks.extractDataGridSelection.mockResolvedValueOnce({ text: "<?php return ['value'];", mimeType: "text/x-php", fileExtension: "php", rowCount: rows.length, columnCount: 1 });
mocks.copyToClipboard.mockResolvedValueOnce(true);
const copy = [...document.querySelectorAll<HTMLButtonElement>("button")].filter((button) => button.textContent?.trim() === "Copy").at(-1);
expect(copy).toBeTruthy();
expect(copy!.disabled).toBe(false);
copy!.click();
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith("<?php return ['value'];"));
const request = mocks.extractDataGridSelection.mock.calls[0]?.[0] as { rows: unknown[][]; extractor: string };
expect(request.extractor).toBe("php");
expect(request.rows).toHaveLength(DATA_GRID_EXTRACTOR_PREVIEW_MAX_ROWS + 2);
expect(request.rows[0]?.[0]).toBe(1);
expect(request.rows.at(-1)?.[0]).toBe(DATA_GRID_EXTRACTOR_PREVIEW_MAX_ROWS + 2);
expect(settingsStore.editorSettings.dataGridCopyExtractor).toBe("smart");
});
it("does not write to the clipboard when the advanced-copy dialog closes during extraction", async () => {
const pending = deferred<{ text: string; mimeType: string; fileExtension: string; rowCount: number; columnCount: number }>();
const { host } = mountGrid(hydratedResult(1, "value"));
await settle();
columnHeader(host, 1).click();
openContextMenu(gridCell(host));
await settle();
contextMenuButton("Copy").dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await settle();
contextMenuButton("Advanced copy…").click();
await settle();
mocks.extractDataGridSelection.mockReturnValueOnce(pending.promise);
const copy = [...document.querySelectorAll<HTMLButtonElement>("button")].filter((button) => button.textContent?.trim() === "Copy").at(-1)!;
copy.click();
await vi.waitFor(() => expect(mocks.extractDataGridSelection).toHaveBeenCalledOnce());
[...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Cancel")!.click();
await settle();
pending.resolve({ text: "stale copy", mimeType: "text/plain", fileExtension: "txt", rowCount: 1, columnCount: 2 });
await settle();
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
expect(document.querySelector('[role="alert"]')).toBeNull();
});
it("invalidates advanced copy when the result changes during extraction", async () => {
const pending = deferred<{ text: string; mimeType: string; fileExtension: string; rowCount: number; columnCount: number }>();
const { host, replaceResult } = mountGrid(hydratedResult(1, "value"));
await settle();
columnHeader(host, 1).click();
openContextMenu(gridCell(host));
await settle();
contextMenuButton("Copy").dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await settle();
contextMenuButton("Advanced copy…").click();
await settle();
mocks.extractDataGridSelection.mockReturnValueOnce(pending.promise);
const copy = [...document.querySelectorAll<HTMLButtonElement>("button")].filter((button) => button.textContent?.trim() === "Copy").at(-1)!;
copy.click();
await vi.waitFor(() => expect(mocks.extractDataGridSelection).toHaveBeenCalledOnce());
replaceResult(hydratedResult(2, "updated"));
await settle();
pending.resolve({ text: "stale result", mimeType: "text/plain", fileExtension: "txt", rowCount: 1, columnCount: 2 });
await settle();
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')).not.toBeNull());
expect(document.querySelector('[role="alert"]')?.textContent).toContain("result or selection changed");
});
it("keeps clipboard success when the result changes after clipboard writing starts", async () => {
const pendingClipboard = deferred<boolean>();
const { host, replaceResult } = mountGrid(hydratedResult(1, "value"));
await settle();
columnHeader(host, 1).click();
openContextMenu(gridCell(host));
await settle();
contextMenuButton("Copy").dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await settle();
contextMenuButton("Advanced copy…").click();
await settle();
mocks.extractDataGridSelection.mockResolvedValueOnce({ text: "copied", mimeType: "text/plain", fileExtension: "txt", rowCount: 1, columnCount: 1 });
mocks.copyToClipboard.mockReturnValueOnce(pendingClipboard.promise);
const copy = [...document.querySelectorAll<HTMLButtonElement>("button")].filter((button) => button.textContent?.trim() === "Copy").at(-1)!;
copy.click();
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith("copied"));
replaceResult(hydratedResult(2, "updated"));
await settle();
pendingClipboard.resolve(true);
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
expect(document.querySelector('[role="alert"]')).toBeNull();
});
it.each(["result", "selection"] as const)("discards advanced-copy preview when the %s changes while it is pending", async (changed) => {
const pendingPreview = deferred<{ text: string; mimeType: string; fileExtension: string; rowCount: number; columnCount: number; sourceRowCount: number; truncated: boolean }>();
const { host, replaceResult } = mountGrid(hydratedResult(1, "value"));
await settle();
columnHeader(host, 1).click();
openContextMenu(gridCell(host));
await settle();
contextMenuButton("Copy").dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await settle();
contextMenuButton("Advanced copy…").click();
await settle();
mocks.extractDataGridSelection.mockReturnValueOnce(pendingPreview.promise);
const previewToggle = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Preview"));
expect(previewToggle).toBeTruthy();
previewToggle!.click();
await vi.waitFor(() => expect(mocks.extractDataGridSelection).toHaveBeenCalledOnce());
if (changed === "result") replaceResult(hydratedResult(2, "updated"));
else selectAllHeader(host).click();
await settle();
pendingPreview.resolve({ text: "stale preview", mimeType: "text/plain", fileExtension: "txt", rowCount: 1, columnCount: 1, sourceRowCount: 1, truncated: false });
await settle();
const preview = [...document.querySelectorAll<HTMLElement>("pre")].find((element) => element.textContent?.includes("result or selection changed"));
expect(preview).toBeTruthy();
expect(document.body.textContent).not.toContain("stale preview");
});
it("invalidates advanced copy when the selection changes during extraction", async () => {
const pending = deferred<{ text: string; mimeType: string; fileExtension: string; rowCount: number; columnCount: number }>();
const { host } = mountGrid(hydratedResult(1, "value"));
await settle();
columnHeader(host, 1).click();
openContextMenu(gridCell(host));
await settle();
contextMenuButton("Copy").dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await settle();
contextMenuButton("Advanced copy…").click();
await settle();
mocks.extractDataGridSelection.mockReturnValueOnce(pending.promise);
const copy = [...document.querySelectorAll<HTMLButtonElement>("button")].filter((button) => button.textContent?.trim() === "Copy").at(-1)!;
copy.click();
await vi.waitFor(() => expect(mocks.extractDataGridSelection).toHaveBeenCalledOnce());
selectAllHeader(host).click();
await settle();
pending.resolve({ text: "stale selection", mimeType: "text/plain", fileExtension: "txt", rowCount: 1, columnCount: 2 });
await settle();
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')).not.toBeNull());
expect(document.querySelector('[role="alert"]')?.textContent).toContain("result or selection changed");
});
it("keeps the initial select-all menu on the data selection target", async () => {
const { host } = mountGrid(hydratedResult(1, "value"));
await settle();
@@ -2,9 +2,9 @@
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeDataGridExtractorOptions, type DataGridExtractPreview } from "@/lib/dataGrid/dataGridCopyExtractor";
import { DATA_GRID_COPY_EXTRACTOR_IDS, DATA_GRID_DEFAULT_VISIBLE_COPY_EXTRACTORS, normalizeDataGridExtractorOptions, type DataGridCopyPreference, type DataGridExtractPreview } from "@/lib/dataGrid/dataGridCopyExtractor";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => (key === "grid.copyExtractorAdvancedOptions" ? "Advanced options" : key === "grid.copyExtractorVisibleFormats" ? "Visible formats" : key) }) }));
vi.mock("@/components/ui/dialog", () => {
const passthrough = defineComponent({
setup:
@@ -58,15 +58,26 @@ function deferred() {
return { promise, resolve, reject };
}
function mountDialog(preview = vi.fn(async () => result("initial")), open = false) {
const props = reactive({ open, preference: "sql-inserts" as const, options: normalizeDataGridExtractorOptions({}), items: [{ value: "sql-inserts" as const, label: "INSERT" }], preview });
function mountDialog(preview = vi.fn(async () => result("initial")), open = false, preference: DataGridCopyPreference = "sql-inserts") {
const props = reactive({
open,
preference,
sourceTableName: undefined as string | undefined,
visibleCopyExtractors: [...DATA_GRID_DEFAULT_VISIBLE_COPY_EXTRACTORS],
options: normalizeDataGridExtractorOptions({}),
items: [{ value: "smart", label: "SMART" }, ...DATA_GRID_COPY_EXTRACTOR_IDS.map((value) => ({ value, label: value.toUpperCase() }))],
mode: "configure" as "configure" | "copy",
canCopy: vi.fn(() => true),
copy: vi.fn(async () => true),
preview,
});
const save = vi.fn();
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp({ render: () => h(DataGridExtractorDialog, { ...props, onSave: save }) });
const app = createApp({ render: () => h(DataGridExtractorDialog, { ...props, visibleItems: DATA_GRID_COPY_EXTRACTOR_IDS.map((value) => ({ value, label: value })), onSave: save }) });
app.mount(host);
mounted.push({ app, host });
return { props, host, save, preview };
return { props, host, save, preview, copy: props.copy };
}
async function advancePreview() {
@@ -84,6 +95,17 @@ async function toggle(host: HTMLElement, key: string) {
await nextTick();
}
async function expandPreview(host: HTMLElement) {
await nextTick();
const button = [...host.querySelectorAll<HTMLButtonElement>("button")].find((item) => item.textContent?.includes("grid.copyExtractorPreview"));
expect(button).toBeTruthy();
if (button!.getAttribute("aria-expanded") !== "true") {
button!.click();
await nextTick();
}
await advancePreview();
}
beforeEach(() => {
vi.useFakeTimers();
});
@@ -96,9 +118,58 @@ afterEach(() => {
});
describe("extractor draft preview", () => {
it("previews an initially open dialog", async () => {
it("shows CSV-specific settings only for CSV and DSV extractors", async () => {
const { host } = mountDialog(undefined, true, "tsv");
expect(host.textContent).not.toContain("grid.copyExtractorEscapeFormulas");
expect(host.textContent).not.toContain("grid.copyExtractorHeaderPosition");
const csv = mountDialog(undefined, true, "csv");
expect(csv.host.textContent).toContain("grid.copyExtractorEscapeFormulas");
expect(csv.host.textContent).toContain("grid.copyExtractorHeaderPosition");
expect(csv.host.querySelectorAll("details")).toHaveLength(2);
expect([...csv.host.querySelectorAll("details")].every((details) => !details.open)).toBe(true);
expect([...csv.host.querySelectorAll("details summary")].map((summary) => summary.textContent)).toContain("Advanced options");
});
it("keeps smart, raw, DbUnit and hidden formats in the property workflow and honors capability disabling", async () => {
const { props, host } = mountDialog(undefined, true, "smart");
expect(host.textContent).toContain("grid.copyExtractorSmart");
expect(host.textContent).not.toContain("grid.copyExtractorValueFormat");
host.querySelector<HTMLInputElement>('input[type="radio"][value="raw"]')!.click();
await nextTick();
expect(host.textContent).toContain("grid.copyExtractorRawHint");
expect(host.textContent).not.toContain("grid.copyExtractorValueFormat");
host.querySelector<HTMLInputElement>('input[type="radio"][value="dbunit"]')!.click();
await nextTick();
expect(host.textContent).toContain("grid.copyDbunitTableName");
props.visibleCopyExtractors = props.visibleCopyExtractors.filter((item) => item !== "php");
props.items.find((item) => item.value === "sql-updates")!.disabled = true;
await nextTick();
expect(host.querySelector<HTMLInputElement>('input[type="radio"][value="php"]')).toBeTruthy();
expect(host.querySelector<HTMLInputElement>('input[type="radio"][value="php"]')?.disabled).toBe(false);
expect(host.querySelector<HTMLInputElement>('input[type="radio"][value="sql-updates"]')?.disabled).toBe(true);
});
it.each(["tsv", "tsv-with-headers", "pipe-separated"] as const)("keeps the row-number option available for %s", async (preference) => {
const { props, host, save } = mountDialog(undefined, true, preference);
const label = Array.from(host.querySelectorAll("label")).find((item) => item.textContent?.includes("grid.copyExtractorIncludeRowHeader"));
expect(label).toBeTruthy();
expect(label?.closest("details")).toBeNull();
await toggle(host, "grid.copyExtractorIncludeRowHeader");
const button = Array.from(host.querySelectorAll("button")).find((item) => item.textContent?.includes("common.save"))!;
button.click();
expect(save).toHaveBeenCalledWith(expect.objectContaining({ options: expect.objectContaining({ dsv: expect.objectContaining({ includeRowHeader: true }) }) }));
expect(props.options.dsv.includeRowHeader).toBe(false);
});
it("does not preview or hydrate until the collapsed preview is expanded", async () => {
const { preview, host } = mountDialog(undefined, true);
await advancePreview();
expect(host.querySelector("pre")).toBeNull();
expect(preview).not.toHaveBeenCalled();
await expandPreview(host);
expect(preview).toHaveBeenCalledOnce();
expect(host.querySelector("pre")?.textContent).toBe("initial");
});
@@ -108,7 +179,7 @@ describe("extractor draft preview", () => {
props.preference = "sql-updates";
props.items = [{ value: "sql-updates", label: "UPDATE" }];
props.open = true;
await advancePreview();
await expandPreview(host);
// 勾选后保存应带上 includeDatabaseName(复制为 UPDATE 也支持带库名/模式名)
await toggle(host, "copyExtractorIncludeDatabaseName");
@@ -120,13 +191,13 @@ describe("extractor draft preview", () => {
it("previews unsaved quote and temporal options without mutating saved settings", async () => {
const { props, host, preview, save } = mountDialog();
props.open = true;
await advancePreview();
await expandPreview(host);
await toggle(host, "copyExtractorQuoteIdentifiers");
const select = Array.from(host.querySelectorAll("select")).find((item) => item.querySelector('option[value="string"]'))!;
expect(select).toBeTruthy();
select.value = "string";
select.dispatchEvent(new Event("change", { bubbles: true }));
await advancePreview();
await expandPreview(host);
expect(preview).toHaveBeenLastCalledWith("sql-inserts", expect.objectContaining({ sql: expect.objectContaining({ quoteIdentifiers: false, temporalFormat: "string" }) }));
expect(props.options.sql).toMatchObject({ quoteIdentifiers: true, temporalFormat: "native" });
expect(save).not.toHaveBeenCalled();
@@ -135,6 +206,88 @@ describe("extractor draft preview", () => {
expect(save).toHaveBeenCalledWith(expect.objectContaining({ options: expect.objectContaining({ sql: expect.objectContaining({ quoteIdentifiers: false, temporalFormat: "string" }) }) }));
});
it("keeps the configured default separate from the format being edited and discards copy-mode drafts", async () => {
const { props, host, save, copy } = mountDialog(undefined, true, "sql-inserts");
const csv = host.querySelector<HTMLInputElement>('input[type="radio"][value="csv"]')!;
csv.click();
await nextTick();
expect(host.textContent).toContain("grid.copyExtractorEscapeFormulas");
const defaultSelect = host.querySelector<HTMLSelectElement>("select");
expect(defaultSelect?.value).toBe("sql-inserts");
defaultSelect!.value = "tsv";
defaultSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await nextTick();
[...host.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("common.cancel"))?.click();
expect(save).not.toHaveBeenCalled();
expect(copy).not.toHaveBeenCalled();
props.open = false;
await nextTick();
props.mode = "copy";
props.open = true;
await nextTick();
const php = host.querySelector<HTMLInputElement>('input[type="radio"][value="php"]')!;
php.click();
await nextTick();
expect(host.querySelector('button[type="button"]')).toBeTruthy();
const copyButton = [...host.querySelectorAll<HTMLButtonElement>("button")].filter((button) => button.textContent?.trim() === "grid.copy").at(-1);
copyButton!.click();
await nextTick();
expect(copy).toHaveBeenCalledWith("php", expect.any(Object));
expect(save).not.toHaveBeenCalled();
});
it("saves only the explicit default selection, not the format currently being browsed", async () => {
const { host, save } = mountDialog(undefined, true, "sql-inserts");
host.querySelector<HTMLInputElement>('input[type="radio"][value="csv"]')!.click();
await nextTick();
const defaultSelect = host.querySelector<HTMLSelectElement>("select")!;
defaultSelect.value = "tsv";
defaultSelect.dispatchEvent(new Event("change", { bubbles: true }));
await nextTick();
[...host.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("common.save"))!.click();
expect(save).toHaveBeenCalledWith(expect.objectContaining({ preference: "tsv" }));
});
it("uses arrow keys to move the format radio selection and focus", async () => {
const { host } = mountDialog(undefined, true, "tsv");
const selected = host.querySelector<HTMLInputElement>('input[type="radio"][value="tsv"]')!;
selected.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
await nextTick();
expect(host.querySelector<HTMLInputElement>('input[type="radio"][value="tsv-with-headers"]')?.checked).toBe(true);
expect(document.activeElement).toBe(host.querySelector('input[type="radio"][value="tsv-with-headers"]'));
const currentRadio = host.querySelector<HTMLInputElement>('input[type="radio"][value="tsv-with-headers"]')!;
currentRadio.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
await nextTick();
expect(host.querySelector<HTMLInputElement>('input[type="radio"][value="php"]')?.checked).toBe(true);
host.querySelector<HTMLInputElement>('input[type="radio"][value="php"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
await nextTick();
expect(host.querySelector<HTMLInputElement>('input[type="radio"][value="smart"]')?.checked).toBe(true);
});
it("disables one-time copy for missing capability or invalid PHP options and never submits on Enter", async () => {
const { props, host, copy } = mountDialog(undefined, true, "smart");
props.mode = "copy";
await nextTick();
props.canCopy = vi.fn(() => false);
await nextTick();
const copyButton = [...host.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "grid.copy")!;
expect(copyButton.disabled).toBe(true);
props.canCopy = vi.fn(() => true);
host.querySelector<HTMLInputElement>('input[type="radio"][value="php"]')!.click();
await nextTick();
const phpVariableLabel = [...host.querySelectorAll("label")].find((label) => label.textContent?.includes("grid.copyPhpVariable"));
const phpVariable = phpVariableLabel?.parentElement?.querySelector<HTMLInputElement>("input");
expect(phpVariable).toBeTruthy();
phpVariable!.value = "invalid name";
phpVariable!.dispatchEvent(new Event("input", { bubbles: true }));
phpVariable!.dispatchEvent(new Event("change", { bubbles: true }));
phpVariable!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await nextTick();
expect(copyButton.disabled).toBe(true);
expect(copy).not.toHaveBeenCalled();
});
it.each(["resolve", "reject"] as const)("ignores an old %s during the debounce window and after a newer preview", async (settle) => {
const old = deferred();
const newer = deferred();
@@ -144,13 +297,13 @@ describe("extractor draft preview", () => {
.mockReturnValueOnce(newer.promise);
const { props, host } = mountDialog(preview);
props.open = true;
await advancePreview();
await expandPreview(host);
await toggle(host, "copyExtractorSkipComputed");
if (settle === "resolve") old.resolve(result("stale"));
else old.reject(new Error("stale error"));
await nextTick();
await nextTick();
expect(host.querySelector("pre")?.textContent).not.toContain("stale");
expect(host.querySelector("pre")?.textContent ?? "").not.toContain("stale");
await advancePreview();
await toggle(host, "copyExtractorSkipGenerated");
await advancePreview();
@@ -160,18 +313,79 @@ describe("extractor draft preview", () => {
expect(host.querySelector("pre")?.textContent).toBe("latest");
});
it("ignores a late preview after switching formats and collapsing the preview", async () => {
const old = deferred();
const preview = vi.fn(async () => result("latest")).mockReturnValueOnce(old.promise);
const { props, host } = mountDialog(preview);
props.open = true;
await expandPreview(host);
host.querySelector<HTMLInputElement>('input[type="radio"][value="csv"]')!.click();
await advancePreview();
const previewButton = [...host.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("grid.copyExtractorPreview"))!;
previewButton.click();
await nextTick();
old.resolve(result("collapsed stale"));
await nextTick();
expect(host.querySelector("pre")).toBeNull();
expect(preview).toHaveBeenCalledTimes(2);
});
it("keeps a failed one-time copy draft open for retry", async () => {
const { props, host, copy, save } = mountDialog();
props.mode = "copy";
props.copy = vi.fn().mockRejectedValueOnce(new Error("clipboard unavailable")).mockResolvedValueOnce(true);
props.open = true;
await nextTick();
const copyButton = [...host.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "grid.copy")!;
copyButton.click();
await Promise.resolve();
await nextTick();
expect(host.querySelector('[role="alert"]')?.textContent).toBe("clipboard unavailable");
expect(props.open).toBe(true);
copyButton.click();
await Promise.resolve();
await nextTick();
expect(props.copy).toHaveBeenCalledTimes(2);
expect(save).not.toHaveBeenCalled();
expect(copy).not.toHaveBeenCalled();
});
it("does not let a completed copy from a closed dialog session affect a reopened dialog", async () => {
let resolveCopy!: (copied: boolean) => void;
const { props, host } = mountDialog();
props.mode = "copy";
props.copy = vi.fn(() => new Promise<boolean>((resolve) => (resolveCopy = resolve)));
props.open = true;
await nextTick();
[...host.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "grid.copy")!.click();
await nextTick();
props.open = false;
await nextTick();
props.open = true;
await nextTick();
resolveCopy(true);
await nextTick();
expect(props.open).toBe(true);
expect(host.querySelector('[role="alert"]')).toBeNull();
expect([...host.querySelectorAll<HTMLButtonElement>("button")].some((button) => button.textContent?.trim() === "grid.copy")).toBe(true);
});
it("shows current errors and resets draft options when reopened", async () => {
const preview = vi.fn(async () => result("reopened")).mockRejectedValueOnce(new Error("preview failed"));
const { props, host } = mountDialog(preview);
props.open = true;
await advancePreview();
await expandPreview(host);
expect(host.querySelector("pre")?.textContent).toBe("preview failed");
expect(host.querySelector("pre")?.classList.contains("break-words")).toBe(true);
expect(host.querySelector("pre")?.classList.contains("whitespace-pre-wrap")).toBe(true);
await toggle(host, "copyExtractorSkipComputed");
props.open = false;
await advancePreview();
expect(preview).toHaveBeenCalledOnce();
props.open = true;
await advancePreview();
await expandPreview(host);
expect(preview).toHaveBeenLastCalledWith("sql-inserts", props.options);
expect(host.querySelector("pre")?.textContent).toBe("reopened");
});
@@ -180,11 +394,11 @@ describe("extractor draft preview", () => {
const pending = deferred();
const { props, host } = mountDialog(vi.fn(() => pending.promise));
props.open = true;
await advancePreview();
await expandPreview(host);
props.open = false;
await nextTick();
pending.resolve(result("closed result"));
await nextTick();
expect(host.querySelector("pre")?.textContent).not.toContain("closed result");
expect(host.querySelector("pre")?.textContent ?? "").not.toContain("closed result");
});
});
@@ -162,6 +162,7 @@ import {
treeNodeRowAction,
treeNodeRowDoubleClickAction,
} from "@/lib/sidebar/treeNodeClick";
import { qualifiedNameForSidebarNode } from "@/lib/sidebar/sidebarTableNameCopy";
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
import { mongoCollectionTableTypeFromNode, mongoCreateDatabasePreview, mongoDropIndexFailureCount } from "@/lib/sidebar/mongoCollectionMutation";
import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy";
@@ -2674,17 +2675,44 @@ async function copyDisplayPath() {
}
}
async function copyQualifiedName() {
const active = activeNode.value;
const selected = selectedTreeNodesInVisibleOrder();
const targets = selected.some((node) => node.id === active.id) ? selected : [active];
const connectionId = active.connectionId;
if (!connectionId || targets.some((node) => node.connectionId !== connectionId)) {
toast(t("contextMenu.copyQualifiedNameSameConnection"), 4000);
return;
}
const options = sidebarTableCopyFormatOptions(active);
const names = targets.map((node) => qualifiedNameForSidebarNode(node, options));
const invalid = names.findIndex((name) => !name);
if (invalid >= 0) {
toast(t("contextMenu.copyQualifiedNameUnavailable", { name: targets[invalid]?.label ?? "" }), 4000);
return;
}
try {
await copyToClipboard(names.join("\n"));
toast(t("connection.copied"), 2000);
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
function copyNameMenuItem(): ContextMenuItem {
const node = activeNode.value;
const connectionName = node.connectionId ? connectionStore.getConfig(node.connectionId)?.name || "" : "";
if (copyDisplayPathForTreeNode(node, connectionName)) {
const displayPath = copyDisplayPathForTreeNode(node, connectionName);
const qualifiedName = qualifiedNameForSidebarNode(node, sidebarTableCopyFormatOptions(node));
if (displayPath || qualifiedName) {
return {
sidebarActionId: "contextMenu.copyName",
label: t("contextMenu.copyName"),
icon: Copy,
children: [
{ sidebarActionId: "contextMenu.name", label: t("contextMenu.name"), action: copyName, icon: Copy },
{ sidebarActionId: "contextMenu.fullPath", label: t("contextMenu.fullPath"), action: copyDisplayPath, icon: Copy },
...(displayPath ? [{ sidebarActionId: "contextMenu.fullPath", label: t("contextMenu.fullPath"), action: copyDisplayPath, icon: Copy }] : []),
...(qualifiedName ? [{ sidebarActionId: "contextMenu.copyQualifiedName", label: t("contextMenu.copyQualifiedName"), action: copyQualifiedName, icon: Copy }] : []),
],
};
}
@@ -7611,7 +7639,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
// 7. Column
if (node.type === "column") {
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push(copyNameMenuItem());
const columnActions: ContextMenuItem[] = [];
columnActions.push({ label: t("contextMenu.viewDdl"), action: openColumnDdl, icon: FileCode });
if (canOpenStructureEditor.value) {
@@ -7638,7 +7666,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
}
if (node.type === "index" || node.type === "fkey" || (node.type === "trigger" && !!node.tableName)) {
items.push(node.type === "trigger" ? copyNameMenuItem() : { label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push(node.type === "trigger" || node.type === "index" ? copyNameMenuItem() : { label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
if (node.type === "trigger") {
items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 });
items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 });
@@ -7731,7 +7759,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
// Open that surface from the tree instead of routing through the generic source dialog.
items.push({ label: t("contextMenu.editObject"), action: () => openObjectBrowser(false, true), icon: Pencil });
items.push({ label: t("contextMenu.viewObject"), action: () => openObjectBrowser(true, true), icon: Eye });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push(copyNameMenuItem());
items.push({ label: t("contextMenu.dropObject"), action: deleteMenuAction(requestDropObject), icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const });
items.push({ label: t("contextMenu.refreshChildren"), action: refresh, icon: RefreshCw, shortcut: shortcutRefresh.value });
return true;
@@ -14,6 +14,7 @@ vi.mock("@/lib/backend/api", async (importOriginal) => {
executeQuery: vi.fn().mockResolvedValue({ success: true, columns: [], rows: [] }),
};
});
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: vi.fn().mockResolvedValue(true) }));
import SidebarTreeRuntimeHost from "@/components/sidebar/SidebarTreeRuntimeHost.vue";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -31,10 +32,11 @@ const mountedApps: App<Element>[] = [];
const labels = (items: ContextMenuItem[]): string[] => items.flatMap((item) => [...(item.label ? [item.label] : []), ...(item.children ? labels(item.children) : [])]);
const tr = (key: string) => i18n.global.t(key);
async function mountHost(dbType: DatabaseType = "firebird") {
async function mountHost(dbType: DatabaseType = "firebird", treeNodes: TreeNode[] = []) {
const pinia = createPinia();
setActivePinia(pinia);
const store = useConnectionStore();
store.treeNodes = treeNodes;
store.connections = [{ id: "test-conn", name: "TestDB", db_type: dbType, driver_profile: dbType, host: "localhost", port: 3050, username: "sysdba", password: "masterkey" }];
vi.spyOn(store, "ensureConnected").mockResolvedValue(undefined as any);
vi.spyOn(store, "refreshTreeNode").mockResolvedValue(undefined as any);
@@ -55,6 +57,7 @@ async function mountHost(dbType: DatabaseType = "firebird") {
describe("Table trigger context menu", () => {
afterEach(() => {
vi.clearAllMocks();
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
@@ -74,6 +77,94 @@ describe("Table trigger context menu", () => {
expect(items).toContain(tr("contextMenu.changeOpenMode"));
});
it("offers Copy Qualified Name for MySQL routines without schema metadata", async () => {
const host = await mountHost("mysql");
const routineNode: TreeNode = { ...node("procedure", "rebuild"), schema: undefined };
const itemLabels = labels(host.buildContextMenu(routineNode));
expect(itemLabels).toContain(tr("contextMenu.copyQualifiedName"));
});
it("offers and executes Copy Qualified Name for a column", async () => {
const { copyToClipboard } = await import("@/lib/common/clipboard");
vi.mocked(copyToClipboard).mockClear();
const host = await mountHost("postgres");
const column: TreeNode = {
...node("column", "id (bigint)", "users"),
schema: "sales",
meta: { name: "id" },
};
const menu = host.buildContextMenu(column);
const copyItem = menu.flatMap((item) => item.children ?? []).find((item) => item.label === tr("contextMenu.copyQualifiedName"));
expect(copyItem).toBeDefined();
await copyItem?.action?.();
expect(copyToClipboard).toHaveBeenCalledWith('"sales"."users"."id"');
});
it("uses the JDBC driver's identifier quote for a Phoenix column in the real menu action", async () => {
const { copyToClipboard } = await import("@/lib/common/clipboard");
vi.mocked(copyToClipboard).mockClear();
const host = await mountHost("jdbc");
const store = useConnectionStore();
store.connections[0]!.driver_profile = "phoenix";
vi.spyOn(store, "connectionIdentifierQuote").mockReturnValue('"');
const column: TreeNode = {
...node("column", "first name", "T"),
schema: "APP",
meta: { name: "first name" },
};
const menu = host.buildContextMenu(column);
const copyItem = menu.flatMap((item) => item.children ?? []).find((item) => item.label === tr("contextMenu.copyQualifiedName"));
expect(copyItem).toBeDefined();
await copyItem?.action?.();
expect(copyToClipboard).toHaveBeenCalledWith('"APP"."T"."first name"');
});
it("rejects cross-connection qualified-name selections before writing the clipboard", async () => {
const { copyToClipboard } = await import("@/lib/common/clipboard");
const active = { ...node("table", "orders"), schema: "sales" };
const other = { ...node("table", "customers"), id: "other:db:table:customers", connectionId: "other", schema: "crm" };
const host = await mountHost("mysql", [active, other]);
const store = useConnectionStore();
store.selectedTreeNodeId = active.id;
store.selectedTreeNodeIds = [active.id, other.id];
const menu = host.buildContextMenu(active);
const copyItem = menu.flatMap((item) => item.children ?? []).find((item) => item.label === tr("contextMenu.copyQualifiedName"));
expect(copyItem).toBeDefined();
await copyItem?.action?.();
expect(copyToClipboard).not.toHaveBeenCalled();
});
it("rejects a selected object whose SQL namespace is unavailable before writing the clipboard", async () => {
const { copyToClipboard } = await import("@/lib/common/clipboard");
const active = { ...node("table", "orders"), schema: "sales" };
const unqualified = { ...node("procedure", "rebuild"), id: "test-conn:db:procedure:rebuild", schema: undefined };
const host = await mountHost("postgres", [active, unqualified]);
const store = useConnectionStore();
store.selectedTreeNodeId = active.id;
store.selectedTreeNodeIds = [active.id, unqualified.id];
const menu = host.buildContextMenu(active);
const copyItem = menu.flatMap((item) => item.children ?? []).find((item) => item.label === tr("contextMenu.copyQualifiedName"));
expect(copyItem).toBeDefined();
await copyItem?.action?.();
expect(copyToClipboard).not.toHaveBeenCalled();
});
it("copies fully qualified names for same-connection cross-schema selections in tree order", async () => {
const { copyToClipboard } = await import("@/lib/common/clipboard");
vi.mocked(copyToClipboard).mockClear();
const active = { ...node("table", "orders"), schema: "sales" };
const second = { ...node("view", "customer view"), id: "test-conn:db:view:customer-view", schema: "crm" };
const host = await mountHost("postgres", [active, second]);
const store = useConnectionStore();
store.selectedTreeNodeId = active.id;
store.selectedTreeNodeIds = [active.id, second.id];
const menu = host.buildContextMenu(active);
const copyItem = menu.flatMap((item) => item.children ?? []).find((item) => item.label === tr("contextMenu.copyQualifiedName"));
expect(copyItem).toBeDefined();
await copyItem?.action?.();
expect(copyToClipboard).toHaveBeenCalledWith('"sales"."orders"\n"crm"."customer view"');
});
it("exposes disableTrigger for enabled SQL Server table trigger and executes SQL on action", async () => {
const api = await import("@/lib/backend/api");
const executeQueryMock = vi.fn().mockResolvedValue({ success: true, columns: [], rows: [] });
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDataGridExport, type UseDataGridExportOptions } from "@/composables/useDataGridExport";
import type { DatabaseType, QueryResult } from "@/types/database";
import { buildDataGridCopyUpdateStatements } from "@/lib/dataGrid/dataGridSql";
import { copyToClipboard } from "@/lib/common/clipboard";
import { copyRichTextToClipboard, copyToClipboard } from "@/lib/common/clipboard";
import type { DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
import type { CellSelectionMatrix, SelectionData } from "@/lib/dataGrid/gridSelection";
import type { CellValue } from "@/lib/dataGrid/cellValue";
@@ -34,6 +34,7 @@ vi.mock("@/lib/common/clipboard", async (importOriginal) => {
return {
...original,
copyToClipboard: vi.fn(),
copyRichTextToClipboard: vi.fn(),
};
});
@@ -1817,6 +1818,23 @@ describe("useDataGridExport prepared row statements", () => {
expect(toast).toHaveBeenCalledWith("grid.copyExtractorWarningOmittedColumns", 5000);
});
it("localizes PHP date ISO warnings after copying", async () => {
const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [0], columns: ["created_at"], rows: [["not a date"]] };
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({
text: "not a date",
mimeType: "text/x-php",
fileExtension: "php",
rowCount: 1,
columnCount: 1,
warnings: [{ code: "php-date-not-iso", message: "backend text" }],
});
const state = createExportState(editableTable, ["created_at"], matrix, ["not a date"]);
await state.copyWithExtractor("php");
expect(toast).toHaveBeenCalledWith("grid.copyExtractorWarningPhpDateNotIso", 5000);
});
it("reports extractor copy failure without presenting post-copy warnings", async () => {
const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [0], columns: ["id"], rows: [[1]] };
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({
@@ -2987,4 +3005,15 @@ describe("useDataGridExport MySQL BLOB 文本复制 (#12413)", () => {
expect(extractDataGridSelection).toHaveBeenLastCalledWith(expect.objectContaining({ extractor: "sql-inserts", rows: [[1, "0x616263"]] }));
});
it("copies HTML with both rich and plain clipboard flavors", async () => {
const matrix: CellSelectionMatrix = { rowIndexes: [0], columnIndexes: [0], columns: ["name"], rows: [["Ada"]] };
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "<table><tr><td>Ada</td></tr></table>", mimeType: "text/html", fileExtension: "html", rowCount: 1, columnCount: 1 });
const state = createExportState(editableTable, ["name"], matrix, ["Ada"]);
await expect(state.copyWithExtractor("html")).resolves.toBe(true);
expect(copyRichTextToClipboard).toHaveBeenCalledWith("<table><tr><td>Ada</td></tr></table>", "<table><tr><td>Ada</td></tr></table>");
expect(copyToClipboard).not.toHaveBeenCalled();
});
});
@@ -4,13 +4,22 @@ import { useDataGridExtractor } from "@/composables/useDataGridExtractor";
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, type DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
import type { DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
import type { DatabaseType } from "@/types/database";
import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridDisplayText, mongoDocumentGridEditorText, mongoDocumentGridExternalValue, mongoDocumentGridValue } from "@/lib/mongo/mongoDocumentValues";
import exactDecimalWire from "@/lib/__tests__/dataGrid/fixtures/exactDecimalQueryResult.json";
const mocks = vi.hoisted(() => ({ extract: vi.fn() }));
vi.mock("@/lib/backend/api", () => ({ extractDataGridSelection: mocks.extract }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
function createState(databaseType: DatabaseType, tableMeta: DataGridTableMeta | undefined, extractorOptions?: DataGridExtractorOptions, includeDatabaseName = false, query?: { columns: string[]; values: unknown[]; visible: number[]; types: string[]; omitFullTypes?: boolean }) {
function createState(
databaseType: DatabaseType,
tableMeta: DataGridTableMeta | undefined,
extractorOptions?: DataGridExtractorOptions,
includeDatabaseName = false,
query?: { columns: string[]; values: unknown[]; visible: number[]; types: string[]; omitFullTypes?: boolean },
overrides: Partial<Parameters<typeof useDataGridExtractor>[0]> = {},
) {
const allColumns = query?.columns ?? ["id", "name"];
const visible = query?.visible ?? [0, 1];
const items = [{ id: 1, data: query?.values ?? [1, "Ada"], isDraft: false }];
@@ -39,6 +48,7 @@ function createState(databaseType: DatabaseType, tableMeta: DataGridTableMeta |
copyText: vi.fn(async () => true),
canCopySqlInsert: () => true,
buildMongoInsert: vi.fn(async () => undefined),
...overrides,
});
}
@@ -68,6 +78,15 @@ describe("useDataGridExtractor includeDatabaseName payload", () => {
mocks.extract.mockResolvedValue({ text: "INSERT INTO users VALUES (1, 'Ada');", rowCount: 1 });
});
it.each(["oracle", "oceanbase-oracle"] as const)("passes %s into PHP extraction for dialect-specific float semantics", async (databaseType) => {
const state = createState(databaseType, undefined);
await state.extractWithExtractor("php");
const request = mocks.extract.mock.calls.at(-1)![0];
expect(request.databaseType).toBe(databaseType);
expect(request.options.php).toMatchObject({ formatDateIso: true });
expect(request.options.php).not.toHaveProperty("formatDateISO");
});
it.each([
["sqlserver", "dbo", "app"],
["snowflake", "PUBLIC", undefined],
@@ -112,6 +131,28 @@ describe("useDataGridExtractor includeDatabaseName payload", () => {
expect(fullRequest.rows).toEqual([values]);
});
it("keeps exact-decimal wire strings through native, editor, and raw pasteback paths", async () => {
const wire = JSON.parse(JSON.stringify(exactDecimalWire)) as { columns: string[]; column_types: string[]; rows: unknown[][] };
const [value] = wire.rows[0]!;
expect(value).toBe("9007199254740993.123456789012");
for (const [token, expected] of [
["1234567890123456.5", "1234567890123456.5"],
["1.23456789012345678e20", "123456789012345680000"],
["0.10000000000000001", "0.1"],
["1e400", "Infinity"],
["1e-400", "0"],
]) {
expect(String(JSON.parse(token))).toBe(expected);
}
const copyText = vi.fn(async () => true);
const state = createState("oceanbase-oracle", undefined, undefined, false, { columns: wire.columns, values: [value], visible: [0], types: wire.column_types }, { copyText });
await state.extractWithExtractor("php", { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, valueFormat: "native" });
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([[value]]);
await state.copyWithExtractor("tsv");
expect(copyText.mock.calls.at(-1)?.[1]?.rows).toEqual([[value]]);
});
it("keeps reordered visible types aligned when a legacy caller has no full types", async () => {
const state = createState("oracle", undefined, undefined, false, {
columns: ["D", "T"],
@@ -128,6 +169,90 @@ describe("useDataGridExtractor includeDatabaseName payload", () => {
]);
});
it("uses display/edit/native value semantics while preserving internal pasteback rows", async () => {
const internalNull = "__bson_null__";
const internalPrefix = "__dbx_internal__:real-string";
const values = [internalNull, "NULL", internalPrefix, "0x00"];
const options = { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, valueFormat: "display" as const };
const copyText = vi.fn(async () => true);
const state = createState(
"mongodb",
undefined,
options,
false,
{ columns: ["null", "text", "prefix", "binary"], values, visible: [0, 1, 2, 3], types: ["json", "text", "text", "varbinary"] },
{
copyText,
externalCellValue: (value) => (value === internalNull ? null : value),
displayValue: (value) => `D:${String(value)}`,
editValue: (value) => `E:${String(value)}`,
},
);
await state.extractWithExtractor("dbunit");
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([[null, "D:NULL", `D:${internalPrefix}`, "D:0x00"]]);
await state.copyWithExtractor("tsv", { ...options, valueFormat: "edit" });
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([[null, "E:NULL", `E:${internalPrefix}`, "E:0x00"]]);
expect(copyText.mock.calls.at(-1)?.[1]?.rows).toEqual([values]);
await state.extractWithExtractor("dbunit", { ...options, valueFormat: "native" });
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([[null, "NULL", internalPrefix, "0x00"]]);
});
it("keeps BSON date and int64 semantics across display, edit, native, and raw pasteback", async () => {
const values = [mongoDocumentGridValue({ $date: { $numberLong: "1789887723123" } }), mongoDocumentGridValue({ $numberLong: "9007199254740993" }), MONGO_DOCUMENT_GRID_NULL];
const copyText = vi.fn(async () => true);
const state = createState(
"mongodb",
undefined,
undefined,
false,
{ columns: ["when", "id", "nothing"], values, visible: [0, 1, 2], types: ["datetime", "int64", "json"] },
{
copyText,
externalCellValue: mongoDocumentGridExternalValue,
displayValue: (value) => mongoDocumentGridDisplayText(value) ?? String(value),
editValue: (value) => mongoDocumentGridEditorText(value) ?? String(value),
},
);
await state.extractWithExtractor("dbunit", { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, valueFormat: "display" });
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([['ISODate("2026-09-20T07:02:03.123Z")', 'NumberLong("9007199254740993")', null]]);
await state.copyWithExtractor("tsv", { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, valueFormat: "edit" });
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([['{"$date":{"$numberLong":"1789887723123"}}', 'NumberLong("9007199254740993")', null]]);
expect(mocks.extract.mock.calls.at(-1)![0].rows.flat().join(" ")).not.toContain("dbx:mongo-document-grid");
expect(copyText.mock.calls[0]![1]?.rows).toEqual([values]);
await state.extractWithExtractor("dbunit", { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, valueFormat: "native" });
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([['{"$date":{"$numberLong":"1789887723123"}}', 'NumberLong("9007199254740993")', null]]);
expect(mocks.extract.mock.calls.at(-1)![0].rows.flat().join(" ")).not.toContain("dbx:mongo-document-grid");
});
it("formats Postgres arrays, temporal values, and large JSON only for clipboard value modes", async () => {
const values = ["{a,b}", "2026-10-20 01:02:03.123456+00", '{"id":9007199254740993}'];
const copyText = vi.fn(async () => true);
const state = createState(
"postgres",
undefined,
undefined,
false,
{ columns: ["items", "created_at", "payload"], values, visible: [0, 1, 2], types: ["text[]", "timestamptz", "json"] },
{
copyText,
displayValue: (value, index) => [`ARRAY[${String(value)}]`, `LOCAL:${String(value)}`, `JSON:${String(value)}`][index]!,
editValue: (value, index) => [`EDIT ARRAY:${String(value)}`, `EDIT TIME:${String(value)}`, `EDIT JSON:${String(value)}`][index]!,
},
);
await state.extractWithExtractor("csv", { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, valueFormat: "display" });
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([["ARRAY[{a,b}]", "LOCAL:2026-10-20 01:02:03.123456+00", `JSON:${values[2]}`]]);
await state.extractWithExtractor("csv", { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, valueFormat: "edit" });
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([["EDIT ARRAY:{a,b}", "EDIT TIME:2026-10-20 01:02:03.123456+00", `EDIT JSON:${values[2]}`]]);
await state.extractWithExtractor("csv", { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, valueFormat: "native" });
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([values]);
await state.extractWithExtractor("sql-inserts");
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([values]);
await state.copyWithExtractor("tsv", { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, valueFormat: "edit" });
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([["EDIT ARRAY:{a,b}", "EDIT TIME:2026-10-20 01:02:03.123456+00", `EDIT JSON:${values[2]}`]]);
expect(copyText.mock.calls[0]![1]?.rows).toEqual([values]);
});
it("formats cells with active column formatters for non-SQL extractors and keeps raw values for SQL extractors", async () => {
const state = useDataGridExtractor({
columns: computed(() => ["id", "amount"]),
@@ -167,4 +292,18 @@ describe("useDataGridExtractor includeDatabaseName payload", () => {
const sqlRequest = mocks.extract.mock.calls.at(-1)![0];
expect(sqlRequest.rows).toEqual([[1, 100]]);
});
it("does not classify INTERVAL as a numeric column when applying CSV number display", async () => {
const options = { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, dsv: { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv, formatNumbers: "display" as const } };
const state = createState("postgres", undefined, options, false, { columns: ["duration", "amount"], values: ["-1 day", 10], visible: [0, 1], types: ["INTERVAL", "DECIMAL"] }, { displayValue: (value) => `D:${String(value)}` });
await state.extractWithExtractor("csv");
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([["-1 day", "D:10"]]);
});
it.each(["DEC", "FIXED", "SMALLMONEY", "UInt8"])("applies numeric display formatting to %s", async (dataType) => {
const options = { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, dsv: { ...DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv, formatNumbers: "display" as const } };
const state = createState("postgres", undefined, options, false, { columns: ["amount"], values: [12], visible: [0], types: [dataType] }, { displayValue: (value) => `D:${String(value)}` });
await state.extractWithExtractor("csv");
expect(mocks.extract.mock.calls.at(-1)![0].rows).toEqual([["D:12"]]);
});
});
@@ -17,7 +17,7 @@ import { binaryCellClipboardText } from "@/lib/dataGrid/binaryCellDownload";
import { materializeSnapshotResultRows, waitForLargeValueSnapshot } from "@/lib/dataGrid/largeValueSnapshot";
import { snapshotExportSelection, assertSnapshotXlsxCellLengths } from "@/lib/dataGrid/snapshotExport";
import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/connection/actionActivation";
import { clipboardLineEndings, copyToClipboard } from "@/lib/common/clipboard";
import { clipboardLineEndings, copyRichTextToClipboard, copyToClipboard } from "@/lib/common/clipboard";
import { clearDataGridClipboardCopy, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard";
import { buildDataGridCopyInsertStatement, type DataGridColumnInfo, type DataGridCopyInsertMode, type DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
import { formatSqlInsert, formatTsv } from "@/lib/export/exportFormats";
@@ -116,6 +116,7 @@ export interface UseDataGridExportOptions {
mongoUpdateTarget?: ComputedRef<MongoCopyUpdateTarget | undefined>;
databaseType: ComputedRef<DatabaseType | undefined>;
displayValue?: (value: CellValue, columnIndex: number) => string;
editValue?: (value: CellValue, columnIndex: number) => string;
hasColumnFormatter?: (columnIndex: number) => boolean;
applyColumnFormattersOnExport?: ComputedRef<boolean>;
cellClipboardText?: (value: CellValue, columnIndex: number) => string | undefined;
@@ -279,12 +280,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
const visibleColumnIndexes = visibleColumnIndexesOption ?? computed(() => columns.value.map((_, index) => index));
const hasColumnSelection = hasColumnSelectionOption ?? computed(() => false);
async function copyText(text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; header?: readonly unknown[] }) {
async function copyText(text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; header?: readonly unknown[] }, richHtml?: string) {
const copiedRows = gridCopy?.rows.map((row) => [...row]);
const copiedHeader = gridCopy?.header ? [...gridCopy.header] : undefined;
clearDataGridClipboardCopy();
try {
await copyToClipboard(text);
if (richHtml !== undefined) await copyRichTextToClipboard(richHtml, text);
else await copyToClipboard(text);
// Remember the text as it now sits on the clipboard, so a paste back into
// the grid still matches and keeps its null-cell metadata.
if (copiedRows) rememberDataGridClipboardCopy(clipboardLineEndings(text), copiedRows, copiedHeader);
@@ -964,6 +966,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
visibleColumnIndexes,
columnTypes,
allColumnTypes: allColumnTypesOption,
columnComments: columnCommentsOption,
allColumnComments: allColumnCommentsOption,
extractorOptions: extractorOptionsOption,
databaseType,
identifierQuote,
@@ -986,6 +990,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
partialLoadInfo: partialLoadCopyInfo,
canBuildMongoUpdate: canBuildMongoExtractorUpdate,
displayValue: options.displayValue,
editValue: options.editValue,
hasColumnFormatter: options.hasColumnFormatter,
applyColumnFormattersOnExport: options.applyColumnFormattersOnExport,
externalCellValue: (value, columnIndex) => {
@@ -20,6 +20,7 @@ import {
type DataGridExtractResult,
type DataGridExtractorOptions,
type DataGridExtractWarningCode,
type DataGridValueFormat,
} from "@/lib/dataGrid/dataGridCopyExtractor";
import type { DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
import { binaryCellClipboardText } from "@/lib/dataGrid/binaryCellDownload";
@@ -44,6 +45,8 @@ interface ExtractorRequestSource {
normalizeValues: boolean;
keepUnsafeJsonText: boolean;
presentBinaryText: boolean;
valueFormat: DataGridValueFormat;
formatNumbers: "inherit" | "native" | "display";
rawRows: unknown[][];
}
@@ -53,6 +56,8 @@ interface ResolvedDataGridExtraction {
result: DataGridExtractResult;
}
const NUMERIC_COLUMN_TYPES = new Set(["int", "integer", "smallint", "bigint", "tinyint", "mediumint", "decimal", "numeric", "number", "float", "double", "real", "serial", "smallserial", "bigserial", "money", "smallmoney", "dec", "fixed"]);
interface UseDataGridExtractorOptions {
columns: ComputedRef<string[]>;
displayItems: ComputedRef<ExtractorRowItem[]>;
@@ -62,6 +67,8 @@ interface UseDataGridExtractorOptions {
visibleColumnIndexes: ComputedRef<number[]>;
columnTypes: ComputedRef<Array<string | undefined> | undefined>;
allColumnTypes?: ComputedRef<Array<string | undefined> | undefined>;
columnComments?: ComputedRef<Array<string | undefined> | undefined>;
allColumnComments?: ComputedRef<Array<string | undefined> | undefined>;
extractorOptions?: ComputedRef<DataGridExtractorOptions>;
databaseType: ComputedRef<DatabaseType | undefined>;
identifierQuote?: ComputedRef<string | undefined>;
@@ -77,8 +84,9 @@ interface UseDataGridExtractorOptions {
resolveSourceValues?: (rowIds: number[], sourceColumnIndexes: number[]) => Promise<Map<number, Map<number, unknown>>>;
contextCell: ComputedRef<{ rowId: number; rowIndex: number; col: number } | null> | Ref<{ rowId: number; rowIndex: number; col: number } | null>;
contextSelectionIsSynthetic: ComputedRef<boolean> | Ref<boolean>;
copyText: (text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; header?: readonly unknown[] }) => Promise<boolean>;
copyText: (text: string, gridCopy?: { rows: readonly (readonly unknown[])[]; header?: readonly unknown[] }, richHtml?: string) => Promise<boolean>;
displayValue?: (value: CellValue, columnIndex: number) => string;
editValue?: (value: CellValue, columnIndex: number) => string;
hasColumnFormatter?: (columnIndex: number) => boolean;
applyColumnFormattersOnExport?: ComputedRef<boolean>;
externalCellValue?: (value: unknown, columnIndex: number) => unknown;
@@ -113,6 +121,12 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
return (dbType === "oracle" || dbType === "oceanbase-oracle") && isTemporalColumnType(columnType);
}
function isNumericColumnType(columnType: string | undefined): boolean {
const tokens = columnType?.toLowerCase().match(/[a-z0-9]+/g) ?? [];
const first = tokens[0];
return !!first && (NUMERIC_COLUMN_TYPES.has(first) || /^(?:int|uint|float)\d+$/.test(first));
}
function shouldApplyColumnFormatter(columnIndex: number): boolean {
if (!options.hasColumnFormatter?.(columnIndex)) return false;
if (options.applyColumnFormattersOnExport?.value !== undefined) {
@@ -124,7 +138,18 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
// 除 SQL 外的剪贴板展示格式都可把文本型 MySQL VARBINARY 从 `0x<hex>` 还原为原始字符串,
// 并把 Oracle temporal 单元格及配置了列格式化的单元格渲染为与网格一致的显示文本;SQL 必须继续持有 hex / 原始时间值
// (display 格式化会丢时区与高精度小数秒)才能保证 round-trip;rawRows 则始终保存原值,供 DBX 内部网格回粘使用。
function extractorCellValue(value: unknown, columnType: string | undefined, normalizeValues: boolean, keepUnsafeJsonText: boolean, presentBinaryText: boolean, columnIndex: number, sourceIndex?: number): unknown {
function extractorCellValue(value: unknown, columnType: string | undefined, normalizeValues: boolean, keepUnsafeJsonText: boolean, presentBinaryText: boolean, columnIndex: number, sourceIndex?: number, valueFormat: DataGridValueFormat = "legacy"): unknown {
if (valueFormat === "display" || valueFormat === "edit") {
const external = options.externalCellValue?.(value, columnIndex);
if ((external === undefined ? value : external) === null || (external === undefined ? value : external) === undefined) return null;
const callbackIndex = sourceIndex ?? columnIndex;
return valueFormat === "display" ? (options.displayValue?.(value as CellValue, callbackIndex) ?? value) : (options.editValue?.(value as CellValue, callbackIndex) ?? value);
}
if (valueFormat === "native") {
const normalized = normalizeValues ? normalizeCellValue(value, columnType, keepUnsafeJsonText) : value;
const external = options.externalCellValue?.(normalized, columnIndex);
return external === undefined ? normalized : external;
}
if (presentBinaryText) {
const text = binaryCellClipboardText(value, columnType, options.databaseType.value);
if (text !== null) return text;
@@ -246,15 +271,19 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
const compactIndexBySource = new Map(requiredSourceIndexes.map((sourceIndex, compactIndex) => [sourceIndex, compactIndex]));
const columnTypesBySource = new Map(visibleIndexes.map((sourceIndex, visibleIndex) => [sourceIndex, options.columnTypes.value?.[visibleIndex]]));
const columnType = (sourceIndex: number) => options.allColumnTypes?.value?.[sourceIndex] ?? columnTypesBySource.get(sourceIndex);
const columns = requiredSourceIndexes.map((sourceIndex, compactIndex) => ({
displayName: fullColumns[sourceIndex],
// Query results without source metadata still expose their database column
// names as display names. Keep the request mapping aligned with the
// availability check so SQL SELECT can use that safe fallback.
sourceName: sourceNames?.[sourceIndex] ?? fullColumns[sourceIndex],
sourceIndex: compactIndex,
dataType: columnType(sourceIndex),
}));
const columns = requiredSourceIndexes.map((sourceIndex, compactIndex) => {
const description = options.allColumnComments?.value?.[sourceIndex] ?? options.columnComments?.value?.[visibleIndexes.indexOf(sourceIndex)];
return {
displayName: fullColumns[sourceIndex],
// Query results without source metadata still expose their database column
// names as display names. Keep the request mapping aligned with the
// availability check so SQL SELECT can use that safe fallback.
sourceName: sourceNames?.[sourceIndex] ?? fullColumns[sourceIndex],
sourceIndex: compactIndex,
dataType: columnType(sourceIndex),
...(description !== undefined ? { description } : {}),
};
});
const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor];
const selectedColumnIndexes = selectedSourceIndexes.map((sourceIndex) => compactIndexBySource.get(sourceIndex)).filter((index): index is number => index !== undefined);
const columnTypes = requiredSourceIndexes.map(columnType);
@@ -262,7 +291,14 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
const keepUnsafeJsonText = descriptor.category === "sql";
const presentBinaryText = descriptor.category !== "sql";
const rawRows = sourceRows.map((row) => requiredSourceIndexes.map((sourceIndex) => row[sourceIndex]));
const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, columnTypes[index], normalizeValues, keepUnsafeJsonText, presentBinaryText, index, requiredSourceIndexes[index])));
const effectiveValueFormat = descriptor.category === "sql" ? "native" : extractorOptions.valueFormat === "default" ? (extractor === "dbunit" || extractor === "php" ? "native" : "legacy") : extractorOptions.valueFormat;
const rows = rawRows.map((row) =>
row.map((value, index) => {
const formatNumbers = ["csv", "csv-with-headers", "dsv"].includes(extractor) ? extractorOptions.dsv.formatNumbers : "inherit";
const cellFormat = formatNumbers !== "inherit" && isNumericColumnType(columnTypes[index]) ? formatNumbers : effectiveValueFormat;
return extractorCellValue(value, columnTypes[index], normalizeValues, keepUnsafeJsonText, presentBinaryText, index, requiredSourceIndexes[index], cellFormat);
}),
);
// 「包含数据库名称」:复制/导出 SQL 时,提取器自身的勾选框与编辑器全局设置
// (`generateSqlIncludeDatabaseName`)任一开启都保留库名/模式名;后者还会影响
// 保存 SQL、侧栏导出等其它路径,因此这里取“或”,不覆盖全局偏好。
@@ -281,9 +317,10 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
const request: DataGridExtractRequest = {
version: DATA_GRID_EXTRACTOR_CONTRACT_VERSION,
extractor,
databaseType: descriptor.category === "sql" ? options.databaseType.value : undefined,
databaseType: descriptor.category === "sql" || extractor === "php" ? options.databaseType.value : undefined,
identifierQuote: descriptor.category === "sql" ? options.identifierQuote?.value : undefined,
tableMeta,
sourceTableName: options.tableMeta.value?.tableName,
columns,
selectedColumnIndexes,
rows,
@@ -302,6 +339,8 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
normalizeValues,
keepUnsafeJsonText,
presentBinaryText,
valueFormat: effectiveValueFormat,
formatNumbers: ["csv", "csv-with-headers", "dsv"].includes(extractor) ? extractorOptions.dsv.formatNumbers : "inherit",
rawRows,
});
return request;
@@ -335,7 +374,12 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
return sourceIndex !== undefined && values.has(sourceIndex) ? values.get(sourceIndex) : value;
});
});
const rows = rawRows.map((row) => row.map((value, index) => extractorCellValue(value, limitedSource.columnTypes[index], limitedSource.normalizeValues, limitedSource.keepUnsafeJsonText, limitedSource.presentBinaryText, index, limitedSource.sourceColumnIndexes[index])));
const rows = rawRows.map((row) =>
row.map((value, index) => {
const cellFormat = limitedSource.formatNumbers !== "inherit" && isNumericColumnType(limitedSource.columnTypes[index]) ? limitedSource.formatNumbers : limitedSource.valueFormat;
return extractorCellValue(value, limitedSource.columnTypes[index], limitedSource.normalizeValues, limitedSource.keepUnsafeJsonText, limitedSource.presentBinaryText, index, limitedSource.sourceColumnIndexes[index], cellFormat);
}),
);
const resolvedRequest = { ...request, rows };
requestSources.set(resolvedRequest, { ...limitedSource, rawRows });
return resolvedRequest;
@@ -445,7 +489,8 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
return { initialRequest, request, result };
}
async function copyWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS): Promise<boolean> {
async function copyWithExtractor(extractor: DataGridCopyExtractorId, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, isSessionValid?: () => boolean): Promise<boolean> {
if (isSessionValid && !isSessionValid()) return false;
if (hasUnsupportedDiscreteSelection.value && !hasContextPredicateTarget(extractor)) {
toast(t("grid.copyExtractorUnsupportedSelection"), 5000);
return false;
@@ -453,7 +498,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
if (!canCopyWithExtractor(extractor, extractorOptions)) return false;
try {
const extraction = await extractWithExtractor(extractor, extractorOptions);
if (!extraction) return false;
if (!extraction || (isSessionValid && !isSessionValid())) return false;
const { request, result } = extraction;
// Derive the grid paste-back payload from the effective request schema so
// hidden support columns, row headers, NULLs, tabs, and newlines keep the
@@ -472,7 +517,8 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
return { rows, header };
})()
: undefined;
const copied = await options.copyText(result.text, gridCopy);
if (isSessionValid && !isSessionValid()) return false;
const copied = await options.copyText(result.text, gridCopy, extractor === "html" ? result.text : undefined);
if (!copied) return false;
showWarnings(result.warnings, result.omittedColumns);
const partialLoadInfo = options.partialLoadInfo?.value;
@@ -495,13 +541,14 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
return { ...result, sourceRowCount, truncated: sourceRowCount > result.rowCount };
}
async function copyWithPreference(preference: DataGridCopyPreference, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS): Promise<boolean> {
async function copyWithPreference(preference: DataGridCopyPreference, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, isSessionValid?: () => boolean): Promise<boolean> {
if (isSessionValid && !isSessionValid()) return false;
if (hasUnsupportedDiscreteSelection.value) {
toast(t("grid.copyExtractorUnsupportedSelection"), 5000);
return false;
}
const extractor = resolveCopyPreference(preference, extractorOptions);
return extractor ? copyWithExtractor(extractor, extractorOptions) : false;
return extractor ? copyWithExtractor(extractor, extractorOptions, isSessionValid) : false;
}
async function previewWithPreference(preference: DataGridCopyPreference, extractorOptions: DataGridExtractorOptions): Promise<DataGridExtractPreview> {
@@ -514,7 +561,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
function showWarnings(warnings: Array<{ code: DataGridExtractWarningCode; message: string }> | undefined, omittedColumns: string[] | undefined) {
if (!warnings?.length) return;
for (const warning of warnings) {
const message = warning.code === "omitted-columns" ? t("grid.copyExtractorWarningOmittedColumns", { columns: omittedColumns?.join(", ") || "-" }) : t("grid.copyExtractorWarningDuplicateJsonColumns");
const message = warning.code === "omitted-columns" ? t("grid.copyExtractorWarningOmittedColumns", { columns: omittedColumns?.join(", ") || "-" }) : warning.code === "php-date-not-iso" ? t("grid.copyExtractorWarningPhpDateNotIso") : t("grid.copyExtractorWarningDuplicateJsonColumns");
toast(message, 5000);
}
}
@@ -6,6 +6,7 @@ import id from "../locales/id";
import it_ from "../locales/it";
import ja from "../locales/ja";
import ko from "../locales/ko";
import pl from "../locales/pl";
import ptBR from "../locales/pt-BR";
import ru from "../locales/ru";
import tr from "../locales/tr";
@@ -21,6 +22,7 @@ const locales: Array<[string, Record<string, unknown>]> = [
["it", it_],
["ja", ja],
["ko", ko],
["pl", pl],
["pt-BR", ptBR],
["ru", ru],
["tr", tr],
@@ -39,5 +41,22 @@ describe("grid copy extractor saved locale parity", () => {
expect(grid.copyExtractorSaved as string, `${_name}: grid.copyExtractorSaved`).not.toHaveLength(0);
expect(grid.copyExtractorRawHint, `${_name}: grid.copyExtractorRawHint`).toBeTypeOf("string");
expect(grid.copyExtractorRawHint as string, `${_name}: grid.copyExtractorRawHint`).not.toHaveLength(0);
expect(grid.copyExtractorAdvancedOptions, `${_name}: grid.copyExtractorAdvancedOptions`).toBeTypeOf("string");
expect(grid.copyExtractorAdvancedOptions as string, `${_name}: grid.copyExtractorAdvancedOptions`).not.toHaveLength(0);
for (const key of [
"copyExtractorAdvancedCopy",
"copyExtractorAdvancedCopyHint",
"copyExtractorConfigureHint",
"copyExtractorFormatList",
"copyExtractorFormatOptions",
"copyExtractorPreviewOnDemand",
"copyExtractorCollapsePreview",
"copyExtractorCopyFailed",
"copyExtractorSelectionChanged",
"copyExtractorCopyRunning",
]) {
expect(grid[key], `${_name}: grid.${key}`).toBeTypeOf("string");
expect(grid[key] as string, `${_name}: grid.${key}`).not.toHaveLength(0);
}
});
});
+13
View File
@@ -1894,6 +1894,17 @@ export default withEnglishFallback({
copyExtractorPipeSeparated: "Şaquli xətlə ayrılmış",
copyExtractorPretty: "Formatlanmış cədvəl",
copyExtractorConfigure: "Verilənləri çıxarma vasitələrini tənzimlə…",
copyExtractorAdvancedCopy: "Ətraflı kopyalama…",
copyExtractorAdvancedCopyHint: "Yalnız bu kopyalama üçün format və seçimlər edin; dəyişikliklər standart kimi saxlanmır.",
copyExtractorConfigureHint: "Standart kopyalama formatını və seçimləri seçin. Ləğv dəyişiklikləri atır.",
copyExtractorFormatList: "Kopyalama formatları",
copyExtractorFormatOptions: "Format seçimləri",
copyExtractorPreviewOnDemand: "Cari seçimlərlə önizləmək üçün genişləndirin",
copyExtractorCollapsePreview: "Önizləməni yığ",
copyExtractorCopyFailed: "Kopyalama tamamlanmadı. Seçimi yoxlayın və ya yenidən cəhd edin.",
copyExtractorSelectionChanged: "Nəticə və ya seçim dəyişdi. Kopyalamadan əvvəl cari seçimi təsdiqləmək üçün bu dialoqu bağlayıb yenidən açın.",
copyExtractorCopyRunning: "Kopyalanır…",
copyExtractorAdvancedOptions: "Ətraflı seçimlər",
copyExtractorSaved: "Veri çıxarıcı parametrləri yadda saxlanıldı",
copyExtractorDefaultFormat: "Standart kopyalama davranışı",
copyExtractorFormat: "Çıxarma vasitəsi",
@@ -1924,6 +1935,7 @@ export default withEnglishFallback({
copyExtractorPreviewTruncated: "Seçilmiş {count} sətrin ilk 100 sətrinə önbaxış göstərilir.",
copyExtractorWarningOmittedColumns: "Kopyalanmış nəticəyə bu sütunlar daxil edilmədi: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "Verilənlərin itirilməsinin qarşısını almaq üçün təkrarlanan JSON sütun adları dəyişdirildi.",
copyExtractorWarningPhpDateNotIso: "Bəzi vaxt dəyərləri etibarlı ISO tarix/vaxt mətni deyildi; orijinal mətn saxlanıldı.",
copyExtractorValidation: {
"column-separator-empty": "Sütun ayırıcısı boş olmamalıdır.",
"row-separator-empty": "Sətir ayırıcısı boş olmamalıdır.",
@@ -1932,6 +1944,7 @@ export default withEnglishFallback({
"separators-overlap": "Sütun və sətir ayırıcıları üst-üstə düşməməlidir.",
"invalid-quote": "Dırnaq işarəsi dəqiq bir simvol olmalıdır.",
"quote-conflicts": "Dırnaq işarəsi sütun və ya sətir ayırıcısının tərkibində olmamalıdır.",
"invalid-php-row-delimiter": "PHP sətir ayırıcısı yalnız boşluq simvollarından ibarət ola bilər.",
},
mongoJsonPreview: "JSON önbaxışı",
mongoJsonPreviewEmpty: "Önbaxış üçün MongoDB sənədi seçin.",
+63
View File
@@ -2647,7 +2647,33 @@ export default {
copyExtractorWhereClause: "Where Clause",
copyExtractorPipeSeparated: "Pipe-separated",
copyExtractorPretty: "Pretty Table",
copyDbunitTableName: "DbUnit table name",
copyDbunitTableNameHint: "Uses the source table when available; enter a name for query results without a source table.",
copyDbunitTableNameRequired: "Enter a DbUnit table name because this result has no source table.",
copyDbunitNullValue: "NULL replacement text",
copyDbunitUppercaseTable: "Uppercase table name",
copyDbunitUppercaseColumns: "Uppercase column names",
copyDbunitIncludeNulls: "Include NULL values",
copyPhpVariable: "PHP variable name",
copyPhpVariableInvalid: "Use a PHP variable name starting with a letter or underscore.",
copyPhpArraySyntax: "Array syntax",
copyPhpQuote: "String quotes",
copyPhpRowDelimiter: "Row delimiter",
copyPhpIsoDates: "Format recognized date values as ISO",
copyExtractorVisibleFormats: "Visible Copy as formats",
copyExtractorRestoreVisibleFormats: "Restore defaults",
copyExtractorConfigure: "Configure Data Extractors…",
copyExtractorAdvancedCopy: "Advanced copy…",
copyExtractorAdvancedCopyHint: "Choose a format and options for this copy only. These changes are not saved as defaults.",
copyExtractorConfigureHint: "Choose the default copy format and options. Cancel discards your changes.",
copyExtractorFormatList: "Copy formats",
copyExtractorFormatOptions: "Format options",
copyExtractorPreviewOnDemand: "Expand to preview with the current options",
copyExtractorCollapsePreview: "Collapse preview",
copyExtractorCopyFailed: "Copy did not complete. Check the selection or try again.",
copyExtractorSelectionChanged: "The result or selection changed. Close and reopen this dialog to confirm the current selection before copying.",
copyExtractorCopyRunning: "Copying…",
copyExtractorAdvancedOptions: "Advanced options",
copyExtractorSaved: "Data extractor settings saved",
copyExtractorDefaultFormat: "Default copy behavior",
copyExtractorFormat: "Extractor",
@@ -2659,6 +2685,37 @@ export default {
copyExtractorQuoteMinimal: "When needed",
copyExtractorQuoteAlways: "Always",
copyExtractorQuoteNever: "Never",
copyExtractorQuoteStrings: "Strings",
copyExtractorQuoteAllButNumbers: "All but numbers",
copyExtractorQuoteAllIncludingNulls: "All including NULLs",
copyExtractorQuoteNumericStrings: "Quote numeric-looking strings",
copyExtractorHeaderPosition: "Header position",
copyExtractorHeaderLegacy: "Use this format's default",
copyExtractorHeaderNone: "No header",
copyExtractorHeaderTop: "Top",
copyExtractorHeaderBottom: "Bottom",
copyExtractorHeaderFormat: "Header content",
copyExtractorHeaderName: "Name",
copyExtractorHeaderDescription: "Description",
copyExtractorHeaderBoth: "Name and description",
copyExtractorHeaderCase: "Header case",
copyExtractorHeaderPreserve: "Preserve",
copyExtractorHeaderUpper: "Uppercase",
copyExtractorHeaderLower: "Lowercase",
copyExtractorEscapeFormulas: "Escape spreadsheet formulas",
copyExtractorValueFormat: "Value format",
copyExtractorValueFormatDefault: "Format default",
copyExtractorValueFormatLegacy: "Follow existing behavior",
copyExtractorValueFormatDisplay: "Grid display",
copyExtractorValueFormatEdit: "Cell editor text",
copyExtractorValueFormatNative: "Native value",
copyExtractorLineFeedEscape: "Replace line feeds with",
copyExtractorArrayFormat: "Array brackets",
copyExtractorArrayPreserve: "Preserve source",
copyExtractorFormatNumbers: "Numeric values",
copyExtractorFormatNumbersInherit: "Follow value format",
copyExtractorFormatNumbersNative: "Native values",
copyExtractorFormatNumbersDisplay: "Grid display",
copyExtractorIncludeHeader: "Include column header",
copyExtractorIncludeRowHeader: "Include row numbers",
copyExtractorSkipComputed: "Skip computed columns",
@@ -2683,6 +2740,7 @@ export default {
copyExtractorPreviewTruncated: "Previewing the first 100 rows of {count} selected rows.",
copyExtractorWarningOmittedColumns: "Copied output omitted these columns: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "Duplicate JSON column names were renamed to prevent data loss.",
copyExtractorWarningPhpDateNotIso: "Some temporal values were not valid ISO date/time text; their original text was preserved.",
copyExtractorValidation: {
"column-separator-empty": "Column separator must not be empty.",
"row-separator-empty": "Row separator must not be empty.",
@@ -2691,6 +2749,7 @@ export default {
"separators-overlap": "Column and row separators must not overlap.",
"invalid-quote": "Quote must be exactly one character.",
"quote-conflicts": "Quote must not be part of a column or row separator.",
"invalid-php-row-delimiter": "PHP row delimiter may contain whitespace characters only.",
},
mongoJsonPreview: "JSON Preview",
mongoJsonPreviewEmpty: "Select a MongoDB document to preview.",
@@ -3354,6 +3413,7 @@ export default {
setNull: "Set NULL",
restoreOriginalValue: "Restore Original",
copyColumnName: "Copy Column Name",
copyColumnMetadata: "Copy Column Metadata",
copyColumnComment: "Copy Column Comment",
columnDragChipMany: "{names} ({count} columns)",
copyAlterColumnSql: "Copy as SQL ALTER",
@@ -4596,6 +4656,9 @@ export default {
copyAdvancedInfo: "Copy Advanced Info",
name: "Name",
fullPath: "Full Path",
copyQualifiedName: "Copy Qualified Name",
copyQualifiedNameSameConnection: "Select objects from one connection to copy qualified names.",
copyQualifiedNameUnavailable: "Cannot resolve a qualified name for {name}.",
copyFinalProxyPort: "Copy Final Proxy Port",
finalProxyPortCopied: "Final proxy port {port} copied",
copyConnectionInfo: "Copy Connection Info",
+63
View File
@@ -3001,6 +3001,7 @@ export default withEnglishFallback({
setNull: "Establecer NULL",
restoreOriginalValue: "Restaurar original",
copyColumnName: "Copiar nombre de columna",
copyColumnMetadata: "Copiar metadatos de columna",
copyColumnComment: "Copiar comentario de columna",
columnDragChipMany: "{names} ({count} columnas)",
copySqlCondition: "Copiar condición SQL",
@@ -3147,7 +3148,33 @@ export default withEnglishFallback({
copyExtractorWhereClause: "Cláusula WHERE",
copyExtractorPipeSeparated: "Separado por barra vertical",
copyExtractorPretty: "Tabla formateada",
copyDbunitTableName: "Nombre de tabla DbUnit",
copyDbunitTableNameHint: "Usa la tabla de origen si está disponible; introduce un nombre para resultados de consulta sin tabla de origen.",
copyDbunitTableNameRequired: "Este resultado no tiene tabla de origen. Introduce un nombre de tabla DbUnit.",
copyDbunitNullValue: "Texto para sustituir NULL",
copyDbunitUppercaseTable: "Convertir el nombre de tabla a mayúsculas",
copyDbunitUppercaseColumns: "Convertir los nombres de columna a mayúsculas",
copyDbunitIncludeNulls: "Incluir valores NULL",
copyPhpVariable: "Nombre de variable PHP",
copyPhpVariableInvalid: "Usa un nombre de variable PHP que empiece por una letra o un guion bajo.",
copyPhpArraySyntax: "Sintaxis de array",
copyPhpQuote: "Comillas de texto",
copyPhpRowDelimiter: "Separador de filas",
copyPhpIsoDates: "Formatear como ISO las fechas reconocidas",
copyExtractorVisibleFormats: "Formatos visibles de Copiar como",
copyExtractorRestoreVisibleFormats: "Restaurar valores predeterminados",
copyExtractorConfigure: "Configurar extractor de datos…",
copyExtractorAdvancedCopy: "Copia avanzada…",
copyExtractorAdvancedCopyHint: "Elige el formato y las opciones solo para esta copia; los cambios no se guardan como predeterminados.",
copyExtractorConfigureHint: "Elige el formato y las opciones de copia predeterminados. Cancelar descarta los cambios.",
copyExtractorFormatList: "Formatos de copia",
copyExtractorFormatOptions: "Opciones del formato",
copyExtractorPreviewOnDemand: "Expande para obtener una vista previa con las opciones actuales",
copyExtractorCollapsePreview: "Contraer vista previa",
copyExtractorCopyFailed: "No se completó la copia. Comprueba la selección o inténtalo de nuevo.",
copyExtractorSelectionChanged: "El resultado o la selección ha cambiado. Cierra y vuelve a abrir este cuadro para confirmar la selección antes de copiar.",
copyExtractorCopyRunning: "Copiando…",
copyExtractorAdvancedOptions: "Opciones avanzadas",
copyExtractorSaved: "Ajustes del extractor de datos guardados",
copyExtractorDefaultFormat: "Comportamiento de copia predeterminado",
copyExtractorFormat: "Extractor",
@@ -3159,6 +3186,37 @@ export default withEnglishFallback({
copyExtractorQuoteMinimal: "Cuando sea necesario",
copyExtractorQuoteAlways: "Siempre",
copyExtractorQuoteNever: "Nunca",
copyExtractorQuoteStrings: "Cadenas de texto",
copyExtractorQuoteAllButNumbers: "Todos excepto los números",
copyExtractorQuoteAllIncludingNulls: "Todos, incluidos los NULL",
copyExtractorQuoteNumericStrings: "Entrecomillar cadenas con aspecto numérico",
copyExtractorHeaderPosition: "Posición del encabezado",
copyExtractorHeaderLegacy: "Usar el valor predeterminado de este formato",
copyExtractorHeaderNone: "Sin encabezado",
copyExtractorHeaderTop: "Arriba",
copyExtractorHeaderBottom: "Abajo",
copyExtractorHeaderFormat: "Contenido del encabezado",
copyExtractorHeaderName: "Nombre",
copyExtractorHeaderDescription: "Descripción",
copyExtractorHeaderBoth: "Nombre y descripción",
copyExtractorHeaderCase: "Mayúsculas del encabezado",
copyExtractorHeaderPreserve: "Mantener",
copyExtractorHeaderUpper: "Mayúsculas",
copyExtractorHeaderLower: "Minúsculas",
copyExtractorEscapeFormulas: "Escapar fórmulas de hojas de cálculo",
copyExtractorValueFormat: "Formato de valores",
copyExtractorValueFormatDefault: "Formato predeterminado",
copyExtractorValueFormatLegacy: "Seguir el comportamiento existente",
copyExtractorValueFormatDisplay: "Visualización de la cuadrícula",
copyExtractorValueFormatEdit: "Texto del editor de celdas",
copyExtractorValueFormatNative: "Valor nativo",
copyExtractorLineFeedEscape: "Sustituir saltos de línea por",
copyExtractorArrayFormat: "Corchetes de arrays",
copyExtractorArrayPreserve: "Conservar el origen",
copyExtractorFormatNumbers: "Valores numéricos",
copyExtractorFormatNumbersInherit: "Seguir el formato del valor",
copyExtractorFormatNumbersNative: "Valores nativos",
copyExtractorFormatNumbersDisplay: "Visualización de la cuadrícula",
copyExtractorIncludeHeader: "Incluir nombres de columnas",
copyExtractorIncludeRowHeader: "Incluir números de fila",
copyExtractorSkipComputed: "Saltar columnas calculadas",
@@ -3178,6 +3236,7 @@ export default withEnglishFallback({
copyExtractorPreviewTruncated: "Actualmente solo se muestran las primeras 100 filas de las {count} seleccionadas.",
copyExtractorWarningOmittedColumns: "El resultado de la copia ha omitido las siguientes columnas: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "Se detectaron nombres de columna JSON duplicados; se renombraron automáticamente para evitar pérdida de datos.",
copyExtractorWarningPhpDateNotIso: "Algunos valores temporales no eran texto ISO válido de fecha y hora; se conservó el texto original.",
copyExtractorValidation: {
"column-separator-empty": "El separador de columnas no puede estar vacío.",
"row-separator-empty": "El separador de filas no puede estar vacío.",
@@ -3186,6 +3245,7 @@ export default withEnglishFallback({
"separators-overlap": "El separador de columnas y el separador de filas no pueden contenerse entre sí.",
"invalid-quote": "El carácter de comillas debe ser exactamente un carácter.",
"quote-conflicts": "El carácter de comillas no puede aparecer en el separador de columnas o de filas.",
"invalid-php-row-delimiter": "El separador de filas de PHP solo puede contener espacios en blanco.",
},
cachedResultUnavailable: "Resultado en caché faltante o incompatible.",
reexecuteQuery: "Volver a ejecutar consulta",
@@ -4352,6 +4412,9 @@ export default withEnglishFallback({
copyAdvancedInfo: "Copiar información avanzada",
name: "Nombre",
fullPath: "Ruta completa",
copyQualifiedName: "Copiar nombre completo",
copyQualifiedNameSameConnection: "Selecciona objetos de una misma conexión para copiar sus nombres completos.",
copyQualifiedNameUnavailable: "No se puede resolver el nombre completo de {name}.",
copyFinalProxyPort: "Copiar puerto proxy final",
finalProxyPortCopied: "Puerto proxy final {port} copiado",
setDefaultDatabase: "Establecer como base de datos predeterminada",
+13
View File
@@ -2192,6 +2192,17 @@ export default withEnglishFallback({
copyExtractorPipeSeparated: "Dipisah Pipe",
copyExtractorPretty: "Tabel Rapi",
copyExtractorConfigure: "Konfigurasi Ekstraktor Data…",
copyExtractorAdvancedCopy: "Salin lanjutan…",
copyExtractorAdvancedCopyHint: "Pilih format dan opsi hanya untuk penyalinan ini; perubahan tidak disimpan sebagai default.",
copyExtractorConfigureHint: "Pilih format dan opsi salin default. Batal membuang perubahan.",
copyExtractorFormatList: "Format salin",
copyExtractorFormatOptions: "Opsi format",
copyExtractorPreviewOnDemand: "Perluas untuk melihat pratinjau dengan opsi saat ini",
copyExtractorCollapsePreview: "Ciutkan pratinjau",
copyExtractorCopyFailed: "Penyalinan tidak selesai. Periksa pilihan atau coba lagi.",
copyExtractorSelectionChanged: "Hasil atau pilihan berubah. Tutup dan buka kembali dialog ini untuk memastikan pilihan sebelum menyalin.",
copyExtractorCopyRunning: "Menyalin…",
copyExtractorAdvancedOptions: "Opsi lanjutan",
copyExtractorSaved: "Pengaturan ekstraktor data disimpan",
copyExtractorDefaultFormat: "Perilaku salin bawaan",
copyExtractorFormat: "Ekstraktor",
@@ -2222,6 +2233,7 @@ export default withEnglishFallback({
copyExtractorPreviewTruncated: "Menampilkan pratinjau 100 baris pertama dari {count} baris terpilih.",
copyExtractorWarningOmittedColumns: "Hasil salinan tidak menyertakan kolom ini: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "Nama kolom JSON yang duplikat telah diganti nama untuk mencegah kehilangan data.",
copyExtractorWarningPhpDateNotIso: "Beberapa nilai waktu bukan teks tanggal/waktu ISO yang valid; teks aslinya dipertahankan.",
copyExtractorValidation: {
"column-separator-empty": "Pemisah kolom tidak boleh kosong.",
"row-separator-empty": "Pemisah baris tidak boleh kosong.",
@@ -2230,6 +2242,7 @@ export default withEnglishFallback({
"separators-overlap": "Pemisah kolom dan baris tidak boleh tumpang tindih.",
"invalid-quote": "Kutip harus tepat satu karakter.",
"quote-conflicts": "Kutip tidak boleh menjadi bagian dari pemisah kolom atau baris.",
"invalid-php-row-delimiter": "Pemisah baris PHP hanya boleh berisi karakter spasi.",
},
mongoJsonPreview: "Pratinjau JSON",
mongoJsonPreviewEmpty: "Pilih dokumen MongoDB untuk melihat pratinjau.",
+63
View File
@@ -2895,6 +2895,7 @@ export default withEnglishFallback({
setNull: "Imposta NULL",
restoreOriginalValue: "Ripristina Originale",
copyColumnName: "Copia Nome Colonna",
copyColumnMetadata: "Copia metadati della colonna",
copyColumnComment: "Copia Commento Colonna",
columnDragChipMany: "{names} ({count} colonne)",
copyAlterColumnSql: "Copia come SQL ALTER",
@@ -3020,7 +3021,33 @@ export default withEnglishFallback({
copyExtractorWhereClause: "Clausola WHERE",
copyExtractorPipeSeparated: "Separato da pipe",
copyExtractorPretty: "Testo tabellare",
copyDbunitTableName: "Nome tabella DbUnit",
copyDbunitTableNameHint: "Usa la tabella di origine se disponibile; inserisci un nome per i risultati di query senza tabella di origine.",
copyDbunitTableNameRequired: "Questo risultato non ha una tabella di origine. Inserisci un nome per la tabella DbUnit.",
copyDbunitNullValue: "Testo sostitutivo per NULL",
copyDbunitUppercaseTable: "Converti il nome della tabella in maiuscolo",
copyDbunitUppercaseColumns: "Converti i nomi delle colonne in maiuscolo",
copyDbunitIncludeNulls: "Includi i valori NULL",
copyPhpVariable: "Nome della variabile PHP",
copyPhpVariableInvalid: "Usa un nome di variabile PHP che inizi con una lettera o un trattino basso.",
copyPhpArraySyntax: "Sintassi array",
copyPhpQuote: "Virgolette per le stringhe",
copyPhpRowDelimiter: "Separatore di riga",
copyPhpIsoDates: "Formatta come ISO le date riconosciute",
copyExtractorVisibleFormats: "Formati visibili di Copia come",
copyExtractorRestoreVisibleFormats: "Ripristina i valori predefiniti",
copyExtractorConfigure: "Configura estrattore dati…",
copyExtractorAdvancedCopy: "Copia avanzata…",
copyExtractorAdvancedCopyHint: "Scegli formato e opzioni solo per questa copia; le modifiche non vengono salvate come predefinite.",
copyExtractorConfigureHint: "Scegli il formato e le opzioni di copia predefiniti. Annulla scarta le modifiche.",
copyExtractorFormatList: "Formati di copia",
copyExtractorFormatOptions: "Opzioni del formato",
copyExtractorPreviewOnDemand: "Espandi per visualizzare l'anteprima con le opzioni correnti",
copyExtractorCollapsePreview: "Comprimi anteprima",
copyExtractorCopyFailed: "Copia non completata. Controlla la selezione o riprova.",
copyExtractorSelectionChanged: "Il risultato o la selezione è cambiato. Chiudi e riapri questa finestra per confermare la selezione prima di copiare.",
copyExtractorCopyRunning: "Copia in corso…",
copyExtractorAdvancedOptions: "Opzioni avanzate",
copyExtractorSaved: "Impostazioni dell'estrattore di dati salvate",
copyExtractorDefaultFormat: "Comportamento di copia predefinito",
copyExtractorFormat: "Estrattore",
@@ -3032,6 +3059,37 @@ export default withEnglishFallback({
copyExtractorQuoteMinimal: "Quando necessario",
copyExtractorQuoteAlways: "Sempre",
copyExtractorQuoteNever: "Mai",
copyExtractorQuoteStrings: "Stringhe",
copyExtractorQuoteAllButNumbers: "Tutti tranne i numeri",
copyExtractorQuoteAllIncludingNulls: "Tutti, inclusi i NULL",
copyExtractorQuoteNumericStrings: "Racchiudi tra virgolette le stringhe con aspetto numerico",
copyExtractorHeaderPosition: "Posizione dell'intestazione",
copyExtractorHeaderLegacy: "Usa il valore predefinito di questo formato",
copyExtractorHeaderNone: "Nessuna intestazione",
copyExtractorHeaderTop: "In alto",
copyExtractorHeaderBottom: "In basso",
copyExtractorHeaderFormat: "Contenuto dell'intestazione",
copyExtractorHeaderName: "Nome",
copyExtractorHeaderDescription: "Descrizione",
copyExtractorHeaderBoth: "Nome e descrizione",
copyExtractorHeaderCase: "Maiuscole dell'intestazione",
copyExtractorHeaderPreserve: "Mantieni",
copyExtractorHeaderUpper: "Maiuscolo",
copyExtractorHeaderLower: "Minuscolo",
copyExtractorEscapeFormulas: "Proteggi le formule dei fogli di calcolo",
copyExtractorValueFormat: "Formato dei valori",
copyExtractorValueFormatDefault: "Formato predefinito",
copyExtractorValueFormatLegacy: "Segui il comportamento esistente",
copyExtractorValueFormatDisplay: "Visualizzazione della griglia",
copyExtractorValueFormatEdit: "Testo dell'editor della cella",
copyExtractorValueFormatNative: "Valore nativo",
copyExtractorLineFeedEscape: "Sostituisci le interruzioni di riga con",
copyExtractorArrayFormat: "Parentesi degli array",
copyExtractorArrayPreserve: "Mantieni il formato originale",
copyExtractorFormatNumbers: "Valori numerici",
copyExtractorFormatNumbersInherit: "Segui il formato del valore",
copyExtractorFormatNumbersNative: "Valori nativi",
copyExtractorFormatNumbersDisplay: "Visualizzazione della griglia",
copyExtractorIncludeHeader: "Includi nomi colonna",
copyExtractorIncludeRowHeader: "Includi numeri riga",
copyExtractorSkipComputed: "Salta colonne calcolate",
@@ -3051,6 +3109,7 @@ export default withEnglishFallback({
copyExtractorPreviewTruncated: "Anteprima solo delle prime 100 righe su {count} selezionate.",
copyExtractorWarningOmittedColumns: "Il risultato della copia ha ignorato le seguenti colonne: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "Rilevati nomi di colonne JSON duplicati, rinominati automaticamente per evitare perdita di dati.",
copyExtractorWarningPhpDateNotIso: "Alcuni valori temporali non erano testo ISO valido; il testo originale è stato mantenuto.",
copyExtractorValidation: {
"column-separator-empty": "Il separatore di colonna non può essere vuoto.",
"row-separator-empty": "Il separatore di riga non può essere vuoto.",
@@ -3059,6 +3118,7 @@ export default withEnglishFallback({
"separators-overlap": "Il separatore di colonna e quello di riga non possono contenersi a vicenda.",
"invalid-quote": "Il carattere di citazione deve essere esattamente un carattere.",
"quote-conflicts": "Il carattere di citazione non può apparire nel separatore di colonna o di riga.",
"invalid-php-row-delimiter": "Il separatore di riga PHP può contenere solo spazi bianchi.",
},
cachedResultUnavailable: "Risultato in cache mancante o non compatibile.",
reexecuteQuery: "Esegui di nuovo la query",
@@ -4196,6 +4256,9 @@ export default withEnglishFallback({
copyAdvancedInfo: "Copia informazioni avanzate",
name: "Nome",
fullPath: "Percorso completo",
copyQualifiedName: "Copia nome completo",
copyQualifiedNameSameConnection: "Seleziona oggetti della stessa connessione per copiare i nomi completi.",
copyQualifiedNameUnavailable: "Impossibile risolvere il nome completo di {name}.",
copyFinalProxyPort: "Copia porta proxy finale",
finalProxyPortCopied: "Porta proxy finale {port} copiata",
setDefaultDatabase: "Imposta come Database Predefinito",
+63
View File
@@ -2899,6 +2899,7 @@ export default withEnglishFallback({
setNull: "NULLに設定",
restoreOriginalValue: "元の値に戻す",
copyColumnName: "列名をコピー",
copyColumnMetadata: "列メタデータをコピー",
copyColumnComment: "列コメントをコピー",
columnDragChipMany: "{names}(全{count}列)",
copyAlterColumnSql: "SQL ALTERとしてコピー",
@@ -3039,7 +3040,33 @@ export default withEnglishFallback({
copyExtractorWhereClause: "WHERE 条件",
copyExtractorPipeSeparated: "パイプ区切り",
copyExtractorPretty: "テーブルテキスト",
copyDbunitTableName: "DbUnit テーブル名",
copyDbunitTableNameHint: "元テーブルがある場合はその名前を使用します。元テーブルのないクエリ結果では名前を入力してください。",
copyDbunitTableNameRequired: "この結果には元テーブルがありません。DbUnit テーブル名を入力してください。",
copyDbunitNullValue: "NULL の置換文字列",
copyDbunitUppercaseTable: "テーブル名を大文字にする",
copyDbunitUppercaseColumns: "列名を大文字にする",
copyDbunitIncludeNulls: "NULL 値を含める",
copyPhpVariable: "PHP 変数名",
copyPhpVariableInvalid: "英字またはアンダースコアで始まる PHP 変数名を入力してください。",
copyPhpArraySyntax: "配列構文",
copyPhpQuote: "文字列の引用符",
copyPhpRowDelimiter: "行の区切り文字",
copyPhpIsoDates: "認識した日付を ISO 形式にする",
copyExtractorVisibleFormats: "「形式を指定してコピー」に表示する形式",
copyExtractorRestoreVisibleFormats: "既定値に戻す",
copyExtractorConfigure: "データ抽出の設定…",
copyExtractorAdvancedCopy: "詳細コピー…",
copyExtractorAdvancedCopyHint: "今回のコピーだけに適用する形式とオプションを選択します。既定値には保存されません。",
copyExtractorConfigureHint: "既定のコピー形式とオプションを選択します。キャンセルすると変更は破棄されます。",
copyExtractorFormatList: "コピー形式",
copyExtractorFormatOptions: "形式オプション",
copyExtractorPreviewOnDemand: "展開すると現在のオプションでプレビューを生成します",
copyExtractorCollapsePreview: "プレビューを折りたたむ",
copyExtractorCopyFailed: "コピーが完了しませんでした。選択内容を確認して再試行してください。",
copyExtractorSelectionChanged: "結果または選択内容が変更されました。コピーする前にダイアログを閉じて開き直し、現在の選択内容を確認してください。",
copyExtractorCopyRunning: "コピー中…",
copyExtractorAdvancedOptions: "詳細オプション",
copyExtractorSaved: "データ抽出設定を保存しました",
copyExtractorDefaultFormat: "デフォルトのコピー動作",
copyExtractorFormat: "抽出器",
@@ -3051,6 +3078,37 @@ export default withEnglishFallback({
copyExtractorQuoteMinimal: "必要時",
copyExtractorQuoteAlways: "常に",
copyExtractorQuoteNever: "なし",
copyExtractorQuoteStrings: "文字列",
copyExtractorQuoteAllButNumbers: "数値以外すべて",
copyExtractorQuoteAllIncludingNulls: "NULL を含むすべて",
copyExtractorQuoteNumericStrings: "数値に見える文字列を引用符で囲む",
copyExtractorHeaderPosition: "ヘッダー位置",
copyExtractorHeaderLegacy: "この形式の既定値を使用",
copyExtractorHeaderNone: "ヘッダーなし",
copyExtractorHeaderTop: "先頭",
copyExtractorHeaderBottom: "末尾",
copyExtractorHeaderFormat: "ヘッダー内容",
copyExtractorHeaderName: "名前",
copyExtractorHeaderDescription: "説明",
copyExtractorHeaderBoth: "名前と説明",
copyExtractorHeaderCase: "ヘッダーの大文字・小文字",
copyExtractorHeaderPreserve: "そのまま",
copyExtractorHeaderUpper: "大文字",
copyExtractorHeaderLower: "小文字",
copyExtractorEscapeFormulas: "表計算式をエスケープする",
copyExtractorValueFormat: "値の形式",
copyExtractorValueFormatDefault: "形式の既定値",
copyExtractorValueFormatLegacy: "既存の動作に従う",
copyExtractorValueFormatDisplay: "グリッドの表示値",
copyExtractorValueFormatEdit: "セル編集テキスト",
copyExtractorValueFormatNative: "ネイティブ値",
copyExtractorLineFeedEscape: "改行を次の文字列に置換",
copyExtractorArrayFormat: "配列の括弧",
copyExtractorArrayPreserve: "元の形式を維持",
copyExtractorFormatNumbers: "数値",
copyExtractorFormatNumbersInherit: "値の形式に従う",
copyExtractorFormatNumbersNative: "ネイティブ値",
copyExtractorFormatNumbersDisplay: "グリッドの表示値",
copyExtractorIncludeHeader: "列名を含む",
copyExtractorIncludeRowHeader: "行番号を含む",
copyExtractorSkipComputed: "計算列をスキップ",
@@ -3070,6 +3128,7 @@ export default withEnglishFallback({
copyExtractorPreviewTruncated: "現在は選択された{count}行のうち最初の100行のみをプレビューしています。",
copyExtractorWarningOmittedColumns: "コピー結果は以下の列を無視しました:{columns}。",
copyExtractorWarningDuplicateJsonColumns: "重複したJSON列名が検出されました。データ損失を避けるために自動的に名前を変更しました。",
copyExtractorWarningPhpDateNotIso: "一部の日時値が有効なISO形式ではないため、元のテキストを保持しました。",
copyExtractorValidation: {
"column-separator-empty": "列区切り文字は空にできません。",
"row-separator-empty": "行区切り文字は空にできません。",
@@ -3078,6 +3137,7 @@ export default withEnglishFallback({
"separators-overlap": "列区切り文字と行区切り文字は互いに含めることはできません。",
"invalid-quote": "引用文字は1文字でなければなりません。",
"quote-conflicts": "引用文字は列区切り文字または行区切り文字に含めることはできません。",
"invalid-php-row-delimiter": "PHP の行区切り文字には空白文字のみ指定できます。",
},
cachedResultUnavailable: "キャッシュ結果が不足しているか互換性がありません。",
reexecuteQuery: "クエリを再実行",
@@ -4217,6 +4277,9 @@ export default withEnglishFallback({
copyAdvancedInfo: "詳細情報をコピー",
name: "名前",
fullPath: "フルパス",
copyQualifiedName: "完全修飾名をコピー",
copyQualifiedNameSameConnection: "完全修飾名をコピーするには、同じ接続のオブジェクトを選択してください。",
copyQualifiedNameUnavailable: "{name} の完全修飾名を解決できません。",
copyFinalProxyPort: "最終プロキシポートをコピー",
finalProxyPortCopied: "最終プロキシポート {port} をコピーしました",
setDefaultDatabase: "デフォルトデータベースに設定",
+63
View File
@@ -2313,7 +2313,33 @@ export default withEnglishFallback({
copyExtractorWhereClause: "WHERE 절",
copyExtractorPipeSeparated: "파이프(|) 구분",
copyExtractorPretty: "보기 좋은 표",
copyDbunitTableName: "DbUnit 테이블 이름",
copyDbunitTableNameHint: "원본 테이블이 있으면 해당 이름을 사용하고, 원본 테이블이 없는 쿼리 결과에는 이름을 입력하세요.",
copyDbunitTableNameRequired: "이 결과에는 원본 테이블이 없습니다. DbUnit 테이블 이름을 입력하세요.",
copyDbunitNullValue: "NULL 대체 텍스트",
copyDbunitUppercaseTable: "테이블 이름을 대문자로 변환",
copyDbunitUppercaseColumns: "컬럼 이름을 대문자로 변환",
copyDbunitIncludeNulls: "NULL 값 포함",
copyPhpVariable: "PHP 변수 이름",
copyPhpVariableInvalid: "문자 또는 밑줄로 시작하는 PHP 변수 이름을 사용하세요.",
copyPhpArraySyntax: "배열 구문",
copyPhpQuote: "문자열 따옴표",
copyPhpRowDelimiter: "행 구분자",
copyPhpIsoDates: "인식된 날짜를 ISO 형식으로 지정",
copyExtractorVisibleFormats: "복사 형식 메뉴에 표시할 형식",
copyExtractorRestoreVisibleFormats: "기본값 복원",
copyExtractorConfigure: "데이터 추출기 설정…",
copyExtractorAdvancedCopy: "고급 복사…",
copyExtractorAdvancedCopyHint: "이번 복사에만 사용할 형식과 옵션을 선택합니다. 변경 사항은 기본값으로 저장되지 않습니다.",
copyExtractorConfigureHint: "기본 복사 형식과 옵션을 선택합니다. 취소하면 변경 사항이 버려집니다.",
copyExtractorFormatList: "복사 형식",
copyExtractorFormatOptions: "형식 옵션",
copyExtractorPreviewOnDemand: "현재 옵션으로 미리 보려면 펼치세요",
copyExtractorCollapsePreview: "미리 보기 접기",
copyExtractorCopyFailed: "복사가 완료되지 않았습니다. 선택 항목을 확인하거나 다시 시도하세요.",
copyExtractorSelectionChanged: "결과 또는 선택 항목이 변경되었습니다. 복사하기 전에 이 대화상자를 닫았다가 다시 열어 선택 항목을 확인하세요.",
copyExtractorCopyRunning: "복사 중…",
copyExtractorAdvancedOptions: "고급 옵션",
copyExtractorSaved: "데이터 추출기 설정이 저장되었습니다",
copyExtractorDefaultFormat: "기본 복사 동작",
copyExtractorFormat: "추출기",
@@ -2325,6 +2351,37 @@ export default withEnglishFallback({
copyExtractorQuoteMinimal: "필요시",
copyExtractorQuoteAlways: "항상",
copyExtractorQuoteNever: "안 함",
copyExtractorQuoteStrings: "문자열",
copyExtractorQuoteAllButNumbers: "숫자를 제외한 모두",
copyExtractorQuoteAllIncludingNulls: "NULL을 포함한 모두",
copyExtractorQuoteNumericStrings: "숫자처럼 보이는 문자열도 인용",
copyExtractorHeaderPosition: "머리글 위치",
copyExtractorHeaderLegacy: "이 형식의 기본값 사용",
copyExtractorHeaderNone: "머리글 없음",
copyExtractorHeaderTop: "위쪽",
copyExtractorHeaderBottom: "아래쪽",
copyExtractorHeaderFormat: "머리글 내용",
copyExtractorHeaderName: "이름",
copyExtractorHeaderDescription: "설명",
copyExtractorHeaderBoth: "이름 및 설명",
copyExtractorHeaderCase: "머리글 대소문자",
copyExtractorHeaderPreserve: "유지",
copyExtractorHeaderUpper: "대문자",
copyExtractorHeaderLower: "소문자",
copyExtractorEscapeFormulas: "스프레드시트 수식 이스케이프",
copyExtractorValueFormat: "값 형식",
copyExtractorValueFormatDefault: "형식 기본값",
copyExtractorValueFormatLegacy: "기존 동작 따르기",
copyExtractorValueFormatDisplay: "그리드 표시값",
copyExtractorValueFormatEdit: "셀 편집기 텍스트",
copyExtractorValueFormatNative: "네이티브 값",
copyExtractorLineFeedEscape: "줄바꿈을 다음으로 바꾸기",
copyExtractorArrayFormat: "배열 괄호",
copyExtractorArrayPreserve: "원본 유지",
copyExtractorFormatNumbers: "숫자 값",
copyExtractorFormatNumbersInherit: "값 형식 따르기",
copyExtractorFormatNumbersNative: "네이티브 값",
copyExtractorFormatNumbersDisplay: "그리드 표시값",
copyExtractorIncludeHeader: "컬럼 머리글 포함",
copyExtractorIncludeRowHeader: "행 번호 포함",
copyExtractorSkipComputed: "계산된 컬럼 건너뛰기",
@@ -2344,6 +2401,7 @@ export default withEnglishFallback({
copyExtractorPreviewTruncated: "선택한 {count}행 중 처음 100행을 미리보는 중.",
copyExtractorWarningOmittedColumns: "복사된 출력에서 다음 컬럼이 생략되었습니다: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "데이터 손실을 방지하기 위해 중복된 JSON 컬럼 이름이 변경되었습니다.",
copyExtractorWarningPhpDateNotIso: "일부 시간 값이 유효한 ISO 날짜/시간 텍스트가 아니어서 원문을 유지했습니다.",
copyExtractorValidation: {
"column-separator-empty": "열 구분자는 비워둘 수 없습니다.",
"row-separator-empty": "행 구분자는 비워둘 수 없습니다.",
@@ -2352,6 +2410,7 @@ export default withEnglishFallback({
"separators-overlap": "열과 행 구분자는 겹칠 수 없습니다.",
"invalid-quote": "인용 부호는 정확히 한 자여야 합니다.",
"quote-conflicts": "인용 부호는 열이나 행 구분자의 일부일 수 없습니다.",
"invalid-php-row-delimiter": "PHP 행 구분자는 공백 문자만 포함할 수 있습니다.",
},
mongoJsonPreview: "JSON 미리보기",
mongoJsonPreviewEmpty: "미리보려면 MongoDB 문서를 선택하세요.",
@@ -2941,6 +3000,7 @@ export default withEnglishFallback({
setNull: "NULL 설정",
restoreOriginalValue: "원래대로 복원",
copyColumnName: "컬럼 이름 복사",
copyColumnMetadata: "컬럼 메타데이터 복사",
copyColumnComment: "컬럼 주석 복사",
columnDragChipMany: "{names} (총 {count}개)",
copyAlterColumnSql: "SQL ALTER로 복사",
@@ -4143,6 +4203,9 @@ export default withEnglishFallback({
copyAdvancedInfo: "고급 정보 복사",
name: "이름",
fullPath: "전체 경로",
copyQualifiedName: "정규화된 이름 복사",
copyQualifiedNameSameConnection: "정규화된 이름을 복사하려면 같은 연결의 객체를 선택하세요.",
copyQualifiedNameUnavailable: "{name}의 정규화된 이름을 확인할 수 없습니다.",
copyFinalProxyPort: "최종 프록시 포트 복사",
finalProxyPortCopied: "최종 프록시 포트 {port} 복사됨",
setDefaultDatabase: "기본 데이터베이스로 설정",
+13
View File
@@ -2273,6 +2273,17 @@ export default withEnglishFallback({
copyExtractorPipeSeparated: "Rozdzielanie pionową kreską",
copyExtractorPretty: "Tabela sformatowana",
copyExtractorConfigure: "Konfiguruj sposoby kopiowania…",
copyExtractorAdvancedCopy: "Kopiowanie zaawansowane…",
copyExtractorAdvancedCopyHint: "Wybierz format i opcje tylko dla tego kopiowania; zmiany nie zostaną zapisane jako domyślne.",
copyExtractorConfigureHint: "Wybierz domyślny format i opcje kopiowania. Anulowanie odrzuca zmiany.",
copyExtractorFormatList: "Formaty kopiowania",
copyExtractorFormatOptions: "Opcje formatu",
copyExtractorPreviewOnDemand: "Rozwiń, aby wyświetlić podgląd z bieżącymi opcjami",
copyExtractorCollapsePreview: "Zwiń podgląd",
copyExtractorCopyFailed: "Kopiowanie nie zostało ukończone. Sprawdź zaznaczenie lub spróbuj ponownie.",
copyExtractorSelectionChanged: "Wynik lub zaznaczenie uległo zmianie. Zamknij i ponownie otwórz okno, aby potwierdzić zaznaczenie przed kopiowaniem.",
copyExtractorCopyRunning: "Kopiowanie…",
copyExtractorAdvancedOptions: "Opcje zaawansowane",
copyExtractorSaved: "Zapisano ustawienia pobierania danych",
copyExtractorDefaultFormat: "Domyślny sposób kopiowania",
copyExtractorFormat: "Sposób kopiowania",
@@ -2306,6 +2317,7 @@ export default withEnglishFallback({
copyExtractorPreviewTruncated: "Podgląd pierwszych 100 spośród {count} zaznaczonych wierszy.",
copyExtractorWarningOmittedColumns: "Pominięto te kolumny w skopiowanym wyniku: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "Zmieniono powtarzające się nazwy kolumn JSON, aby zapobiec utracie danych.",
copyExtractorWarningPhpDateNotIso: "Niektóre wartości czasu nie były poprawnym tekstem ISO; zachowano oryginalny tekst.",
copyExtractorValidation: {
"column-separator-empty": "Column separator must not be empty.",
"row-separator-empty": "Row separator must not be empty.",
@@ -2314,6 +2326,7 @@ export default withEnglishFallback({
"separators-overlap": "Column and row separators must not overlap.",
"invalid-quote": "Quote must be exactly one character.",
"quote-conflicts": "Quote must not be part of a column or row separator.",
"invalid-php-row-delimiter": "PHP row delimiter may contain whitespace characters only.",
},
mongoJsonPreview: "Podgląd JSON",
mongoJsonPreviewEmpty: "Wybierz dokument MongoDB do podglądu.",
+63
View File
@@ -2895,6 +2895,7 @@ export default withEnglishFallback({
setNull: "Definir NULL",
restoreOriginalValue: "Restaurar Original",
copyColumnName: "Copiar Nome da Coluna",
copyColumnMetadata: "Copiar metadados da coluna",
copyColumnComment: "Copiar Comentário da Coluna",
columnDragChipMany: "{names} ({count} colunas)",
copyAlterColumnSql: "Copiar como SQL ALTER",
@@ -3020,7 +3021,33 @@ export default withEnglishFallback({
copyExtractorWhereClause: "Condição WHERE",
copyExtractorPipeSeparated: "Separado por pipe",
copyExtractorPretty: "Tabela formatada",
copyDbunitTableName: "Nome da tabela DbUnit",
copyDbunitTableNameHint: "Usa a tabela de origem quando disponível; informe um nome para resultados de consulta sem tabela de origem.",
copyDbunitTableNameRequired: "Este resultado não tem tabela de origem. Informe um nome para a tabela DbUnit.",
copyDbunitNullValue: "Texto substituto para NULL",
copyDbunitUppercaseTable: "Converter nome da tabela para maiúsculas",
copyDbunitUppercaseColumns: "Converter nomes das colunas para maiúsculas",
copyDbunitIncludeNulls: "Incluir valores NULL",
copyPhpVariable: "Nome da variável PHP",
copyPhpVariableInvalid: "Use um nome de variável PHP iniciado por uma letra ou sublinhado.",
copyPhpArraySyntax: "Sintaxe de array",
copyPhpQuote: "Aspas de texto",
copyPhpRowDelimiter: "Separador de linhas",
copyPhpIsoDates: "Formatar datas reconhecidas como ISO",
copyExtractorVisibleFormats: "Formatos visíveis em Copiar como",
copyExtractorRestoreVisibleFormats: "Restaurar padrões",
copyExtractorConfigure: "Configurar extrator de dados…",
copyExtractorAdvancedCopy: "Cópia avançada…",
copyExtractorAdvancedCopyHint: "Escolha o formato e as opções apenas para esta cópia; as alterações não são salvas como padrão.",
copyExtractorConfigureHint: "Escolha o formato e as opções padrão. Cancelar descarta as alterações.",
copyExtractorFormatList: "Formatos de cópia",
copyExtractorFormatOptions: "Opções do formato",
copyExtractorPreviewOnDemand: "Expanda para visualizar com as opções atuais",
copyExtractorCollapsePreview: "Recolher visualização",
copyExtractorCopyFailed: "A cópia não foi concluída. Verifique a seleção ou tente novamente.",
copyExtractorSelectionChanged: "O resultado ou a seleção mudou. Feche e reabra esta janela para confirmar a seleção antes de copiar.",
copyExtractorCopyRunning: "Copiando…",
copyExtractorAdvancedOptions: "Opções avançadas",
copyExtractorSaved: "Configurações do extrator de dados salvas",
copyExtractorDefaultFormat: "Comportamento de cópia padrão",
copyExtractorFormat: "Extrator",
@@ -3032,6 +3059,37 @@ export default withEnglishFallback({
copyExtractorQuoteMinimal: "Quando necessário",
copyExtractorQuoteAlways: "Sempre",
copyExtractorQuoteNever: "Nunca",
copyExtractorQuoteStrings: "Strings",
copyExtractorQuoteAllButNumbers: "Todos, exceto números",
copyExtractorQuoteAllIncludingNulls: "Todos, incluindo NULLs",
copyExtractorQuoteNumericStrings: "Colocar strings com aparência numérica entre aspas",
copyExtractorHeaderPosition: "Posição do cabeçalho",
copyExtractorHeaderLegacy: "Usar o padrão deste formato",
copyExtractorHeaderNone: "Sem cabeçalho",
copyExtractorHeaderTop: "No início",
copyExtractorHeaderBottom: "No final",
copyExtractorHeaderFormat: "Conteúdo do cabeçalho",
copyExtractorHeaderName: "Nome",
copyExtractorHeaderDescription: "Descrição",
copyExtractorHeaderBoth: "Nome e descrição",
copyExtractorHeaderCase: "Maiúsculas do cabeçalho",
copyExtractorHeaderPreserve: "Manter",
copyExtractorHeaderUpper: "Maiúsculas",
copyExtractorHeaderLower: "Minúsculas",
copyExtractorEscapeFormulas: "Escapar fórmulas de planilha",
copyExtractorValueFormat: "Formato dos valores",
copyExtractorValueFormatDefault: "Padrão do formato",
copyExtractorValueFormatLegacy: "Seguir o comportamento existente",
copyExtractorValueFormatDisplay: "Exibição da grade",
copyExtractorValueFormatEdit: "Texto do editor da célula",
copyExtractorValueFormatNative: "Valor nativo",
copyExtractorLineFeedEscape: "Substituir quebras de linha por",
copyExtractorArrayFormat: "Colchetes de arrays",
copyExtractorArrayPreserve: "Manter origem",
copyExtractorFormatNumbers: "Valores numéricos",
copyExtractorFormatNumbersInherit: "Seguir o formato do valor",
copyExtractorFormatNumbersNative: "Valores nativos",
copyExtractorFormatNumbersDisplay: "Exibição da grade",
copyExtractorIncludeHeader: "Incluir nomes de colunas",
copyExtractorIncludeRowHeader: "Incluir números de linha",
copyExtractorSkipComputed: "Pular colunas computadas",
@@ -3051,6 +3109,7 @@ export default withEnglishFallback({
copyExtractorPreviewTruncated: "Atualmente visualizando apenas as primeiras 100 das {count} linhas selecionadas.",
copyExtractorWarningOmittedColumns: "O resultado da cópia omitiu as seguintes colunas: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "Nomes de colunas JSON duplicados detectados, renomeados automaticamente para evitar perda de dados.",
copyExtractorWarningPhpDateNotIso: "Alguns valores temporais não eram texto ISO válido de data/hora; o texto original foi preservado.",
copyExtractorValidation: {
"column-separator-empty": "O separador de coluna não pode estar vazio.",
"row-separator-empty": "O separador de linha não pode estar vazio.",
@@ -3059,6 +3118,7 @@ export default withEnglishFallback({
"separators-overlap": "O separador de coluna e o separador de linha não podem se conter mutuamente.",
"invalid-quote": "O caractere de aspas deve ter exatamente um caractere.",
"quote-conflicts": "O caractere de aspas não pode aparecer no separador de coluna ou no separador de linha.",
"invalid-php-row-delimiter": "O separador de linhas do PHP pode conter apenas espaços em branco.",
},
cachedResultUnavailable: "Resultado em cache ausente ou incompatível.",
reexecuteQuery: "Reexecutar consulta",
@@ -4198,6 +4258,9 @@ export default withEnglishFallback({
copyAdvancedInfo: "Copiar informações avançadas",
name: "Nome",
fullPath: "Caminho completo",
copyQualifiedName: "Copiar nome totalmente qualificado",
copyQualifiedNameSameConnection: "Selecione objetos de uma única conexão para copiar nomes totalmente qualificados.",
copyQualifiedNameUnavailable: "Não foi possível resolver o nome totalmente qualificado de {name}.",
setDefaultDatabase: "Definir como Banco de Dados Padrão",
clearDefaultDatabase: "Limpar Banco de Dados Padrão",
setDefaultSchema: "Definir como Schema Padrão",
+63
View File
@@ -2403,7 +2403,33 @@ export default withEnglishFallback({
copyExtractorWhereClause: "Условие WHERE",
copyExtractorPipeSeparated: "С разделителем-вертикальной чертой",
copyExtractorPretty: "Удобная таблица",
copyDbunitTableName: "Имя таблицы DbUnit",
copyDbunitTableNameHint: "Если исходная таблица доступна, используется её имя; для результата запроса без исходной таблицы укажите имя.",
copyDbunitTableNameRequired: "У результата нет исходной таблицы. Укажите имя таблицы DbUnit.",
copyDbunitNullValue: "Текст вместо NULL",
copyDbunitUppercaseTable: "Преобразовать имя таблицы в верхний регистр",
copyDbunitUppercaseColumns: "Преобразовать имена столбцов в верхний регистр",
copyDbunitIncludeNulls: "Включать значения NULL",
copyPhpVariable: "Имя переменной PHP",
copyPhpVariableInvalid: "Имя переменной PHP должно начинаться с буквы или подчёркивания.",
copyPhpArraySyntax: "Синтаксис массива",
copyPhpQuote: "Кавычки для строк",
copyPhpRowDelimiter: "Разделитель строк",
copyPhpIsoDates: "Форматировать распознанные даты по ISO",
copyExtractorVisibleFormats: "Форматы в меню «Копировать как»",
copyExtractorRestoreVisibleFormats: "Восстановить значения по умолчанию",
copyExtractorConfigure: "Настроить извлечение данных…",
copyExtractorAdvancedCopy: "Расширенное копирование…",
copyExtractorAdvancedCopyHint: "Выберите формат и параметры только для этого копирования; изменения не сохраняются как значения по умолчанию.",
copyExtractorConfigureHint: "Выберите формат и параметры копирования по умолчанию. Отмена отменяет изменения.",
copyExtractorFormatList: "Форматы копирования",
copyExtractorFormatOptions: "Параметры формата",
copyExtractorPreviewOnDemand: "Разверните, чтобы построить предпросмотр с текущими параметрами",
copyExtractorCollapsePreview: "Свернуть предпросмотр",
copyExtractorCopyFailed: "Копирование не выполнено. Проверьте выборку или повторите попытку.",
copyExtractorSelectionChanged: "Результат или выборка изменились. Закройте и снова откройте диалог, чтобы подтвердить выборку перед копированием.",
copyExtractorCopyRunning: "Копирование…",
copyExtractorAdvancedOptions: "Дополнительные параметры",
copyExtractorSaved: "Настройки извлечения данных сохранены",
copyExtractorDefaultFormat: "Поведение копирования по умолчанию",
copyExtractorFormat: "Извлечение",
@@ -2415,6 +2441,37 @@ export default withEnglishFallback({
copyExtractorQuoteMinimal: "При необходимости",
copyExtractorQuoteAlways: "Всегда",
copyExtractorQuoteNever: "Никогда",
copyExtractorQuoteStrings: "Строки",
copyExtractorQuoteAllButNumbers: "Все, кроме чисел",
copyExtractorQuoteAllIncludingNulls: "Все, включая NULL",
copyExtractorQuoteNumericStrings: "Заключать в кавычки строки, похожие на числа",
copyExtractorHeaderPosition: "Положение заголовка",
copyExtractorHeaderLegacy: "Использовать значение по умолчанию для этого формата",
copyExtractorHeaderNone: "Без заголовка",
copyExtractorHeaderTop: "Вверху",
copyExtractorHeaderBottom: "Внизу",
copyExtractorHeaderFormat: "Содержимое заголовка",
copyExtractorHeaderName: "Имя",
copyExtractorHeaderDescription: "Описание",
copyExtractorHeaderBoth: "Имя и описание",
copyExtractorHeaderCase: "Регистр заголовка",
copyExtractorHeaderPreserve: "Сохранить",
copyExtractorHeaderUpper: "Верхний регистр",
copyExtractorHeaderLower: "Нижний регистр",
copyExtractorEscapeFormulas: "Экранировать формулы электронных таблиц",
copyExtractorValueFormat: "Формат значений",
copyExtractorValueFormatDefault: "Формат по умолчанию",
copyExtractorValueFormatLegacy: "Следовать прежнему поведению",
copyExtractorValueFormatDisplay: "Отображение в таблице",
copyExtractorValueFormatEdit: "Текст редактора ячейки",
copyExtractorValueFormatNative: "Исходное значение",
copyExtractorLineFeedEscape: "Заменять переводы строк на",
copyExtractorArrayFormat: "Скобки массива",
copyExtractorArrayPreserve: "Сохранить исходный вид",
copyExtractorFormatNumbers: "Числовые значения",
copyExtractorFormatNumbersInherit: "Следовать формату значения",
copyExtractorFormatNumbersNative: "Исходные значения",
copyExtractorFormatNumbersDisplay: "Отображение в таблице",
copyExtractorIncludeHeader: "Включить заголовок столбцов",
copyExtractorIncludeRowHeader: "Включить номера строк",
copyExtractorSkipComputed: "Пропускать вычисляемые столбцы",
@@ -2434,6 +2491,7 @@ export default withEnglishFallback({
copyExtractorPreviewTruncated: "Показаны первые 100 строк из {count} выбранных.",
copyExtractorWarningOmittedColumns: "В скопированном результате пропущены столбцы: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "Повторяющиеся имена столбцов JSON переименованы во избежание потери данных.",
copyExtractorWarningPhpDateNotIso: "Некоторые значения времени не являются допустимым текстом даты/времени ISO; исходный текст сохранён.",
copyExtractorValidation: {
"column-separator-empty": "Разделитель столбцов не может быть пустым.",
"row-separator-empty": "Разделитель строк не может быть пустым.",
@@ -2442,6 +2500,7 @@ export default withEnglishFallback({
"separators-overlap": "Разделители столбцов и строк не должны совпадать.",
"invalid-quote": "Кавычка должна состоять ровно из одного символа.",
"quote-conflicts": "Кавычка не должна входить в разделитель столбцов или строк.",
"invalid-php-row-delimiter": "Разделитель строк PHP может содержать только пробельные символы.",
},
mongoJsonPreview: "Предпросмотр JSON",
mongoJsonPreviewEmpty: "Выберите документ MongoDB для предпросмотра.",
@@ -3080,6 +3139,7 @@ export default withEnglishFallback({
setNull: "Установить NULL",
restoreOriginalValue: "Восстановить оригинал",
copyColumnName: "Копировать имя столбца",
copyColumnMetadata: "Копировать метаданные столбца",
copyColumnComment: "Копировать комментарий столбца",
columnDragChipMany: "{names} ({count} столбцов)",
copyAlterColumnSql: "Копировать как SQL ALTER",
@@ -4306,6 +4366,9 @@ export default withEnglishFallback({
copyAdvancedInfo: "Копировать расширенную информацию",
name: "Имя",
fullPath: "Полный путь",
copyQualifiedName: "Копировать полное имя",
copyQualifiedNameSameConnection: "Выберите объекты одного подключения, чтобы скопировать полные имена.",
copyQualifiedNameUnavailable: "Не удалось определить полное имя для {name}.",
copyFinalProxyPort: "Копировать итоговый порт прокси",
finalProxyPortCopied: "Итоговый порт прокси {port} скопирован",
copyConnectionInfo: "Копировать сведения о подключении",
+63
View File
@@ -2603,7 +2603,33 @@ export default withEnglishFallback({
copyExtractorWhereClause: "Where Clause",
copyExtractorPipeSeparated: "คั่นด้วย Pipe",
copyExtractorPretty: "ตารางสวยงาม",
copyDbunitTableName: "ชื่อตาราง DbUnit",
copyDbunitTableNameHint: "ใช้ตารางต้นทางเมื่อมีอยู่; ป้อนชื่อสำหรับผลลัพธ์คิวรีที่ไม่มีตารางต้นทาง",
copyDbunitTableNameRequired: "ผลลัพธ์นี้ไม่มีตารางต้นทาง โปรดป้อนชื่อตาราง DbUnit",
copyDbunitNullValue: "ข้อความแทนค่า NULL",
copyDbunitUppercaseTable: "แปลงชื่อตารางเป็นตัวพิมพ์ใหญ่",
copyDbunitUppercaseColumns: "แปลงชื่อคอลัมน์เป็นตัวพิมพ์ใหญ่",
copyDbunitIncludeNulls: "รวมค่า NULL",
copyPhpVariable: "ชื่อตัวแปร PHP",
copyPhpVariableInvalid: "ใช้ชื่อตัวแปร PHP ที่ขึ้นต้นด้วยตัวอักษรหรือขีดล่าง",
copyPhpArraySyntax: "ไวยากรณ์ Array",
copyPhpQuote: "เครื่องหมายคำพูดของสตริง",
copyPhpRowDelimiter: "ตัวคั่นแถว",
copyPhpIsoDates: "จัดรูปแบบวันที่ที่รู้จักเป็น ISO",
copyExtractorVisibleFormats: "รูปแบบที่แสดงในคัดลอกเป็น",
copyExtractorRestoreVisibleFormats: "คืนค่าเริ่มต้น",
copyExtractorConfigure: "กำหนดค่า Data Extractor…",
copyExtractorAdvancedCopy: "คัดลอกขั้นสูง…",
copyExtractorAdvancedCopyHint: "เลือกรูปแบบและตัวเลือกสำหรับการคัดลอกครั้งนี้เท่านั้น การเปลี่ยนแปลงจะไม่บันทึกเป็นค่าเริ่มต้น",
copyExtractorConfigureHint: "เลือกรูปแบบและตัวเลือกการคัดลอกเริ่มต้น การยกเลิกจะละทิ้งการเปลี่ยนแปลง",
copyExtractorFormatList: "รูปแบบการคัดลอก",
copyExtractorFormatOptions: "ตัวเลือกรูปแบบ",
copyExtractorPreviewOnDemand: "ขยายเพื่อดูตัวอย่างด้วยตัวเลือกปัจจุบัน",
copyExtractorCollapsePreview: "ยุบตัวอย่าง",
copyExtractorCopyFailed: "คัดลอกไม่สำเร็จ ตรวจสอบรายการที่เลือกหรือลองอีกครั้ง",
copyExtractorSelectionChanged: "ผลลัพธ์หรือรายการที่เลือกเปลี่ยนไป ปิดแล้วเปิดกล่องโต้ตอบนี้อีกครั้งเพื่อยืนยันรายการก่อนคัดลอก",
copyExtractorCopyRunning: "กำลังคัดลอก…",
copyExtractorAdvancedOptions: "ตัวเลือกขั้นสูง",
copyExtractorSaved: "บันทึกการตั้งค่า Data Extractor แล้ว",
copyExtractorDefaultFormat: "พฤติกรรมการคัดลอกเริ่มต้น",
copyExtractorFormat: "Extractor",
@@ -2615,6 +2641,37 @@ export default withEnglishFallback({
copyExtractorQuoteMinimal: "เมื่อจำเป็น",
copyExtractorQuoteAlways: "เสมอ",
copyExtractorQuoteNever: "ไม่ใช้",
copyExtractorQuoteStrings: "สตริง",
copyExtractorQuoteAllButNumbers: "ทั้งหมด ยกเว้นตัวเลข",
copyExtractorQuoteAllIncludingNulls: "ทั้งหมด รวมถึง NULL",
copyExtractorQuoteNumericStrings: "ใส่เครื่องหมายคำพูดให้สตริงที่ดูเหมือนตัวเลข",
copyExtractorHeaderPosition: "ตำแหน่งส่วนหัว",
copyExtractorHeaderLegacy: "ใช้ค่าเริ่มต้นของรูปแบบนี้",
copyExtractorHeaderNone: "ไม่มีส่วนหัว",
copyExtractorHeaderTop: "ด้านบน",
copyExtractorHeaderBottom: "ด้านล่าง",
copyExtractorHeaderFormat: "เนื้อหาส่วนหัว",
copyExtractorHeaderName: "ชื่อ",
copyExtractorHeaderDescription: "คำอธิบาย",
copyExtractorHeaderBoth: "ชื่อและคำอธิบาย",
copyExtractorHeaderCase: "ตัวพิมพ์ของส่วนหัว",
copyExtractorHeaderPreserve: "คงเดิม",
copyExtractorHeaderUpper: "ตัวพิมพ์ใหญ่",
copyExtractorHeaderLower: "ตัวพิมพ์เล็ก",
copyExtractorEscapeFormulas: "Escape สูตรในสเปรดชีต",
copyExtractorValueFormat: "รูปแบบค่า",
copyExtractorValueFormatDefault: "ค่าเริ่มต้นของรูปแบบ",
copyExtractorValueFormatLegacy: "ใช้พฤติกรรมเดิม",
copyExtractorValueFormatDisplay: "ค่าที่แสดงในกริด",
copyExtractorValueFormatEdit: "ข้อความตัวแก้ไขเซลล์",
copyExtractorValueFormatNative: "ค่าดั้งเดิม",
copyExtractorLineFeedEscape: "แทนที่การขึ้นบรรทัดใหม่ด้วย",
copyExtractorArrayFormat: "วงเล็บ Array",
copyExtractorArrayPreserve: "คงรูปแบบต้นทาง",
copyExtractorFormatNumbers: "ค่าตัวเลข",
copyExtractorFormatNumbersInherit: "ใช้รูปแบบค่าตามเดิม",
copyExtractorFormatNumbersNative: "ค่าดั้งเดิม",
copyExtractorFormatNumbersDisplay: "ค่าที่แสดงในกริด",
copyExtractorIncludeHeader: "รวมส่วนหัวคอลัมน์",
copyExtractorIncludeRowHeader: "รวมหมายเลขแถว",
copyExtractorSkipComputed: "ข้ามคอลัมน์คำนวณ",
@@ -2639,6 +2696,7 @@ export default withEnglishFallback({
copyExtractorPreviewTruncated: "กำลังดูตัวอย่าง 100 แถวแรกจากแถวที่เลือก {count} แถว",
copyExtractorWarningOmittedColumns: "เอาต์พุตที่คัดลอกไม่รวมคอลัมน์เหล่านี้: {columns}",
copyExtractorWarningDuplicateJsonColumns: "เปลี่ยนชื่อคอลัมน์ JSON ที่ซ้ำกันเพื่อป้องกันข้อมูลสูญหาย",
copyExtractorWarningPhpDateNotIso: "ค่าบางรายการไม่ใช่ข้อความวันที่/เวลารูปแบบ ISO ที่ถูกต้อง จึงคงข้อความเดิมไว้",
copyExtractorValidation: {
"column-separator-empty": "ตัวคั่นคอลัมน์ต้องไม่ว่าง",
"row-separator-empty": "ตัวคั่นแถวต้องไม่ว่าง",
@@ -2647,6 +2705,7 @@ export default withEnglishFallback({
"separators-overlap": "ตัวคั่นคอลัมน์และแถวต้องไม่ซ้อนกัน",
"invalid-quote": "Quote ต้องเป็นอักขระเดียวเท่านั้น",
"quote-conflicts": "Quote ต้องไม่เป็นส่วนหนึ่งของตัวคั่นคอลัมน์หรือแถว",
"invalid-php-row-delimiter": "ตัวคั่นแถว PHP ต้องมีเฉพาะช่องว่างเท่านั้น",
},
mongoJsonPreview: "ตัวอย่าง JSON",
mongoJsonPreviewEmpty: "เลือกเอกสาร MongoDB เพื่อดูตัวอย่าง",
@@ -3279,6 +3338,7 @@ export default withEnglishFallback({
setNull: "ตั้งเป็น NULL",
restoreOriginalValue: "คืนค่าต้นฉบับ",
copyColumnName: "คัดลอกชื่อคอลัมน์",
copyColumnMetadata: "คัดลอกข้อมูลเมตาของคอลัมน์",
copyColumnComment: "คัดลอกคอมเมนต์คอลัมน์",
columnDragChipMany: "{names} ({count} คอลัมน์)",
copyAlterColumnSql: "คัดลอกเป็น SQL ALTER",
@@ -4517,6 +4577,9 @@ export default withEnglishFallback({
copyAdvancedInfo: "คัดลอกข้อมูลขั้นสูง",
name: "ชื่อ",
fullPath: "Path เต็ม",
copyQualifiedName: "คัดลอกชื่อแบบระบุครบถ้วน",
copyQualifiedNameSameConnection: "เลือกออบเจ็กต์จากการเชื่อมต่อเดียวกันเพื่อคัดลอกชื่อแบบระบุครบถ้วน",
copyQualifiedNameUnavailable: "ไม่สามารถระบุชื่อแบบครบถ้วนของ {name} ได้",
copyFinalProxyPort: "คัดลอก Final Proxy Port",
finalProxyPortCopied: "คัดลอก Final proxy port {port} แล้ว",
copyConnectionInfo: "คัดลอกข้อมูลการเชื่อมต่อ",
+13
View File
@@ -1892,6 +1892,17 @@ export default withEnglishFallback({
copyExtractorPipeSeparated: "Dikey çizgiyle ayrılmış",
copyExtractorPretty: "Düzenli Tablo",
copyExtractorConfigure: "Veri Çıkarıcılarını Yapılandır…",
copyExtractorAdvancedCopy: "Gelişmiş kopyalama…",
copyExtractorAdvancedCopyHint: "Biçimi ve seçenekleri yalnızca bu kopyalama için seçin; değişiklikler varsayılan olarak kaydedilmez.",
copyExtractorConfigureHint: "Varsayılan kopyalama biçimini ve seçeneklerini seçin. İptal değişiklikleri siler.",
copyExtractorFormatList: "Kopyalama biçimleri",
copyExtractorFormatOptions: "Biçim seçenekleri",
copyExtractorPreviewOnDemand: "Geçerli seçeneklerle önizlemek için genişletin",
copyExtractorCollapsePreview: "Önizlemeyi daralt",
copyExtractorCopyFailed: "Kopyalama tamamlanmadı. Seçimi kontrol edin veya yeniden deneyin.",
copyExtractorSelectionChanged: "Sonuç veya seçim değişti. Kopyalamadan önce geçerli seçimi doğrulamak için bu iletişim kutusunu kapatıp yeniden açın.",
copyExtractorCopyRunning: "Kopyalanıyor…",
copyExtractorAdvancedOptions: "Gelişmiş seçenekler",
copyExtractorSaved: "Veri çıkarıcı ayarları kaydedildi",
copyExtractorDefaultFormat: "Varsayılan kopyalama davranışı",
copyExtractorFormat: "Çıkarıcı",
@@ -1922,6 +1933,7 @@ export default withEnglishFallback({
copyExtractorPreviewTruncated: "Seçili {count} satırın ilk 100 satırı önizleniyor.",
copyExtractorWarningOmittedColumns: "Kopyalanan çıktıda şu sütunlar atlandı: {columns}.",
copyExtractorWarningDuplicateJsonColumns: "Veri kaybını önlemek için yinelenen JSON sütun adları yeniden adlandırıldı.",
copyExtractorWarningPhpDateNotIso: "Bazı zaman değerleri geçerli ISO tarih/saat metni değildi; özgün metin korundu.",
copyExtractorValidation: {
"column-separator-empty": "Sütun ayırıcısı boş olamaz.",
"row-separator-empty": "Satır ayırıcısı boş olamaz.",
@@ -1930,6 +1942,7 @@ export default withEnglishFallback({
"separators-overlap": "Sütun ve satır ayırıcıları çakışmamalıdır.",
"invalid-quote": "Tırnak tam olarak bir karakter olmalıdır.",
"quote-conflicts": "Tırnak, sütun veya satır ayırıcısının parçası olamaz.",
"invalid-php-row-delimiter": "PHP satır ayırıcısı yalnızca boşluk karakterleri içerebilir.",
},
mongoJsonPreview: "JSON Önizlemesi",
mongoJsonPreviewEmpty: "Önizlemek için bir MongoDB belgesi seçin.",
+63
View File
@@ -2578,7 +2578,33 @@ export default withEnglishFallback({
copyExtractorWhereClause: "WHERE 条件",
copyExtractorPipeSeparated: "竖线分隔",
copyExtractorPretty: "表格文本",
copyDbunitTableName: "DbUnit 表名",
copyDbunitTableNameHint: "有源表时使用源表名;查询结果没有源表时请填写名称。",
copyDbunitTableNameRequired: "此结果没有源表,请填写 DbUnit 表名。",
copyDbunitNullValue: "NULL 替代文本",
copyDbunitUppercaseTable: "表名转为大写",
copyDbunitUppercaseColumns: "列名转为大写",
copyDbunitIncludeNulls: "包含 NULL 值",
copyPhpVariable: "PHP 变量名",
copyPhpVariableInvalid: "PHP 变量名须以字母或下划线开头。",
copyPhpArraySyntax: "数组语法",
copyPhpQuote: "字符串引号",
copyPhpRowDelimiter: "行分隔符",
copyPhpIsoDates: "识别的日期使用 ISO 格式",
copyExtractorVisibleFormats: "可见的复制格式",
copyExtractorRestoreVisibleFormats: "恢复默认",
copyExtractorConfigure: "配置数据提取器…",
copyExtractorAdvancedCopy: "高级复制…",
copyExtractorAdvancedCopyHint: "为本次复制选择格式并调整参数;不会保存为默认设置。",
copyExtractorConfigureHint: "选择默认复制格式并调整参数;取消不会保存更改。",
copyExtractorFormatList: "复制格式",
copyExtractorFormatOptions: "格式选项",
copyExtractorPreviewOnDemand: "展开后按当前参数生成预览",
copyExtractorCollapsePreview: "收起预览",
copyExtractorCopyFailed: "复制未完成,请检查选择或重试。",
copyExtractorSelectionChanged: "结果或选择已变化。请关闭并重新打开对话框,确认当前选择后再复制。",
copyExtractorCopyRunning: "正在复制…",
copyExtractorAdvancedOptions: "高级选项",
copyExtractorSaved: "数据提取器配置已保存",
copyExtractorDefaultFormat: "默认复制方式",
copyExtractorFormat: "提取器",
@@ -2590,6 +2616,37 @@ export default withEnglishFallback({
copyExtractorQuoteMinimal: "必要时",
copyExtractorQuoteAlways: "始终",
copyExtractorQuoteNever: "从不",
copyExtractorQuoteStrings: "字符串",
copyExtractorQuoteAllButNumbers: "除数字外全部引用",
copyExtractorQuoteAllIncludingNulls: "全部引用(含 NULL)",
copyExtractorQuoteNumericStrings: "引用数字样式的字符串",
copyExtractorHeaderPosition: "表头位置",
copyExtractorHeaderLegacy: "使用此格式默认值",
copyExtractorHeaderNone: "无表头",
copyExtractorHeaderTop: "顶部",
copyExtractorHeaderBottom: "底部",
copyExtractorHeaderFormat: "表头内容",
copyExtractorHeaderName: "名称",
copyExtractorHeaderDescription: "说明",
copyExtractorHeaderBoth: "名称和说明",
copyExtractorHeaderCase: "表头大小写",
copyExtractorHeaderPreserve: "保持",
copyExtractorHeaderUpper: "大写",
copyExtractorHeaderLower: "小写",
copyExtractorEscapeFormulas: "转义电子表格公式",
copyExtractorValueFormat: "值表现",
copyExtractorValueFormatDefault: "格式默认值",
copyExtractorValueFormatLegacy: "跟随现有行为",
copyExtractorValueFormatDisplay: "网格显示值",
copyExtractorValueFormatEdit: "单元格编辑文本",
copyExtractorValueFormatNative: "原生值",
copyExtractorLineFeedEscape: "将换行替换为",
copyExtractorArrayFormat: "数组括号",
copyExtractorArrayPreserve: "保持原样",
copyExtractorFormatNumbers: "数字表现",
copyExtractorFormatNumbersInherit: "跟随值表现",
copyExtractorFormatNumbersNative: "原生值",
copyExtractorFormatNumbersDisplay: "网格显示值",
copyExtractorIncludeHeader: "包含列名",
copyExtractorIncludeRowHeader: "包含行号",
copyExtractorSkipComputed: "跳过计算列",
@@ -2614,6 +2671,7 @@ export default withEnglishFallback({
copyExtractorPreviewTruncated: "当前仅预览所选 {count} 行中的前 100 行。",
copyExtractorWarningOmittedColumns: "复制结果已忽略以下列:{columns}。",
copyExtractorWarningDuplicateJsonColumns: "检测到重复的 JSON 列名,已自动重命名以避免数据丢失。",
copyExtractorWarningPhpDateNotIso: "部分时间值不是有效的 ISO 日期/时间文本,已保留原始文本。",
copyExtractorValidation: {
"column-separator-empty": "列分隔符不能为空。",
"row-separator-empty": "行分隔符不能为空。",
@@ -2622,6 +2680,7 @@ export default withEnglishFallback({
"separators-overlap": "列分隔符和行分隔符不能相互包含。",
"invalid-quote": "引用字符必须正好是一个字符。",
"quote-conflicts": "引用字符不能出现在列分隔符或行分隔符中。",
"invalid-php-row-delimiter": "PHP 行分隔符只能包含空白字符。",
},
mongoJsonPreview: "JSON 预览",
mongoJsonPreviewEmpty: "选择一条 MongoDB 文档以预览。",
@@ -3286,6 +3345,7 @@ export default withEnglishFallback({
setNull: "设为 NULL",
restoreOriginalValue: "恢复原值",
copyColumnName: "复制列名",
copyColumnMetadata: "复制列元数据",
copyColumnComment: "复制列注释",
columnDragChipMany: "{names} 等 {count} 列",
copyAlterColumnSql: "复制为SQL ALTER",
@@ -4524,6 +4584,9 @@ export default withEnglishFallback({
copyAdvancedInfo: "复制高级信息",
name: "名称",
fullPath: "完整路径",
copyQualifiedName: "复制全限定名",
copyQualifiedNameSameConnection: "请选择同一连接中的对象以复制全限定名。",
copyQualifiedNameUnavailable: "无法解析 {name} 的全限定名。",
copyFinalProxyPort: "复制最终代理端口",
finalProxyPortCopied: "最终代理端口 {port} 已复制",
copyConnectionInfo: "复制连接信息",
+63
View File
@@ -2892,6 +2892,7 @@ export default withEnglishFallback({
setNull: "設為 NULL",
restoreOriginalValue: "復原資料",
copyColumnName: "複製欄位名稱",
copyColumnMetadata: "複製欄位中繼資料",
copyColumnComment: "複製欄位註解",
columnDragChipMany: "{names} 等 {count} 欄",
copyAlterColumnSql: "複製為 SQL ALTER",
@@ -3023,7 +3024,33 @@ export default withEnglishFallback({
copyExtractorWhereClause: "WHERE 條件",
copyExtractorPipeSeparated: "豎線分隔",
copyExtractorPretty: "表格文本",
copyDbunitTableName: "DbUnit 資料表名稱",
copyDbunitTableNameHint: "有來源資料表時使用其名稱;查詢結果沒有來源資料表時請輸入名稱。",
copyDbunitTableNameRequired: "此結果沒有來源資料表,請輸入 DbUnit 資料表名稱。",
copyDbunitNullValue: "NULL 替代文字",
copyDbunitUppercaseTable: "將資料表名稱轉為大寫",
copyDbunitUppercaseColumns: "將欄位名稱轉為大寫",
copyDbunitIncludeNulls: "包含 NULL 值",
copyPhpVariable: "PHP 變數名稱",
copyPhpVariableInvalid: "PHP 變數名稱須以英文字母或底線開頭。",
copyPhpArraySyntax: "陣列語法",
copyPhpQuote: "字串引號",
copyPhpRowDelimiter: "資料列分隔符號",
copyPhpIsoDates: "將辨識出的日期格式化為 ISO",
copyExtractorVisibleFormats: "「複製為」中顯示的格式",
copyExtractorRestoreVisibleFormats: "還原預設值",
copyExtractorConfigure: "配置資料提取器…",
copyExtractorAdvancedCopy: "進階複製…",
copyExtractorAdvancedCopyHint: "選擇僅套用於此次複製的格式與選項;變更不會儲存為預設值。",
copyExtractorConfigureHint: "選擇預設複製格式與選項。取消會捨棄變更。",
copyExtractorFormatList: "複製格式",
copyExtractorFormatOptions: "格式選項",
copyExtractorPreviewOnDemand: "展開後以目前選項產生預覽",
copyExtractorCollapsePreview: "收合預覽",
copyExtractorCopyFailed: "複製未完成。請檢查選取內容或重試。",
copyExtractorSelectionChanged: "結果或選取內容已變更。請關閉並重新開啟對話框,確認目前的選取內容後再複製。",
copyExtractorCopyRunning: "正在複製…",
copyExtractorAdvancedOptions: "進階選項",
copyExtractorSaved: "資料提取器設定已儲存",
copyExtractorDefaultFormat: "預設複製方式",
copyExtractorFormat: "提取器",
@@ -3035,6 +3062,37 @@ export default withEnglishFallback({
copyExtractorQuoteMinimal: "必要時",
copyExtractorQuoteAlways: "始終",
copyExtractorQuoteNever: "從不",
copyExtractorQuoteStrings: "字串",
copyExtractorQuoteAllButNumbers: "除數字外全部",
copyExtractorQuoteAllIncludingNulls: "全部包含 NULL",
copyExtractorQuoteNumericStrings: "為數字樣式的字串加上引號",
copyExtractorHeaderPosition: "標頭位置",
copyExtractorHeaderLegacy: "使用此格式的預設值",
copyExtractorHeaderNone: "不含標頭",
copyExtractorHeaderTop: "上方",
copyExtractorHeaderBottom: "下方",
copyExtractorHeaderFormat: "標頭內容",
copyExtractorHeaderName: "名稱",
copyExtractorHeaderDescription: "描述",
copyExtractorHeaderBoth: "名稱與描述",
copyExtractorHeaderCase: "標頭大小寫",
copyExtractorHeaderPreserve: "保留原樣",
copyExtractorHeaderUpper: "大寫",
copyExtractorHeaderLower: "小寫",
copyExtractorEscapeFormulas: "逸出試算表公式",
copyExtractorValueFormat: "值格式",
copyExtractorValueFormatDefault: "格式預設值",
copyExtractorValueFormatLegacy: "沿用現有行為",
copyExtractorValueFormatDisplay: "資料格顯示值",
copyExtractorValueFormatEdit: "儲存格編輯文字",
copyExtractorValueFormatNative: "原生值",
copyExtractorLineFeedEscape: "將換行取代為",
copyExtractorArrayFormat: "陣列括號",
copyExtractorArrayPreserve: "保留來源格式",
copyExtractorFormatNumbers: "數值",
copyExtractorFormatNumbersInherit: "沿用值格式",
copyExtractorFormatNumbersNative: "原生值",
copyExtractorFormatNumbersDisplay: "資料格顯示值",
copyExtractorIncludeHeader: "包含列名",
copyExtractorIncludeRowHeader: "包含行號",
copyExtractorSkipComputed: "跳過計算列",
@@ -3059,6 +3117,7 @@ export default withEnglishFallback({
copyExtractorPreviewTruncated: "目前僅預覽所選 {count} 行中的前 100 行。",
copyExtractorWarningOmittedColumns: "複製結果已忽略以下列:{columns}。",
copyExtractorWarningDuplicateJsonColumns: "偵測到重複的 JSON 列名,已自動重新命名以避免資料遺失。",
copyExtractorWarningPhpDateNotIso: "部分時間值不是有效的 ISO 日期/時間文字,已保留原始文字。",
copyExtractorValidation: {
"column-separator-empty": "列分隔符不能為空。",
"row-separator-empty": "行分隔符不能為空。",
@@ -3067,6 +3126,7 @@ export default withEnglishFallback({
"separators-overlap": "列分隔符和行分隔符不能相互包含。",
"invalid-quote": "引用字元必須正好是一個字元。",
"quote-conflicts": "引用字元不能出現在列分隔符或行分隔符中。",
"invalid-php-row-delimiter": "PHP 資料列分隔符號只能包含空白字元。",
},
cachedResultUnavailable: "快取結果缺失或不相容。",
reexecuteQuery: "重新執行查詢",
@@ -4194,6 +4254,9 @@ export default withEnglishFallback({
copyAdvancedInfo: "複製進階資訊",
name: "名稱",
fullPath: "完整路徑",
copyQualifiedName: "複製限定名稱",
copyQualifiedNameSameConnection: "請選取來自同一連線的物件以複製限定名稱。",
copyQualifiedNameUnavailable: "無法解析 {name} 的限定名稱。",
copyFinalProxyPort: "複製最終代理連接埠",
finalProxyPortCopied: "最終代理連接埠 {port} 已複製",
setDefaultDatabase: "設為預設資料庫",
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { encodeSpreadsheetTsvCell } from "@/lib/common/spreadsheetTsv";
describe("encodeSpreadsheetTsvCell", () => {
it("preserves legal decimal and at-sign text while guarding formulas", () => {
expect(encodeSpreadsheetTsvCell(undefined)).toBe("");
expect(encodeSpreadsheetTsvCell("-123.45")).toBe("-123.45");
expect(encodeSpreadsheetTsvCell("-1234567890123456")).toBe("'-1234567890123456");
expect(encodeSpreadsheetTsvCell("@")).toBe("@");
expect(encodeSpreadsheetTsvCell("@ASCII")).toBe("'@ASCII");
expect(encodeSpreadsheetTsvCell("@冯十二")).toBe("@冯十二");
expect(encodeSpreadsheetTsvCell("=1+1")).toBe("'=1+1");
});
it.each([
["", ""],
["-123456789012345", "-123456789012345"],
[" +1", "' +1"],
["-1e3", "'-1e3"],
["\t=1", `"'\t=1"`],
["\r=1", `"'\r=1"`],
[" @", " @"],
])("preserves the formula guard boundary for %j", (input, expected) => {
expect(encodeSpreadsheetTsvCell(input)).toBe(expected);
});
it("guards before TSV quoting and doubles embedded quotes", () => {
expect(encodeSpreadsheetTsvCell('=a\t"b')).toBe(`"'=a\t""b"`);
expect(encodeSpreadsheetTsvCell("x\r\ny")).toBe('"x\r\ny"');
});
});
@@ -1,7 +1,20 @@
import { describe, expect, it } from "vitest";
import { coerceDataGridCellValue, dataGridCellDisplayText } from "@/lib/dataGrid/dataGridCellCoercion";
import { coerceDataGridCellValue, dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion";
describe("dataGridCellDisplayText", () => {
it("keeps precision-sensitive decimal text through editor display and unchanged commit coercion", () => {
const value = "9007199254740993.123456789012";
expect(dataGridCellEditorText({ value, databaseType: "oceanbase-oracle", columnInfo: { data_type: "NUMBER(38,12)" } })).toBe(value);
expect(
coerceDataGridCellValue({
value,
oldValue: value,
databaseType: "oceanbase-oracle",
columnInfo: { data_type: "NUMBER(38,12)" },
}),
).toBe(value);
});
it("formats Oracle DATE values without RFC3339 separators", () => {
expect(
dataGridCellDisplayText({
@@ -16,6 +16,12 @@ describe("data grid filter candidate value serialization", () => {
expect(parseFilterValues(raw)).toEqual(["north,east", "line 1\nline 2", "O'Brien", "null"]);
});
it("keeps exact decimal filter literals as text for lossless SQL generation", () => {
const value = "9007199254740993.123456789012";
expect(parseFilterValue(value, { data_type: "NUMBER(38,12)" }, "oceanbase-oracle")).toBe(value);
expect(parseFilterValues(`'${value}', '${value}1'`, { data_type: "NUMBER(38,12)" }, "oceanbase-oracle")).toEqual([value, `${value}1`]);
});
it("maps a selected NULL candidate to a null-aware single-value operator", () => {
expect(dataGridNullSuggestionFilterMode("equals")).toBe("is-null");
expect(dataGridNullSuggestionFilterMode("like")).toBe("is-null");
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { formatDataGridColumnMetadataForCopy } from "@/lib/dataGrid/dataGridColumnMetadataCopy";
describe("formatDataGridColumnMetadataForCopy", () => {
it("keeps unknown metadata empty and escapes TSV cells", () => {
expect(
formatDataGridColumnMetadataForCopy([
{ name: "a\tb", type: "VARCHAR", source: "t.a", nullable: true, comment: 'x\n"y' },
{ name: "expr", type: "", nullable: undefined },
]),
).toBe('Name\tType\tSource\tNullable\tComment\n"a\tb"\tVARCHAR\tt.a\tYES\t"x\n""y"\nexpr\t\t\t\t');
});
it("neutralizes spreadsheet formulas in copied metadata without changing TSV escaping", () => {
expect(
formatDataGridColumnMetadataForCopy([
{ name: "=1+1", comment: " +cmd" },
{ name: "-123.45", comment: "@冯十二" },
]),
).toBe("Name\tType\tSource\tNullable\tComment\n'=1+1\t\t\t\t' +cmd\n-123.45\t\t\t\t@冯十二");
});
it("preserves standalone and non-ASCII at-sign text while guarding ASCII formulas", () => {
expect(
formatDataGridColumnMetadataForCopy([
{ name: "@", comment: " @" },
{ name: "@ASCII", comment: "@text" },
]),
).toBe("Name\tType\tSource\tNullable\tComment\n@\t\t\t\t @\n'@ASCII\t\t\t\t'@text");
});
});
@@ -160,6 +160,7 @@ describe("dataGridContextMenu", () => {
hiddenColumnCount?: number;
canHideIdenticalColumns?: boolean;
canCopyComment?: boolean;
canCopyMetadata?: boolean;
}) {
const action = vi.fn();
const filter = createDataGridFilterSubmenu({
@@ -175,6 +176,7 @@ describe("dataGridContextMenu", () => {
contextColumn: overrides.contextColumn ?? false,
canCopyAlterSql: false,
canCopyComment: overrides.canCopyComment ?? false,
canCopyMetadata: overrides.canCopyMetadata ?? false,
canFilter: false,
hasSort: false,
sortMode: "database",
@@ -186,6 +188,7 @@ describe("dataGridContextMenu", () => {
labels: {
copyName: "copy name",
copyComment: "copy comment",
copyMetadata: "copy metadata",
copyNames: "copy names",
details: "details",
copyAlterSql: "alter",
@@ -206,6 +209,7 @@ describe("dataGridContextMenu", () => {
actions: {
copyName: action,
copyComment: action,
copyMetadata: action,
copyNames: action,
details: action,
copyAlterSql: action,
@@ -223,6 +227,10 @@ describe("dataGridContextMenu", () => {
});
}
it("offers result column metadata from the header menu", () => {
expect(columnMenuItems({ canCopyMetadata: true }).map((item) => item.label)).toContain("copy metadata");
});
it("offers copy-column-comment right below copy-column-name only when the column has a comment", () => {
expect(columnMenuItems({}).map((item) => item.label)).not.toContain("copy comment");
@@ -1,5 +1,15 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, extractorUnavailableForDatabase, normalizeDataGridCopyPreference, normalizeDataGridExtractorOptions, resolveDataGridCopyPreference, validateDataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
import {
DATA_GRID_COPY_EXTRACTOR_IDS,
DATA_GRID_DEFAULT_COPY_PREFERENCES,
DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS,
extractorUnavailableForDatabase,
normalizeDataGridCopyPreference,
normalizeDataGridExtractorOptions,
normalizeVisibleDataGridCopyExtractors,
resolveDataGridCopyPreference,
validateDataGridExtractorOptions,
} from "@/lib/dataGrid/dataGridCopyExtractor";
describe("data-grid extractor options", () => {
it("preserves native SQL defaults and normalizes portable INSERT options", () => {
@@ -32,6 +42,16 @@ describe("data-grid extractor options", () => {
expect(normalizeDataGridExtractorOptions({}).dsv.nullText).toBe("");
});
it("normalizes the additive CSV copy controls without changing legacy defaults", () => {
expect(normalizeDataGridExtractorOptions({}).dsv).toMatchObject({ headerPosition: "legacy", headerFormat: "name", headerCase: "preserve", quotePolicy: "minimal", escapeFormulas: false, arrayFormat: "preserve", formatNumbers: "inherit" });
expect(
normalizeDataGridExtractorOptions({
valueFormat: "edit",
dsv: { headerPosition: "bottom", headerFormat: "both", headerCase: "upper", quotePolicy: "all-including-nulls", quoteNumericStrings: true, escapeFormulas: true, lineFeedEscapeString: "\\n", arrayFormat: "brackets", formatNumbers: "display" },
}),
).toMatchObject({ valueFormat: "edit", dsv: { headerPosition: "bottom", headerFormat: "both", headerCase: "upper", quotePolicy: "all-including-nulls", quoteNumericStrings: true, escapeFormulas: true, lineFeedEscapeString: "\\n", arrayFormat: "brackets", formatNumbers: "display" } });
});
it("resolves smart copy to raw for one cell and TSV otherwise", () => {
expect(normalizeDataGridCopyPreference(undefined)).toBe("smart");
expect(normalizeDataGridCopyPreference("smart")).toBe("smart");
@@ -112,6 +132,16 @@ describe("data-grid extractor options", () => {
expect(validateDataGridExtractorOptions("dsv", options)).toBe("null-text-too-long");
});
it("requires PHP row delimiters to be whitespace only", () => {
const options = normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS);
options.php.rowDelimiter = ";";
expect(validateDataGridExtractorOptions("php", options)).toBe("invalid-php-row-delimiter");
options.php.rowDelimiter = "\n\t";
expect(validateDataGridExtractorOptions("php", options)).toBeNull();
options.php.rowDelimiter = "\u00a0";
expect(validateDataGridExtractorOptions("php", options)).toBe("invalid-php-row-delimiter");
});
it("handles sql-deletes-inserts copy preference and database availability", () => {
expect(normalizeDataGridCopyPreference("sql-deletes-inserts")).toBe("sql-deletes-inserts");
expect(resolveDataGridCopyPreference("sql-deletes-inserts", 1)).toBe("sql-deletes-inserts");
@@ -122,4 +152,20 @@ describe("data-grid extractor options", () => {
expect(extractorUnavailableForDatabase("sql-deletes-inserts", "neo4j")).toBe(true);
expect(extractorUnavailableForDatabase("sql-deletes-inserts", "tdengine")).toBe(true);
});
it("adds DbUnit and PHP without changing the extractor contract version", () => {
expect(DATA_GRID_COPY_EXTRACTOR_IDS).toContain("dbunit");
expect(DATA_GRID_COPY_EXTRACTOR_IDS).toContain("php");
expect(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dbunit).toMatchObject({ upperCaseTableName: true, upperCaseColumnNames: true, includeNullValues: true, nullValueString: "[NULL]" });
expect(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.php).toMatchObject({ arraySyntax: "short", formatDateIso: true, rowDelimiter: "\n", variableName: "" });
expect(normalizeDataGridExtractorOptions({ php: { formatDateISO: false } }).php.formatDateIso).toBe(false);
});
it("normalizes visible copy formats deterministically and preserves an empty selection", () => {
expect(normalizeVisibleDataGridCopyExtractors(undefined)).toEqual(DATA_GRID_COPY_EXTRACTOR_IDS);
expect(normalizeVisibleDataGridCopyExtractors(undefined)).toContain("sql-select");
expect(DATA_GRID_DEFAULT_COPY_PREFERENCES).not.toContain("sql-select");
expect(normalizeVisibleDataGridCopyExtractors(["php", "php", "unknown", "csv"])).toEqual(["csv", "php"]);
expect(normalizeVisibleDataGridCopyExtractors([])).toEqual([]);
});
});
@@ -72,6 +72,12 @@ describe("compareDataGridValues numeric ordering", () => {
expect(compareDataGridValues("-9007199254740992", "42", columnType)).toBeLessThan(0);
expect(compareDataGridValues("1391198305898897409", "1391198305898897409", columnType)).toBe(0);
});
it("orders exact decimal strings without converting them to rounded JavaScript numbers", () => {
const columnType = "NUMBER(38,12)";
expect(compareDataGridValues("9007199254740993.123456789012", "9007199254740993.123456789013", columnType)).toBeLessThan(0);
expect(compareDataGridValues("-9007199254740993.123456789013", "-9007199254740993.123456789012", columnType)).toBeLessThan(0);
});
});
describe("compareDataGridValues datetime ordering", () => {
@@ -0,0 +1 @@
{"columns":["AMOUNT"],"column_types":["NUMBER(38,12)"],"column_sortables":[],"rows":[["9007199254740993.123456789012"]],"affected_rows":0,"execution_time_ms":0,"truncated":false,"session_id":null,"has_more":false}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { TreeNode } from "@/types/database";
import { columnNameForSidebarDrag, formatSidebarTableNamesForCopy, normalizeSidebarCopyTableNameSeparator, resolveSidebarColumnDragNames, resolveSidebarColumnDragTargets, resolveSidebarTableCopyTargets } from "@/lib/sidebar/sidebarTableNameCopy";
import { columnNameForSidebarDrag, formatSidebarTableNamesForCopy, normalizeSidebarCopyTableNameSeparator, qualifiedNameForSidebarNode, resolveSidebarColumnDragNames, resolveSidebarColumnDragTargets, resolveSidebarTableCopyTargets } from "@/lib/sidebar/sidebarTableNameCopy";
describe("sidebarTableNameCopy", () => {
const table = (id: string, label: string, schema = "public"): TreeNode => ({
@@ -45,6 +45,76 @@ describe("sidebarTableNameCopy", () => {
expect(formatSidebarTableNamesForCopy([table("t1", "users", "default")], { separator: "comma", includeSchema: true, databaseType: "hive", schema: "default" })).toBe("`default`.`users`");
});
it("resolves SQL names from object identity and quotes each namespace segment", () => {
expect(qualifiedNameForSidebarNode({ ...table("t1", "a.b"), database: "sales db" }, { databaseType: "mysql" })).toBe("`sales db`.`a.b`");
const routine: TreeNode = { id: "f", label: "find user", objectName: "find user", type: "function", connectionId: "c1", database: "db", schema: "app" };
expect(qualifiedNameForSidebarNode(routine, { databaseType: "postgres" })).toBe('"app"."find user"');
const member: TreeNode = { ...routine, id: "m", label: "lookup", objectName: "lookup", parentName: "User API", parentType: "package", type: "function" };
expect(qualifiedNameForSidebarNode(member, { databaseType: "oracle" })).toBe('"app"."User API"."lookup"');
const packageBody: TreeNode = { ...routine, id: "body", label: "User API body", objectName: "User API", type: "package-body" };
expect(qualifiedNameForSidebarNode(packageBody, { databaseType: "oracle" })).toBe('"app"."User API"');
const typeMember: TreeNode = { ...routine, id: "attr", label: "address (type)", objectName: "address", parentName: "ADDRESS_T", parentType: "type", type: "type-attribute" };
expect(qualifiedNameForSidebarNode(typeMember, { databaseType: "oracle" })).toBe('"app"."ADDRESS_T"."address"');
expect(qualifiedNameForSidebarNode({ ...routine, schema: undefined }, { databaseType: "postgres" })).toBeNull();
expect(qualifiedNameForSidebarNode({ ...routine, type: "trigger" }, { databaseType: "postgres" })).toBeNull();
const trigger: TreeNode = { ...routine, id: "tr", objectName: "audit change", type: "trigger", meta: { name: "audit change" }, tableName: "users" };
expect(qualifiedNameForSidebarNode(trigger, { databaseType: "sqlserver" })).toBe("[app].[audit change]");
expect(qualifiedNameForSidebarNode(trigger, { databaseType: "oracle" })).toBe('"app"."audit change"');
expect(qualifiedNameForSidebarNode({ ...trigger, type: "index", meta: { name: "idx_users" } }, { databaseType: "sqlserver" })).toBeNull();
expect(qualifiedNameForSidebarNode({ ...routine, type: "type-attribute", objectName: "street" }, { databaseType: "oracle" })).toBeNull();
const event: TreeNode = { ...routine, id: "event", type: "event", objectName: "nightly job" };
expect(qualifiedNameForSidebarNode(event, { databaseType: "mysql" })).toBe("`db`.`nightly job`");
const col = column("c", "id (bigint)", "id");
expect(qualifiedNameForSidebarNode(col, { databaseType: "mysql" })).toBe("`db`.`users`.`id`");
});
it("uses the MySQL database as the routine namespace when the node has no schema", () => {
const routine: TreeNode = { id: "f", label: "rebuild", objectName: "rebuild", type: "procedure", connectionId: "c1", database: "sales" };
expect(qualifiedNameForSidebarNode(routine, { databaseType: "mysql" })).toBe("`sales`.`rebuild`");
});
it("maps supported object namespaces to their database-qualified names and rejects unsupported namespaces", () => {
const schemaNode = (id: string, type: TreeNode["type"], name: string): TreeNode => ({
id,
label: name,
objectName: name,
type,
connectionId: "c1",
database: "db",
schema: "app",
});
expect(qualifiedNameForSidebarNode(schemaNode("seq", "sequence", "seq"), { databaseType: "postgres" })).toBe('"app"."seq"');
expect(qualifiedNameForSidebarNode(schemaNode("syn", "synonym", "syn"), { databaseType: "oracle" })).toBe('"app"."syn"');
expect(qualifiedNameForSidebarNode(schemaNode("pkg", "package", "pkg"), { databaseType: "oracle" })).toBe('"app"."pkg"');
expect(qualifiedNameForSidebarNode(schemaNode("type", "type", "address_t"), { databaseType: "oracle" })).toBe('"app"."address_t"');
expect(qualifiedNameForSidebarNode(schemaNode("type-body", "type-body", "address_t"), { databaseType: "oracle" })).toBe('"app"."address_t"');
expect(qualifiedNameForSidebarNode({ ...schemaNode("index", "index", "idx_users"), meta: { name: "idx_users" } }, { databaseType: "postgres" })).toBe('"app"."idx_users"');
expect(qualifiedNameForSidebarNode(schemaNode("mysql-trigger", "trigger", "trg_users"), { databaseType: "mysql" })).toBeNull();
expect(qualifiedNameForSidebarNode(schemaNode("mysql-index", "index", "idx_users"), { databaseType: "mysql" })).toBeNull();
expect(qualifiedNameForSidebarNode({ ...schemaNode("doris", "table", "orders"), schema: undefined, database: "sales", catalog: "lake" }, { databaseType: "doris" })).toBe("`lake`.`sales`.`orders`");
});
it("quotes JDBC routine names using the driver's identifier quote", () => {
const routine: TreeNode = { id: "f", label: "find user", objectName: "find user", type: "function", connectionId: "c1", database: "db", schema: "app" };
expect(qualifiedNameForSidebarNode(routine, { databaseType: "jdbc", identifierQuote: '"' })).toBe('app."find user"');
});
it("quotes JDBC column names using the driver's identifier quote", () => {
const phoenixColumn: TreeNode = {
id: "phoenix-column",
label: "first name",
objectName: "first name",
type: "column",
connectionId: "phoenix",
database: "APP",
schema: "APP",
tableName: "T",
meta: { name: "first name" },
};
expect(qualifiedNameForSidebarNode(phoenixColumn, { databaseType: "jdbc", driverProfile: "phoenix", identifierQuote: '"' })).toBe('"APP"."T"."first name"');
});
it("resolves multi-column drag targets for the same table", () => {
const first = column("c1", "id (bigint)", "id");
const second = column("c2", "name (text)", "name");
@@ -0,0 +1,14 @@
import { encodeSpreadsheetTsvCell } from "@/lib/common/spreadsheetTsv";
export interface DataGridColumnMetadataForCopy {
name: string;
type?: string;
source?: string;
nullable?: boolean;
comment?: string;
}
export function formatDataGridColumnMetadataForCopy(columns: readonly DataGridColumnMetadataForCopy[]): string {
const rows = [["Name", "Type", "Source", "Nullable", "Comment"], ...columns.map((column) => [column.name, column.type ?? "", column.source ?? "", column.nullable === undefined ? "" : column.nullable ? "YES" : "NO", column.comment ?? ""])];
return rows.map((row) => row.map(encodeSpreadsheetTsvCell).join("\t")).join("\n");
}
@@ -115,6 +115,7 @@ export function createDataGridColumnContextMenuItems(options: {
headerColumn: boolean;
contextColumn: boolean;
canCopyAlterSql: boolean;
canCopyMetadata?: boolean;
/** 当前列(或选中列)解析到了非空注释时才提供「复制列注释」。 */
canCopyComment?: boolean;
canFilter: boolean;
@@ -135,7 +136,7 @@ export function createDataGridColumnContextMenuItems(options: {
"copyName" | "copyComment" | "copyNames" | "details" | "copyAlterSql" | "databaseAscending" | "databaseDescending" | "localAscending" | "localDescending" | "clearSort" | "freezeToColumn" | "freezeSelectedColumns" | "unfreezeColumns" | "hideColumn" | "hideSelectedColumns" | "showAllColumnsMenu",
string
> &
Partial<Record<"freezeCurrentColumn" | "unfreezeCurrentColumn" | "hideIdenticalColumns", string>>;
Partial<Record<"freezeCurrentColumn" | "unfreezeCurrentColumn" | "hideIdenticalColumns" | "copyMetadata", string>>;
icons: Pick<DataGridContextMenuIcons, "copy" | "columnDetails" | "database" | "ascending" | "descending" | "clearSort">;
actions: {
copyName: () => void;
@@ -143,6 +144,7 @@ export function createDataGridColumnContextMenuItems(options: {
copyNames: () => void;
details: () => void;
copyAlterSql: () => void;
copyMetadata?: () => void;
sort: (direction: "asc" | "desc" | null, mode: "database" | "local") => void;
freezeToColumn: () => void;
freezeSelectedColumns: () => void;
@@ -160,6 +162,7 @@ export function createDataGridColumnContextMenuItems(options: {
const items: DataGridContextMenuItem[] = [];
if (options.headerColumn) {
items.push({ label: options.labels.copyName, action: options.actions.copyName, icon: options.icons.copy });
if (options.canCopyMetadata) items.push({ label: options.labels.copyMetadata ?? "Copy column metadata", action: options.actions.copyMetadata!, icon: options.icons.copy });
// 只有解析到注释时才给出入口:无元数据的查询结果里永远为空,展示反而误导。
if (options.canCopyComment) items.push({ label: options.labels.copyComment, action: options.actions.copyComment, icon: options.icons.copy });
items.push({ label: options.labels.copyNames, action: options.actions.copyNames, icon: options.icons.copy });
@@ -22,6 +22,8 @@ export const DATA_GRID_COPY_EXTRACTOR_IDS = [
"html",
"xml",
"pretty",
"dbunit",
"php",
] as const;
export type DataGridCopyExtractorId = (typeof DATA_GRID_COPY_EXTRACTOR_IDS)[number];
@@ -49,7 +51,10 @@ export const DATA_GRID_DEFAULT_COPY_PREFERENCES: readonly DataGridCopyPreference
"html",
"xml",
"pretty",
"dbunit",
"php",
];
export const DATA_GRID_DEFAULT_VISIBLE_COPY_EXTRACTORS: readonly DataGridCopyExtractorId[] = DATA_GRID_COPY_EXTRACTOR_IDS;
export const DATA_GRID_EXTRACTOR_CONTRACT_VERSION = 1 as const;
// v2:复制/导出 SQL 的「包含数据库名称」改为由提取器自身的勾选框决定,默认关闭(不带库名/模式名),
@@ -77,11 +82,14 @@ export const DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS: Record<DataGridCopyExtractorI
html: { category: "document" },
xml: { category: "document" },
pretty: { category: "document" },
dbunit: { category: "document", separatorBefore: true },
php: { category: "document" },
};
export type DataGridSelectionKind = "cells" | "rows" | "columns";
export type DataGridQuotePolicy = "always" | "minimal" | "never";
export type DataGridExtractorOptionsError = "column-separator-empty" | "row-separator-empty" | "separator-too-long" | "null-text-too-long" | "separators-overlap" | "invalid-quote" | "quote-conflicts";
export type DataGridValueFormat = "default" | "legacy" | "display" | "edit" | "native";
export type DataGridQuotePolicy = "always" | "minimal" | "never" | "strings" | "all-but-numbers" | "all-including-nulls";
export type DataGridExtractorOptionsError = "column-separator-empty" | "row-separator-empty" | "separator-too-long" | "null-text-too-long" | "separators-overlap" | "invalid-quote" | "quote-conflicts" | "invalid-php-row-delimiter";
/** Extractors that emit relational SQL predicates not supported by some stores.
* sql-updates has a Mongo-specific updateOne path, so Mongo is NOT disabled;
@@ -105,8 +113,16 @@ export interface DataGridDsvOptions {
nullText: string;
quote: string;
quotePolicy: DataGridQuotePolicy;
quoteNumericStrings: boolean;
includeColumnHeader: boolean;
includeRowHeader: boolean;
headerPosition: "legacy" | "none" | "top" | "bottom";
headerFormat: "name" | "description" | "both";
headerCase: "preserve" | "upper" | "lower";
escapeFormulas: boolean;
lineFeedEscapeString: string;
arrayFormat: "preserve" | "braces" | "brackets" | "parentheses";
formatNumbers: "inherit" | "native" | "display";
}
export type DataGridPrimaryKeyMode = "include" | "exclude-generated" | "exclude-all";
@@ -128,6 +144,9 @@ export interface DataGridExtractorOptions {
/** Bare array of values instead of objects; only honored for single-column selections. */
valuesOnly: boolean;
};
dbunit: { tableName: string; upperCaseTableName: boolean; upperCaseColumnNames: boolean; includeNullValues: boolean; nullValueString: string };
php: { arraySyntax: "legacy" | "short"; quoteChar: "'" | '"'; formatDateIso: boolean; rowDelimiter: string; variableName: string };
valueFormat: DataGridValueFormat;
}
export interface DataGridExtractColumn {
@@ -135,6 +154,7 @@ export interface DataGridExtractColumn {
sourceName?: string;
sourceIndex: number;
dataType?: string;
description?: string;
}
export interface DataGridExtractRequest {
@@ -143,6 +163,7 @@ export interface DataGridExtractRequest {
databaseType?: DatabaseType;
identifierQuote?: string;
tableMeta?: DataGridTableMeta;
sourceTableName?: string;
columns: DataGridExtractColumn[];
selectedColumnIndexes: number[];
rows: unknown[][];
@@ -150,7 +171,7 @@ export interface DataGridExtractRequest {
options: DataGridExtractorOptions;
}
export type DataGridExtractWarningCode = "omitted-columns" | "duplicate-json-column-names";
export type DataGridExtractWarningCode = "omitted-columns" | "duplicate-json-column-names" | "php-date-not-iso";
export interface DataGridExtractResult {
text: string;
@@ -176,8 +197,16 @@ export const DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS: DataGridExtractorOptions = {
nullText: "",
quote: '"',
quotePolicy: "minimal",
quoteNumericStrings: false,
includeColumnHeader: false,
includeRowHeader: false,
headerPosition: "legacy",
headerFormat: "name",
headerCase: "preserve",
escapeFormulas: false,
lineFeedEscapeString: "",
arrayFormat: "preserve",
formatNumbers: "inherit",
},
sql: {
skipComputedColumns: true,
@@ -189,6 +218,9 @@ export const DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS: DataGridExtractorOptions = {
temporalFormat: "native",
},
json: { pretty: true, camelCaseFieldNames: false, valuesOnly: false },
dbunit: { tableName: "", upperCaseTableName: true, upperCaseColumnNames: true, includeNullValues: true, nullValueString: "[NULL]" },
php: { arraySyntax: "short", quoteChar: '"', formatDateIso: true, rowDelimiter: "\n", variableName: "" },
valueFormat: "default",
};
function unicodeCodePointLength(value: string): number {
@@ -200,6 +232,9 @@ export function normalizeDataGridExtractorOptions(value: unknown): DataGridExtra
const dsv: Partial<DataGridDsvOptions> = typeof source.dsv === "object" && source.dsv !== null ? source.dsv : {};
const sql: Record<string, unknown> = typeof source.sql === "object" && source.sql !== null ? (source.sql as Record<string, unknown>) : {};
const json: Partial<DataGridExtractorOptions["json"]> = typeof source.json === "object" && source.json !== null ? source.json : {};
const dbunit: Partial<DataGridExtractorOptions["dbunit"]> = typeof source.dbunit === "object" && source.dbunit !== null ? source.dbunit : {};
const php: Partial<DataGridExtractorOptions["php"]> = typeof source.php === "object" && source.php !== null ? source.php : {};
const phpLegacy = php as Record<string, unknown>;
const separator = (candidate: unknown, fallback: string) => (typeof candidate === "string" && unicodeCodePointLength(candidate) > 0 && unicodeCodePointLength(candidate) <= 8 ? candidate : fallback);
const quote = typeof dsv.quote === "string" && unicodeCodePointLength(dsv.quote) === 1 ? dsv.quote : DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv.quote;
return {
@@ -208,9 +243,17 @@ export function normalizeDataGridExtractorOptions(value: unknown): DataGridExtra
rowSeparator: separator(dsv.rowSeparator, DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv.rowSeparator),
nullText: typeof dsv.nullText === "string" && unicodeCodePointLength(dsv.nullText) <= 64 ? dsv.nullText : DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS.dsv.nullText,
quote,
quotePolicy: dsv.quotePolicy === "always" || dsv.quotePolicy === "never" ? dsv.quotePolicy : "minimal",
quotePolicy: dsv.quotePolicy === "always" || dsv.quotePolicy === "never" || dsv.quotePolicy === "strings" || dsv.quotePolicy === "all-but-numbers" || dsv.quotePolicy === "all-including-nulls" ? dsv.quotePolicy : "minimal",
quoteNumericStrings: dsv.quoteNumericStrings === true,
includeColumnHeader: dsv.includeColumnHeader === true,
includeRowHeader: dsv.includeRowHeader === true,
headerPosition: dsv.headerPosition === "none" || dsv.headerPosition === "top" || dsv.headerPosition === "bottom" ? dsv.headerPosition : "legacy",
headerFormat: dsv.headerFormat === "description" || dsv.headerFormat === "both" ? dsv.headerFormat : "name",
headerCase: dsv.headerCase === "upper" || dsv.headerCase === "lower" ? dsv.headerCase : "preserve",
escapeFormulas: dsv.escapeFormulas === true,
lineFeedEscapeString: typeof dsv.lineFeedEscapeString === "string" && dsv.lineFeedEscapeString.length <= 32 ? dsv.lineFeedEscapeString : "",
arrayFormat: dsv.arrayFormat === "braces" || dsv.arrayFormat === "brackets" || dsv.arrayFormat === "parentheses" ? dsv.arrayFormat : "preserve",
formatNumbers: dsv.formatNumbers === "native" || dsv.formatNumbers === "display" ? dsv.formatNumbers : "inherit",
},
sql: {
skipComputedColumns: sql.skipComputedColumns !== false,
@@ -226,6 +269,21 @@ export function normalizeDataGridExtractorOptions(value: unknown): DataGridExtra
camelCaseFieldNames: json.camelCaseFieldNames === true,
valuesOnly: json.valuesOnly === true,
},
dbunit: {
tableName: typeof dbunit.tableName === "string" ? dbunit.tableName : "",
upperCaseTableName: dbunit.upperCaseTableName !== false,
upperCaseColumnNames: dbunit.upperCaseColumnNames !== false,
includeNullValues: dbunit.includeNullValues !== false,
nullValueString: typeof dbunit.nullValueString === "string" && dbunit.nullValueString.length <= 128 ? dbunit.nullValueString : "[NULL]",
},
php: {
arraySyntax: php.arraySyntax === "legacy" ? "legacy" : "short",
quoteChar: php.quoteChar === "'" ? "'" : '"',
formatDateIso: typeof php.formatDateIso === "boolean" ? php.formatDateIso : phpLegacy.formatDateISO !== false,
rowDelimiter: typeof php.rowDelimiter === "string" && php.rowDelimiter.length <= 8 ? php.rowDelimiter : "\n",
variableName: typeof php.variableName === "string" && (php.variableName === "" || /^[A-Za-z_][A-Za-z0-9_]*$/.test(php.variableName)) ? php.variableName : "",
},
valueFormat: source.valueFormat === "legacy" || source.valueFormat === "display" || source.valueFormat === "edit" || source.valueFormat === "native" ? source.valueFormat : "default",
};
}
@@ -235,12 +293,19 @@ export function normalizeDataGridCopyPreference(value: unknown): DataGridCopyPre
return typeof value === "string" && DATA_GRID_COPY_PREFERENCE_SET.has(value) ? (value as DataGridCopyPreference) : "smart";
}
export function normalizeVisibleDataGridCopyExtractors(value: unknown): DataGridCopyExtractorId[] {
if (!Array.isArray(value)) return [...DATA_GRID_DEFAULT_VISIBLE_COPY_EXTRACTORS];
const selected = new Set(value.filter((item): item is DataGridCopyExtractorId => typeof item === "string" && DATA_GRID_COPY_EXTRACTOR_IDS.includes(item as DataGridCopyExtractorId)));
return DATA_GRID_DEFAULT_VISIBLE_COPY_EXTRACTORS.filter((item) => selected.has(item));
}
export function resolveDataGridCopyPreference(preference: DataGridCopyPreference, selectedCellCount: number): DataGridCopyExtractorId {
if (preference !== "smart") return preference;
return selectedCellCount === 1 ? "raw" : "tsv";
}
export function validateDataGridExtractorOptions(extractor: DataGridCopyExtractorId, options: DataGridExtractorOptions): DataGridExtractorOptionsError | null {
if (extractor === "php" && !/^[ \t\r\n\f\v]*$/.test(options.php.rowDelimiter)) return "invalid-php-row-delimiter";
const usesDsv = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor].category === "delimited";
if (!usesDsv) return null;
const quoteCodePoint = options.dsv.quote.codePointAt(0);
@@ -1,7 +1,8 @@
import type { ColumnInfo, DatabaseType, TreeNode } from "@/types/database";
import { COLUMN_NAME_COPY_SEPARATOR_VALUES, type ColumnNameCopySeparator, isColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { copyNameForTreeNode } from "@/lib/sidebar/treeNodeClick";
import { qualifiedTableName } from "@/lib/table/tableSelectSql";
import { qualifiedTableName, quoteTableIdentifier, quoteTableIdentifierIfNeeded } from "@/lib/table/tableSelectSql";
import { qualifiedRoutineName } from "@/lib/table/routineExecutionSql";
export type SidebarTableCopyTarget = TreeNode & { connectionId: string; database: string };
@@ -88,3 +89,75 @@ export function formatSidebarTableCopyText(activeNode: TreeNode, selectedNodes:
}
return sidebarTableCopyNodes(activeNode, selectedNodes).map(copyNameForTreeNode).join(separator);
}
const QUALIFIED_NODE_TYPES = new Set<TreeNode["type"]>(["table", "view", "materialized_view", "column", "procedure", "function", "trigger", "event", "sequence", "synonym", "package", "package-body", "type", "type-body", "type-member", "type-attribute", "type-method", "index"]);
/** Resolve SQL identity from node metadata; never use the rendered tree path. */
export function qualifiedNameForSidebarNode(node: TreeNode, options: Omit<FormatSidebarTableNamesOptions, "separator" | "includeSchema">): string | null {
if (!QUALIFIED_NODE_TYPES.has(node.type) || !node.database || !node.connectionId || !options.databaseType) return null;
const dbType = options.databaseType;
const schema = node.parentSchema || node.schema;
if (!schema && !["mysql", "clickhouse", "doris", "starrocks", "goldendb"].includes(dbType)) return null;
const quote = (part: string) => (dbType === "jdbc" ? quoteTableIdentifierIfNeeded(dbType, part, options.identifierQuote) : quoteTableIdentifier(dbType, part));
if (node.type === "table" || node.type === "view" || node.type === "materialized_view") {
return qualifiedTableName({ ...options, schema: node.schema, tableName: node.objectName || node.tableName || node.label, database: node.database, catalog: node.catalog, includeDatabaseName: true });
}
if (node.type === "column") {
const column = node.meta as Partial<ColumnInfo> | undefined;
const parent = node.tableName;
const name = column?.name;
if (!parent || !name) return null;
const qualifiedParent = qualifiedTableName({ ...options, schema, tableName: parent, database: node.database, catalog: node.catalog, includeDatabaseName: true });
return `${qualifiedParent}.${quote(name)}`;
}
if (node.type === "procedure" || node.type === "function") {
const name = node.objectName;
if (!name) return null;
const qualifiedRoutine = (routineName: string) => {
if (dbType === "mysql") return qualifiedTableName({ ...options, schema, tableName: routineName, database: node.database, includeDatabaseName: true });
if (dbType === "jdbc") {
const parts = [schema, routineName].filter((part): part is string => !!part);
return parts.map((part) => quoteTableIdentifierIfNeeded(dbType, part, options.identifierQuote)).join(".");
}
return qualifiedRoutineName({ databaseType: dbType, schema, routineName });
};
const routine = qualifiedRoutine(name);
if (node.parentName && node.parentType === "package") {
const qualifiedPackage = qualifiedRoutine(node.parentName);
return `${qualifiedPackage}.${quote(name)}`;
}
return routine;
}
if (node.type === "trigger") {
if (!["sqlserver", "oracle", "oceanbase-oracle"].includes(dbType)) return null;
const trigger = node.meta as { name?: string } | undefined;
const name = node.objectName || trigger?.name;
if (!name || !schema) return null;
return qualifiedRoutineName({ databaseType: dbType, schema, routineName: name });
}
if (node.type === "event") {
if (!["mysql", "mariadb"].includes(dbType)) return null;
const event = node.meta as { name?: string } | undefined;
const name = node.objectName || event?.name;
return name ? qualifiedTableName({ ...options, schema, tableName: name, database: node.database, catalog: node.catalog, includeDatabaseName: true }) : null;
}
if (node.type === "package-body" || node.type === "type-body") {
if (!node.objectName) return null;
return qualifiedRoutineName({ databaseType: dbType, schema, routineName: node.objectName });
}
if (node.type === "index") {
if (!["postgres", "oracle", "oceanbase-oracle"].includes(dbType)) return null;
const index = node.meta as { name?: string } | undefined;
const name = index?.name;
return name ? qualifiedTableName({ ...options, schema, tableName: name, database: node.database, catalog: node.catalog, includeDatabaseName: true }) : null;
}
if (["type-member", "type-attribute", "type-method"].includes(node.type)) {
if (!["oracle", "oceanbase-oracle"].includes(dbType) || !node.parentName || !["package", "type"].includes(node.parentType ?? "")) return null;
}
const name = node.objectName || node.label;
if (!name) return null;
const namespace = node.parentName && ["package", "type"].includes(node.parentType ?? "") ? node.parentName : name;
const qualifiedNamespace = qualifiedTableName({ ...options, schema, tableName: namespace, database: node.database, catalog: node.catalog, includeDatabaseName: true });
if (node.parentName && ["package", "type"].includes(node.parentType ?? "")) return `${qualifiedNamespace}.${quote(name)}`;
return qualifiedNamespace;
}
+6 -1
View File
@@ -27,7 +27,7 @@ import {
COMPACT_TAB_TITLE_DEFAULT_VERSION,
} from "@/stores/settingsStore";
import type { AiConfigItem } from "@/types/ai";
import { DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION } from "@/lib/dataGrid/dataGridCopyExtractor";
import { DATA_GRID_DEFAULT_VISIBLE_COPY_EXTRACTORS, DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION } from "@/lib/dataGrid/dataGridCopyExtractor";
import { DEFAULT_MONO_FONT_FAMILY } from "@/lib/app/appFonts";
describe("normalizeEditorSettings", () => {
@@ -655,6 +655,8 @@ describe("normalizeEditorSettings", () => {
quote: '"',
quotePolicy: "minimal",
});
expect(defaults.valueFormat).toBe("default");
expect(normalizeEditorSettings({}).dataGridVisibleCopyExtractors).toEqual(DATA_GRID_DEFAULT_VISIBLE_COPY_EXTRACTORS);
expect(defaults.sql).toMatchObject({
skipComputedColumns: true,
skipGeneratedColumns: true,
@@ -672,6 +674,9 @@ describe("normalizeEditorSettings", () => {
expect(configured.dsv.quotePolicy).toBe("always");
expect(configured.sql.insertMode).toBe("row-by-row");
expect(configured.json.pretty).toBe(false);
const visible = normalizeEditorSettings({ dataGridVisibleCopyExtractors: ["php", "php", "unknown"] }).dataGridVisibleCopyExtractors;
expect(visible).toEqual(["php"]);
expect(normalizeEditorSettings({ dataGridVisibleCopyExtractors: [] }).dataGridVisibleCopyExtractors).toEqual([]);
});
it("migrates the legacy NULL clipboard sentinel once", () => {
+14 -1
View File
@@ -14,7 +14,16 @@ import { setDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
import { type ColumnFormatterConfig, type CustomColumnFormatterConfig, normalizeColumnFormatter, normalizeCustomColumnFormatter, normalizeGlobalDateTimePattern } from "@/lib/dataGrid/columnFormatter";
import { type BooleanCellValueStyle, normalizeBooleanCellValueStyle } from "@/lib/dataGrid/dataGridBooleanColumn";
import { type DataGridCopyPreference, type DataGridExtractorOptions, DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION, DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridCopyPreference, normalizeDataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
import {
type DataGridCopyPreference,
type DataGridCopyExtractorId,
type DataGridExtractorOptions,
DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION,
DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS,
normalizeDataGridCopyPreference,
normalizeDataGridExtractorOptions,
normalizeVisibleDataGridCopyExtractors,
} from "@/lib/dataGrid/dataGridCopyExtractor";
import { DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_DEFAULT, normalizeDataGridTextFilterPanelHeight } from "@/lib/dataGrid/dataGridTextFilterPanel";
import { DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID, type DataGridTypeColorScheme, normalizeActiveDataGridTypeColorSchemeId, normalizeDataGridTypeColorSchemes } from "@/lib/dataGrid/dataGridTypeColorScheme";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
@@ -1098,6 +1107,7 @@ export interface EditorSettings {
dataGridRowNumberMode: DataGridRowNumberMode;
dataGridCopyExtractor: DataGridCopyPreference;
dataGridExtractorOptions: DataGridExtractorOptions;
dataGridVisibleCopyExtractors: DataGridCopyExtractorId[];
dataGridExtractorOptionsMigrationVersion: number;
resultRunDisplayMode: ResultRunDisplayMode;
defaultAutoKeepResults: boolean;
@@ -1448,6 +1458,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
dataGridRowNumberMode: "view",
dataGridCopyExtractor: "smart",
dataGridExtractorOptions: normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS),
dataGridVisibleCopyExtractors: normalizeVisibleDataGridCopyExtractors(undefined),
dataGridExtractorOptionsMigrationVersion: DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION,
resultRunDisplayMode: "tabs",
defaultAutoKeepResults: false,
@@ -2115,6 +2126,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
dataGridRowNumberMode: normalizeDataGridRowNumberMode(settings.dataGridRowNumberMode),
dataGridCopyExtractor: normalizeDataGridCopyPreference(settings.dataGridCopyExtractor),
dataGridExtractorOptions,
dataGridVisibleCopyExtractors: normalizeVisibleDataGridCopyExtractors(settings.dataGridVisibleCopyExtractors),
dataGridExtractorOptionsMigrationVersion: DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION,
resultRunDisplayMode: normalizeResultRunDisplayMode(settings.resultRunDisplayMode),
defaultAutoKeepResults: settings.defaultAutoKeepResults === true,
@@ -3044,6 +3056,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.dataGridRowNumberMode !== undefined) editorSettings.value.dataGridRowNumberMode = normalizeDataGridRowNumberMode(partial.dataGridRowNumberMode);
if (partial.dataGridCopyExtractor !== undefined) editorSettings.value.dataGridCopyExtractor = normalizeDataGridCopyPreference(partial.dataGridCopyExtractor);
if (partial.dataGridExtractorOptions !== undefined) editorSettings.value.dataGridExtractorOptions = normalizeDataGridExtractorOptions(partial.dataGridExtractorOptions);
if (partial.dataGridVisibleCopyExtractors !== undefined) editorSettings.value.dataGridVisibleCopyExtractors = normalizeVisibleDataGridCopyExtractors(partial.dataGridVisibleCopyExtractors);
if (partial.dataGridExtractorOptionsMigrationVersion !== undefined) editorSettings.value.dataGridExtractorOptionsMigrationVersion = DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION;
if (partial.resultRunDisplayMode !== undefined) editorSettings.value.resultRunDisplayMode = normalizeResultRunDisplayMode(partial.resultRunDisplayMode);
if (partial.defaultAutoKeepResults !== undefined) editorSettings.value.defaultAutoKeepResults = partial.defaultAutoKeepResults === true;
+28
View File
@@ -1272,6 +1272,34 @@ mod tests {
assert!(value.get("messages").is_none());
}
#[test]
fn format_query_preserves_exact_decimal_values_in_json_table_and_csv() {
let value = "9007199254740993.123456789012";
let result = QueryResult {
columns: vec!["amount".to_string()],
column_types: vec!["NUMBER(38,12)".to_string()],
column_sortables: Vec::new(),
spatial_columns: Vec::new(),
spatial_values: Vec::new(),
rows: vec![vec![serde_json::from_str(value).unwrap()]],
affected_rows: 0,
execution_time_ms: 0,
server_execute_time_us: None,
query_timings_ms: None,
truncated: false,
session_id: None,
has_more: false,
elasticsearch_raw_body: None,
messages: Vec::new(),
};
let json_output = format_query("local", &result, OutputFormat::Json).unwrap();
let json_value: Value = serde_json::from_str(&json_output).unwrap();
assert_eq!(json_value["rows"][0]["amount"].as_number().unwrap().as_str(), value);
assert!(format_query("local", &result, OutputFormat::Table).unwrap().contains(value));
assert!(format_query("local", &result, OutputFormat::Csv).unwrap().contains(value));
}
#[tokio::test]
async fn routes_legacy_mongo_insert_through_shared_mongo_backend() {
let flags = parse_flags(&args(&[
@@ -32,7 +32,7 @@
//! statement or a JSON document would corrupt it.
use dbx_formats::csv_export::needs_formula_guard;
use dbx_sql::data_grid_extractors::{DataGridExtractRequest, DataGridExtractorId};
use dbx_sql::data_grid_extractors::{DataGridExtractRequest, DataGridExtractorId, DataGridHeaderFormat};
use serde_json::Value;
/// Extractors whose output is pasted straight into a spreadsheet cell grid.
@@ -71,7 +71,9 @@ fn is_spreadsheet_extractor(extractor: DataGridExtractorId) -> bool {
| DataGridExtractorId::Markdown
| DataGridExtractorId::Html
| DataGridExtractorId::Xml
| DataGridExtractorId::Pretty => false,
| DataGridExtractorId::Pretty
| DataGridExtractorId::Dbunit
| DataGridExtractorId::Php => false,
}
}
@@ -99,6 +101,13 @@ pub fn neutralize_spreadsheet_formulas(mut request: DataGridExtractRequest) -> D
if needs_formula_guard(&column.display_name) {
column.display_name.insert(0, '\'');
}
if request.options.dsv.header_format == DataGridHeaderFormat::Description {
if let Some(description) = &mut column.description {
if needs_formula_guard(description) {
description.insert(0, '\'');
}
}
}
}
for row in &mut request.rows {
@@ -136,11 +145,13 @@ mod tests {
database_type: None,
identifier_quote: None,
table_meta: None,
source_table_name: None,
columns: vec![DataGridExtractColumn {
display_name: "value".to_string(),
source_name: Some("value".to_string()),
source_index: 0,
data_type: None,
description: None,
}],
selected_column_indexes: vec![0],
rows,
@@ -315,6 +326,21 @@ mod tests {
}
}
#[test]
fn description_headers_are_guarded_only_when_they_are_the_effective_header() {
let mut request = request(DataGridExtractorId::CsvWithHeaders, vec![vec![json!(1)]]);
request.columns[0].display_name = "value".into();
request.columns[0].description = Some("=SUM(A1)".into());
request.options.dsv.header_format = DataGridHeaderFormat::Description;
let guarded = neutralize_spreadsheet_formulas(request.clone());
assert_eq!(guarded.columns[0].description.as_deref(), Some("'=SUM(A1)"));
assert_eq!(dbx_sql::data_grid_extractors::extract_data_grid_selection(guarded).unwrap().text, "'=SUM(A1)\n1");
request.options.dsv.header_format = DataGridHeaderFormat::Both;
let guarded = neutralize_spreadsheet_formulas(request);
assert_eq!(guarded.columns[0].description.as_deref(), Some("=SUM(A1)"));
}
#[test]
fn non_spreadsheet_headers_are_untouched() {
let mut request = request(DataGridExtractorId::SqlInserts, vec![vec![json!(1)]]);
@@ -338,6 +364,7 @@ mod tests {
source_name: Some("formula".to_string()),
source_index: 1,
data_type: None,
description: None,
});
let guarded = neutralize_spreadsheet_formulas(request);
let result = dbx_sql::data_grid_extractors::extract_data_grid_selection(guarded)
@@ -38,6 +38,8 @@ pub enum DataGridExtractorId {
Html,
Xml,
Pretty,
Dbunit,
Php,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -58,6 +60,73 @@ pub enum DataGridQuotePolicy {
#[default]
Minimal,
Never,
Strings,
AllButNumbers,
AllIncludingNulls,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DataGridHeaderPosition {
#[default]
Legacy,
None,
Top,
Bottom,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DataGridHeaderFormat {
#[default]
Name,
Description,
Both,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DataGridHeaderCase {
#[default]
Preserve,
Upper,
Lower,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DataGridArrayFormat {
#[default]
Preserve,
Braces,
Brackets,
Parentheses,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DataGridValueFormat {
#[default]
Default,
Legacy,
Display,
Edit,
Native,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DataGridNumberFormat {
#[default]
Inherit,
Native,
Display,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -69,8 +138,16 @@ pub struct DataGridDsvOptions {
pub null_text: String,
pub quote: char,
pub quote_policy: DataGridQuotePolicy,
pub quote_numeric_strings: bool,
pub include_column_header: bool,
pub include_row_header: bool,
pub header_position: DataGridHeaderPosition,
pub header_format: DataGridHeaderFormat,
pub header_case: DataGridHeaderCase,
pub escape_formulas: bool,
pub line_feed_escape_string: String,
pub array_format: DataGridArrayFormat,
pub format_numbers: DataGridNumberFormat,
}
impl Default for DataGridDsvOptions {
@@ -81,8 +158,16 @@ impl Default for DataGridDsvOptions {
null_text: DEFAULT_DSV_NULL_TEXT.to_owned(),
quote: DEFAULT_DSV_QUOTE,
quote_policy: DataGridQuotePolicy::Minimal,
quote_numeric_strings: false,
include_column_header: false,
include_row_header: false,
header_position: DataGridHeaderPosition::Legacy,
header_format: DataGridHeaderFormat::Name,
header_case: DataGridHeaderCase::Preserve,
escape_formulas: false,
line_feed_escape_string: String::new(),
array_format: DataGridArrayFormat::Preserve,
format_numbers: DataGridNumberFormat::Inherit,
}
}
}
@@ -134,6 +219,61 @@ pub struct DataGridJsonExtractorOptions {
pub values_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
pub struct DataGridDbUnitOptions {
pub table_name: String,
pub upper_case_table_name: bool,
pub upper_case_column_names: bool,
pub include_null_values: bool,
pub null_value_string: String,
}
impl Default for DataGridDbUnitOptions {
fn default() -> Self {
Self {
table_name: String::new(),
upper_case_table_name: true,
upper_case_column_names: true,
include_null_values: true,
null_value_string: "[NULL]".into(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum DataGridPhpArraySyntax {
Legacy,
#[default]
Short,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
pub struct DataGridPhpOptions {
pub array_syntax: DataGridPhpArraySyntax,
pub quote_char: char,
pub format_date_iso: bool,
pub row_delimiter: String,
pub variable_name: String,
}
impl Default for DataGridPhpOptions {
fn default() -> Self {
Self {
array_syntax: DataGridPhpArraySyntax::Short,
quote_char: '"',
format_date_iso: true,
row_delimiter: "\n".into(),
variable_name: String::new(),
}
}
}
impl Default for DataGridJsonExtractorOptions {
fn default() -> Self {
Self { pretty: DEFAULT_JSON_PRETTY, camel_case_field_names: false, values_only: false }
@@ -147,6 +287,9 @@ pub struct DataGridExtractorOptions {
pub dsv: DataGridDsvOptions,
pub sql: DataGridSqlExtractorOptions,
pub json: DataGridJsonExtractorOptions,
pub dbunit: DataGridDbUnitOptions,
pub php: DataGridPhpOptions,
pub value_format: DataGridValueFormat,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -159,6 +302,8 @@ pub struct DataGridExtractColumn {
pub source_index: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -173,6 +318,8 @@ pub struct DataGridExtractRequest {
pub identifier_quote: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub table_meta: Option<DataGridTableMeta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_table_name: Option<String>,
#[serde(default)]
pub columns: Vec<DataGridExtractColumn>,
#[serde(default)]
@@ -191,6 +338,7 @@ pub struct DataGridExtractRequest {
pub enum DataGridExtractWarningCode {
OmittedColumns,
DuplicateJsonColumnNames,
PhpDateNotIso,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -232,6 +380,7 @@ pub enum DataGridExtractErrorCode {
NoWritableColumns,
UnsupportedDatabase,
InvalidDsvConfiguration,
InvalidFormatConfiguration,
InputTooLarge,
OutputTooLarge,
EncodingFailed,
@@ -0,0 +1,823 @@
use super::{
write_bytes, DataGridExtractError, DataGridExtractErrorCode, DataGridExtractWarning, DataGridExtractWarningCode,
DataGridPhpArraySyntax, ExtractContext,
};
use crate::models::connection::DatabaseType;
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime};
use serde_json::Value;
use std::io::Write;
pub(super) fn write_dbunit(context: &ExtractContext<'_>, output: &mut dyn Write) -> Result<(), DataGridExtractError> {
let options = &context.request.options.dbunit;
let raw_name = context
.request
.source_table_name
.as_deref()
.filter(|name| !name.trim().is_empty())
.unwrap_or(&options.table_name);
let table_name = if options.upper_case_table_name { raw_name.to_uppercase() } else { raw_name.to_string() };
validate_xml_name(&table_name, "table")?;
let columns = context
.selected_columns
.iter()
.map(|column| {
let name = if options.upper_case_column_names {
column.display_name.to_uppercase()
} else {
column.display_name.clone()
};
validate_xml_name(&name, "column")?;
Ok(name)
})
.collect::<Result<Vec<_>, DataGridExtractError>>()?;
let mut names = std::collections::HashSet::new();
for name in &columns {
if !names.insert(name) {
return Err(invalid_format(format!("DbUnit column names collide after case conversion: '{name}'.")));
}
}
if options.include_null_values {
for row in &context.request.rows {
for (column, index) in context.selected_columns.iter().zip(&context.selected_source_indexes) {
let value = &row[*index];
if !value.is_null()
&& dbunit_value_text(value, is_binary_type(column.data_type.as_deref()))?
== options.null_value_string
{
return Err(invalid_format(
"A selected value matches the DbUnit NULL marker after serialization. Choose another marker before copying.",
));
}
}
}
}
write_bytes(output, b"<dataset>\n")?;
for row in &context.request.rows {
write_bytes(output, format!(" <{table_name}").as_bytes())?;
for ((column, name), index) in
context.selected_columns.iter().zip(&columns).zip(&context.selected_source_indexes)
{
let value = &row[*index];
if value.is_null() && !options.include_null_values {
continue;
}
write_bytes(output, b" ")?;
write_bytes(output, name.as_bytes())?;
write_bytes(output, b"=\"")?;
if value.is_null() {
write_xml_attribute(output, &options.null_value_string)?;
} else {
write_xml_attribute(output, &dbunit_value_text(value, is_binary_type(column.data_type.as_deref()))?)?;
}
write_bytes(output, b"\"")?;
}
write_bytes(output, b" />\n")?;
}
write_bytes(output, b"</dataset>")
}
pub(super) fn write_php(
context: &ExtractContext<'_>,
output: &mut dyn Write,
) -> Result<Vec<DataGridExtractWarning>, DataGridExtractError> {
let options = &context.request.options.php;
let source_variable = context.request.source_table_name.as_deref().filter(|name| valid_php_identifier(name));
let variable_name =
if options.variable_name.is_empty() { source_variable.unwrap_or("data") } else { &options.variable_name };
if !valid_php_identifier(variable_name) {
return Err(invalid_format(
"PHP variable name must start with a letter or underscore and contain only letters, numbers, and underscores.",
));
}
if options.row_delimiter.chars().count() > 8
|| !options.row_delimiter.chars().all(|character| character.is_ascii_whitespace())
{
return Err(invalid_format("PHP row delimiter must contain at most 8 whitespace characters."));
}
let short = options.array_syntax == DataGridPhpArraySyntax::Short;
let (open, close) = if short { ("[", "]") } else { ("array(", ")") };
let mut column_names = std::collections::HashSet::new();
for column in &context.selected_columns {
if !column_names.insert(&column.display_name) {
return Err(invalid_format(format!(
"PHP array keys are duplicated for column '{}'. Add distinct query aliases before copying.",
column.display_name
)));
}
}
write_bytes(output, format!("<?php\n${} = {}{}", variable_name, open, options.row_delimiter).as_bytes())?;
let mut invalid_date = false;
for (row_index, row) in context.request.rows.iter().enumerate() {
write_bytes(output, b" ")?;
write_bytes(output, open.as_bytes())?;
for (column_position, (column, source_index)) in
context.selected_columns.iter().zip(&context.selected_source_indexes).enumerate()
{
if column_position > 0 {
write_bytes(output, b", ")?;
}
write_php_string(output, &column.display_name, options.quote_char)?;
write_bytes(output, b" => ")?;
let value = &row[*source_index];
if !value.is_null() && is_binary_type(column.data_type.as_deref()) {
write_bytes(output, format!("base64_decode('{}', true)", base64(&binary_bytes(value)?)).as_bytes())?;
} else {
invalid_date |= write_php_value(
output,
value,
column.data_type.as_deref(),
context.request.database_type,
options.quote_char,
options.format_date_iso,
)?;
}
}
write_bytes(output, close.as_bytes())?;
let delimiter = if row_index + 1 == context.request.rows.len() {
options.row_delimiter.clone()
} else {
format!(",{}", options.row_delimiter)
};
write_bytes(output, delimiter.as_bytes())?;
}
write_bytes(output, format!("{};{}?>", if short { "]" } else { ");" }, options.row_delimiter).as_bytes())?;
Ok(if invalid_date {
vec![DataGridExtractWarning {
code: DataGridExtractWarningCode::PhpDateNotIso,
message: "Some temporal values were not valid ISO date/time text; their original text was preserved."
.to_string(),
}]
} else {
Vec::new()
})
}
fn write_php_value(
output: &mut dyn Write,
value: &Value,
data_type: Option<&str>,
database_type: Option<DatabaseType>,
quote: char,
format_date_iso: bool,
) -> Result<bool, DataGridExtractError> {
match value {
Value::Null => {
write_bytes(output, b"null")?;
Ok(false)
}
Value::Bool(value) => {
write_bytes(output, if *value { b"true" } else { b"false" })?;
Ok(false)
}
Value::Number(number) if number.as_i64().is_some_and(|value| value.unsigned_abs() <= 9_007_199_254_740_991) => {
write_bytes(output, number.to_string().as_bytes())?;
Ok(false)
}
Value::Number(number) if number.as_u64().is_some_and(|value| value <= 9_007_199_254_740_991) => {
write_bytes(output, number.to_string().as_bytes())?;
Ok(false)
}
Value::Number(number) if number.is_f64() && is_floating_type(data_type, database_type) => {
write_bytes(output, number.to_string().as_bytes())?;
Ok(false)
}
Value::Number(number) => {
write_php_string(output, &number.to_string(), quote)?;
Ok(false)
}
Value::String(text) => {
let should_format = format_date_iso && is_temporal_type(data_type);
let formatted = if should_format { iso_date(text) } else { Some(text.clone()) };
write_php_string(output, formatted.as_deref().unwrap_or(text), quote)?;
Ok(should_format && formatted.is_none())
}
Value::Array(_) | Value::Object(_) => {
write_php_string(output, &value.to_string(), quote)?;
Ok(false)
}
}
}
fn write_php_string(output: &mut dyn Write, value: &str, quote: char) -> Result<(), DataGridExtractError> {
if quote == '\'' && (value.contains('\r') || value.contains('\n')) {
// Clipboard line-ending normalization rewrites literal CR/LF bytes in the
// generated source. Keep those value bytes inside PHP escape sequences.
let mut start = 0;
for (index, character) in value.char_indices().filter(|(_, character)| matches!(character, '\r' | '\n')) {
write_php_string_literal(output, &value[start..index], quote)?;
let escaped: &[u8] = if character == '\r' { br#" . "\r" . "# } else { br#" . "\n" . "# };
write_bytes(output, escaped)?;
start = index + character.len_utf8();
}
return write_php_string_literal(output, &value[start..], quote);
}
write_php_string_literal(output, value, quote)
}
fn write_php_string_literal(output: &mut dyn Write, value: &str, quote: char) -> Result<(), DataGridExtractError> {
let mut encoded = String::with_capacity(value.len() + 2);
encoded.push(quote);
for character in value.chars() {
match character {
'\\' => encoded.push_str("\\\\"),
'\n' if quote == '"' => encoded.push_str("\\n"),
'\n' => encoded.push('\n'),
'\r' if quote == '"' => encoded.push_str("\\r"),
'\r' => encoded.push('\r'),
'\t' if quote == '"' => encoded.push_str("\\t"),
'\t' => encoded.push('\t'),
control if control.is_ascii_control() && quote == '"' => {
encoded.push_str(&format!("\\x{:02x}", control as u32))
}
control if control.is_ascii_control() => encoded.push(control),
control if control.is_control() && quote == '"' => {
let mut buffer = [0; 4];
for byte in control.encode_utf8(&mut buffer).as_bytes() {
encoded.push_str(&format!("\\x{byte:02x}"));
}
}
control if control.is_control() => encoded.push(control),
'\'' if quote == '\'' => encoded.push_str("\\'"),
'"' if quote == '"' => encoded.push_str("\\\""),
'$' if quote == '"' => encoded.push_str("\\$"),
_ => encoded.push(character),
}
}
encoded.push(quote);
write_bytes(output, encoded.as_bytes())
}
fn iso_date(value: &str) -> Option<String> {
if NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok() || NaiveTime::parse_from_str(value, "%H:%M:%S%.f").is_ok() {
return Some(value.to_owned());
}
let normalized = if value.len() > 10 && value.as_bytes().get(10) == Some(&b' ') {
format!("{}T{}", &value[..10], &value[11..])
} else {
value.to_owned()
};
if DateTime::parse_from_rfc3339(&normalized).is_ok()
|| NaiveDateTime::parse_from_str(&normalized, "%Y-%m-%dT%H:%M:%S%.f").is_ok()
{
Some(normalized)
} else {
None
}
}
fn is_temporal_type(data_type: Option<&str>) -> bool {
let ty = data_type.unwrap_or_default().to_ascii_lowercase();
["date", "time", "timestamp", "datetime"].iter().any(|part| ty.contains(part))
}
fn is_floating_type(data_type: Option<&str>, database_type: Option<DatabaseType>) -> bool {
let ty = data_type.unwrap_or_default().trim().to_ascii_lowercase();
if ty.starts_with("binary_float") || ty.starts_with("binary_double") {
return true;
}
let Some(database_type) = database_type else { return false };
if matches!(database_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle) {
return false;
}
let first = ty
.split(|character: char| !character.is_ascii_alphanumeric())
.find(|token| !token.is_empty())
.unwrap_or_default();
matches!(first, "float" | "double" | "real")
|| ["float", "double"].iter().any(|prefix| {
first
.strip_prefix(prefix)
.is_some_and(|suffix| !suffix.is_empty() && suffix.chars().all(|character| character.is_ascii_digit()))
})
}
fn is_binary_type(data_type: Option<&str>) -> bool {
let ty = data_type.unwrap_or_default().trim().to_ascii_lowercase();
if ty.starts_with("binary_float") || ty.starts_with("binary_double") {
return false;
}
["blob", "varbinary", "bytea", "raw", "image", "bytes"].iter().any(|part| ty.starts_with(part))
|| ty.starts_with("binary")
}
fn binary_text(value: &Value) -> Result<String, DataGridExtractError> {
Ok(base64(&binary_bytes(value)?))
}
fn dbunit_value_text(value: &Value, binary: bool) -> Result<String, DataGridExtractError> {
if binary {
binary_text(value)
} else {
Ok(value_text(value))
}
}
fn binary_bytes(value: &Value) -> Result<Vec<u8>, DataGridExtractError> {
let Some(text) = value.as_str() else {
return Err(invalid_format("Binary value is not in the grid's hexadecimal representation."));
};
let hex = text
.strip_prefix("0x")
.or_else(|| text.strip_prefix("0X"))
.or_else(|| text.strip_prefix("\\x"))
.or_else(|| text.strip_prefix("\\X"));
let Some(hex) = hex else {
return Ok(text.as_bytes().to_vec());
};
if hex.len() % 2 != 0 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(invalid_format("Binary value contains invalid hexadecimal data."));
}
(0..hex.len())
.step_by(2)
.map(|index| {
u8::from_str_radix(&hex[index..index + 2], 16)
.map_err(|_| invalid_format("Binary value contains invalid hexadecimal data."))
})
.collect()
}
fn base64(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let first = chunk[0];
let second = *chunk.get(1).unwrap_or(&0);
let third = *chunk.get(2).unwrap_or(&0);
output.push(TABLE[(first >> 2) as usize] as char);
output.push(TABLE[(((first & 0x03) << 4) | (second >> 4)) as usize] as char);
output.push(if chunk.len() > 1 {
TABLE[(((second & 0x0f) << 2) | (third >> 6)) as usize] as char
} else {
'='
});
output.push(if chunk.len() > 2 { TABLE[(third & 0x3f) as usize] as char } else { '=' });
}
output
}
fn value_text(value: &Value) -> String {
match value {
Value::String(value) => value.clone(),
Value::Null => String::new(),
_ => value.to_string(),
}
}
fn valid_php_identifier(value: &str) -> bool {
let mut chars = value.chars();
chars.next().is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
&& chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
}
fn validate_xml_name(value: &str, kind: &str) -> Result<(), DataGridExtractError> {
let valid_segment = |segment: &str| {
let mut chars = segment.chars();
!segment.is_empty()
&& chars.next().is_some_and(|character| character != ':' && xml_name_start(character))
&& chars.all(|character| character != ':' && xml_name_char(character))
};
// Namespaced element/attribute names need a bound xmlns prefix. The copied
// dataset has no namespace configuration, so reject colon names rather than
// emit XML that only looks valid lexically.
let valid = !value.contains(':') && valid_segment(value);
if !valid {
return Err(invalid_format(format!("DbUnit {kind} name '{value}' is not a valid XML name.")));
}
Ok(())
}
fn xml_name_start(character: char) -> bool {
matches!(character, ':' | 'A'..='Z' | '_' | 'a'..='z' | '\u{00C0}'..='\u{00D6}' | '\u{00D8}'..='\u{00F6}' | '\u{00F8}'..='\u{02FF}' | '\u{0370}'..='\u{037D}' | '\u{037F}'..='\u{1FFF}' | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | '\u{10000}'..='\u{EFFFF}')
}
fn xml_name_char(character: char) -> bool {
xml_name_start(character)
|| matches!(character, '-' | '.' | '0'..='9' | '\u{00B7}' | '\u{0300}'..='\u{036F}' | '\u{203F}'..='\u{2040}')
}
fn write_xml_attribute(output: &mut dyn Write, value: &str) -> Result<(), DataGridExtractError> {
let mut escaped = String::new();
for character in value.chars() {
match character {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
'\r' => escaped.push_str("&#13;"),
'\n' => escaped.push_str("&#10;"),
'\t' => escaped.push_str("&#9;"),
c if is_xml_char(c) => escaped.push(c),
c => {
return Err(invalid_format(format!(
"DbUnit value contains XML 1.0-invalid character U+{:04X}.",
c as u32
)));
}
}
}
write_bytes(output, escaped.as_bytes())
}
fn is_xml_char(c: char) -> bool {
matches!(c as u32, 0x9 | 0xA | 0xD | 0x20..=0xD7FF | 0xE000..=0xFFFD | 0x10000..=0x10FFFF)
}
fn invalid_format(message: impl Into<String>) -> DataGridExtractError {
DataGridExtractError::new(DataGridExtractErrorCode::InvalidFormatConfiguration, message)
}
#[cfg(test)]
mod tests {
use super::{is_temporal_type, iso_date};
use crate::data_grid_extractors::tests::request;
use crate::data_grid_extractors::{
extract_data_grid_selection, DataGridExtractColumn, DataGridExtractWarningCode, DataGridExtractorId,
};
use crate::models::connection::DatabaseType;
use serde_json::json;
#[test]
fn dbunit_writes_escaped_flat_dataset_and_distinguishes_null() {
let mut request = request(DataGridExtractorId::Dbunit);
request.source_table_name = Some("my_table".into());
request.columns[0].display_name = "a_b".into();
request.columns[0].data_type = Some("VARCHAR".into());
request.rows = vec![vec![json!("<x&>\n\t\""), json!(null)]];
request.options.dbunit.upper_case_table_name = false;
request.options.dbunit.upper_case_column_names = false;
let text = extract_data_grid_selection(request).unwrap().text;
assert_eq!(text, "<dataset>\n <my_table a_b=\"&lt;x&amp;>&#10;&#9;&quot;\" name=\"[NULL]\" />\n</dataset>");
}
#[test]
fn dbunit_writes_the_validated_column_names_in_configured_case() {
let mut request = request(DataGridExtractorId::Dbunit);
request.source_table_name = Some("users".into());
request.columns[0].display_name = "user_id".into();
request.columns[1].display_name = "displayName".into();
request.rows = vec![vec![json!(1), json!("Ada")]];
let upper = extract_data_grid_selection(request.clone()).unwrap().text;
assert!(upper.contains("<USERS USER_ID=\"1\" DISPLAYNAME=\"Ada\" />"));
request.options.dbunit.upper_case_table_name = false;
request.options.dbunit.upper_case_column_names = false;
let original_case = extract_data_grid_selection(request).unwrap().text;
assert!(original_case.contains("<users user_id=\"1\" displayName=\"Ada\" />"));
}
#[test]
fn dbunit_rejects_invalid_names_case_collisions_and_marker_conflicts() {
let mut request = request(DataGridExtractorId::Dbunit);
request.source_table_name = Some("bad name".into());
assert!(extract_data_grid_selection(request.clone()).unwrap_err().message.contains("valid XML name"));
request.source_table_name = Some("TABLE".into());
request.columns = vec![
DataGridExtractColumn {
display_name: "id".into(),
source_name: None,
source_index: 0,
data_type: None,
description: None,
},
DataGridExtractColumn {
display_name: "ID".into(),
source_name: None,
source_index: 1,
data_type: None,
description: None,
},
];
request.rows = vec![vec![json!("x"), json!("y")]];
assert!(extract_data_grid_selection(request.clone()).unwrap_err().message.contains("collide"));
request.columns.truncate(1);
request.selected_column_indexes = vec![0];
request.rows = vec![vec![json!("[NULL]")]];
assert!(extract_data_grid_selection(request).unwrap_err().message.contains("matches the DbUnit NULL marker"));
}
#[test]
fn dbunit_binary_bytes_do_not_collide_with_text_null_marker() {
let mut request = request(DataGridExtractorId::Dbunit);
request.source_table_name = Some("T".into());
request.columns.truncate(1);
request.columns[0].data_type = Some("BLOB".into());
request.selected_column_indexes = vec![0];
request.rows = vec![vec![json!("[NULL]")]];
let result = extract_data_grid_selection(request).expect("binary marker text is encoded as bytes");
assert!(result.text.contains("W05VTExd"));
}
#[test]
fn dbunit_rejects_numeric_output_matching_null_marker() {
let mut request = request(DataGridExtractorId::Dbunit);
request.source_table_name = Some("T".into());
request.columns.truncate(1);
request.columns[0].data_type = Some("INTEGER".into());
request.selected_column_indexes = vec![0];
request.rows = vec![vec![json!(1)]];
request.options.dbunit.null_value_string = "1".into();
let error = extract_data_grid_selection(request.clone()).unwrap_err();
assert!(error.message.contains("matches the DbUnit NULL marker"));
request.options.dbunit.null_value_string = "[NULL]".into();
let result = extract_data_grid_selection(request).expect("a different NULL marker permits the value");
assert!(result.text.contains("ID=\"1\""), "{}", result.text);
}
#[test]
fn dbunit_rejects_boolean_output_matching_null_marker() {
let mut request = request(DataGridExtractorId::Dbunit);
request.source_table_name = Some("T".into());
request.columns.truncate(1);
request.columns[0].data_type = Some("BOOLEAN".into());
request.selected_column_indexes = vec![0];
request.rows = vec![vec![json!(true)]];
request.options.dbunit.null_value_string = "true".into();
let error = extract_data_grid_selection(request).unwrap_err();
assert!(error.message.contains("matches the DbUnit NULL marker"));
}
#[test]
fn dbunit_rejects_binary_output_matching_null_marker() {
let mut request = request(DataGridExtractorId::Dbunit);
request.source_table_name = Some("T".into());
request.columns.truncate(1);
request.columns[0].data_type = Some("BLOB".into());
request.selected_column_indexes = vec![0];
request.rows = vec![vec![json!("0x0102")]];
request.options.dbunit.null_value_string = "AQI=".into();
let error = extract_data_grid_selection(request).unwrap_err();
assert!(error.message.contains("matches the DbUnit NULL marker"));
}
#[test]
fn dbunit_without_null_attributes_keeps_numeric_and_binary_text_values() {
let mut request = request(DataGridExtractorId::Dbunit);
request.source_table_name = Some("T".into());
request.columns[0].data_type = Some("INTEGER".into());
request.columns[1].data_type = Some("BLOB".into());
request.selected_column_indexes = vec![0, 1];
request.rows = vec![vec![json!(1), json!("[NULL]")]];
request.options.dbunit.include_null_values = false;
request.options.dbunit.null_value_string = "1".into();
let result = extract_data_grid_selection(request).expect("non-NULL values do not collide after serialization");
assert!(result.text.contains("ID=\"1\""), "{}", result.text);
assert!(result.text.contains("NAME=\"W05VTExd\""), "{}", result.text);
}
#[test]
fn php_and_dbunit_keep_oracle_binary_float_values_numeric_not_binary() {
let columns = vec!["BINARY_FLOAT", "BINARY_DOUBLE"]
.into_iter()
.enumerate()
.map(|(source_index, display_name)| DataGridExtractColumn {
display_name: display_name.into(),
source_name: None,
source_index,
data_type: Some(display_name.into()),
description: None,
})
.collect::<Vec<_>>();
let mut php = request(DataGridExtractorId::Php);
php.columns = columns.clone();
php.selected_column_indexes = vec![0, 1];
php.database_type = Some(DatabaseType::Oracle);
php.rows = vec![vec![json!(1.5), json!(2.25)]];
let php_text = extract_data_grid_selection(php).expect("PHP binary-float extraction").text;
assert!(php_text.contains("\"BINARY_FLOAT\" => 1.5"), "{php_text}");
assert!(php_text.contains("\"BINARY_DOUBLE\" => 2.25"), "{php_text}");
let mut dbunit = request(DataGridExtractorId::Dbunit);
dbunit.source_table_name = Some("T".into());
dbunit.columns = columns;
dbunit.selected_column_indexes = vec![0, 1];
dbunit.rows = vec![vec![json!(1.5), json!(2.25)]];
let dbunit_text = extract_data_grid_selection(dbunit).expect("DbUnit binary-float extraction").text;
assert!(dbunit_text.contains("BINARY_FLOAT=\"1.5\""), "{dbunit_text}");
assert!(dbunit_text.contains("BINARY_DOUBLE=\"2.25\""), "{dbunit_text}");
}
#[test]
fn php_and_dbunit_keep_byte_types_in_binary_encoding_path() {
let columns = ["BINARY", "VARBINARY", "RAW", "BLOB"]
.into_iter()
.enumerate()
.map(|(source_index, data_type)| DataGridExtractColumn {
display_name: format!("C{source_index}"),
source_name: None,
source_index,
data_type: Some(data_type.into()),
description: None,
})
.collect::<Vec<_>>();
let mut php = request(DataGridExtractorId::Php);
php.columns = columns.clone();
php.selected_column_indexes = vec![0, 1, 2, 3];
php.rows = vec![vec![json!("0x0102"), json!("0x0102"), json!("0x0102"), json!("0x0102")]];
let php_text = extract_data_grid_selection(php).expect("PHP byte extraction").text;
assert_eq!(php_text.matches("base64_decode('AQI=', true)").count(), 4, "{php_text}");
let mut dbunit = request(DataGridExtractorId::Dbunit);
dbunit.source_table_name = Some("T".into());
dbunit.columns = columns;
dbunit.selected_column_indexes = vec![0, 1, 2, 3];
dbunit.rows = vec![vec![json!("0x0102"), json!("0x0102"), json!("0x0102"), json!("0x0102")]];
let dbunit_text = extract_data_grid_selection(dbunit).expect("DbUnit byte extraction").text;
assert_eq!(dbunit_text.matches("AQI=").count(), 4, "{dbunit_text}");
}
#[test]
fn php_uses_valid_literals_and_escapes_interpolation() {
let mut request = request(DataGridExtractorId::Php);
request.columns[0].display_name = "key$".into();
request.rows = vec![vec![json!("$x\\\"\n\u{0085}"), json!(9007199254740993_u64)]];
let text = extract_data_grid_selection(request).unwrap().text;
assert!(text.contains("\\$x\\\\\\\"\\n"));
assert!(text.contains("\\xc2\\x85"));
assert!(text.contains("\"9007199254740993\""));
assert!(text.starts_with("<?php\n$data = [\n"));
}
#[test]
fn php_single_quoted_line_breaks_survive_windows_clipboard_normalization() {
let mut request = request(DataGridExtractorId::Php);
request.columns.truncate(1);
request.selected_column_indexes = vec![0];
request.rows = vec![vec![json!("a'b\nc\rd")]];
request.options.php.quote_char = '\'';
request.options.php.row_delimiter = "\r\n".into();
let result = extract_data_grid_selection(request).expect("PHP extraction");
let value_expression = "'a\\'b' . \"\\n\" . 'c' . \"\\r\" . 'd'";
assert!(result.text.contains(value_expression), "{}", result.text);
// Same transformation as clipboardLineEndings on Windows: PHP code's
// physical line endings become CRLF, while value escapes remain intact.
let copied_text = result.text.replace("\r\n", "\n").replace('\r', "\n").replace('\n', "\r\n");
assert!(copied_text.contains(value_expression), "{copied_text}");
assert!(!copied_text.contains("'a\\'b\\r\\nc\\r\\nd'"), "{copied_text}");
}
#[test]
fn php_preserves_floating_point_json_numbers_as_numbers_and_large_integers_as_strings() {
let mut request = request(DataGridExtractorId::Php);
request.columns = vec![
DataGridExtractColumn {
display_name: "float".into(),
source_name: None,
source_index: 0,
data_type: Some("DOUBLE".into()),
description: None,
},
DataGridExtractColumn {
display_name: "bigint".into(),
source_name: None,
source_index: 1,
data_type: Some("BIGINT".into()),
description: None,
},
];
request.selected_column_indexes = vec![0, 1];
request.rows = vec![vec![json!(1.5), json!(9007199254740993_u64)]];
let text = extract_data_grid_selection(request).unwrap().text;
assert!(text.contains("\"float\" => 1.5"), "{text}");
assert!(text.contains("\"bigint\" => \"9007199254740993\""), "{text}");
}
#[test]
fn php_keeps_arbitrary_precision_decimal_numbers_as_strings() {
let mut request = request(DataGridExtractorId::Php);
request.columns.truncate(1);
request.selected_column_indexes = vec![0];
request.columns[0].data_type = Some("DECIMAL".into());
request.rows = vec![vec![serde_json::from_str("0.1234567890123456789").unwrap()]];
let text = extract_data_grid_selection(request).unwrap().text;
assert!(text.contains("\"0.1234567890123456789\""), "{text}");
}
#[test]
fn php_preserves_oracle_float_numeric_precision_as_text() {
let mut request = request(DataGridExtractorId::Php);
request.columns.truncate(1);
request.selected_column_indexes = vec![0];
request.columns[0].data_type = Some("FLOAT".into());
request.database_type = Some(DatabaseType::Oracle);
request.rows = vec![vec![serde_json::from_str("0.1234567890123456789").unwrap()]];
let text = extract_data_grid_selection(request).unwrap().text;
assert!(text.contains("\"0.1234567890123456789\""), "{text}");
}
#[test]
fn php_keeps_float_values_as_strings_when_database_type_is_unknown() {
let mut request = request(DataGridExtractorId::Php);
request.columns.truncate(1);
request.selected_column_indexes = vec![0];
request.columns[0].data_type = Some("FLOAT".into());
request.database_type = None;
request.rows = vec![vec![serde_json::from_str("1.5").unwrap()]];
let text = extract_data_grid_selection(request).unwrap().text;
assert!(text.contains("\"1.5\""), "{text}");
}
#[test]
fn php_rejects_non_whitespace_row_delimiters() {
let mut invalid_request = request(DataGridExtractorId::Php);
invalid_request.options.php.row_delimiter = ";".into();
assert!(extract_data_grid_selection(invalid_request).unwrap_err().message.contains("whitespace"));
let mut request = request(DataGridExtractorId::Php);
request.options.php.row_delimiter.clear();
let text = extract_data_grid_selection(request).unwrap().text;
assert!(text.starts_with("<?php\n"));
assert!(text.ends_with("?>"));
}
#[test]
fn php_date_iso_preserves_valid_precision_and_warns_for_unparseable_text() {
assert_eq!(iso_date("2026-01-02 03:04:05.123456+08:00"), Some("2026-01-02T03:04:05.123456+08:00".into()));
assert!(is_temporal_type(Some("TIMESTAMP WITH TIME ZONE")));
let mut request = request(DataGridExtractorId::Php);
request.columns = vec![DataGridExtractColumn {
display_name: "created_at".into(),
source_name: None,
source_index: 0,
data_type: Some("TIMESTAMP WITH TIME ZONE".into()),
description: None,
}];
request.selected_column_indexes = vec![0];
request.rows = vec![vec![json!("2026-01-02 03:04:05.123456+08:00")], vec![json!("not a date")]];
request.options.php.format_date_iso = true;
let result = extract_data_grid_selection(request).expect("PHP extraction");
assert!(result.text.contains("2026-01-02T03:04:05.123456+08:00"), "{}", result.text);
assert!(result.text.contains("not a date"));
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].code, DataGridExtractWarningCode::PhpDateNotIso);
}
#[test]
fn php_request_accepts_the_frontend_camel_case_date_option() {
let request: crate::data_grid_extractors::DataGridExtractRequest = serde_json::from_value(json!({
"version": 1,
"extractor": "php",
"columns": [],
"selectedColumnIndexes": [],
"rows": [],
"selectionKind": "rows",
"options": {
"dsv": {
"columnSeparator": ",",
"rowSeparator": "\n",
"nullText": "",
"quote": "\"",
"quotePolicy": "minimal",
"quoteNumericStrings": false,
"includeColumnHeader": false,
"includeRowHeader": false,
"headerPosition": "legacy",
"headerFormat": "name",
"headerCase": "preserve",
"escapeFormulas": false,
"lineFeedEscapeString": "",
"arrayFormat": "preserve",
"formatNumbers": "inherit"
},
"sql": {
"skipComputedColumns": true,
"skipGeneratedColumns": true,
"insertMode": "merged",
"primaryKeyMode": "include",
"includeDatabaseName": false,
"quoteIdentifiers": true,
"temporalFormat": "native"
},
"json": { "pretty": true, "camelCaseFieldNames": false, "valuesOnly": false },
"dbunit": {
"tableName": "",
"upperCaseTableName": true,
"upperCaseColumnNames": true,
"includeNullValues": true,
"nullValueString": "[NULL]"
},
"php": {
"arraySyntax": "short",
"quoteChar": "\"",
"formatDateIso": true,
"rowDelimiter": "\n",
"variableName": ""
},
"valueFormat": "default"
}
}))
.expect("frontend PHP options deserialize");
assert!(request.options.php.format_date_iso);
}
}
@@ -1,6 +1,6 @@
use super::{
value_text, write_bytes, DataGridDsvOptions, DataGridExtractError, DataGridExtractErrorCode, DataGridQuotePolicy,
ExtractContext,
value_text, write_bytes, DataGridArrayFormat, DataGridDsvOptions, DataGridExtractError, DataGridExtractErrorCode,
DataGridHeaderCase, DataGridHeaderFormat, DataGridHeaderPosition, DataGridQuotePolicy, ExtractContext,
};
use std::borrow::Cow;
use std::io::Write;
@@ -13,7 +13,7 @@ const MAX_NULL_TEXT_CHARACTERS: usize = 64;
/// string value that happens to equal it.
enum DsvCell<'a> {
Null,
Text(Cow<'a, str>),
Text(Cow<'a, str>, bool, bool),
}
pub(super) fn write_dsv(
@@ -23,11 +23,16 @@ pub(super) fn write_dsv(
quote_char_triggers_quoting: bool,
) -> Result<(), DataGridExtractError> {
validate_dsv_options(options, true)?;
let header_position = match options.header_position {
DataGridHeaderPosition::Top | DataGridHeaderPosition::Bottom => Some(options.header_position),
DataGridHeaderPosition::Legacy if options.include_column_header => Some(DataGridHeaderPosition::Top),
DataGridHeaderPosition::Legacy | DataGridHeaderPosition::None => None,
};
let mut first_row = true;
if options.include_column_header {
if header_position == Some(DataGridHeaderPosition::Top) {
write_dsv_row(
output,
context.selected_columns.iter().map(|column| Cow::Borrowed(column.display_name.as_str())),
context.selected_columns.iter().map(|column| Cow::Owned(header_label(column, options))),
options,
options.include_row_header.then(|| "#".to_string()),
quote_char_triggers_quoting,
@@ -40,12 +45,28 @@ pub(super) fn write_dsv(
}
write_dsv_data_row(
output,
context.selected_source_indexes.iter().map(|source_index| {
context.selected_source_indexes.iter().zip(&context.selected_columns).map(|(source_index, column)| {
let value = &row[*source_index];
if value.is_null() {
DsvCell::Null
} else {
DsvCell::Text(value_text(value))
let is_numeric_string =
value.as_str().is_some_and(|text| !text.is_empty() && text.parse::<f64>().is_ok());
let numeric_type = is_numeric_type(column.data_type.as_deref());
let mut text = format_array_value(
value_text(value).into_owned(),
value,
column.data_type.as_deref(),
options.array_format,
);
text = replace_line_feeds(text, &options.line_feed_escape_string);
if options.escape_formulas
&& starts_formula(&text)
&& !(numeric_type && text.parse::<f64>().is_ok())
{
text.insert(0, '\'');
}
DsvCell::Text(Cow::Owned(text), numeric_type, is_numeric_string)
}
}),
options,
@@ -54,9 +75,125 @@ pub(super) fn write_dsv(
)?;
first_row = false;
}
if header_position == Some(DataGridHeaderPosition::Bottom) {
if !first_row {
write_bytes(output, options.row_separator.as_bytes())?;
}
write_dsv_row(
output,
context.selected_columns.iter().map(|column| Cow::Owned(header_label(column, options))),
options,
options.include_row_header.then(|| "#".to_string()),
quote_char_triggers_quoting,
)?;
}
Ok(())
}
fn header_label(column: &super::DataGridExtractColumn, options: &DataGridDsvOptions) -> String {
let label = match options.header_format {
DataGridHeaderFormat::Name => column.display_name.clone(),
DataGridHeaderFormat::Description => {
column.description.as_deref().filter(|text| !text.is_empty()).unwrap_or(&column.display_name).to_owned()
}
DataGridHeaderFormat::Both => column
.description
.as_deref()
.filter(|text| !text.is_empty())
.map(|description| format!("{} ({description})", column.display_name))
.unwrap_or_else(|| column.display_name.clone()),
};
let mut label = match options.header_case {
DataGridHeaderCase::Preserve => label,
DataGridHeaderCase::Upper => label.to_uppercase(),
DataGridHeaderCase::Lower => label.to_lowercase(),
};
label = replace_line_feeds(label, &options.line_feed_escape_string);
if options.escape_formulas && starts_formula(&label) {
label.insert(0, '\'');
}
label
}
fn is_numeric_type(data_type: Option<&str>) -> bool {
let ty = data_type.unwrap_or_default().trim().to_ascii_lowercase();
let tokens: Vec<_> =
ty.split(|character: char| !character.is_ascii_alphanumeric()).filter(|token| !token.is_empty()).collect();
let Some(first) = tokens.first().copied() else { return false };
let exact = [
"int",
"integer",
"smallint",
"bigint",
"tinyint",
"mediumint",
"decimal",
"numeric",
"number",
"float",
"double",
"real",
"serial",
"bigserial",
"smallserial",
"money",
"smallmoney",
"dec",
"fixed",
];
exact.contains(&first)
|| tokens.contains(&"unsigned")
|| ["int", "uint", "float", "double", "decimal", "numeric", "number", "serial"].iter().any(|prefix| {
first
.strip_prefix(prefix)
.is_some_and(|suffix| !suffix.is_empty() && suffix.chars().all(|character| character.is_ascii_digit()))
})
}
fn starts_formula(value: &str) -> bool {
value
.chars()
.next()
.is_some_and(|first| matches!(first, '=' | '+' | '-' | '@' | '=' | '+' | '-' | '@' | '\t' | '\r' | '\n'))
}
fn replace_line_feeds(mut value: String, replacement: &str) -> String {
if replacement.is_empty() {
return value;
}
value = value.replace("\r\n", replacement).replace('\n', replacement);
value
}
fn format_array_value(
mut text: String,
value: &serde_json::Value,
data_type: Option<&str>,
format: DataGridArrayFormat,
) -> String {
if format == DataGridArrayFormat::Preserve {
return text;
}
let is_array = value.is_array() || data_type.unwrap_or_default().to_ascii_lowercase().contains("array");
if !is_array {
return text;
}
let (open, close) = match format {
DataGridArrayFormat::Braces => ('{', '}'),
DataGridArrayFormat::Brackets => ('[', ']'),
DataGridArrayFormat::Parentheses => ('(', ')'),
DataGridArrayFormat::Preserve => return text,
};
let first = text.chars().next();
let last = text.chars().last();
if matches!((first, last), (Some('['), Some(']')) | (Some('{'), Some('}')) | (Some('('), Some(')'))) {
text.replace_range(..1, &open.to_string());
let last_index = text.len() - 1;
text.replace_range(last_index.., &close.to_string());
}
text
}
/// Writes a row of header strings (column names / row-number header). Headers
/// are never NULL, so they use the plain string field writer.
fn write_dsv_row<'a>(
@@ -152,6 +289,9 @@ fn write_dsv_field(
let should_quote = match options.quote_policy {
DataGridQuotePolicy::Always => true,
DataGridQuotePolicy::Never => false,
DataGridQuotePolicy::Strings | DataGridQuotePolicy::AllButNumbers | DataGridQuotePolicy::AllIncludingNulls => {
true
}
DataGridQuotePolicy::Minimal => dsv_needs_quote(value, options, quote_char_triggers_quoting),
};
if !should_quote {
@@ -172,13 +312,26 @@ fn write_dsv_data_field(
quote_char_triggers_quoting: bool,
) -> Result<(), DataGridExtractError> {
match cell {
DsvCell::Null => write_bytes(output, options.null_text.as_bytes()),
DsvCell::Text(value) => {
DsvCell::Null => match options.quote_policy {
DataGridQuotePolicy::AllIncludingNulls => write_quoted_dsv_field(output, &options.null_text, options),
_ => write_bytes(output, options.null_text.as_bytes()),
},
DsvCell::Text(value, numeric_type, numeric_string) => {
let should_quote = match options.quote_policy {
DataGridQuotePolicy::Always => true,
DataGridQuotePolicy::Never => false,
DataGridQuotePolicy::Strings => {
!numeric_type
|| (options.quote_numeric_strings && *numeric_string)
|| dsv_needs_quote(value, options, quote_char_triggers_quoting)
}
DataGridQuotePolicy::AllButNumbers => {
!numeric_type || dsv_needs_quote(value, options, quote_char_triggers_quoting)
}
DataGridQuotePolicy::AllIncludingNulls => true,
DataGridQuotePolicy::Minimal => {
dsv_needs_quote(value, options, quote_char_triggers_quoting)
|| (options.quote_numeric_strings && *numeric_string)
|| value.as_ref() == options.null_text.as_str()
}
};
@@ -193,21 +346,24 @@ fn write_dsv_data_field(
pub(super) fn write_one_row(context: &ExtractContext<'_>, output: &mut dyn Write) -> Result<(), DataGridExtractError> {
let options = DataGridDsvOptions { column_separator: ",".to_string(), ..context.request.options.dsv.clone() };
validate_dsv_options(&options, false)?;
let values = context
.request
.rows
.iter()
.flat_map(|row| context.selected_source_indexes.iter().map(move |source_index| &row[*source_index]));
let mut first = true;
for value in values {
if !first {
write_bytes(output, options.column_separator.as_bytes())?;
for row in &context.request.rows {
for (source_index, column) in context.selected_source_indexes.iter().zip(&context.selected_columns) {
if !first {
write_bytes(output, options.column_separator.as_bytes())?;
}
let value = &row[*source_index];
let cell = if value.is_null() {
DsvCell::Null
} else {
let numeric_string = value.as_str().is_some_and(|text| !text.is_empty() && text.parse::<f64>().is_ok());
DsvCell::Text(value_text(value), is_numeric_type(column.data_type.as_deref()), numeric_string)
};
// A one-row extraction is an RFC4180 CSV record, so embedded quotes must
// be escaped.
write_dsv_data_field(output, &cell, &options, true)?;
first = false;
}
let cell = if value.is_null() { DsvCell::Null } else { DsvCell::Text(value_text(value)) };
// A one-row extraction is an RFC4180 CSV record, so embedded quotes must
// be escaped.
write_dsv_data_field(output, &cell, &options, true)?;
first = false;
}
Ok(())
}
@@ -222,10 +378,11 @@ fn validate_dsv_options(options: &DataGridDsvOptions, uses_row_separator: bool)
if options.column_separator.chars().count() > MAX_SEPARATOR_CHARACTERS
|| options.row_separator.chars().count() > MAX_SEPARATOR_CHARACTERS
|| options.null_text.chars().count() > MAX_NULL_TEXT_CHARACTERS
|| options.line_feed_escape_string.chars().count() > 32
{
return Err(DataGridExtractError::new(
DataGridExtractErrorCode::InvalidDsvConfiguration,
"DSV separators may contain at most 8 characters and NULL text at most 64 characters.",
"DSV separators may contain at most 8 characters, NULL text at most 64 characters, and line-feed replacement at most 32 characters.",
));
}
if uses_row_separator
@@ -3,6 +3,7 @@ use std::borrow::Cow;
use std::io::{self, Write};
mod contract;
mod copy_formats;
mod delimited;
mod document;
mod json;
@@ -11,6 +12,7 @@ mod sql;
use crate::data_grid_sql::unique_column_info_match;
pub use contract::*;
use copy_formats::{write_dbunit, write_php};
use delimited::{write_dsv, write_one_row};
use document::{write_html, write_markdown, write_pretty, write_xml};
use json::{write_json, write_json_lines};
@@ -167,10 +169,17 @@ fn validate_request_budget(request: &DataGridExtractRequest) -> Result<(), DataG
.fold(0usize, |size, column| {
size.saturating_add(column.display_name.len())
.saturating_add(column.source_name.as_deref().map_or(0, str::len))
.saturating_add(column.description.as_deref().map_or(0, str::len))
})
.saturating_add(request.options.dsv.column_separator.len())
.saturating_add(request.options.dsv.row_separator.len())
.saturating_add(request.options.dsv.null_text.len())
.saturating_add(request.options.dsv.line_feed_escape_string.len())
.saturating_add(request.source_table_name.as_deref().map_or(0, str::len))
.saturating_add(request.options.dbunit.table_name.len())
.saturating_add(request.options.dbunit.null_value_string.len())
.saturating_add(request.options.php.variable_name.len())
.saturating_add(request.options.php.row_delimiter.len())
.saturating_add(estimated_table_meta_bytes(request.table_meta.as_ref()));
if estimated_bytes > DATA_GRID_EXTRACTOR_MAX_INPUT_BYTES {
return Err(input_too_large_error());
@@ -259,11 +268,16 @@ fn write_extraction(
Ok(text_metadata("text/plain", "txt"))
}
DataGridExtractorId::Tsv | DataGridExtractorId::TsvWithHeaders => {
let options = DataGridDsvOptions {
column_separator: "\t".to_string(),
include_column_header: extractor == DataGridExtractorId::TsvWithHeaders,
..context.request.options.dsv.clone()
};
let mut options = context.request.options.dsv.clone();
options.column_separator = "\t".to_string();
options.include_column_header = extractor == DataGridExtractorId::TsvWithHeaders;
options.quote_numeric_strings = false;
options.header_position = DataGridHeaderPosition::Legacy;
options.header_format = DataGridHeaderFormat::Name;
options.header_case = DataGridHeaderCase::Preserve;
options.escape_formulas = false;
options.line_feed_escape_string.clear();
options.array_format = DataGridArrayFormat::Preserve;
// TSV is split on the tab separator with no quote state machine, so a
// value that merely contains a double quote must not be escaped.
write_dsv(context, output, &options, false)?;
@@ -272,15 +286,23 @@ fn write_extraction(
DataGridExtractorId::Csv | DataGridExtractorId::CsvWithHeaders => {
let options = DataGridDsvOptions {
column_separator: ",".to_string(),
include_column_header: extractor == DataGridExtractorId::CsvWithHeaders,
include_column_header: extractor == DataGridExtractorId::CsvWithHeaders
&& context.request.options.dsv.header_position == DataGridHeaderPosition::Legacy,
..context.request.options.dsv.clone()
};
write_dsv(context, output, &options, true)?;
Ok(text_metadata("text/csv", "csv"))
}
DataGridExtractorId::PipeSeparated => {
let options =
DataGridDsvOptions { column_separator: "|".to_string(), ..context.request.options.dsv.clone() };
let mut options = context.request.options.dsv.clone();
options.column_separator = "|".to_string();
options.quote_numeric_strings = false;
options.header_position = DataGridHeaderPosition::Legacy;
options.header_format = DataGridHeaderFormat::Name;
options.header_case = DataGridHeaderCase::Preserve;
options.escape_formulas = false;
options.line_feed_escape_string.clear();
options.array_format = DataGridArrayFormat::Preserve;
write_dsv(context, output, &options, false)?;
Ok(text_metadata("text/plain", "txt"))
}
@@ -341,6 +363,14 @@ fn write_extraction(
write_pretty(context, output)?;
Ok(text_metadata("text/plain", "txt"))
}
DataGridExtractorId::Dbunit => {
write_dbunit(context, output)?;
Ok(text_metadata("application/xml", "xml"))
}
DataGridExtractorId::Php => {
let warnings = write_php(context, output)?;
Ok(WriteMetadata { mime_type: "text/x-php", file_extension: "php", omitted_columns: Vec::new(), warnings })
}
}
}
@@ -9,16 +9,18 @@ fn column(name: &str, source_index: usize) -> DataGridExtractColumn {
source_name: Some(name.to_string()),
source_index,
data_type: None,
description: None,
}
}
fn request(extractor: DataGridExtractorId) -> DataGridExtractRequest {
pub(super) fn request(extractor: DataGridExtractorId) -> DataGridExtractRequest {
DataGridExtractRequest {
version: DATA_GRID_EXTRACTOR_CONTRACT_VERSION,
extractor,
database_type: Some(DatabaseType::Postgres),
identifier_quote: None,
table_meta: None,
source_table_name: None,
columns: vec![column("id", 0), column("name", 1)],
selected_column_indexes: vec![0, 1],
rows: vec![vec![json!(1), json!("Ada")], vec![json!(2), json!("Grace, Hopper")]],
@@ -110,8 +112,10 @@ fn query_insert_result_types_preserve_metadata_priority_and_unknown_strings() {
#[test]
fn sql_insert_temporal_format_contract_and_output() {
let native = temporal_insert_request();
assert_eq!(extract_data_grid_selection(native.clone()).unwrap().text,
"INSERT INTO \"APP\".\"EVENTS\" (\"CREATED_AT\", \"NOTE\", \"AMOUNT\") VALUES (TO_DATE('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS'), 'it''s text', 42);");
assert_eq!(
extract_data_grid_selection(native.clone()).unwrap().text,
"INSERT INTO \"APP\".\"EVENTS\" (\"CREATED_AT\", \"NOTE\", \"AMOUNT\") VALUES (TO_DATE('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS'), 'it''s text', 42);"
);
let mut payload = serde_json::to_value(&native).unwrap();
payload["options"]["sql"]["quoteIdentifiers"] = json!(false);
payload["options"]["sql"]["temporalFormat"] = json!("string");
@@ -459,6 +463,175 @@ fn extracts_csv_with_minimal_standard_quoting() {
assert_eq!(result.text, "1,Ada\n2,\"Grace, Hopper\"");
}
#[test]
fn csv_supports_bottom_description_headers_case_and_quote_modes() {
let mut request = request(DataGridExtractorId::Csv);
request.columns[0].description = Some("identifier".into());
request.options.dsv.header_position = DataGridHeaderPosition::Bottom;
request.options.dsv.header_format = DataGridHeaderFormat::Both;
request.options.dsv.header_case = DataGridHeaderCase::Upper;
let result = extract_data_grid_selection(request).expect("CSV extraction");
assert_eq!(result.text, "1,Ada\n2,\"Grace, Hopper\"\nID (IDENTIFIER),NAME");
}
#[test]
fn csv_applies_formula_line_feed_array_and_typed_quote_options() {
let mut request = request(DataGridExtractorId::Csv);
request.columns = vec![column("amount", 0), column("text", 1), column("items", 2)];
request.columns[0].data_type = Some("INTEGER".into());
request.columns[1].data_type = Some("VARCHAR".into());
request.columns[2].data_type = Some("JSON_ARRAY".into());
request.selected_column_indexes = vec![0, 1, 2];
request.rows =
vec![vec![json!(-1), json!("=SUM(A1)\r\nnext"), json!(["a", "b"])], vec![json!(2), json!("001"), json!(["c"])]];
request.options.dsv.escape_formulas = true;
request.options.dsv.line_feed_escape_string = "\\n".into();
request.options.dsv.array_format = DataGridArrayFormat::Braces;
request.options.dsv.quote_policy = DataGridQuotePolicy::Strings;
request.options.dsv.quote_numeric_strings = true;
let result = extract_data_grid_selection(request).expect("CSV extraction");
assert_eq!(result.text, "-1,\"'=SUM(A1)\\nnext\",\"{\"\"a\"\",\"\"b\"\"}\"\n2,\"001\",\"{\"\"c\"\"}\"");
}
#[test]
fn csv_type_quote_policies_still_quote_values_that_contain_delimiters() {
let mut request = request(DataGridExtractorId::Csv);
request.columns =
vec![column("comma_amount", 0), column("newline_amount", 1), column("quote_amount", 2), column("name", 3)];
for column in &mut request.columns[..3] {
column.data_type = Some("DECIMAL".into());
}
request.columns[3].data_type = Some("VARCHAR".into());
request.selected_column_indexes = vec![0, 1, 2, 3];
request.rows = vec![vec![json!("1,234"), json!("1\n234"), json!("1\"234"), json!("Ada")]];
request.options.dsv.quote_policy = DataGridQuotePolicy::AllButNumbers;
let result = extract_data_grid_selection(request).expect("CSV extraction");
assert_eq!(result.text, "\"1,234\",\"1\n234\",\"1\"\"234\",\"Ada\"");
assert_eq!(parse_csv_record(&result.text), vec!["1,234", "1\n234", "1\"234", "Ada"]);
}
#[test]
fn one_row_keeps_numeric_types_for_typed_quote_policies_and_quotes_string_columns() {
let mut request = request(DataGridExtractorId::OneRow);
request.columns = vec![column("amount", 0), column("name", 1), column("code", 2)];
request.columns[0].data_type = Some("INTEGER".into());
request.columns[1].data_type = Some("VARCHAR".into());
request.columns[2].data_type = Some("VARCHAR".into());
request.selected_column_indexes = vec![0, 1, 2];
request.rows = vec![vec![json!(42), json!("Ada"), json!("001")]];
request.options.dsv.quote_policy = DataGridQuotePolicy::Strings;
request.options.dsv.quote_numeric_strings = true;
let result = extract_data_grid_selection(request).expect("one-row extraction");
assert_eq!(result.text, "42,\"Ada\",\"001\"");
assert_eq!(parse_csv_record(&result.text), vec!["42", "Ada", "001"]);
}
#[test]
fn clickhouse_uint_columns_are_treated_as_numeric() {
let mut request = request(DataGridExtractorId::Csv);
request.columns.truncate(1);
request.selected_column_indexes = vec![0];
request.columns[0].data_type = Some("UInt8".into());
request.rows = vec![vec![json!(255)]];
request.options.dsv.quote_policy = DataGridQuotePolicy::AllButNumbers;
assert_eq!(extract_data_grid_selection(request).unwrap().text, "255");
}
#[test]
fn csv_strings_policy_preserves_formula_tabs_arrays_and_large_numeric_text() {
let mut request = request(DataGridExtractorId::Csv);
request.columns = vec![column("formula", 0), column("items", 1), column("id", 2)];
request.columns[0].data_type = Some("VARCHAR".into());
request.columns[1].data_type = Some("JSON_ARRAY".into());
request.columns[2].data_type = Some("BIGINT".into());
request.selected_column_indexes = vec![0, 1, 2];
request.rows = vec![vec![json!("\t=1+1"), json!(["a", "b\"c"]), json!("9007199254740993")]];
request.options.dsv.quote_policy = DataGridQuotePolicy::Strings;
request.options.dsv.quote_numeric_strings = true;
request.options.dsv.escape_formulas = true;
let result = extract_data_grid_selection(request).expect("CSV extraction");
assert_eq!(parse_csv_record(&result.text), vec!["'\t=1+1", r#"["a","b\"c"]"#, "9007199254740993"]);
}
#[test]
fn typed_quote_policy_recognizes_decimal_aliases_as_numbers() {
for data_type in ["DEC", "FIXED", "SMALLMONEY"] {
let mut request = request(DataGridExtractorId::Csv);
request.columns = vec![column("amount", 0)];
request.columns[0].data_type = Some(data_type.into());
request.selected_column_indexes = vec![0];
request.rows = vec![vec![json!("1")]];
request.options.dsv.quote_policy = DataGridQuotePolicy::Strings;
assert_eq!(extract_data_grid_selection(request).unwrap().text, "1", "{data_type}");
}
}
fn parse_csv_record(record: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut field = String::new();
let mut chars = record.chars().peekable();
let mut quoted = false;
while let Some(character) = chars.next() {
match character {
'"' if quoted && chars.peek() == Some(&'"') => {
chars.next();
field.push('"');
}
'"' => quoted = !quoted,
',' if !quoted => fields.push(std::mem::take(&mut field)),
_ => field.push(character),
}
}
fields.push(field);
fields
}
#[test]
fn csv_formula_protection_covers_fullwidth_prefixes_but_not_typed_negative_numbers() {
let mut request = request(DataGridExtractorId::Csv);
request.columns = vec![column("amount", 0), column("formula", 1)];
request.columns[0].data_type = Some("DECIMAL".into());
request.columns[1].data_type = Some("TEXT".into());
request.selected_column_indexes = vec![0, 1];
request.rows = vec![vec![json!(-123), json!("=1+1")]];
request.options.dsv.escape_formulas = true;
let result = extract_data_grid_selection(request).expect("CSV extraction");
assert_eq!(result.text, "-123,'=1+1");
}
#[test]
fn tsv_keeps_legacy_headers_and_does_not_apply_csv_only_options() {
let mut request = request(DataGridExtractorId::TsvWithHeaders);
request.columns = vec![column("=header", 0), column("value", 1)];
request.selected_column_indexes = vec![0, 1];
request.rows = vec![vec![json!("=SUM(A1)"), json!("line1\nline2")]];
request.options.dsv.header_position = DataGridHeaderPosition::Bottom;
request.options.dsv.escape_formulas = true;
request.options.dsv.line_feed_escape_string = "\\n".into();
let result = extract_data_grid_selection(request).expect("TSV extraction");
assert_eq!(result.text, "=header\tvalue\n=SUM(A1)\t\"line1\nline2\"");
}
#[test]
fn formula_escape_does_not_misclassify_interval_as_numeric() {
let mut request = request(DataGridExtractorId::Csv);
request.columns = vec![column("duration", 0)];
request.columns[0].data_type = Some("INTERVAL".into());
request.selected_column_indexes = vec![0];
request.rows = vec![vec![json!("-1 day")]];
request.options.dsv.escape_formulas = true;
let result = extract_data_grid_selection(request).expect("CSV extraction");
assert_eq!(result.text, "'-1 day");
}
#[test]
fn extracts_raw_values_without_escaping_quotes() {
let mut request = request(DataGridExtractorId::Raw);
@@ -608,10 +781,7 @@ fn extracts_all_remaining_text_table_formats() {
(DataGridExtractorId::TsvWithHeaders, "id\tname\n1\tAda\n2\tGrace, Hopper"),
(DataGridExtractorId::CsvWithHeaders, "id,name\n1,Ada\n2,\"Grace, Hopper\""),
(DataGridExtractorId::PipeSeparated, "1|Ada\n2|Grace, Hopper"),
(
DataGridExtractorId::Markdown,
"| id | name |\n| --- | --- |\n| 1 | Ada |\n| 2 | Grace, Hopper |",
),
(DataGridExtractorId::Markdown, "| id | name |\n| --- | --- |\n| 1 | Ada |\n| 2 | Grace, Hopper |"),
(
DataGridExtractorId::Pretty,
"+----+---------------+\n| id | name |\n+----+---------------+\n| 1 | Ada |\n| 2 | Grace, Hopper |\n+----+---------------+\n",
@@ -1215,7 +1385,10 @@ fn select_cells_joins_multiple_columns_and_rows() {
let result = extract_data_grid_selection(request).expect("multi-cell SELECT extraction");
assert_eq!(result.text, "SELECT * FROM \"users\" WHERE (\"id\" = 1 AND \"name\" = 'Ada') OR (\"id\" = 2 AND \"name\" = 'Grace, Hopper');");
assert_eq!(
result.text,
"SELECT * FROM \"users\" WHERE (\"id\" = 1 AND \"name\" = 'Ada') OR (\"id\" = 2 AND \"name\" = 'Grace, Hopper');"
);
}
#[test]
@@ -1310,7 +1483,13 @@ fn select_cells_falls_back_to_display_name_like_where_clause() {
columns: None,
});
request.columns = vec![
DataGridExtractColumn { display_name: "id".to_string(), source_name: None, source_index: 0, data_type: None },
DataGridExtractColumn {
display_name: "id".to_string(),
source_name: None,
source_index: 0,
data_type: None,
description: None,
},
column("name", 1),
];
let mut where_request = request.clone();
+246 -14
View File
@@ -681,7 +681,7 @@ impl Serialize for QueryResult {
if !self.spatial_values.is_empty() {
state.serialize_field("spatial_values", &self.spatial_values)?;
}
state.serialize_field("rows", &JsSafeRows(&self.rows))?;
state.serialize_field("rows", &JsSafeRows(&self.rows, &self.column_types))?;
state.serialize_field("affected_rows", &self.affected_rows)?;
state.serialize_field("execution_time_ms", &self.execution_time_ms)?;
if let Some(server_execute_time_us) = &self.server_execute_time_us {
@@ -703,9 +703,9 @@ impl Serialize for QueryResult {
}
}
/// Serializes result rows with [`JsSafeCell`] wrapping so unsafe integers
/// become strings without cloning the row data.
struct JsSafeRows<'a>(&'a [Vec<serde_json::Value>]);
/// Serializes result rows with [`JsSafeCell`] wrapping so values which cannot
/// round-trip through JavaScript without losing precision become strings.
struct JsSafeRows<'a>(&'a [Vec<serde_json::Value>], &'a [String]);
impl Serialize for JsSafeRows<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
@@ -716,13 +716,13 @@ impl Serialize for JsSafeRows<'_> {
let mut seq = serializer.serialize_seq(Some(self.0.len()))?;
for row in self.0 {
seq.serialize_element(&JsSafeCells(row))?;
seq.serialize_element(&JsSafeCells(row, self.1))?;
}
seq.end()
}
}
struct JsSafeCells<'a>(&'a [serde_json::Value]);
struct JsSafeCells<'a>(&'a [serde_json::Value], &'a [String]);
impl Serialize for JsSafeCells<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
@@ -732,17 +732,17 @@ impl Serialize for JsSafeCells<'_> {
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(self.0.len()))?;
for cell in self.0 {
seq.serialize_element(&JsSafeCell(cell))?;
for (index, cell) in self.0.iter().enumerate() {
seq.serialize_element(&JsSafeCell(cell, self.1.get(index).map(String::as_str)))?;
}
seq.end()
}
}
/// Serializes one cell, replacing unsafe integer numbers with their decimal
/// string form and recursing into arrays/objects the same way
/// `db::json_value_for_js` does.
struct JsSafeCell<'a>(&'a serde_json::Value);
/// Serializes one cell, replacing unsafe integers and non-round-trippable exact
/// decimal values with strings. Nested JSON values retain their existing
/// normalization behavior.
struct JsSafeCell<'a>(&'a serde_json::Value, Option<&'a str>);
impl Serialize for JsSafeCell<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
@@ -753,6 +753,10 @@ impl Serialize for JsSafeCell<'_> {
match self.0 {
serde_json::Value::Number(number) => {
let value = number.as_str();
if self.1.is_some_and(is_exact_decimal_type) && !decimal_round_trips_through_f64(value) {
return serializer.serialize_str(value);
}
if let Some(value) = number.as_i64() {
if !(-crate::json::JS_MAX_SAFE_INTEGER..=crate::json::JS_MAX_SAFE_INTEGER).contains(&value) {
return serializer.serialize_str(&value.to_string());
@@ -773,14 +777,14 @@ impl Serialize for JsSafeCell<'_> {
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(values.len()))?;
for value in values {
seq.serialize_element(&JsSafeCell(value))?;
seq.serialize_element(&JsSafeCell(value, None))?;
}
seq.end()
}
serde_json::Value::Object(entries) => {
let mut map = serializer.serialize_map(Some(entries.len()))?;
for (key, value) in entries {
map.serialize_entry(key, &JsSafeCell(value))?;
map.serialize_entry(key, &JsSafeCell(value, None))?;
}
map.end()
}
@@ -789,6 +793,82 @@ impl Serialize for JsSafeCell<'_> {
}
}
fn is_exact_decimal_type(data_type: &str) -> bool {
let normalized = data_type.trim().to_ascii_lowercase();
let normalized = normalized.strip_suffix(" unsigned").unwrap_or(&normalized).trim_end();
let base_end = normalized
.find(|character: char| character == '(' || character == '[' || character.is_whitespace())
.unwrap_or(normalized.len());
let base = &normalized[..base_end];
if !matches!(
base,
"decimal"
| "numeric"
| "number"
| "dec"
| "fixed"
| "money"
| "smallmoney"
| "bigdecimal"
| "bignumeric"
| "big_numeric"
) {
return false;
}
let suffix = normalized[base_end..].trim();
if suffix.is_empty() {
return true;
}
suffix.starts_with('(')
&& suffix.ends_with(')')
&& suffix[1..suffix.len() - 1]
.chars()
.all(|character| character.is_ascii_digit() || character == ',' || character.is_whitespace())
}
fn decimal_round_trips_through_f64(value: &str) -> bool {
let Ok(number) = value.parse::<f64>() else { return false };
if !number.is_finite() || (number == 0.0 && canonical_decimal(value).is_some_and(|decimal| decimal.digits != "0")) {
return false;
}
canonical_decimal(value)
.is_some_and(|original| canonical_decimal(&number.to_string()).is_some_and(|round_trip| original == round_trip))
}
#[derive(Debug, PartialEq, Eq)]
struct CanonicalDecimal {
negative: bool,
digits: String,
exponent: i64,
}
fn canonical_decimal(value: &str) -> Option<CanonicalDecimal> {
let (mantissa, exponent) = value
.split_once(['e', 'E'])
.map(|(mantissa, exponent)| Some((mantissa, exponent.parse::<i64>().ok()?)))
.unwrap_or_else(|| Some((value, 0)))?;
let (negative, unsigned) = match mantissa.strip_prefix('-') {
Some(unsigned) => (true, unsigned),
None => (false, mantissa.strip_prefix('+').unwrap_or(mantissa)),
};
let (integer, fraction) = unsigned.split_once('.').unwrap_or((unsigned, ""));
if integer.is_empty() && fraction.is_empty()
|| !integer.bytes().chain(fraction.bytes()).all(|byte| byte.is_ascii_digit())
{
return None;
}
let mut digits = format!("{integer}{fraction}");
let first_significant = digits.bytes().position(|digit| digit != b'0');
let Some(first_significant) = first_significant else {
return Some(CanonicalDecimal { negative: false, digits: "0".to_string(), exponent: 0 });
};
digits.drain(..first_significant);
let trailing_zero_count = digits.bytes().rev().take_while(|digit| *digit == b'0').count();
digits.truncate(digits.len() - trailing_zero_count);
let exponent = exponent.checked_sub(fraction.len() as i64)?.checked_add(trailing_zero_count as i64)?;
Some(CanonicalDecimal { negative, digits, exponent })
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexInfo {
pub name: String,
@@ -1569,6 +1649,158 @@ mod tests {
assert_eq!(serde_json::to_value(&deserialized.rows).unwrap(), expected);
}
#[test]
fn query_result_preserves_precision_sensitive_exact_numeric_cells_for_js() {
for value in [
"9007199254740993.123456789012",
"-9007199254740993.123456789012",
"0.1234567890123456789012345",
"1.234567890123456789e30",
"12345678901234567",
"1e400",
"1e-400",
] {
let mut result = bigint_result_sample();
result.columns = vec!["AMOUNT".to_string()];
result.column_types = vec!["NUMBER(38,12)".to_string()];
result.rows = vec![vec![serde_json::from_str(value).unwrap()]];
let rows_before = result.rows.clone();
let number_token = result.rows[0][0].as_number().unwrap().as_str();
let wire = serde_json::to_string(&result).unwrap();
let serialized: serde_json::Value = serde_json::from_str(&wire).unwrap();
assert_eq!(serialized["rows"][0][0], number_token, "{value}");
assert_eq!(result.rows, rows_before, "serialization must not mutate in-memory rows");
let decoded: super::QueryResult = serde_json::from_str(&wire).unwrap();
assert_eq!(serde_json::to_value(&decoded).unwrap()["rows"][0][0], serialized["rows"][0][0]);
}
}
#[test]
fn query_result_precision_wire_matches_shared_javascript_fixture() {
let mut result = bigint_result_sample();
result.columns = vec!["AMOUNT".to_string()];
result.column_types = vec!["NUMBER(38,12)".to_string()];
result.column_sortables.clear();
result.rows = vec![vec![serde_json::from_str("9007199254740993.123456789012").unwrap()]];
let serialized = serde_json::to_string(&result).unwrap();
let expected: serde_json::Value = serde_json::from_str(include_str!(
"../../../apps/desktop/src/lib/__tests__/dataGrid/fixtures/exactDecimalQueryResult.json"
))
.unwrap();
assert_eq!(serde_json::from_str::<serde_json::Value>(&serialized).unwrap(), expected);
}
#[test]
fn query_result_keeps_exact_decimals_as_numbers_when_binary64_decimal_roundtrip_is_safe() {
for value in [
"0.1",
"123.45",
"1.25e-7",
"1234567890123456.5",
"1234567890123456",
"123.4500",
"1.2345e2",
"1234567890123456.0",
"0e-400",
] {
let mut result = bigint_result_sample();
result.columns = vec!["AMOUNT".to_string()];
result.column_types = vec!["DECIMAL(38,12)".to_string()];
result.rows = vec![vec![serde_json::from_str(value).unwrap()]];
let serialized = serde_json::to_value(&result).unwrap();
assert!(serialized["rows"][0][0].is_number(), "{value}: {serialized}");
}
let mut result = bigint_result_sample();
result.column_types = vec!["DECIMAL".to_string()];
result.rows = vec![vec![serde_json::from_str("10000000000000000").unwrap()]];
assert_eq!(serde_json::to_value(result).unwrap()["rows"][0][0], "10000000000000000");
}
#[test]
fn query_result_exact_decimal_guard_is_type_scoped_and_tolerates_missing_metadata() {
let original = "9007199254740993.123456789012";
for column_type in [
"decimal",
"numeric(38,12)",
"number (38, 12)",
"dec",
"fixed",
"money",
"smallmoney",
"bigdecimal",
"bignumeric",
"big_numeric",
"decimal(38,12) unsigned",
] {
let mut result = bigint_result_sample();
result.column_types = vec![column_type.to_string()];
result.rows = vec![vec![serde_json::from_str(original).unwrap()]];
assert_eq!(serde_json::to_value(&result).unwrap()["rows"][0][0], original, "{column_type}");
}
for column_type in [
"FLOAT",
"DOUBLE PRECISION",
"REAL",
"BINARY_FLOAT",
"BINARY_DOUBLE",
"NUMBER[]",
"JSON",
"my_numeric_domain",
] {
let mut result = bigint_result_sample();
result.column_types = vec![column_type.to_string()];
result.rows = vec![vec![serde_json::from_str(original).unwrap()]];
let serialized = serde_json::to_value(&result).unwrap();
assert!(serialized["rows"][0][0].is_number(), "{column_type}");
}
for column_types in [Vec::new(), vec!["NUMBER".to_string()]] {
let mut result = bigint_result_sample();
result.columns = vec!["AMOUNT".into(), "NOTE".into()];
result.column_types = column_types;
result.rows = vec![
vec![serde_json::from_str(original).unwrap(), serde_json::json!("text")],
vec![serde_json::Value::Null, serde_json::json!("unchanged")],
];
let serialized = serde_json::to_value(&result).unwrap();
let exact_cell_expected = if result.column_types.is_empty() {
serde_json::from_str::<serde_json::Value>(original).unwrap()
} else {
serde_json::json!(original)
};
assert_eq!(serialized["rows"][0][0], exact_cell_expected);
assert_eq!(serialized["rows"][0][1], "text");
assert_eq!(serialized["rows"][1][0], serde_json::Value::Null);
}
let mut result = bigint_result_sample();
result.column_types = vec!["NUMBER".to_string()];
result.rows = vec![vec![serde_json::json!(original)]];
assert_eq!(serde_json::to_value(result).unwrap()["rows"][0][0], original);
}
#[test]
fn query_result_uses_column_type_only_for_top_level_cells() {
let mut result = bigint_result_sample();
result.column_types = vec!["NUMBER".to_string()];
result.rows = vec![vec![serde_json::from_str(
r#"{"decimal":9007199254740993.123456789012,"id":2026081810175800100000000000}"#,
)
.unwrap()]];
let serialized = serde_json::to_value(&result).unwrap();
assert!(serialized["rows"][0][0]["decimal"].is_number());
assert_eq!(serialized["rows"][0][0]["id"], "2026081810175800100000000000");
}
#[test]
fn query_result_serialization_matches_js_normalization_for_nested_numbers() {
let cell: serde_json::Value = serde_json::from_str(
+27
View File
@@ -0,0 +1,27 @@
# 复制查询结果
在结果网格选择单元格或行后,右键菜单“复制”下有两种用途:
- **配置数据提取器…**:选择默认复制格式、调整各格式参数和菜单可见格式。只有点“保存”才会持久化;取消会丢弃草稿,“恢复默认”只恢复可见格式清单。普通复制和快捷键继续使用已保存的默认格式。
- **高级复制…**:为本次复制选择格式并调整参数,点“复制”立即复制,点“取消”丢弃更改。它不会修改默认格式、其他格式参数或菜单可见格式。预览默认收起,展开后才按当前参数生成;预览只读,不会写入剪贴板。
普通“复制为”菜单项仍按已保存参数立即复制。隐藏某个格式只影响菜单,不会改变默认复制/快捷键格式。
对话框左侧选择格式,右侧调整对应参数;高级选项默认折叠。要让调整用于以后的普通复制,在“配置数据提取器…”中保存。
## 适合粘贴到其他工具的格式
- **TSV、CSV、分隔值**:适合电子表格和文本编辑器。CSV 可设置表头位置(顶部、底部或无)、表头名称/注释、大小写、引号、公式防护、换行替代、数组括号和数字格式。公式防护会给危险文本加前缀;真正的数值负数不改写。换行替代和数组格式会改变文本表示,需按接收端调整。
- **HTML**:同次写入 `text/html` 富文本表格和 `text/plain` HTML 源码;支持富文本的目标可粘贴表格,其余目标可读取源码。
- **DbUnit Flat XML**:输出 `<dataset>` 和逐行表元素。NULL 输出 `[NULL]` 标记,需在 DbUnit `ReplacementDataSet` 配置该标记;若关闭 NULL 属性,首行 NULL 可能影响 DbUnit 的列识别。二进制值输出 Base64,不会自动生成数据库写入脚本。
- **PHP 数组**:可选 `array()` 或短数组语法、单双引号、日期 ISO 表现、行分隔符和变量名。重复列名会被拒绝,以免 PHP 数组键覆盖;无法无损表示的整数/decimal 保留为字符串,二进制以 Base64 解码表达式输出。
## 复制数据库对象身份
对象导航树中的“复制名称”→“复制全限定名”按数据库方言引用每一级对象标识符,例如 `"sales"."Order Details"`。多选时按树中顺序逐行复制。仅在节点携带完整连接/目录/模式身份且所选对象均支持 SQL 限定名时提供该操作;不使用树上的显示路径猜测名称。列、例程及包成员会包含其父对象名,例程签名和 package body 不属于限定名。
查询结果表头菜单中的“复制列元数据”只复制列信息(名称、类型、来源、可空性、注释),不复制单元格数据,也不会为复制额外读取数据库或 LOB。
## 值表现与原值
高级复制的值表现可选择兼容默认、显示值、编辑值或原生值。兼容默认保持原有格式行为;显示值和编辑值分别采用网格显示/编辑格式;原生值使用数据库驱动解码后的值,并保留无法由 JavaScript 数字精确往返的十进制数值。MongoDB BSON 值保持其外部语义,不把内部传输标记复制到剪贴板。SQL 和内部回粘载荷不因文本格式变化而改写。
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB