feat(settings): add global date and time formats
This commit is contained in:
parent
df0415333a
commit
0a1efdcb85
|
|
@ -112,6 +112,7 @@ import { DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES, DEFAULT_WEB_DAV_REMOTE_PA
|
|||
import { apiUrl } from "@/lib/common/webPath";
|
||||
import { DEFAULT_UI_FONT_FAMILY, SYSTEM_UI_FONT_FAMILY } from "@/lib/app/appFonts";
|
||||
import { buildAppSupportInfoRows, formatAppSupportInfoForClipboard, type AppSupportInfoLabels } from "@/lib/app/supportInfo";
|
||||
import { DateTimePatterns, normalizeSupportedDateTimePattern } from "@/lib/dataGrid/columnFormatter";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -340,6 +341,9 @@ const editSidebarHiddenTablePrefixes = ref(settingsStore.editorSettings.sidebarH
|
|||
const editSidebarHideTableComments = ref(settingsStore.editorSettings.sidebarHideTableComments);
|
||||
const editSidebarAllowHorizontalScroll = ref(settingsStore.editorSettings.sidebarAllowHorizontalScroll);
|
||||
const editExportBatchSize = ref(settingsStore.editorSettings.exportBatchSize);
|
||||
const editGlobalDateTimeDisplayFormat = ref(settingsStore.editorSettings.globalDateTimeDisplayFormat);
|
||||
const editGlobalDateTimeExportFormat = ref(settingsStore.editorSettings.globalDateTimeExportFormat);
|
||||
const editGlobalDateTimeImportFormat = ref(settingsStore.editorSettings.globalDateTimeImportFormat);
|
||||
const editExportRowLimitEnabled = ref(settingsStore.editorSettings.exportRowLimitEnabled);
|
||||
const editExportRowLimit = ref(settingsStore.editorSettings.exportRowLimit);
|
||||
const editQueryExportKeysetOptimizationEnabled = ref(settingsStore.editorSettings.queryExportKeysetOptimizationEnabled);
|
||||
|
|
@ -427,6 +431,9 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
|
|||
sidebarAllowHorizontalScroll: editSidebarAllowHorizontalScroll.value,
|
||||
sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value),
|
||||
exportBatchSize: editExportBatchSize.value,
|
||||
globalDateTimeDisplayFormat: editGlobalDateTimeDisplayFormat.value,
|
||||
globalDateTimeExportFormat: editGlobalDateTimeExportFormat.value,
|
||||
globalDateTimeImportFormat: editGlobalDateTimeImportFormat.value,
|
||||
exportRowLimitEnabled: editExportRowLimitEnabled.value,
|
||||
exportRowLimit: editExportRowLimit.value,
|
||||
queryExportKeysetOptimizationEnabled: editQueryExportKeysetOptimizationEnabled.value,
|
||||
|
|
@ -704,6 +711,9 @@ function syncEditorSettingsDraftFromStore() {
|
|||
editSidebarHideTableComments.value = settingsStore.editorSettings.sidebarHideTableComments;
|
||||
editSidebarAllowHorizontalScroll.value = settingsStore.editorSettings.sidebarAllowHorizontalScroll;
|
||||
editExportBatchSize.value = settingsStore.editorSettings.exportBatchSize;
|
||||
editGlobalDateTimeDisplayFormat.value = settingsStore.editorSettings.globalDateTimeDisplayFormat;
|
||||
editGlobalDateTimeExportFormat.value = settingsStore.editorSettings.globalDateTimeExportFormat;
|
||||
editGlobalDateTimeImportFormat.value = settingsStore.editorSettings.globalDateTimeImportFormat;
|
||||
editExportRowLimitEnabled.value = settingsStore.editorSettings.exportRowLimitEnabled;
|
||||
editExportRowLimit.value = settingsStore.editorSettings.exportRowLimit;
|
||||
editQueryExportKeysetOptimizationEnabled.value = settingsStore.editorSettings.queryExportKeysetOptimizationEnabled;
|
||||
|
|
@ -910,6 +920,9 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
|||
editDuckDbWorkerMaxProcesses.value = DEFAULT_DESKTOP_SETTINGS.duckdb_worker_max_processes;
|
||||
editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(DEFAULT_EDITOR_SETTINGS.tableColumnTemplateFields);
|
||||
editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize;
|
||||
editGlobalDateTimeDisplayFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat;
|
||||
editGlobalDateTimeExportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeExportFormat;
|
||||
editGlobalDateTimeImportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeImportFormat;
|
||||
editExportRowLimitEnabled.value = DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled;
|
||||
editExportRowLimit.value = DEFAULT_EDITOR_SETTINGS.exportRowLimit;
|
||||
editQueryExportKeysetOptimizationEnabled.value = DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled;
|
||||
|
|
@ -977,6 +990,9 @@ function resetAllDefaults() {
|
|||
editSidebarAllowHorizontalScroll.value = DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll;
|
||||
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
|
||||
editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize;
|
||||
editGlobalDateTimeDisplayFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat;
|
||||
editGlobalDateTimeExportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeExportFormat;
|
||||
editGlobalDateTimeImportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeImportFormat;
|
||||
editExportRowLimitEnabled.value = DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled;
|
||||
editExportRowLimit.value = DEFAULT_EDITOR_SETTINGS.exportRowLimit;
|
||||
editQueryExportKeysetOptimizationEnabled.value = DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled;
|
||||
|
|
@ -3590,6 +3606,62 @@ onUnmounted(cleanupPreviewEditor);
|
|||
<Separator />
|
||||
</template>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-muted-foreground">{{ t("settings.dateTimeSection") }}</div>
|
||||
<div class="grid gap-3 rounded-md border bg-muted/20 px-3 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(220px,0.8fr)] sm:items-center">
|
||||
<div>
|
||||
<Label>{{ t("settings.globalDateTimeDisplayFormat") }}</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ t("settings.globalDateTimeDisplayFormatDescription") }}</p>
|
||||
</div>
|
||||
<SearchableSelect
|
||||
v-model="editGlobalDateTimeDisplayFormat"
|
||||
:options="DateTimePatterns"
|
||||
:placeholder="t('settings.dateTimeFormatRaw')"
|
||||
:search-placeholder="t('settings.dateTimeFormatSearchPlaceholder')"
|
||||
:empty-text="t('settings.dateTimeFormatEmpty')"
|
||||
:normalize-custom="normalizeSupportedDateTimePattern"
|
||||
allow-custom
|
||||
clearable
|
||||
trigger-variant="outline"
|
||||
trigger-class="h-9 w-full max-w-none justify-between"
|
||||
/>
|
||||
<div>
|
||||
<Label>{{ t("settings.globalDateTimeExportFormat") }}</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ t("settings.globalDateTimeExportFormatDescription") }}</p>
|
||||
</div>
|
||||
<SearchableSelect
|
||||
v-model="editGlobalDateTimeExportFormat"
|
||||
:options="DateTimePatterns"
|
||||
:placeholder="t('settings.dateTimeFormatRaw')"
|
||||
:search-placeholder="t('settings.dateTimeFormatSearchPlaceholder')"
|
||||
:empty-text="t('settings.dateTimeFormatEmpty')"
|
||||
:normalize-custom="normalizeSupportedDateTimePattern"
|
||||
allow-custom
|
||||
clearable
|
||||
trigger-variant="outline"
|
||||
trigger-class="h-9 w-full max-w-none justify-between"
|
||||
/>
|
||||
<div>
|
||||
<Label>{{ t("settings.globalDateTimeImportFormat") }}</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ t("settings.globalDateTimeImportFormatDescription") }}</p>
|
||||
</div>
|
||||
<SearchableSelect
|
||||
v-model="editGlobalDateTimeImportFormat"
|
||||
:options="DateTimePatterns"
|
||||
:placeholder="t('settings.dateTimeFormatAuto')"
|
||||
:search-placeholder="t('settings.dateTimeFormatSearchPlaceholder')"
|
||||
:empty-text="t('settings.dateTimeFormatEmpty')"
|
||||
:normalize-custom="normalizeSupportedDateTimePattern"
|
||||
allow-custom
|
||||
clearable
|
||||
trigger-variant="outline"
|
||||
trigger-class="h-9 w-full max-w-none justify-between"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-muted-foreground">{{ t("settings.exportSection") }}</div>
|
||||
<div class="space-y-2">
|
||||
|
|
|
|||
|
|
@ -963,7 +963,10 @@ function columnFormatter(columnIndex: number): ColumnFormatterConfig | undefined
|
|||
const column = props.result.columns[columnIndex];
|
||||
if (!column) return undefined;
|
||||
const key = formatterKeyForColumn(column);
|
||||
return key ? resolveColumnFormatter(settingsStore.editorSettings.columnFormatters[key], settingsStore.editorSettings.customColumnFormatters) : undefined;
|
||||
return resolveColumnFormatter(key ? settingsStore.editorSettings.columnFormatters[key] : undefined, settingsStore.editorSettings.customColumnFormatters, {
|
||||
pattern: settingsStore.editorSettings.globalDateTimeDisplayFormat,
|
||||
columnType: props.result.column_types?.[columnIndex] ?? tableColumnForGridColumn(columnIndex)?.data_type,
|
||||
});
|
||||
}
|
||||
|
||||
function savedColumnFormatter(columnIndex: number): ColumnFormatterConfig | undefined {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { autoMapImportColumns, nextTableImportWizardStep, previousTableImportWizardStep, requiredImportTargetColumns, suggestImportTargetDataTypes, validateImportMappings, type TableImportWizardStep } from "@/lib/table/tableImport";
|
||||
import { getDataTypeOptions } from "@/lib/table/tableStructureEditorState";
|
||||
|
|
@ -20,6 +21,7 @@ import * as api from "@/lib/backend/api";
|
|||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { toast } = useToast();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
|
|
@ -625,6 +627,7 @@ async function startImport() {
|
|||
mode: targetMode.value === "create" ? "append" : importMode.value,
|
||||
createTable: targetMode.value === "create",
|
||||
batchSize: Math.max(1, Number(batchSize.value) || 500),
|
||||
dateTimeFormat: settingsStore.editorSettings.globalDateTimeImportFormat || undefined,
|
||||
},
|
||||
(nextProgress) => {
|
||||
progress.value = nextProgress;
|
||||
|
|
@ -700,6 +703,7 @@ async function startBatchImport() {
|
|||
mode: "append",
|
||||
createTable: true,
|
||||
batchSize: Math.max(1, Number(batchSize.value) || 500),
|
||||
dateTimeFormat: settingsStore.editorSettings.globalDateTimeImportFormat || undefined,
|
||||
},
|
||||
(nextProgress) => {
|
||||
task.rowsImported = nextProgress.rowsImported;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import type { DatabaseType, QueryResult } from "@/types/database";
|
|||
import type { QueryResultExportRequest } from "@/lib/backend/api";
|
||||
import { usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
|
||||
import { buildXlsxSqlWorksheet } from "@/lib/export/xlsxSqlSheet";
|
||||
import { formatTemporalRowsForExport } from "@/lib/dataGrid/columnFormatter";
|
||||
|
||||
/**
|
||||
* Format metadata for backend table exports. Each entry maps a format key
|
||||
|
|
@ -202,10 +203,15 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
return displayItems.value.filter((item) => rowIdSet.has(item.id) && !item.isDraft);
|
||||
}
|
||||
|
||||
async function resultToExport(rowIds?: number[], onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void, useFullExport = true): Promise<{ columns: string[]; columnTypes: string[]; rows: CellValue[][] }> {
|
||||
function applyGlobalDateTimeExportFormat(result: { columns: string[]; columnTypes: string[]; rows: CellValue[][] }, enabled: boolean) {
|
||||
const pattern = enabled ? useSettingsStore().editorSettings.globalDateTimeExportFormat : "";
|
||||
return pattern ? { ...result, rows: formatTemporalRowsForExport(result.rows, result.columnTypes, pattern) } : result;
|
||||
}
|
||||
|
||||
async function resultToExport(rowIds?: number[], onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void, useFullExport = true, formatDateTime = true): Promise<{ columns: string[]; columnTypes: string[]; rows: CellValue[][] }> {
|
||||
if (useFullExport && rowIds === undefined && fullExportResult && !hasCompleteLocalResult?.value) {
|
||||
const result = await fullExportResult(onProgress);
|
||||
if (result) return { columns: result.columns, columnTypes: result.column_types ?? [], rows: result.rows };
|
||||
if (result) return applyGlobalDateTimeExportFormat({ columns: result.columns, columnTypes: result.column_types ?? [], rows: result.rows }, formatDateTime);
|
||||
}
|
||||
// The full result is already in memory — export the raw QueryResult (all
|
||||
// rows, all columns, committed values) so "export all data" matches the
|
||||
|
|
@ -213,13 +219,16 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
// and reflects client-side filters/search and unsaved edits, which would
|
||||
// silently change what the export contains.
|
||||
if (useFullExport && rowIds === undefined && hasCompleteLocalResult?.value && completeLocalResult?.value) {
|
||||
return { columns: completeLocalResult.value.columns, columnTypes: completeLocalResult.value.column_types ?? [], rows: completeLocalResult.value.rows };
|
||||
return applyGlobalDateTimeExportFormat({ columns: completeLocalResult.value.columns, columnTypes: completeLocalResult.value.column_types ?? [], rows: completeLocalResult.value.rows }, formatDateTime);
|
||||
}
|
||||
return {
|
||||
columns: columns.value,
|
||||
columnTypes: (columnTypes.value ?? []).map((type) => type ?? ""),
|
||||
rows: rowsToExport(rowIds).map((item) => item.data),
|
||||
};
|
||||
return applyGlobalDateTimeExportFormat(
|
||||
{
|
||||
columns: columns.value,
|
||||
columnTypes: (columnTypes.value ?? []).map((type) => type ?? ""),
|
||||
rows: rowsToExport(rowIds).map((item) => item.data),
|
||||
},
|
||||
formatDateTime,
|
||||
);
|
||||
}
|
||||
|
||||
function currentXlsxSheetName(): string {
|
||||
|
|
@ -1026,11 +1035,12 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
outputPath = path as string;
|
||||
}
|
||||
|
||||
const exportPattern = useSettingsStore().editorSettings.globalDateTimeExportFormat;
|
||||
const worksheets = sheets.map((sheet) => ({
|
||||
sheetName: sheet.sheetName,
|
||||
columns: sheet.result.columns,
|
||||
columnTypes: sheet.result.column_types ?? [],
|
||||
rows: sheet.result.rows,
|
||||
rows: formatTemporalRowsForExport(sheet.result.rows, sheet.result.column_types ?? [], exportPattern),
|
||||
}));
|
||||
const sqlWorksheet = includeSqlSheet ? buildXlsxSqlWorksheet(sheets.map((sheet) => ({ resultName: sheet.sheetName, sql: sheet.sql || sheet.result.sourceStatement || "" }))) : undefined;
|
||||
await api.exportQueryResultsXlsx(outputPath, sqlWorksheet ? [...worksheets, sqlWorksheet] : worksheets);
|
||||
|
|
@ -1110,6 +1120,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
skipCount: false,
|
||||
batchSize: exportBatchSize.value,
|
||||
rowLimit,
|
||||
dateTimeFormat: editorSettings.globalDateTimeExportFormat || undefined,
|
||||
},
|
||||
(progress) => {
|
||||
if (exportProgressState) {
|
||||
|
|
@ -1156,7 +1167,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
}
|
||||
|
||||
const exportId = uuid();
|
||||
const request = await queryResultExportRequest({ exportId, filePath: outputPath, format, includeSqlSheet });
|
||||
const baseRequest = await queryResultExportRequest({ exportId, filePath: outputPath, format, includeSqlSheet });
|
||||
const request = baseRequest ? { ...baseRequest, dateTimeFormat: useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined } : undefined;
|
||||
if (!request) throw new Error("Unable to build query result export request");
|
||||
|
||||
if (exportProgressState) {
|
||||
|
|
@ -1204,7 +1216,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
try {
|
||||
if (await exportFullTableDataViaBackend("sql", rowIds)) return;
|
||||
|
||||
const result = await resultToExport(rowIds);
|
||||
const result = await resultToExport(rowIds, undefined, true, false);
|
||||
const exportData = sqlInsertExportData(result);
|
||||
const content = await formatSqlInsert({
|
||||
databaseType: databaseType.value,
|
||||
|
|
@ -1225,7 +1237,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
async function exportCurrentPageSql() {
|
||||
await runExclusiveExport(async () => {
|
||||
try {
|
||||
const result = await resultToExport(undefined, undefined, false);
|
||||
const result = await resultToExport(undefined, undefined, false, false);
|
||||
const exportData = sqlInsertExportData(result);
|
||||
const content = await formatSqlInsert({
|
||||
databaseType: databaseType.value,
|
||||
|
|
|
|||
|
|
@ -3498,6 +3498,17 @@ export default {
|
|||
redisScanPageSizeDescription: "Keys requested per Redis SCAN page when browsing keys.",
|
||||
redisScanPageSizeOption: "{count} keys",
|
||||
exportBatchSize: "Export batch size",
|
||||
dateTimeSection: "Date and time",
|
||||
globalDateTimeDisplayFormat: "Global display format",
|
||||
globalDateTimeDisplayFormatDescription: "Applies to temporal columns without a column-specific formatter in every database.",
|
||||
globalDateTimeExportFormat: "Global export format",
|
||||
globalDateTimeExportFormatDescription: "Formats temporal columns consistently in CSV, Excel, JSON, Markdown, and text exports.",
|
||||
globalDateTimeImportFormat: "Global import format",
|
||||
globalDateTimeImportFormatDescription: "Parses imported temporal values with this format. Leave empty to detect common formats automatically.",
|
||||
dateTimeFormatRaw: "Keep database value",
|
||||
dateTimeFormatAuto: "Auto detect",
|
||||
dateTimeFormatSearchPlaceholder: "Select or enter a format",
|
||||
dateTimeFormatEmpty: "Enter a custom datetime format",
|
||||
exportBatchSizeDescription: "Rows fetched per batch when exporting data (100-100000).",
|
||||
exportRowLimitEnabled: "Limit exported rows",
|
||||
exportRowLimitEnabledDescription: "When on, query result and table data exports stop at the row limit below.",
|
||||
|
|
|
|||
|
|
@ -3474,6 +3474,17 @@ export default withEnglishFallback({
|
|||
shortcutUppercaseSelection: "Convertir selección a mayúsculas",
|
||||
shortcutLowercaseSelection: "Convertir selección a minúsculas",
|
||||
shortcutExPasteSqlInCondition: "ExPaste: pegar como condición IN",
|
||||
dateTimeSection: "Fecha y hora",
|
||||
globalDateTimeDisplayFormat: "Formato de visualización global",
|
||||
globalDateTimeDisplayFormatDescription: "Se aplica a todos los campos de fecha y hora que no tienen un formato de columna configurado individualmente en las bases de datos.",
|
||||
globalDateTimeExportFormat: "Formato de exportación global",
|
||||
globalDateTimeExportFormatDescription: "Formatea uniformemente los campos de fecha y hora al exportar a CSV, Excel, JSON, Markdown y texto.",
|
||||
globalDateTimeImportFormat: "Formato de importación global",
|
||||
globalDateTimeImportFormatDescription: "Analiza las fechas y horas en los archivos importados según este formato; si se deja vacío, se reconocen automáticamente los formatos comunes.",
|
||||
dateTimeFormatRaw: "Mantener el valor original de la base de datos",
|
||||
dateTimeFormatAuto: "Reconocimiento automático",
|
||||
dateTimeFormatSearchPlaceholder: "Seleccionar o ingresar formato",
|
||||
dateTimeFormatEmpty: "Ingresar formato de fecha y hora personalizado",
|
||||
},
|
||||
driverStore: {
|
||||
progressJreExtract: "Extrayendo JRE...",
|
||||
|
|
|
|||
|
|
@ -3472,6 +3472,17 @@ export default withEnglishFallback({
|
|||
shortcutUppercaseSelection: "Converti selezione in maiuscolo",
|
||||
shortcutLowercaseSelection: "Converti selezione in minuscolo",
|
||||
shortcutExPasteSqlInCondition: "ExPaste: incolla come condizione IN",
|
||||
dateTimeSection: "Data e Ora",
|
||||
globalDateTimeDisplayFormat: "Formato di visualizzazione globale",
|
||||
globalDateTimeDisplayFormatDescription: "Applicato a tutti i campi data/ora nei database che non hanno una configurazione di formato colonna individuale.",
|
||||
globalDateTimeExportFormat: "Formato di esportazione globale",
|
||||
globalDateTimeExportFormatDescription: "Formatta uniformemente i campi data/ora durante l'esportazione in CSV, Excel, JSON, Markdown e testo.",
|
||||
globalDateTimeImportFormat: "Formato di importazione globale",
|
||||
globalDateTimeImportFormatDescription: "Analizza i campi data/ora nei file importati secondo questo formato; lascia vuoto per rilevare automaticamente i formati comuni.",
|
||||
dateTimeFormatRaw: "Mantieni il valore originale del database",
|
||||
dateTimeFormatAuto: "Riconoscimento automatico",
|
||||
dateTimeFormatSearchPlaceholder: "Seleziona o inserisci il formato",
|
||||
dateTimeFormatEmpty: "Inserisci un formato data/ora personalizzato",
|
||||
},
|
||||
driverStore: {
|
||||
progressJreExtract: "Estrazione JRE...",
|
||||
|
|
|
|||
|
|
@ -3473,6 +3473,17 @@ export default withEnglishFallback({
|
|||
shortcutUppercaseSelection: "選択範囲を大文字に変換",
|
||||
shortcutLowercaseSelection: "選択範囲を小文字に変換",
|
||||
shortcutExPasteSqlInCondition: "ExPaste: IN条件として貼り付け",
|
||||
dateTimeSection: "日付と時刻",
|
||||
globalDateTimeDisplayFormat: "グローバル表示形式",
|
||||
globalDateTimeDisplayFormatDescription: "すべてのデータベースで列形式が個別に設定されていない日時フィールドに適用されます。",
|
||||
globalDateTimeExportFormat: "グローバルエクスポート形式",
|
||||
globalDateTimeExportFormatDescription: "CSV、Excel、JSON、Markdown、テキストをエクスポートする際に日時フィールドを統一フォーマットします。",
|
||||
globalDateTimeImportFormat: "グローバルインポート形式",
|
||||
globalDateTimeImportFormatDescription: "この形式でインポートファイル内の日時を解析します。空欄の場合は一般的な形式を自動認識します。",
|
||||
dateTimeFormatRaw: "データベースの元の値を保持",
|
||||
dateTimeFormatAuto: "自動認識",
|
||||
dateTimeFormatSearchPlaceholder: "形式を選択または入力",
|
||||
dateTimeFormatEmpty: "カスタム日時形式を入力",
|
||||
},
|
||||
driverStore: {
|
||||
progressJreExtract: "JREを展開中...",
|
||||
|
|
|
|||
|
|
@ -3474,6 +3474,17 @@ export default withEnglishFallback({
|
|||
shortcutUppercaseSelection: "Converter seleção em maiúsculas",
|
||||
shortcutLowercaseSelection: "Converter seleção em minúsculas",
|
||||
shortcutExPasteSqlInCondition: "ExPaste: colar como condição IN",
|
||||
dateTimeSection: "Data e Hora",
|
||||
globalDateTimeDisplayFormat: "Formato de exibição global",
|
||||
globalDateTimeDisplayFormatDescription: "Aplicado a todos os campos de data e hora em todos os bancos de dados que não tenham formato de coluna configurado individualmente.",
|
||||
globalDateTimeExportFormat: "Formato de exportação global",
|
||||
globalDateTimeExportFormatDescription: "Formata campos de data e hora de forma uniforme ao exportar para CSV, Excel, JSON, Markdown e texto.",
|
||||
globalDateTimeImportFormat: "Formato de importação global",
|
||||
globalDateTimeImportFormatDescription: "Analisa datas e horas em arquivos importados de acordo com este formato; deixe em branco para detectar automaticamente formatos comuns.",
|
||||
dateTimeFormatRaw: "Manter valor original do banco de dados",
|
||||
dateTimeFormatAuto: "Detecção automática",
|
||||
dateTimeFormatSearchPlaceholder: "Selecione ou digite um formato",
|
||||
dateTimeFormatEmpty: "Insira um formato de data e hora personalizado",
|
||||
},
|
||||
driverStore: {
|
||||
progressJreExtract: "Extraindo JRE...",
|
||||
|
|
|
|||
|
|
@ -3488,6 +3488,17 @@ export default withEnglishFallback({
|
|||
redisScanPageSizeDescription: "浏览 Redis Key 时每次 SCAN 请求的 Key 数量。",
|
||||
redisScanPageSizeOption: "{count} 个 Key",
|
||||
exportBatchSize: "导出批次大小",
|
||||
dateTimeSection: "日期与时间",
|
||||
globalDateTimeDisplayFormat: "全局显示格式",
|
||||
globalDateTimeDisplayFormatDescription: "应用于所有数据库中未单独配置列格式的日期时间字段。",
|
||||
globalDateTimeExportFormat: "全局导出格式",
|
||||
globalDateTimeExportFormatDescription: "导出 CSV、Excel、JSON、Markdown 和文本时统一格式化日期时间字段。",
|
||||
globalDateTimeImportFormat: "全局导入格式",
|
||||
globalDateTimeImportFormatDescription: "按此格式解析导入文件中的日期时间;留空时自动识别常见格式。",
|
||||
dateTimeFormatRaw: "保持数据库原始值",
|
||||
dateTimeFormatAuto: "自动识别",
|
||||
dateTimeFormatSearchPlaceholder: "选择或输入格式",
|
||||
dateTimeFormatEmpty: "输入自定义日期时间格式",
|
||||
exportBatchSizeDescription: "导出数据时每批读取的行数(100-100000)。",
|
||||
exportRowLimitEnabled: "限制导出行数",
|
||||
exportRowLimitEnabledDescription: "开启时,查询结果和表数据导出最多为下方设置的行数。",
|
||||
|
|
|
|||
|
|
@ -3319,6 +3319,17 @@ export default withEnglishFallback({
|
|||
updateDownloadSourceAtomgit: "AtomGit",
|
||||
exportSection: "匯出",
|
||||
exportBatchSize: "匯出批次大小",
|
||||
dateTimeSection: "日期與時間",
|
||||
globalDateTimeDisplayFormat: "全域顯示格式",
|
||||
globalDateTimeDisplayFormatDescription: "套用到所有資料庫中未單獨設定欄位格式的日期時間欄位。",
|
||||
globalDateTimeExportFormat: "全域匯出格式",
|
||||
globalDateTimeExportFormatDescription: "匯出 CSV、Excel、JSON、Markdown 和文字時統一格式化日期時間欄位。",
|
||||
globalDateTimeImportFormat: "全域匯入格式",
|
||||
globalDateTimeImportFormatDescription: "依此格式解析匯入檔案中的日期時間;留空時自動辨識常見格式。",
|
||||
dateTimeFormatRaw: "保留資料庫原始值",
|
||||
dateTimeFormatAuto: "自動辨識",
|
||||
dateTimeFormatSearchPlaceholder: "選擇或輸入格式",
|
||||
dateTimeFormatEmpty: "輸入自訂日期時間格式",
|
||||
exportBatchSizeDescription: "匯出資料時每批次擷取的列數(100-100000)。",
|
||||
exportRowLimitEnabled: "限制匯出行數",
|
||||
exportRowLimitEnabledDescription: "開啟後,查詢結果和資料表資料匯出將在下方設定的行數上限處停止。",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeSupportedDateTimePattern } from "@/lib/dataGrid/columnFormatter";
|
||||
|
||||
describe("normalizeSupportedDateTimePattern", () => {
|
||||
it("accepts the format grammar shared by the frontend and backend", () => {
|
||||
expect(normalizeSupportedDateTimePattern(" YYYY/M/D [at] HH:mm:ss.SSSZ ")).toBe("YYYY/M/D [at] HH:mm:ss.SSSZ");
|
||||
});
|
||||
|
||||
it("rejects unsupported or malformed Day.js tokens", () => {
|
||||
expect(normalizeSupportedDateTimePattern("MM/DD/YYYY hh:mm A")).toBe("");
|
||||
expect(normalizeSupportedDateTimePattern("YYYY-MM-DD [at HH:mm:ss")).toBe("");
|
||||
expect(normalizeSupportedDateTimePattern("%Y-%m-%d")).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -2201,6 +2201,7 @@ export interface TableImportRequest {
|
|||
mode: TableImportMode;
|
||||
createTable?: boolean;
|
||||
batchSize: number;
|
||||
dateTimeFormat?: string;
|
||||
}
|
||||
|
||||
export interface TableImportSummary {
|
||||
|
|
@ -2291,6 +2292,7 @@ export interface TableExportRequest {
|
|||
skipCount?: boolean;
|
||||
batchSize?: number;
|
||||
rowLimit?: number | null;
|
||||
dateTimeFormat?: string;
|
||||
}
|
||||
|
||||
export interface TableCsvExportOptions {
|
||||
|
|
@ -2332,6 +2334,7 @@ export interface QueryResultExportRequest {
|
|||
keysetOptimizationEnabled: boolean;
|
||||
clientSessionId?: string;
|
||||
executionId?: string;
|
||||
dateTimeFormat?: string;
|
||||
}
|
||||
|
||||
export async function startTableExport(request: TableExportRequest, onProgress: (progress: TableExportProgress) => void): Promise<TableExportProgress> {
|
||||
|
|
|
|||
|
|
@ -10,9 +10,47 @@ dayjs.extend(timezone);
|
|||
|
||||
export type DateTimeFormatterUnit = "seconds" | "milliseconds" | "auto";
|
||||
const DEFAULT_DATETIME_PATTERN = "YYYY-MM-DD HH:mm:ss";
|
||||
export const DateTimePatterns = ["HH:mm:ss", "HH:mm:ss.SSS", "YYYY-MM-DD HH:mm:ss", "YYYY-MM-DD HH:mm:ss.SSS", "YYYY/MM/DD HH:mm:ss", "YYYY/MM/DD HH:mm:ss.SSS", "YYYY-MM-DDTHH:mm:ssZ", "YYYY-MM-DDTHH:mm:ss.SSSZ", "YYYY/MM/DDTHH:mm:ssZ", "YYYY/MM/DDTHH:mm:ss.SSSZ"];
|
||||
const STRICT_LOCAL_DATETIME_INPUT_PATTERNS = ["YYYY-MM-DD", "YYYY/MM/DD", "YYYY-MM-DD HH:mm:ss", "YYYY-MM-DD HH:mm:ss.SSS", "YYYY/MM/DD HH:mm:ss", "YYYY/MM/DD HH:mm:ss.SSS", "YYYY-MM-DDTHH:mm:ss", "YYYY-MM-DDTHH:mm:ss.SSS", "YYYY/MM/DDTHH:mm:ss", "YYYY/MM/DDTHH:mm:ss.SSS"];
|
||||
const ISO_OFFSET_DATETIME_PATTERN = /^(\d{4})([-/])(\d{2})\2(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/;
|
||||
export const DateTimePatterns = [
|
||||
"YYYY-MM-DD",
|
||||
"YYYY/MM/DD",
|
||||
"YYYY/M/D",
|
||||
"HH:mm:ss",
|
||||
"HH:mm:ss.SSS",
|
||||
"YYYY-MM-DD HH:mm:ss",
|
||||
"YYYY-MM-DD HH:mm:ss.SSS",
|
||||
"YYYY/MM/DD HH:mm:ss",
|
||||
"YYYY/MM/DD HH:mm:ss.SSS",
|
||||
"YYYY/M/D HH:mm:ss",
|
||||
"YYYY-MM-DDTHH:mm:ssZ",
|
||||
"YYYY-MM-DDTHH:mm:ss.SSSZ",
|
||||
"YYYY/MM/DDTHH:mm:ssZ",
|
||||
"YYYY/MM/DDTHH:mm:ss.SSSZ",
|
||||
];
|
||||
const SUPPORTED_DATE_TIME_PATTERN_TOKENS = ["YYYY", "SSS", "ZZ", "MM", "DD", "HH", "mm", "ss", "M", "D", "H", "m", "s", "Z"];
|
||||
const STRICT_LOCAL_DATETIME_INPUT_PATTERNS = [
|
||||
"YYYY-MM-DD",
|
||||
"YYYY-M-D",
|
||||
"YYYY/MM/DD",
|
||||
"YYYY/M/D",
|
||||
"YYYY-MM-DD HH:mm:ss",
|
||||
"YYYY-M-D H:m:s",
|
||||
"YYYY-MM-DD HH:mm:ss.SSS",
|
||||
"YYYY-M-D H:m:s.SSS",
|
||||
"YYYY/MM/DD HH:mm:ss",
|
||||
"YYYY/M/D H:m:s",
|
||||
"YYYY/MM/DD HH:mm:ss.SSS",
|
||||
"YYYY/M/D H:m:s.SSS",
|
||||
"YYYY-MM-DDTHH:mm:ss",
|
||||
"YYYY-M-DTH:m:s",
|
||||
"YYYY-MM-DDTHH:mm:ss.SSS",
|
||||
"YYYY-M-DTH:m:s.SSS",
|
||||
"YYYY/MM/DDTHH:mm:ss",
|
||||
"YYYY/M/DTH:m:s",
|
||||
"YYYY/MM/DDTHH:mm:ss.SSS",
|
||||
"YYYY/M/DTH:m:s.SSS",
|
||||
];
|
||||
const ISO_OFFSET_DATETIME_PATTERN = /^(\d{4})([-/])(\d{2})\2(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
|
||||
const FRACTIONAL_LOCAL_DATETIME_PATTERN = /^(\d{4})([-/])(\d{1,2})\2(\d{1,2})([ T])(\d{1,2}):(\d{1,2}):(\d{1,2})\.(\d{1,9})$/;
|
||||
|
||||
export interface CustomColumnFormatterConfig {
|
||||
id: string;
|
||||
|
|
@ -20,6 +58,33 @@ export interface CustomColumnFormatterConfig {
|
|||
template: string;
|
||||
}
|
||||
|
||||
export function normalizeSupportedDateTimePattern(value: string): string {
|
||||
const pattern = value.trim();
|
||||
if (!pattern || pattern.length > 100 || pattern.includes("%")) return "";
|
||||
|
||||
let index = 0;
|
||||
while (index < pattern.length) {
|
||||
const remaining = pattern.slice(index);
|
||||
if (remaining.startsWith("[")) {
|
||||
const closeIndex = remaining.indexOf("]");
|
||||
if (closeIndex < 0) return "";
|
||||
index += closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = SUPPORTED_DATE_TIME_PATTERN_TOKENS.find((candidate) => remaining.startsWith(candidate));
|
||||
if (token) {
|
||||
index += token.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/[A-Za-z]/.test(pattern[index])) return "";
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
export type ColumnFormatterConfig = { kind: "datetime"; unit: DateTimeFormatterUnit; pattern: string } | { kind: "json-path"; path: string } | { kind: "mask"; prefix: number; suffix: number } | { kind: "custom-template"; template: string } | { kind: "custom-ref"; formatterId: string };
|
||||
|
||||
export interface ColumnFormatterKeyParts {
|
||||
|
|
@ -82,13 +147,69 @@ export function normalizeCustomColumnFormatter(value: unknown): CustomColumnForm
|
|||
};
|
||||
}
|
||||
|
||||
export function resolveColumnFormatter(formatter: ColumnFormatterConfig | undefined, customFormatters: Record<string, CustomColumnFormatterConfig>): ColumnFormatterConfig | undefined {
|
||||
if (!formatter) return undefined;
|
||||
export function normalizeGlobalDateTimePattern(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().slice(0, 100) : "";
|
||||
}
|
||||
|
||||
export function isTemporalColumnType(dataType: string | null | undefined): boolean {
|
||||
const normalized = String(dataType || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ");
|
||||
if (!normalized) return false;
|
||||
const base = normalized.split(/[(:]/)[0]?.trim() ?? "";
|
||||
return (
|
||||
["date", "date32", "daten", "time", "time64", "timen", "timetz", "datetime", "datetime2", "datetime4", "datetime64", "datetimen", "datetimeoffset", "datetimeoffsetn", "smalldatetime", "timestamp", "timestampdty", "timestamptz"].includes(base) ||
|
||||
base.startsWith("timestamp_") ||
|
||||
normalized.startsWith("timestamp with ") ||
|
||||
normalized.startsWith("timestamp without ") ||
|
||||
normalized.startsWith("time with ") ||
|
||||
normalized.startsWith("time without ")
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveColumnFormatter(formatter: ColumnFormatterConfig | undefined, customFormatters: Record<string, CustomColumnFormatterConfig>, globalDateTime?: { pattern?: string; columnType?: string | null }): ColumnFormatterConfig | undefined {
|
||||
if (!formatter) {
|
||||
const pattern = normalizeGlobalDateTimePattern(globalDateTime?.pattern);
|
||||
return pattern && isTemporalColumnType(globalDateTime?.columnType) ? { kind: "datetime", unit: "auto", pattern } : undefined;
|
||||
}
|
||||
if (formatter.kind !== "custom-ref") return formatter;
|
||||
const customFormatter = customFormatters[formatter.formatterId];
|
||||
return customFormatter ? { kind: "custom-template", template: customFormatter.template } : undefined;
|
||||
}
|
||||
|
||||
export function formatTemporalRowsForExport<T extends CellValue>(rows: readonly (readonly T[])[], columnTypes: readonly (string | null | undefined)[], pattern: string): T[][] {
|
||||
const normalizedPattern = normalizeGlobalDateTimePattern(pattern);
|
||||
if (!normalizedPattern) return rows.map((row) => [...row]);
|
||||
return rows.map((row) =>
|
||||
row.map((value, index) => {
|
||||
if (!isTemporalColumnType(columnTypes[index])) return value;
|
||||
return formatTemporalValueForExport(value, normalizedPattern) as T;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function formatTemporalValueForExport(value: CellValue, pattern: string): string {
|
||||
if (typeof value === "string") {
|
||||
const match = value.match(ISO_OFFSET_DATETIME_PATTERN);
|
||||
if (match) {
|
||||
const [, year, separator, month, day, hour, minute, second, fraction = "", zone] = match;
|
||||
if (!isValidDateTimeParts(year, month, day, hour, minute, second) || !isValidOffset(zone)) {
|
||||
return displayCellValue(value);
|
||||
}
|
||||
const normalizedFraction = fraction ? `.${fraction.slice(1, 4).padEnd(3, "0")}` : "";
|
||||
const localValue = `${year}${separator}${month}${separator}${day}T${hour}:${minute}:${second}${normalizedFraction}`;
|
||||
const inputPattern = `${separator === "-" ? "YYYY-MM-DD" : "YYYY/MM/DD"}THH:mm:ss${fraction ? ".SSS" : ""}`;
|
||||
const parsed = dayjs(localValue, inputPattern, true);
|
||||
if (parsed.isValid()) {
|
||||
const offsetMinutes = zone === "Z" ? 0 : (zone.startsWith("-") ? -1 : 1) * (Number(zone.slice(1, 3)) * 60 + Number(zone.slice(4, 6)));
|
||||
return parsed.utcOffset(offsetMinutes, true).format(pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
return applyColumnFormatter(value, { kind: "datetime", unit: "auto", pattern });
|
||||
}
|
||||
|
||||
export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatterConfig | undefined): string {
|
||||
if (!formatter) return displayCellValue(value);
|
||||
|
||||
|
|
@ -142,6 +263,16 @@ function parseStrictDateTimeString(value: string): Dayjs | undefined {
|
|||
const parsedIsoOffset = parseIsoOffsetDateTimeString(value);
|
||||
if (parsedIsoOffset) return parsedIsoOffset;
|
||||
|
||||
const fractionalLocalMatch = value.match(FRACTIONAL_LOCAL_DATETIME_PATTERN);
|
||||
if (fractionalLocalMatch) {
|
||||
const [, yearText, , monthText, dayText, , hourText, minuteText, secondText, fractionText] = fractionalLocalMatch;
|
||||
if (isValidDateTimeParts(yearText, monthText, dayText, hourText, minuteText, secondText)) {
|
||||
const normalized = `${yearText}-${monthText.padStart(2, "0")}-${dayText.padStart(2, "0")}T${hourText.padStart(2, "0")}:${minuteText.padStart(2, "0")}:${secondText.padStart(2, "0")}.${fractionText.slice(0, 3).padEnd(3, "0")}`;
|
||||
const parsed = dayjs(normalized, "YYYY-MM-DDTHH:mm:ss.SSS", true);
|
||||
if (parsed.isValid()) return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of STRICT_LOCAL_DATETIME_INPUT_PATTERNS) {
|
||||
// Day.js non-strict parsing normalizes overflow dates such as 2022-01-33.
|
||||
// Keep cell text strict so invalid values fall back unchanged.
|
||||
|
|
@ -159,7 +290,8 @@ function parseIsoOffsetDateTimeString(value: string): Dayjs | undefined {
|
|||
if (!isValidDateTimeParts(yearText, monthText, dayText, hourText, minuteText, secondText)) return undefined;
|
||||
if (!isValidOffset(zoneText)) return undefined;
|
||||
|
||||
const normalized = `${yearText}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}${fractionText}${zoneText}`;
|
||||
const normalizedFraction = fractionText ? `.${fractionText.slice(1, 4).padEnd(3, "0")}` : "";
|
||||
const normalized = `${yearText}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}${normalizedFraction}${zoneText}`;
|
||||
const parsed = dayjs(normalized);
|
||||
return parsed.isValid() ? parsed : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
|
|||
"exportRowLimitEnabled",
|
||||
"exportRowLimit",
|
||||
"queryExportKeysetOptimizationEnabled",
|
||||
"globalDateTimeDisplayFormat",
|
||||
"globalDateTimeExportFormat",
|
||||
"globalDateTimeImportFormat",
|
||||
"updateDownloadSource",
|
||||
"toolbarItems",
|
||||
"snippets",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { normalizeColumnFormatter, normalizeCustomColumnFormatter, type ColumnFormatterConfig, type CustomColumnFormatterConfig } from "@/lib/dataGrid/columnFormatter";
|
||||
import { normalizeColumnFormatter, normalizeCustomColumnFormatter, normalizeGlobalDateTimePattern, type ColumnFormatterConfig, type CustomColumnFormatterConfig } from "@/lib/dataGrid/columnFormatter";
|
||||
import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry";
|
||||
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
||||
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
|
||||
|
|
@ -421,6 +421,9 @@ export interface EditorSettings {
|
|||
sidebarAllowHorizontalScroll: boolean;
|
||||
columnFormatters: Record<string, ColumnFormatterConfig>;
|
||||
customColumnFormatters: Record<string, CustomColumnFormatterConfig>;
|
||||
globalDateTimeDisplayFormat: string;
|
||||
globalDateTimeExportFormat: string;
|
||||
globalDateTimeImportFormat: string;
|
||||
snippets: SqlSnippet[];
|
||||
tableColumnTemplateFields: string[];
|
||||
exportBatchSize: number;
|
||||
|
|
@ -559,6 +562,9 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
|||
sidebarAllowHorizontalScroll: false,
|
||||
columnFormatters: {},
|
||||
customColumnFormatters: {},
|
||||
globalDateTimeDisplayFormat: "",
|
||||
globalDateTimeExportFormat: "",
|
||||
globalDateTimeImportFormat: "",
|
||||
snippets: DEFAULT_SQL_SNIPPETS,
|
||||
tableColumnTemplateFields: [...DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS],
|
||||
exportBatchSize: 2000,
|
||||
|
|
@ -797,6 +803,9 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
|
|||
sidebarAllowHorizontalScroll: settings.sidebarAllowHorizontalScroll ?? DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll,
|
||||
columnFormatters: normalizeColumnFormatters(settings.columnFormatters),
|
||||
customColumnFormatters: normalizeCustomColumnFormatters(settings.customColumnFormatters),
|
||||
globalDateTimeDisplayFormat: normalizeGlobalDateTimePattern(settings.globalDateTimeDisplayFormat),
|
||||
globalDateTimeExportFormat: normalizeGlobalDateTimePattern(settings.globalDateTimeExportFormat),
|
||||
globalDateTimeImportFormat: normalizeGlobalDateTimePattern(settings.globalDateTimeImportFormat),
|
||||
snippets: normalizeSqlSnippets(settings.snippets, existing?.snippets),
|
||||
tableColumnTemplateFields: normalizeTableColumnTemplateFields(settings.tableColumnTemplateFields),
|
||||
exportBatchSize: typeof settings.exportBatchSize === "number" && settings.exportBatchSize >= 100 && settings.exportBatchSize <= 100000 ? Math.round(settings.exportBatchSize) : DEFAULT_EDITOR_SETTINGS.exportBatchSize,
|
||||
|
|
@ -1047,6 +1056,9 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||
if (partial.sidebarAllowHorizontalScroll !== undefined) editorSettings.value.sidebarAllowHorizontalScroll = partial.sidebarAllowHorizontalScroll;
|
||||
if (partial.columnFormatters !== undefined) editorSettings.value.columnFormatters = partial.columnFormatters;
|
||||
if (partial.customColumnFormatters !== undefined) editorSettings.value.customColumnFormatters = partial.customColumnFormatters;
|
||||
if (partial.globalDateTimeDisplayFormat !== undefined) editorSettings.value.globalDateTimeDisplayFormat = normalizeGlobalDateTimePattern(partial.globalDateTimeDisplayFormat);
|
||||
if (partial.globalDateTimeExportFormat !== undefined) editorSettings.value.globalDateTimeExportFormat = normalizeGlobalDateTimePattern(partial.globalDateTimeExportFormat);
|
||||
if (partial.globalDateTimeImportFormat !== undefined) editorSettings.value.globalDateTimeImportFormat = normalizeGlobalDateTimePattern(partial.globalDateTimeImportFormat);
|
||||
if (partial.snippets !== undefined) editorSettings.value.snippets = normalizeSqlSnippets(partial.snippets);
|
||||
if (partial.tableColumnTemplateFields !== undefined) editorSettings.value.tableColumnTemplateFields = normalizeTableColumnTemplateFields(partial.tableColumnTemplateFields);
|
||||
if (partial.exportBatchSize !== undefined) editorSettings.value.exportBatchSize = Math.min(100000, Math.max(100, Math.round(partial.exportBatchSize)));
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ pub struct SqlServerStreamExportSummary {
|
|||
}
|
||||
|
||||
pub enum SqlServerStreamItem<'a> {
|
||||
Columns(&'a [String]),
|
||||
Columns { columns: &'a [String], column_types: &'a [String] },
|
||||
Row(&'a [serde_json::Value]),
|
||||
}
|
||||
|
||||
|
|
@ -638,6 +638,7 @@ pub async fn stream_first_result_set(
|
|||
let mut stream = sqlserver_driver_result(client.query(query_sql.as_str(), &[])).await?;
|
||||
let mut active_result_index: Option<usize> = None;
|
||||
let mut columns: Vec<String> = Vec::new();
|
||||
let mut column_types: Vec<String> = Vec::new();
|
||||
let mut columns_emitted = false;
|
||||
let mut rows_exported = 0_u64;
|
||||
|
||||
|
|
@ -663,7 +664,8 @@ pub async fn stream_first_result_set(
|
|||
if active_result_index.is_none() {
|
||||
active_result_index = Some(metadata.result_index());
|
||||
columns = columns_from_metadata(&metadata);
|
||||
on_item(SqlServerStreamItem::Columns(&columns))?;
|
||||
column_types = column_types_from_metadata(&metadata);
|
||||
on_item(SqlServerStreamItem::Columns { columns: &columns, column_types: &column_types })?;
|
||||
columns_emitted = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -671,7 +673,8 @@ pub async fn stream_first_result_set(
|
|||
if active_result_index.is_none() {
|
||||
active_result_index = Some(row.result_index());
|
||||
columns = row.columns().iter().map(|c| c.name().to_string()).collect();
|
||||
on_item(SqlServerStreamItem::Columns(&columns))?;
|
||||
column_types = row.columns().iter().map(sqlserver_column_type_name).collect();
|
||||
on_item(SqlServerStreamItem::Columns { columns: &columns, column_types: &column_types })?;
|
||||
columns_emitted = true;
|
||||
}
|
||||
if Some(row.result_index()) != active_result_index {
|
||||
|
|
@ -688,7 +691,7 @@ pub async fn stream_first_result_set(
|
|||
}
|
||||
|
||||
if !columns_emitted {
|
||||
on_item(SqlServerStreamItem::Columns(&columns))?;
|
||||
on_item(SqlServerStreamItem::Columns { columns: &columns, column_types: &column_types })?;
|
||||
}
|
||||
Ok(SqlServerStreamExportSummary { columns, rows_exported })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ pub mod table_export;
|
|||
pub mod table_import;
|
||||
pub mod table_structure_sql;
|
||||
pub mod task_supervisor;
|
||||
pub mod temporal_format;
|
||||
pub mod text_export;
|
||||
pub mod token_usage;
|
||||
pub mod transfer;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ pub struct QueryResultExportRequest {
|
|||
pub client_session_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub execution_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub date_time_format: Option<String>,
|
||||
}
|
||||
|
||||
fn split_excel_cell_text(value: &str) -> Vec<String> {
|
||||
|
|
@ -563,6 +565,11 @@ async fn export_query_result_core_inner(
|
|||
result.rows.truncate(this_page);
|
||||
}
|
||||
let row_count = result.rows.len();
|
||||
let formatted_rows = crate::temporal_format::format_temporal_export_rows_with_string_types(
|
||||
&result.rows,
|
||||
&column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
|
||||
if format == "csv" || format == "txt" {
|
||||
if let Some(file) = text_file.as_mut() {
|
||||
|
|
@ -570,12 +577,12 @@ async fn export_query_result_core_inner(
|
|||
let header = format_text_export_header(&format, &columns);
|
||||
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
|
||||
if row_count > 0 {
|
||||
let rows = format_text_export_rows(&format, &result.rows);
|
||||
let rows = format_text_export_rows(&format, &formatted_rows);
|
||||
write!(file, "\n{rows}").map_err(|e| format!("Failed to write export rows: {e}"))?;
|
||||
}
|
||||
wrote_text_header = true;
|
||||
} else if row_count > 0 {
|
||||
let rows = format_text_export_rows(&format, &result.rows);
|
||||
let rows = format_text_export_rows(&format, &formatted_rows);
|
||||
write!(file, "\n{rows}").map_err(|e| format!("Failed to write export rows: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
|
@ -591,7 +598,7 @@ async fn export_query_result_core_inner(
|
|||
)?);
|
||||
}
|
||||
if let Some(writer) = xlsx.as_mut() {
|
||||
for row in &result.rows {
|
||||
for row in &formatted_rows {
|
||||
writer.write_row(row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
|
@ -708,6 +715,7 @@ async fn try_export_postgres_query_result_stream(
|
|||
if xlsx_hard_limit_active { row_limit.map(|limit| limit.saturating_add(1)) } else { row_limit };
|
||||
let progress_row_interval = request.page_size.max(1) as u64;
|
||||
let mut columns: Vec<String> = Vec::new();
|
||||
let mut temporal_column_types: Vec<String> = Vec::new();
|
||||
let mut rows_exported = 0_u64;
|
||||
let mut last_progress_rows = 0_u64;
|
||||
let mut last_progress_at = Instant::now();
|
||||
|
|
@ -735,6 +743,7 @@ async fn try_export_postgres_query_result_stream(
|
|||
match item {
|
||||
crate::db::postgres::PostgresQueryStreamItem::Columns { columns: stream_columns, column_types } => {
|
||||
columns = stream_columns;
|
||||
temporal_column_types = column_types.clone();
|
||||
if let Some(file) = text_file.as_mut() {
|
||||
let header = format_text_export_header(format, &columns);
|
||||
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
|
||||
|
|
@ -753,18 +762,23 @@ async fn try_export_postgres_query_result_stream(
|
|||
if xlsx_hard_limit_active && rows_exported as usize >= XLSX_MAX_DATA_ROWS {
|
||||
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
|
||||
}
|
||||
let formatted = crate::temporal_format::format_temporal_export_row_with_string_types(
|
||||
&row,
|
||||
&temporal_column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
if let Some(file) = text_file.as_mut() {
|
||||
let rows = format_text_export_rows(format, std::slice::from_ref(&row));
|
||||
let rows = format_text_export_rows(format, std::slice::from_ref(&formatted));
|
||||
write!(file, "\n{rows}").map_err(|e| format!("Failed to write export rows: {e}"))?;
|
||||
} else if let Some(writer) = xlsx.as_mut() {
|
||||
writer.write_row(&row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
} else {
|
||||
let xlsx_file =
|
||||
File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?;
|
||||
xlsx =
|
||||
Some(start_query_result_xlsx_workbook(BufWriter::new(xlsx_file), request, &columns, &[])?);
|
||||
if let Some(writer) = xlsx.as_mut() {
|
||||
writer.write_row(&row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
}
|
||||
}
|
||||
rows_exported += 1;
|
||||
|
|
@ -870,6 +884,7 @@ async fn try_export_mysql_query_result_stream(
|
|||
if xlsx_hard_limit_active { row_limit.map(|limit| limit.saturating_add(1)) } else { row_limit };
|
||||
let progress_row_interval = request.page_size.max(1) as u64;
|
||||
let mut columns: Vec<String> = Vec::new();
|
||||
let mut temporal_column_types: Vec<String> = Vec::new();
|
||||
let mut rows_exported = 0_u64;
|
||||
let mut last_progress_rows = 0_u64;
|
||||
let mut last_progress_at = Instant::now();
|
||||
|
|
@ -946,6 +961,7 @@ async fn try_export_mysql_query_result_stream(
|
|||
match item {
|
||||
crate::db::mysql::MySqlQueryStreamItem::Columns { columns: stream_columns, column_types } => {
|
||||
columns = stream_columns;
|
||||
temporal_column_types = column_types.clone();
|
||||
if let Some(file) = text_file.as_mut() {
|
||||
let header = format_text_export_header(format, &columns);
|
||||
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
|
||||
|
|
@ -964,18 +980,23 @@ async fn try_export_mysql_query_result_stream(
|
|||
if xlsx_hard_limit_active && rows_exported as usize >= XLSX_MAX_DATA_ROWS {
|
||||
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
|
||||
}
|
||||
let formatted = crate::temporal_format::format_temporal_export_row_with_string_types(
|
||||
&row,
|
||||
&temporal_column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
if let Some(file) = text_file.as_mut() {
|
||||
let rows = format_text_export_rows(format, std::slice::from_ref(&row));
|
||||
let rows = format_text_export_rows(format, std::slice::from_ref(&formatted));
|
||||
write!(file, "\n{rows}").map_err(|e| format!("Failed to write export rows: {e}"))?;
|
||||
} else if let Some(writer) = xlsx.as_mut() {
|
||||
writer.write_row(&row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
} else {
|
||||
let xlsx_file =
|
||||
File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?;
|
||||
xlsx =
|
||||
Some(start_query_result_xlsx_workbook(BufWriter::new(xlsx_file), request, &columns, &[])?);
|
||||
if let Some(writer) = xlsx.as_mut() {
|
||||
writer.write_row(&row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
}
|
||||
}
|
||||
rows_exported += 1;
|
||||
|
|
@ -1115,6 +1136,7 @@ async fn try_export_clickhouse_query_result_stream(
|
|||
if xlsx_hard_limit_active { row_limit.map(|limit| limit.saturating_add(1)) } else { row_limit };
|
||||
let progress_row_interval = request.page_size.max(1) as u64;
|
||||
let mut columns: Vec<String> = Vec::new();
|
||||
let mut temporal_column_types: Vec<String> = Vec::new();
|
||||
let mut rows_exported = 0_u64;
|
||||
let mut last_progress_rows = 0_u64;
|
||||
let mut last_progress_at = Instant::now();
|
||||
|
|
@ -1142,6 +1164,7 @@ async fn try_export_clickhouse_query_result_stream(
|
|||
column_types,
|
||||
} => {
|
||||
columns = stream_columns;
|
||||
temporal_column_types = column_types.clone();
|
||||
if let Some(file) = text_file.as_mut() {
|
||||
let header = format_text_export_header(format, &columns);
|
||||
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
|
||||
|
|
@ -1160,18 +1183,23 @@ async fn try_export_clickhouse_query_result_stream(
|
|||
if xlsx_hard_limit_active && rows_exported as usize >= XLSX_MAX_DATA_ROWS {
|
||||
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
|
||||
}
|
||||
let formatted = crate::temporal_format::format_temporal_export_row_with_string_types(
|
||||
&row,
|
||||
&temporal_column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
if let Some(file) = text_file.as_mut() {
|
||||
let rows = format_text_export_rows(format, std::slice::from_ref(&row));
|
||||
let rows = format_text_export_rows(format, std::slice::from_ref(&formatted));
|
||||
write!(file, "\n{rows}").map_err(|e| format!("Failed to write export rows: {e}"))?;
|
||||
} else if let Some(writer) = xlsx.as_mut() {
|
||||
writer.write_row(&row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
} else {
|
||||
let xlsx_file =
|
||||
File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?;
|
||||
xlsx =
|
||||
Some(start_query_result_xlsx_workbook(BufWriter::new(xlsx_file), request, &columns, &[])?);
|
||||
if let Some(writer) = xlsx.as_mut() {
|
||||
writer.write_row(&row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
}
|
||||
}
|
||||
rows_exported += 1;
|
||||
|
|
@ -1267,6 +1295,7 @@ async fn try_export_sqlserver_query_result_stream(
|
|||
let stream_row_limit =
|
||||
if xlsx_hard_limit_active { row_limit.map(|limit| limit.saturating_add(1)) } else { row_limit };
|
||||
let mut columns: Vec<String> = Vec::new();
|
||||
let mut temporal_column_types: Vec<String> = Vec::new();
|
||||
let mut rows_exported = 0_u64;
|
||||
let mut last_progress_rows = 0_u64;
|
||||
let mut last_progress_at = Instant::now();
|
||||
|
|
@ -1299,8 +1328,9 @@ async fn try_export_sqlserver_query_result_stream(
|
|||
cancel_token.clone(),
|
||||
|item| {
|
||||
match item {
|
||||
crate::db::sqlserver::SqlServerStreamItem::Columns(stream_columns) => {
|
||||
crate::db::sqlserver::SqlServerStreamItem::Columns { columns: stream_columns, column_types } => {
|
||||
columns = stream_columns.to_vec();
|
||||
temporal_column_types = column_types.to_vec();
|
||||
if let Some(file) = text_file.as_mut() {
|
||||
let header = format_text_export_header(format, &columns);
|
||||
file.write_all(header.as_bytes()).map_err(|e| format!("Failed to write export header: {e}"))?;
|
||||
|
|
@ -1315,18 +1345,23 @@ async fn try_export_sqlserver_query_result_stream(
|
|||
if xlsx_hard_limit_active && rows_exported as usize >= XLSX_MAX_DATA_ROWS {
|
||||
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
|
||||
}
|
||||
let formatted = crate::temporal_format::format_temporal_export_row_with_string_types(
|
||||
row,
|
||||
&temporal_column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
if let Some(file) = text_file.as_mut() {
|
||||
let rows = format_text_export_rows(format, &[row.to_vec()]);
|
||||
let rows = format_text_export_rows(format, std::slice::from_ref(&formatted));
|
||||
write!(file, "\n{rows}").map_err(|e| format!("Failed to write export rows: {e}"))?;
|
||||
} else if let Some(writer) = xlsx.as_mut() {
|
||||
writer.write_row(row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
} else {
|
||||
let xlsx_file =
|
||||
File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?;
|
||||
xlsx =
|
||||
Some(start_query_result_xlsx_workbook(BufWriter::new(xlsx_file), request, &columns, &[])?);
|
||||
if let Some(writer) = xlsx.as_mut() {
|
||||
writer.write_row(row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
}
|
||||
}
|
||||
rows_exported += 1;
|
||||
|
|
@ -1394,6 +1429,7 @@ mod tests {
|
|||
keyset_optimization_enabled: true,
|
||||
client_session_id: None,
|
||||
execution_id: None,
|
||||
date_time_format: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ pub struct TableExportRequest {
|
|||
pub batch_size: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub row_limit: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub date_time_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
|
@ -516,7 +518,12 @@ async fn try_export_native_table_stream(
|
|||
&cancelled,
|
||||
cancel_token.clone(),
|
||||
|row| {
|
||||
let row_csv = format_csv_rows(&[row.to_vec()]);
|
||||
let formatted = crate::temporal_format::format_temporal_export_row(
|
||||
row,
|
||||
column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
let row_csv = format_csv_rows(&[formatted]);
|
||||
write!(file, "\n{row_csv}").map_err(|e| format!("Failed to write CSV rows: {e}"))?;
|
||||
rows_exported += 1;
|
||||
if rows_exported % progress_interval == 0 {
|
||||
|
|
@ -555,7 +562,12 @@ async fn try_export_native_table_stream(
|
|||
&cancelled,
|
||||
cancel_token.clone(),
|
||||
|row| {
|
||||
let row_tsv = format_tsv_rows(&[row.to_vec()]);
|
||||
let formatted = crate::temporal_format::format_temporal_export_row(
|
||||
row,
|
||||
column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
let row_tsv = format_tsv_rows(&[formatted]);
|
||||
write!(file, "\n{row_tsv}").map_err(|e| format!("Failed to write TXT rows: {e}"))?;
|
||||
rows_exported += 1;
|
||||
if rows_exported % progress_interval == 0 {
|
||||
|
|
@ -578,14 +590,14 @@ async fn try_export_native_table_stream(
|
|||
result
|
||||
}
|
||||
"xlsx" => {
|
||||
let column_types = export_column_types(request);
|
||||
let xlsx_column_types = export_column_types(request);
|
||||
let xlsx_file =
|
||||
std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?;
|
||||
let mut writer = start_streaming_xlsx_workbook(
|
||||
BufWriter::new(xlsx_file),
|
||||
Some(&request.table_name),
|
||||
col_names,
|
||||
&column_types,
|
||||
&xlsx_column_types,
|
||||
)?;
|
||||
let result = stream_native_table_rows(
|
||||
state,
|
||||
|
|
@ -596,7 +608,12 @@ async fn try_export_native_table_stream(
|
|||
&cancelled,
|
||||
cancel_token.clone(),
|
||||
|row| {
|
||||
writer.write_row(row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
let formatted = crate::temporal_format::format_temporal_export_row(
|
||||
row,
|
||||
column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
rows_exported += 1;
|
||||
if rows_exported % progress_interval == 0 {
|
||||
on_progress(TableExportProgress {
|
||||
|
|
@ -645,7 +662,12 @@ async fn try_export_native_table_stream(
|
|||
if !is_first_row {
|
||||
file.write_all(b",\n").map_err(|e| format!("Failed to write JSON: {e}"))?;
|
||||
}
|
||||
write_json_row_object(&mut file, col_names, row)?;
|
||||
let formatted = crate::temporal_format::format_temporal_export_row(
|
||||
row,
|
||||
column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
write_json_row_object(&mut file, col_names, &formatted)?;
|
||||
is_first_row = false;
|
||||
rows_exported += 1;
|
||||
if rows_exported % progress_interval == 0 {
|
||||
|
|
@ -684,7 +706,12 @@ async fn try_export_native_table_stream(
|
|||
&cancelled,
|
||||
cancel_token.clone(),
|
||||
|row| {
|
||||
let rows_markdown = format_markdown_rows(&[row.to_vec()]);
|
||||
let formatted = crate::temporal_format::format_temporal_export_row(
|
||||
row,
|
||||
column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
let rows_markdown = format_markdown_rows(&[formatted]);
|
||||
if !rows_markdown.is_empty() {
|
||||
if wrote_rows {
|
||||
file.write_all(b"\n").map_err(|e| format!("Failed to write Markdown: {e}"))?;
|
||||
|
|
@ -1016,15 +1043,20 @@ pub async fn export_table_data_core(
|
|||
if row_count == 0 {
|
||||
break;
|
||||
}
|
||||
let formatted_rows = crate::temporal_format::format_temporal_export_rows(
|
||||
&result.rows,
|
||||
&column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
|
||||
if is_first_batch {
|
||||
// First batch: write header + rows via format_csv
|
||||
let csv_content = format_csv(&col_names, &result.rows);
|
||||
let csv_content = format_csv(&col_names, &formatted_rows);
|
||||
file.write_all(csv_content.as_bytes()).map_err(|e| format!("Failed to write CSV: {e}"))?;
|
||||
is_first_batch = false;
|
||||
} else {
|
||||
// Subsequent batches: write rows only (prepend newline for separation)
|
||||
let rows_csv = format_csv_rows(&result.rows);
|
||||
let rows_csv = format_csv_rows(&formatted_rows);
|
||||
if !rows_csv.is_empty() {
|
||||
write!(file, "\n{rows_csv}").map_err(|e| format!("Failed to write CSV rows: {e}"))?;
|
||||
}
|
||||
|
|
@ -1098,13 +1130,18 @@ pub async fn export_table_data_core(
|
|||
if row_count == 0 {
|
||||
break;
|
||||
}
|
||||
let formatted_rows = crate::temporal_format::format_temporal_export_rows(
|
||||
&result.rows,
|
||||
&column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
|
||||
if is_first_batch {
|
||||
let rows_tsv = format_tsv_rows(&result.rows);
|
||||
let rows_tsv = format_tsv_rows(&formatted_rows);
|
||||
write!(file, "\n{rows_tsv}").map_err(|e| format!("Failed to write TXT rows: {e}"))?;
|
||||
is_first_batch = false;
|
||||
} else {
|
||||
let rows_tsv = format_tsv_rows(&result.rows);
|
||||
let rows_tsv = format_tsv_rows(&formatted_rows);
|
||||
if !rows_tsv.is_empty() {
|
||||
write!(file, "\n{rows_tsv}").map_err(|e| format!("Failed to write TXT rows: {e}"))?;
|
||||
}
|
||||
|
|
@ -1135,7 +1172,7 @@ pub async fn export_table_data_core(
|
|||
}
|
||||
}
|
||||
"xlsx" => {
|
||||
let column_types = export_column_types(request);
|
||||
let xlsx_column_types = export_column_types(request);
|
||||
// Create a dedicated file handle for the streaming XLSX writer
|
||||
// instead of cloning the outer BufWriter's handle. This avoids
|
||||
// sharing a file descriptor between two independent buffers.
|
||||
|
|
@ -1145,7 +1182,7 @@ pub async fn export_table_data_core(
|
|||
BufWriter::new(xlsx_file),
|
||||
Some(&request.table_name),
|
||||
&col_names,
|
||||
&column_types,
|
||||
&xlsx_column_types,
|
||||
)?;
|
||||
|
||||
loop {
|
||||
|
|
@ -1188,7 +1225,12 @@ pub async fn export_table_data_core(
|
|||
}
|
||||
|
||||
for row in &result.rows {
|
||||
writer.write_row(row).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
let formatted = crate::temporal_format::format_temporal_export_row(
|
||||
row,
|
||||
&column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
writer.write_row(&formatted).map_err(|e| format!("Failed to write XLSX row: {e}"))?;
|
||||
}
|
||||
rows_exported += row_count as u64;
|
||||
|
||||
|
|
@ -1278,7 +1320,12 @@ pub async fn export_table_data_core(
|
|||
if !is_first_row {
|
||||
file.write_all(b",\n").map_err(|e| format!("Failed to write JSON: {e}"))?;
|
||||
}
|
||||
write_json_row_object(&mut file, &col_names, row)?;
|
||||
let formatted = crate::temporal_format::format_temporal_export_row(
|
||||
row,
|
||||
&column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
write_json_row_object(&mut file, &col_names, &formatted)?;
|
||||
is_first_row = false;
|
||||
}
|
||||
|
||||
|
|
@ -1350,7 +1397,12 @@ pub async fn export_table_data_core(
|
|||
break;
|
||||
}
|
||||
|
||||
let rows_markdown = format_markdown_rows(&result.rows);
|
||||
let formatted_rows = crate::temporal_format::format_temporal_export_rows(
|
||||
&result.rows,
|
||||
&column_types,
|
||||
request.date_time_format.as_deref(),
|
||||
);
|
||||
let rows_markdown = format_markdown_rows(&formatted_rows);
|
||||
if !rows_markdown.is_empty() {
|
||||
if wrote_rows {
|
||||
file.write_all(b"\n").map_err(|e| format!("Failed to write Markdown: {e}"))?;
|
||||
|
|
@ -1622,6 +1674,7 @@ mod tests {
|
|||
skip_count: false,
|
||||
batch_size: Some(500),
|
||||
row_limit: Some(1000),
|
||||
date_time_format: None,
|
||||
};
|
||||
|
||||
let sql = table_cursor_sql(
|
||||
|
|
@ -1669,6 +1722,7 @@ mod tests {
|
|||
skip_count: false,
|
||||
batch_size: Some(100),
|
||||
row_limit: None,
|
||||
date_time_format: None,
|
||||
};
|
||||
let sql = table_cursor_sql(&request, &DatabaseType::Oracle, &columns, &primary_keys);
|
||||
assert_eq!(sql, "SELECT \"ID\", \"NAME\" FROM \"APP\".\"USERS\"");
|
||||
|
|
|
|||
|
|
@ -200,6 +200,8 @@ pub struct TableImportRequest {
|
|||
#[serde(default)]
|
||||
pub create_table: bool,
|
||||
pub batch_size: usize,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub date_time_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
|
@ -1334,6 +1336,29 @@ pub fn build_import_insert_batch_from_rows(
|
|||
table: &str,
|
||||
schema: &str,
|
||||
db_type: &DatabaseType,
|
||||
) -> Result<Option<ImportSqlBatch>, String> {
|
||||
build_import_insert_batch_from_rows_with_format(
|
||||
rows,
|
||||
columns,
|
||||
mappings,
|
||||
target_column_types,
|
||||
table,
|
||||
schema,
|
||||
db_type,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_import_insert_batch_from_rows_with_format(
|
||||
rows: &[Vec<serde_json::Value>],
|
||||
columns: &[String],
|
||||
mappings: &[TableImportColumnMapping],
|
||||
target_column_types: &[(String, String)],
|
||||
table: &str,
|
||||
schema: &str,
|
||||
db_type: &DatabaseType,
|
||||
date_time_format: Option<&str>,
|
||||
) -> Result<Option<ImportSqlBatch>, String> {
|
||||
if rows.is_empty() {
|
||||
return Ok(None);
|
||||
|
|
@ -1365,7 +1390,16 @@ pub fn build_import_insert_batch_from_rows(
|
|||
.map(|row| {
|
||||
mapped
|
||||
.iter()
|
||||
.map(|(source_index, _)| row.get(*source_index).cloned().unwrap_or(serde_json::Value::Null))
|
||||
.enumerate()
|
||||
.map(|(target_index, (source_index, _))| {
|
||||
let value = row.get(*source_index).cloned().unwrap_or(serde_json::Value::Null);
|
||||
normalize_import_temporal_value(
|
||||
&value,
|
||||
column_types.get(target_index).and_then(|data_type| data_type.as_deref()),
|
||||
db_type,
|
||||
date_time_format,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
|
@ -1377,6 +1411,21 @@ fn supports_multi_row_insert_values(db_type: &DatabaseType) -> bool {
|
|||
!matches!(db_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle | DatabaseType::Iris)
|
||||
}
|
||||
|
||||
fn normalize_import_temporal_value(
|
||||
value: &serde_json::Value,
|
||||
data_type: Option<&str>,
|
||||
db_type: &DatabaseType,
|
||||
date_time_format: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let oracle_date_time = matches!(db_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle)
|
||||
&& data_type.is_some_and(|data_type| data_type.trim().eq_ignore_ascii_case("date"));
|
||||
crate::temporal_format::normalize_temporal_import_value(
|
||||
value,
|
||||
if oracle_date_time { Some("datetime") } else { data_type },
|
||||
date_time_format,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_import_insert_batches(
|
||||
data: &ParsedImportFile,
|
||||
mappings: &[TableImportColumnMapping],
|
||||
|
|
@ -1385,6 +1434,29 @@ pub fn build_import_insert_batches(
|
|||
schema: &str,
|
||||
db_type: &DatabaseType,
|
||||
batch_size: usize,
|
||||
) -> Result<Vec<ImportSqlBatch>, String> {
|
||||
build_import_insert_batches_with_format(
|
||||
data,
|
||||
mappings,
|
||||
target_column_types,
|
||||
table,
|
||||
schema,
|
||||
db_type,
|
||||
batch_size,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_import_insert_batches_with_format(
|
||||
data: &ParsedImportFile,
|
||||
mappings: &[TableImportColumnMapping],
|
||||
target_column_types: &[(String, String)],
|
||||
table: &str,
|
||||
schema: &str,
|
||||
db_type: &DatabaseType,
|
||||
batch_size: usize,
|
||||
date_time_format: Option<&str>,
|
||||
) -> Result<Vec<ImportSqlBatch>, String> {
|
||||
if *db_type == DatabaseType::CloudflareD1 {
|
||||
return crate::db::cloudflare_d1::build_import_insert_batches(
|
||||
|
|
@ -1419,7 +1491,16 @@ pub fn build_import_insert_batches(
|
|||
.map(|row| {
|
||||
mapped
|
||||
.iter()
|
||||
.map(|(source_index, _)| row.get(*source_index).cloned().unwrap_or(serde_json::Value::Null))
|
||||
.enumerate()
|
||||
.map(|(target_index, (source_index, _))| {
|
||||
let value = row.get(*source_index).cloned().unwrap_or(serde_json::Value::Null);
|
||||
normalize_import_temporal_value(
|
||||
&value,
|
||||
column_types.get(target_index).and_then(|data_type| data_type.as_deref()),
|
||||
db_type,
|
||||
date_time_format,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
|
@ -2036,7 +2117,7 @@ where
|
|||
pending_rows.push(delimited_record_to_row(&record, columns.len(), config));
|
||||
|
||||
if pending_rows.len() >= effective_batch_size {
|
||||
let batch = match build_import_insert_batch_from_rows(
|
||||
let batch = match build_import_insert_batch_from_rows_with_format(
|
||||
&pending_rows,
|
||||
&columns,
|
||||
&request.mappings,
|
||||
|
|
@ -2044,6 +2125,7 @@ where
|
|||
&request.table,
|
||||
&request.schema,
|
||||
db_type,
|
||||
request.date_time_format.as_deref(),
|
||||
) {
|
||||
Ok(Some(batch)) => batch,
|
||||
Ok(None) => {
|
||||
|
|
@ -2086,7 +2168,7 @@ where
|
|||
});
|
||||
return Err("Import cancelled".to_string());
|
||||
}
|
||||
let batch = match build_import_insert_batch_from_rows(
|
||||
let batch = match build_import_insert_batch_from_rows_with_format(
|
||||
&pending_rows,
|
||||
&columns,
|
||||
&request.mappings,
|
||||
|
|
@ -2094,6 +2176,7 @@ where
|
|||
&request.table,
|
||||
&request.schema,
|
||||
db_type,
|
||||
request.date_time_format.as_deref(),
|
||||
) {
|
||||
Ok(Some(batch)) => batch,
|
||||
Ok(None) => ImportSqlBatch { sql: String::new(), row_count: 0 },
|
||||
|
|
@ -2163,7 +2246,7 @@ where
|
|||
target_column_types = created_column_types.clone().unwrap_or_default();
|
||||
}
|
||||
|
||||
let batches = match build_import_insert_batches(
|
||||
let batches = match build_import_insert_batches_with_format(
|
||||
&parsed,
|
||||
&request.mappings,
|
||||
&target_column_types,
|
||||
|
|
@ -2171,6 +2254,7 @@ where
|
|||
&request.schema,
|
||||
db_type,
|
||||
batch_size,
|
||||
request.date_time_format.as_deref(),
|
||||
) {
|
||||
Ok(batches) => batches,
|
||||
Err(error) => {
|
||||
|
|
@ -3115,6 +3199,85 @@ mod tests {
|
|||
}]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_insert_batches_normalize_oracle_unpadded_slash_dates() {
|
||||
let mappings = vec![TableImportColumnMapping {
|
||||
source_column: "created_at".to_string(),
|
||||
target_column: "created_at".to_string(),
|
||||
target_data_type: None,
|
||||
}];
|
||||
let data = ParsedImportFile {
|
||||
columns: vec!["created_at".to_string()],
|
||||
rows: vec![vec![serde_json::json!("2024/2/25 13:02:15")]],
|
||||
total_rows: 1,
|
||||
effective_encoding: None,
|
||||
};
|
||||
|
||||
let batches = build_import_insert_batches(
|
||||
&data,
|
||||
&mappings,
|
||||
&[("created_at".to_string(), "DATE".to_string())],
|
||||
"events",
|
||||
"APP",
|
||||
&DatabaseType::Oracle,
|
||||
500,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
batches[0].sql,
|
||||
"INSERT INTO \"APP\".\"events\" (\"created_at\") VALUES\n(TO_DATE('2024-02-25 13:02:15', 'YYYY-MM-DD HH24:MI:SS'))"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_insert_batch_normalizes_oracle_date_and_timestamp_columns() {
|
||||
let mappings = vec![
|
||||
TableImportColumnMapping {
|
||||
source_column: "event_id".to_string(),
|
||||
target_column: "EVENT_ID".to_string(),
|
||||
target_data_type: None,
|
||||
},
|
||||
TableImportColumnMapping {
|
||||
source_column: "created_at".to_string(),
|
||||
target_column: "CREATED_AT".to_string(),
|
||||
target_data_type: None,
|
||||
},
|
||||
TableImportColumnMapping {
|
||||
source_column: "updated_at".to_string(),
|
||||
target_column: "UPDATED_AT".to_string(),
|
||||
target_data_type: None,
|
||||
},
|
||||
];
|
||||
let rows = vec![vec![
|
||||
serde_json::json!(1),
|
||||
serde_json::json!("2024/2/25 13:02:15"),
|
||||
serde_json::json!("2024/2/25 14:03:16"),
|
||||
]];
|
||||
|
||||
let batch = build_import_insert_batch_from_rows_with_format(
|
||||
&rows,
|
||||
&["event_id".to_string(), "created_at".to_string(), "updated_at".to_string()],
|
||||
&mappings,
|
||||
&[
|
||||
("EVENT_ID".to_string(), "NUMBER".to_string()),
|
||||
("CREATED_AT".to_string(), "DATE".to_string()),
|
||||
("UPDATED_AT".to_string(), "TIMESTAMP(6)".to_string()),
|
||||
],
|
||||
"EVENTS",
|
||||
"APP",
|
||||
&DatabaseType::Oracle,
|
||||
Some("YYYY/M/D HH:mm:ss"),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
batch.sql,
|
||||
"INSERT INTO \"APP\".\"EVENTS\" (\"EVENT_ID\", \"CREATED_AT\", \"UPDATED_AT\") VALUES\n(1, TO_DATE('2024-02-25 13:02:15', 'YYYY-MM-DD HH24:MI:SS'), TO_TIMESTAMP('2024-02-25 14:03:16', 'YYYY-MM-DD HH24:MI:SS'))"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_insert_batches_preserve_sqlserver_unicode_text() {
|
||||
let mappings = vec![TableImportColumnMapping {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,321 @@
|
|||
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime};
|
||||
use serde_json::Value;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TemporalKind {
|
||||
Date,
|
||||
Time,
|
||||
DateTime,
|
||||
DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
enum ParsedTemporal {
|
||||
Zoned(DateTime<FixedOffset>),
|
||||
DateTime(NaiveDateTime),
|
||||
Date(NaiveDate),
|
||||
Time(NaiveTime),
|
||||
}
|
||||
|
||||
fn temporal_kind(data_type: Option<&str>) -> Option<TemporalKind> {
|
||||
let normalized = data_type?.trim().to_ascii_lowercase().replace(char::is_whitespace, " ");
|
||||
let base = normalized.split(['(', ':', ' ']).next().unwrap_or("");
|
||||
if matches!(base, "datetimeoffset" | "datetimeoffsetn" | "timestamptz")
|
||||
|| (base == "timestamp"
|
||||
&& (normalized.contains("with time zone") || normalized.contains("with local time zone")))
|
||||
{
|
||||
return Some(TemporalKind::DateTimeWithTimeZone);
|
||||
}
|
||||
match base {
|
||||
"date" | "date32" | "daten" => Some(TemporalKind::Date),
|
||||
"time" | "time64" | "timen" | "timetz" => Some(TemporalKind::Time),
|
||||
"datetime" | "datetime2" | "datetime4" | "datetime64" | "datetimen" | "smalldatetime" | "timestamp"
|
||||
| "timestampdty" => Some(TemporalKind::DateTime),
|
||||
_ if base.starts_with("timestamp_") => Some(TemporalKind::DateTime),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn dayjs_to_chrono_pattern(pattern: &str) -> Option<String> {
|
||||
let pattern = pattern.trim();
|
||||
if pattern.is_empty() || pattern.len() > 100 || pattern.contains('%') {
|
||||
return None;
|
||||
}
|
||||
let tokens = [
|
||||
("YYYY", "%Y"),
|
||||
("SSS", "%3f"),
|
||||
("ZZ", "%z"),
|
||||
("MM", "%m"),
|
||||
("DD", "%d"),
|
||||
("HH", "%H"),
|
||||
("mm", "%M"),
|
||||
("ss", "%S"),
|
||||
("M", "%-m"),
|
||||
("D", "%-d"),
|
||||
("H", "%-H"),
|
||||
("m", "%-M"),
|
||||
("s", "%-S"),
|
||||
("Z", "%:z"),
|
||||
];
|
||||
let mut output = String::with_capacity(pattern.len() * 2);
|
||||
let mut index = 0;
|
||||
while index < pattern.len() {
|
||||
let remaining = &pattern[index..];
|
||||
if remaining.starts_with('[') {
|
||||
let close = remaining.find(']')?;
|
||||
output.push_str(&remaining[1..close]);
|
||||
index += close + 1;
|
||||
continue;
|
||||
}
|
||||
if let Some((token, replacement)) = tokens.iter().find(|(token, _)| remaining.starts_with(token)) {
|
||||
output.push_str(replacement);
|
||||
index += token.len();
|
||||
continue;
|
||||
}
|
||||
let ch = remaining.chars().next()?;
|
||||
// Reject unknown Day.js tokens instead of silently exporting different text than the frontend displays.
|
||||
if ch.is_ascii_alphabetic() {
|
||||
return None;
|
||||
}
|
||||
output.push(ch);
|
||||
index += ch.len_utf8();
|
||||
}
|
||||
Some(output)
|
||||
}
|
||||
|
||||
fn parse_with_pattern(value: &str, pattern: &str) -> Option<ParsedTemporal> {
|
||||
let pattern = dayjs_to_chrono_pattern(pattern)?;
|
||||
DateTime::parse_from_str(value, &pattern)
|
||||
.map(ParsedTemporal::Zoned)
|
||||
.ok()
|
||||
.or_else(|| NaiveDateTime::parse_from_str(value, &pattern).map(ParsedTemporal::DateTime).ok())
|
||||
.or_else(|| NaiveDate::parse_from_str(value, &pattern).map(ParsedTemporal::Date).ok())
|
||||
.or_else(|| NaiveTime::parse_from_str(value, &pattern).map(ParsedTemporal::Time).ok())
|
||||
}
|
||||
|
||||
fn parse_known_temporal(value: &str) -> Option<ParsedTemporal> {
|
||||
if let Ok(parsed) = DateTime::parse_from_rfc3339(value) {
|
||||
return Some(ParsedTemporal::Zoned(parsed));
|
||||
}
|
||||
for pattern in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S%.f", "%Y/%m/%d %H:%M:%S%.f", "%Y/%m/%dT%H:%M:%S%.f"] {
|
||||
if let Ok(parsed) = NaiveDateTime::parse_from_str(value, pattern) {
|
||||
return Some(ParsedTemporal::DateTime(parsed));
|
||||
}
|
||||
}
|
||||
for pattern in ["%Y-%m-%d", "%Y/%m/%d"] {
|
||||
if let Ok(parsed) = NaiveDate::parse_from_str(value, pattern) {
|
||||
return Some(ParsedTemporal::Date(parsed));
|
||||
}
|
||||
}
|
||||
for pattern in ["%H:%M:%S%.f", "%H:%M:%S"] {
|
||||
if let Ok(parsed) = NaiveTime::parse_from_str(value, pattern) {
|
||||
return Some(ParsedTemporal::Time(parsed));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_temporal(value: &str, preferred_pattern: Option<&str>) -> Option<ParsedTemporal> {
|
||||
preferred_pattern
|
||||
.filter(|pattern| !pattern.trim().is_empty())
|
||||
.and_then(|pattern| parse_with_pattern(value, pattern))
|
||||
.or_else(|| parse_known_temporal(value))
|
||||
}
|
||||
|
||||
fn format_parsed(parsed: ParsedTemporal, pattern: &str) -> Option<String> {
|
||||
let pattern = dayjs_to_chrono_pattern(pattern)?;
|
||||
let mut output = String::new();
|
||||
// Chrono reports missing date/time fields through fmt::Error; propagate it so exports preserve the raw value.
|
||||
match parsed {
|
||||
ParsedTemporal::Zoned(value) => write!(&mut output, "{}", value.format(&pattern)),
|
||||
ParsedTemporal::DateTime(value) => write!(&mut output, "{}", value.format(&pattern)),
|
||||
ParsedTemporal::Date(value) => write!(&mut output, "{}", value.format(&pattern)),
|
||||
ParsedTemporal::Time(value) => write!(&mut output, "{}", value.format(&pattern)),
|
||||
}
|
||||
.ok()?;
|
||||
Some(output)
|
||||
}
|
||||
|
||||
pub fn format_temporal_export_value(value: &Value, data_type: Option<&str>, pattern: Option<&str>) -> Value {
|
||||
let Some(pattern) = pattern.filter(|pattern| !pattern.trim().is_empty()) else {
|
||||
return value.clone();
|
||||
};
|
||||
if temporal_kind(data_type).is_none() {
|
||||
return value.clone();
|
||||
}
|
||||
let Some(raw) = value.as_str() else {
|
||||
return value.clone();
|
||||
};
|
||||
parse_known_temporal(raw)
|
||||
.and_then(|parsed| format_parsed(parsed, pattern))
|
||||
.map(Value::String)
|
||||
.unwrap_or_else(|| value.clone())
|
||||
}
|
||||
|
||||
pub fn format_temporal_export_row(row: &[Value], column_types: &[Option<String>], pattern: Option<&str>) -> Vec<Value> {
|
||||
row.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| {
|
||||
format_temporal_export_value(
|
||||
value,
|
||||
column_types.get(index).and_then(|data_type| data_type.as_deref()),
|
||||
pattern,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn format_temporal_export_rows(
|
||||
rows: &[Vec<Value>],
|
||||
column_types: &[Option<String>],
|
||||
pattern: Option<&str>,
|
||||
) -> Vec<Vec<Value>> {
|
||||
rows.iter().map(|row| format_temporal_export_row(row, column_types, pattern)).collect()
|
||||
}
|
||||
|
||||
pub fn format_temporal_export_row_with_string_types(
|
||||
row: &[Value],
|
||||
column_types: &[String],
|
||||
pattern: Option<&str>,
|
||||
) -> Vec<Value> {
|
||||
row.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| format_temporal_export_value(value, column_types.get(index).map(String::as_str), pattern))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn format_temporal_export_rows_with_string_types(
|
||||
rows: &[Vec<Value>],
|
||||
column_types: &[String],
|
||||
pattern: Option<&str>,
|
||||
) -> Vec<Vec<Value>> {
|
||||
rows.iter().map(|row| format_temporal_export_row_with_string_types(row, column_types, pattern)).collect()
|
||||
}
|
||||
|
||||
pub fn normalize_temporal_import_value(value: &Value, data_type: Option<&str>, pattern: Option<&str>) -> Value {
|
||||
let Some(kind) = temporal_kind(data_type) else {
|
||||
return value.clone();
|
||||
};
|
||||
let Some(raw) = value.as_str() else {
|
||||
return value.clone();
|
||||
};
|
||||
let Some(parsed) = parse_temporal(raw.trim(), pattern) else {
|
||||
return value.clone();
|
||||
};
|
||||
|
||||
let normalized = match (kind, parsed) {
|
||||
(TemporalKind::Date, ParsedTemporal::Zoned(value)) => value.date_naive().format("%Y-%m-%d").to_string(),
|
||||
(TemporalKind::Date, ParsedTemporal::DateTime(value)) => value.date().format("%Y-%m-%d").to_string(),
|
||||
(TemporalKind::Date, ParsedTemporal::Date(value)) => value.format("%Y-%m-%d").to_string(),
|
||||
(TemporalKind::Date, ParsedTemporal::Time(_)) => return value.clone(),
|
||||
(TemporalKind::Time, ParsedTemporal::Zoned(value)) => value.time().format("%H:%M:%S%.f").to_string(),
|
||||
(TemporalKind::Time, ParsedTemporal::DateTime(value)) => value.time().format("%H:%M:%S%.f").to_string(),
|
||||
(TemporalKind::Time, ParsedTemporal::Time(value)) => value.format("%H:%M:%S%.f").to_string(),
|
||||
(TemporalKind::Time, ParsedTemporal::Date(_)) => return value.clone(),
|
||||
(TemporalKind::DateTime, ParsedTemporal::Zoned(value)) => {
|
||||
value.naive_local().format("%Y-%m-%d %H:%M:%S%.f").to_string()
|
||||
}
|
||||
(TemporalKind::DateTime, ParsedTemporal::DateTime(value)) => value.format("%Y-%m-%d %H:%M:%S%.f").to_string(),
|
||||
(TemporalKind::DateTime, ParsedTemporal::Date(date)) => {
|
||||
let Some(value) = date.and_hms_opt(0, 0, 0) else {
|
||||
return value.clone();
|
||||
};
|
||||
value.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
(TemporalKind::DateTime, ParsedTemporal::Time(_)) => return value.clone(),
|
||||
(TemporalKind::DateTimeWithTimeZone, ParsedTemporal::Zoned(value)) => {
|
||||
value.format("%Y-%m-%dT%H:%M:%S%.f%:z").to_string()
|
||||
}
|
||||
(TemporalKind::DateTimeWithTimeZone, ParsedTemporal::DateTime(value)) => {
|
||||
value.format("%Y-%m-%d %H:%M:%S%.f").to_string()
|
||||
}
|
||||
(TemporalKind::DateTimeWithTimeZone, ParsedTemporal::Date(date)) => {
|
||||
let Some(value) = date.and_hms_opt(0, 0, 0) else {
|
||||
return value.clone();
|
||||
};
|
||||
value.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
(TemporalKind::DateTimeWithTimeZone, ParsedTemporal::Time(_)) => return value.clone(),
|
||||
};
|
||||
Value::String(normalized)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn normalizes_unpadded_slash_dates_for_import() {
|
||||
assert_eq!(
|
||||
normalize_temporal_import_value(&json!("2024/2/25 13:02:15"), Some("DATE"), None),
|
||||
json!("2024-02-25")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_temporal_import_value(
|
||||
&json!("25.02.2024 13:02:15"),
|
||||
Some("TIMESTAMP(6)"),
|
||||
Some("DD.MM.YYYY HH:mm:ss")
|
||||
),
|
||||
json!("2024-02-25 13:02:15")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_only_typed_temporal_export_values() {
|
||||
let row = vec![json!(1), json!("2024-02-25 13:02:15"), json!("2024-02-25 13:02:15")];
|
||||
assert_eq!(
|
||||
format_temporal_export_row(
|
||||
&row,
|
||||
&[Some("NUMBER".into()), Some("TIMESTAMP".into()), Some("VARCHAR2".into())],
|
||||
Some("YYYY/M/D HH:mm:ss")
|
||||
),
|
||||
vec![json!(1), json!("2024/2/25 13:02:15"), json!("2024-02-25 13:02:15")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_raw_export_values_when_pattern_requires_missing_fields() {
|
||||
assert_eq!(
|
||||
format_temporal_export_value(&json!("2024-02-25"), Some("DATE"), Some("YYYY-MM-DD HH:mm:ss")),
|
||||
json!("2024-02-25")
|
||||
);
|
||||
assert_eq!(
|
||||
format_temporal_export_value(&json!("13:02:15"), Some("TIME"), Some("YYYY-MM-DD HH:mm:ss")),
|
||||
json!("13:02:15")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_dayjs_tokens_but_allows_literal_text() {
|
||||
assert_eq!(dayjs_to_chrono_pattern("MM/DD/YYYY hh:mm A"), None);
|
||||
assert_eq!(dayjs_to_chrono_pattern("YYYY-MM-DD [at] HH:mm:ss"), Some("%Y-%m-%d at %H:%M:%S".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_common_driver_temporal_type_aliases() {
|
||||
for data_type in ["DateTime64(3)", "date32", "timestamp_ns", "TimeStampDTY", "datetimeoffsetn", "timen"] {
|
||||
assert!(temporal_kind(Some(data_type)).is_some(), "{data_type}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_formatting_preserves_offset_datetime_fields() {
|
||||
assert_eq!(
|
||||
format_temporal_export_value(&json!("2024-02-25T13:02:15Z"), Some("DATE"), Some("YYYY/M/D HH:mm:ss")),
|
||||
json!("2024/2/25 13:02:15")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_normalization_preserves_timezone_offsets() {
|
||||
assert_eq!(
|
||||
normalize_temporal_import_value(
|
||||
&json!("2024-02-25T13:02:15+08:00"),
|
||||
Some("timestamp with time zone"),
|
||||
None
|
||||
),
|
||||
json!("2024-02-25T13:02:15+08:00")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1073,8 +1073,8 @@ pub fn escape_value_typed(val: &serde_json::Value, db_type: &DatabaseType, colum
|
|||
if let Some(numeric_literal) = format_mysql_numeric_string_literal(s, db_type, column_type) {
|
||||
return numeric_literal;
|
||||
}
|
||||
if let Some(date_literal) = format_oracle_date_sql_literal(s, db_type, column_type) {
|
||||
return date_literal;
|
||||
if let Some(temporal_literal) = format_oracle_temporal_sql_literal(s, db_type, column_type) {
|
||||
return temporal_literal;
|
||||
}
|
||||
|
||||
let literal = format_literal_string(s, db_type, column_type);
|
||||
|
|
@ -1178,21 +1178,46 @@ fn format_mysql_binary_sql_literal(value: &str, db_type: &DatabaseType, column_t
|
|||
}
|
||||
}
|
||||
|
||||
fn format_oracle_date_sql_literal(value: &str, db_type: &DatabaseType, column_type: Option<&str>) -> Option<String> {
|
||||
fn format_oracle_temporal_sql_literal(
|
||||
value: &str,
|
||||
db_type: &DatabaseType,
|
||||
column_type: Option<&str>,
|
||||
) -> Option<String> {
|
||||
if !matches!(db_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle) {
|
||||
return None;
|
||||
}
|
||||
if temporal_column_kind(column_type) != Some("date") {
|
||||
return None;
|
||||
}
|
||||
let kind = temporal_column_kind(column_type)?;
|
||||
let normalized_column_type = column_type?.trim().to_ascii_lowercase();
|
||||
let parts = oracle_export_date_parts(value)?;
|
||||
Some(format_oracle_date_sql_literal_parts(&parts))
|
||||
match kind {
|
||||
"date" => Some(format_oracle_date_sql_literal_parts(&parts)),
|
||||
"datetime"
|
||||
if (normalized_column_type.contains("with time zone")
|
||||
|| normalized_column_type.contains("with local time zone"))
|
||||
&& parts.zone.is_some() =>
|
||||
{
|
||||
let fraction = parts.fraction.unwrap_or_default();
|
||||
let mask = if fraction.is_empty() { "YYYY-MM-DD HH24:MI:SS" } else { "YYYY-MM-DD HH24:MI:SS.FF" };
|
||||
let zone = match parts.zone.unwrap_or_default() {
|
||||
"Z" | "z" => "+00:00",
|
||||
zone => zone,
|
||||
};
|
||||
Some(format!("TO_TIMESTAMP_TZ('{} {}{fraction} {zone}', '{mask} TZH:TZM')", parts.date, parts.time))
|
||||
}
|
||||
"datetime" => {
|
||||
let fraction = parts.fraction.unwrap_or_default();
|
||||
let mask = if fraction.is_empty() { "YYYY-MM-DD HH24:MI:SS" } else { "YYYY-MM-DD HH24:MI:SS.FF" };
|
||||
Some(format!("TO_TIMESTAMP('{} {}{fraction}', '{mask}')", parts.date, parts.time))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
struct OracleExportDateParts<'a> {
|
||||
date: &'a str,
|
||||
time: &'a str,
|
||||
fraction: Option<&'a str>,
|
||||
zone: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn format_oracle_date_sql_literal_parts(parts: &OracleExportDateParts<'_>) -> String {
|
||||
|
|
@ -1218,7 +1243,7 @@ fn oracle_export_date_parts(value: &str) -> Option<OracleExportDateParts<'_>> {
|
|||
return None;
|
||||
}
|
||||
if bytes.len() == 10 {
|
||||
return Some(OracleExportDateParts { date, time: "00:00:00", fraction: None });
|
||||
return Some(OracleExportDateParts { date, time: "00:00:00", fraction: None, zone: None });
|
||||
}
|
||||
let separator = *bytes.get(10)?;
|
||||
if separator != b'T' && separator != b' ' {
|
||||
|
|
@ -1233,7 +1258,7 @@ fn oracle_export_date_parts(value: &str) -> Option<OracleExportDateParts<'_>> {
|
|||
}
|
||||
let rest = &value[19..];
|
||||
if rest.is_empty() || is_timezone_suffix(rest) {
|
||||
return Some(OracleExportDateParts { date, time, fraction: None });
|
||||
return Some(OracleExportDateParts { date, time, fraction: None, zone: (!rest.is_empty()).then_some(rest) });
|
||||
}
|
||||
if let Some(after_dot) = rest.strip_prefix('.') {
|
||||
let digit_count = after_dot.bytes().take_while(|byte| byte.is_ascii_digit()).count();
|
||||
|
|
@ -1242,7 +1267,12 @@ fn oracle_export_date_parts(value: &str) -> Option<OracleExportDateParts<'_>> {
|
|||
}
|
||||
let zone = &after_dot[digit_count..];
|
||||
if zone.is_empty() || is_timezone_suffix(zone) {
|
||||
return Some(OracleExportDateParts { date, time, fraction: Some(&value[19..19 + 1 + digit_count]) });
|
||||
return Some(OracleExportDateParts {
|
||||
date,
|
||||
time,
|
||||
fraction: Some(&value[19..19 + 1 + digit_count]),
|
||||
zone: (!zone.is_empty()).then_some(zone),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
|
|
@ -5987,12 +6017,20 @@ mod tests {
|
|||
|
||||
assert_eq!(
|
||||
sql,
|
||||
"INSERT INTO \"APP\".\"events\" (\"id\", \"created_on\", \"created_at\", \"raw_text\") VALUES\n(1, TO_DATE('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS'), '2022-08-25T09:58:43Z', '2022-08-25T09:58:43Z')"
|
||||
"INSERT INTO \"APP\".\"events\" (\"id\", \"created_on\", \"created_at\", \"raw_text\") VALUES\n(1, TO_DATE('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS'), TO_TIMESTAMP('2022-08-25 09:58:43', 'YYYY-MM-DD HH24:MI:SS'), '2022-08-25T09:58:43Z')"
|
||||
);
|
||||
assert_eq!(
|
||||
escape_value_typed(&json!("2022-08-25T00:00:00Z"), &DatabaseType::Oracle, Some("DATE")),
|
||||
"DATE '2022-08-25'"
|
||||
);
|
||||
assert_eq!(
|
||||
escape_value_typed(
|
||||
&json!("2022-08-25T09:58:43.123456+08:00"),
|
||||
&DatabaseType::Oracle,
|
||||
Some("TIMESTAMP(6) WITH TIME ZONE")
|
||||
),
|
||||
"TO_TIMESTAMP_TZ('2022-08-25 09:58:43.123456 +08:00', 'YYYY-MM-DD HH24:MI:SS.FF TZH:TZM')"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ async fn live_clickhouse_query_result_export_xlsx_streams_random_order_query_onc
|
|||
keyset_optimization_enabled: false,
|
||||
client_session_id: None,
|
||||
execution_id: Some(format!("live-clickhouse-query-export-{suffix}")),
|
||||
date_time_format: None,
|
||||
};
|
||||
let done_seen = AtomicBool::new(false);
|
||||
let result = export_query_result_core(&state, &request, None, |progress| {
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ async fn live_mysql_query_result_export_xlsx_streams_single_query_without_duplic
|
|||
keyset_optimization_enabled: false,
|
||||
client_session_id: None,
|
||||
execution_id: Some(format!("live-mysql-query-export-{suffix}")),
|
||||
date_time_format: None,
|
||||
};
|
||||
let done_seen = AtomicBool::new(false);
|
||||
let result = export_query_result_core(&state, &request, None, |progress| {
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ async fn live_postgres_query_result_export_uses_single_streamed_query() {
|
|||
keyset_optimization_enabled: true,
|
||||
client_session_id: None,
|
||||
execution_id: Some(format!("live-postgres-query-export-{suffix}")),
|
||||
date_time_format: None,
|
||||
};
|
||||
let done_seen = AtomicBool::new(false);
|
||||
let result = export_query_result_core(&state, &request, None, |progress| {
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ async fn live_sqlserver_stream_first_result_set_exports_cte_query_rows() {
|
|||
let mut rows = Vec::new();
|
||||
let result = dbx_core::db::sqlserver::stream_first_result_set(&mut client, &sql, None, None, |item| {
|
||||
match item {
|
||||
dbx_core::db::sqlserver::SqlServerStreamItem::Columns(stream_columns) => {
|
||||
dbx_core::db::sqlserver::SqlServerStreamItem::Columns { columns: stream_columns, .. } => {
|
||||
columns = stream_columns.to_vec();
|
||||
}
|
||||
dbx_core::db::sqlserver::SqlServerStreamItem::Row(row) => {
|
||||
|
|
@ -369,6 +369,7 @@ async fn live_sqlserver_query_result_export_streams_cte_query_to_csv() {
|
|||
keyset_optimization_enabled: true,
|
||||
client_session_id: None,
|
||||
execution_id: Some(format!("live-sqlserver-export-{suffix}")),
|
||||
date_time_format: None,
|
||||
};
|
||||
let done_seen = AtomicBool::new(false);
|
||||
let result = export_query_result_core(&state, &request, None, |progress| {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,27 @@ test("formats unix timestamps in seconds, milliseconds, and auto mode", () => {
|
|||
assert.equal(applyColumnFormatter("1715758200", { kind: "datetime", unit: "auto", pattern: "YYYY-MM-DD HH:mm:ssZ" }), "2024-05-15 15:30:00+08:00");
|
||||
});
|
||||
|
||||
test("uses the global datetime formatter only for temporal columns without a column override", () => {
|
||||
const global = { pattern: "YYYY/MM/DD HH:mm:ss", columnType: "TIMESTAMP(6)" };
|
||||
|
||||
assert.deepEqual(resolveColumnFormatter(undefined, {}, global), { kind: "datetime", unit: "auto", pattern: "YYYY/MM/DD HH:mm:ss" });
|
||||
assert.equal(resolveColumnFormatter(undefined, {}, { ...global, columnType: "VARCHAR2(100)" }), undefined);
|
||||
assert.deepEqual(resolveColumnFormatter({ kind: "mask", prefix: 2, suffix: 2 }, {}, global), { kind: "mask", prefix: 2, suffix: 2 });
|
||||
assert.deepEqual(resolveColumnFormatter(undefined, {}, { ...global, columnType: "DateTime64(3)" }), { kind: "datetime", unit: "auto", pattern: global.pattern });
|
||||
assert.deepEqual(resolveColumnFormatter(undefined, {}, { ...global, columnType: "datetimeoffsetn" }), { kind: "datetime", unit: "auto", pattern: global.pattern });
|
||||
});
|
||||
|
||||
test("formats only typed temporal cells for export", async () => {
|
||||
const { formatTemporalRowsForExport } = await import("../../apps/desktop/src/lib/dataGrid/columnFormatter.ts");
|
||||
const rows = formatTemporalRowsForExport([[1, "2024-02-25T05:02:15Z", "2024-02-25T05:02:15Z", "2024-02-25T05:02:15.123456+08:00", "2024-02-25 13:02:15.987654"]], ["NUMBER", "TIMESTAMP", "VARCHAR2", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP(6)"], "YYYY/MM/DD HH:mm:ss.SSSZ");
|
||||
|
||||
assert.deepEqual(rows, [[1, "2024/02/25 05:02:15.000+00:00", "2024-02-25T05:02:15Z", "2024/02/25 05:02:15.123+08:00", "2024/02/25 13:02:15.987+08:00"]]);
|
||||
});
|
||||
|
||||
test("formats Oracle timestamp fractional precision in the data grid", () => {
|
||||
assert.equal(applyColumnFormatter("2024-02-25 13:02:15.123456", { kind: "datetime", unit: "auto", pattern: "YYYY/MM/DD HH:mm:ss.SSS" }), "2024/02/25 13:02:15.123");
|
||||
});
|
||||
|
||||
test("does not treat compact date strings as unix timestamps", () => {
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { beforeEach, test, vi } from "vitest";
|
||||
import { computed, ref } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useDataGridExport } from "../../apps/desktop/src/composables/useDataGridExport.ts";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import * as api from "@/lib/backend/api";
|
||||
|
|
@ -21,11 +22,13 @@ vi.mock("@/lib/backend/api", () => ({
|
|||
|
||||
const draftRowId = Number.MIN_SAFE_INTEGER;
|
||||
|
||||
function createExportContext(options: {
|
||||
contextRowId?: number;
|
||||
selectedRowIds?: Set<number>;
|
||||
fullExportResult?: () => Promise<{ columns: string[]; rows: Array<Array<string | number | boolean | null>>; affected_rows: number; execution_time_ms: number }>;
|
||||
} = {}) {
|
||||
function createExportContext(
|
||||
options: {
|
||||
contextRowId?: number;
|
||||
selectedRowIds?: Set<number>;
|
||||
fullExportResult?: () => Promise<{ columns: string[]; rows: Array<Array<string | number | boolean | null>>; affected_rows: number; execution_time_ms: number }>;
|
||||
} = {},
|
||||
) {
|
||||
const contextRowId = options.contextRowId ?? draftRowId;
|
||||
const selectedRowIds = ref(options.selectedRowIds ?? new Set<number>());
|
||||
const rows = [
|
||||
|
|
@ -75,6 +78,7 @@ function createExportContext(options: {
|
|||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
globalThis.window = {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
|
|
|
|||
|
|
@ -342,6 +342,19 @@ test("defaults column formatters to an empty record", () => {
|
|||
assert.deepEqual(normalizeEditorSettings({}).columnFormatters, {});
|
||||
});
|
||||
|
||||
test("normalizes global datetime display and transfer formats", () => {
|
||||
assert.equal(DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat, "");
|
||||
const settings = normalizeEditorSettings({
|
||||
globalDateTimeDisplayFormat: " YYYY/MM/DD HH:mm:ss ",
|
||||
globalDateTimeExportFormat: "YYYY-M-D H:m:s",
|
||||
globalDateTimeImportFormat: 123,
|
||||
} as any);
|
||||
|
||||
assert.equal(settings.globalDateTimeDisplayFormat, "YYYY/MM/DD HH:mm:ss");
|
||||
assert.equal(settings.globalDateTimeExportFormat, "YYYY-M-D H:m:s");
|
||||
assert.equal(settings.globalDateTimeImportFormat, "");
|
||||
});
|
||||
|
||||
test("keeps only valid saved column formatter configs", () => {
|
||||
const settings = normalizeEditorSettings({
|
||||
columnFormatters: {
|
||||
|
|
|
|||
|
|
@ -52,10 +52,18 @@ function buildExportHarness(
|
|||
options: {
|
||||
currentResultLabel?: string;
|
||||
exportFileBaseName?: string;
|
||||
columns?: string[];
|
||||
columnTypes?: Array<string | undefined>;
|
||||
rows?: QueryResult["rows"];
|
||||
allExportResults?: Array<{ sheetName: string; result: QueryResult; sql?: string }>;
|
||||
} = {},
|
||||
) {
|
||||
const exportColumns = options.columns ?? ["id", "name"];
|
||||
const exportRows = options.rows ?? [
|
||||
[1, "Ada"],
|
||||
[2, "Lin"],
|
||||
];
|
||||
const rowItems = exportRows.map((data, index) => ({ id: index + 1, data, isNew: false, isDeleted: false, isDirtyCol: data.map(() => false), status: "" }));
|
||||
const exportProgressDialog = ref(false);
|
||||
const exportProgressState = ref({
|
||||
title: "",
|
||||
|
|
@ -93,11 +101,8 @@ function buildExportHarness(
|
|||
}));
|
||||
|
||||
const composable = useDataGridExport({
|
||||
columns: computed(() => ["id", "name"]),
|
||||
displayItems: computed(() => [
|
||||
{ id: 1, data: [1, "Ada"], isNew: false, isDeleted: false, isDirtyCol: [false, false], status: "" },
|
||||
{ id: 2, data: [2, "Lin"], isNew: false, isDeleted: false, isDirtyCol: [false, false], status: "" },
|
||||
]),
|
||||
columns: computed(() => exportColumns),
|
||||
displayItems: computed(() => rowItems),
|
||||
sql: computed(() => "SELECT * FROM users"),
|
||||
exportSql: computed(() => "SELECT * FROM users ORDER BY id DESC"),
|
||||
tableMeta: computed(() => undefined),
|
||||
|
|
@ -114,11 +119,7 @@ function buildExportHarness(
|
|||
selectedCells: computed(() => ({ columns: [], rows: [] })),
|
||||
selectedRange: computed(() => null),
|
||||
contextCell: ref(null),
|
||||
getRowItem: (rowId: number) =>
|
||||
[
|
||||
{ id: 1, data: [1, "Ada"], isNew: false, isDeleted: false, isDirtyCol: [false, false], status: "" },
|
||||
{ id: 2, data: [2, "Lin"], isNew: false, isDeleted: false, isDirtyCol: [false, false], status: "" },
|
||||
].find((item) => item.id === rowId),
|
||||
getRowItem: (rowId: number) => rowItems.find((item) => item.id === rowId),
|
||||
selectedRowIds: ref(new Set<number>()),
|
||||
hasRowSelection: computed(() => false),
|
||||
fullExportResult,
|
||||
|
|
@ -652,6 +653,7 @@ test("default data grid export file names use sanitized base names and compact l
|
|||
});
|
||||
|
||||
test("full query result CSV export streams through the backend without loading all rows", async () => {
|
||||
useSettingsStore().updateEditorSettings({ globalDateTimeExportFormat: "YYYY/M/D HH:mm:ss" });
|
||||
const { composable, fullExportResult, queryResultExportRequest, exportProgressDialog, exportProgressState } = buildExportHarness();
|
||||
|
||||
await composable.exportCsv();
|
||||
|
|
@ -659,6 +661,7 @@ test("full query result CSV export streams through the backend without loading a
|
|||
assert.equal(fullExportResult.mock.calls.length, 0);
|
||||
assert.equal(queryResultExportRequest.mock.calls.length, 1);
|
||||
assert.equal(apiMock.startQueryResultExport.mock.calls.length, 1);
|
||||
assert.equal(apiMock.startQueryResultExport.mock.calls[0][0].dateTimeFormat, "YYYY/M/D HH:mm:ss");
|
||||
assert.equal(apiMock.exportQueryResultCsv.mock.calls.length, 0);
|
||||
assert.equal(exportProgressDialog.value, true);
|
||||
assert.equal(exportProgressState.value.status, "Done");
|
||||
|
|
@ -773,6 +776,20 @@ test("selected query result CSV export keeps the existing in-memory path", async
|
|||
assert.deepEqual(apiMock.exportQueryResultCsv.mock.calls[0][2], [[1, "Ada"]]);
|
||||
});
|
||||
|
||||
test("selected query result CSV export formats only typed temporal columns", async () => {
|
||||
useSettingsStore().updateEditorSettings({ globalDateTimeExportFormat: "YYYY/M/D HH:mm:ss" });
|
||||
const rawDateTime = "2024-02-25 13:02:15";
|
||||
const { composable } = buildExportHarness({
|
||||
columns: ["created_at", "note"],
|
||||
columnTypes: ["timestamp", "varchar"],
|
||||
rows: [[rawDateTime, rawDateTime]],
|
||||
});
|
||||
|
||||
await composable.exportCsv([1]);
|
||||
|
||||
assert.deepEqual(apiMock.exportQueryResultCsv.mock.calls[0][2], [["2024/2/25 13:02:15", rawDateTime]]);
|
||||
});
|
||||
|
||||
test("selected query result XLSX export uses the current source label as the sheet name", async () => {
|
||||
const { composable, queryResultExportRequest } = buildExportHarness({ currentResultLabel: "aaa.apis", columnTypes: ["bigint(20)", "varchar(64)"] });
|
||||
|
||||
|
|
@ -888,6 +905,20 @@ test("table data export leaves row limit unset by default", async () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("table data export passes the global date time export format", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
try {
|
||||
useSettingsStore().updateEditorSettings({ globalDateTimeExportFormat: "YYYY/MM/DD HH:mm:ss" });
|
||||
const { composable } = buildTableDataExportHarness();
|
||||
|
||||
await composable.exportCsv();
|
||||
|
||||
assert.equal(apiMock.startTableExport.mock.calls[0][0].dateTimeFormat, "YYYY/MM/DD HH:mm:ss");
|
||||
} finally {
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
|
||||
test("table data export requests row count for determinate progress", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in New Issue