Merge branch 'main' of https://github.com/Abeautifulsnow/dbx into feat/ai-agent-loop-phase1
This commit is contained in:
commit
453169ae45
|
|
@ -6,11 +6,13 @@ import { CanvasRenderer } from "echarts/renderers";
|
|||
import { LineChart, BarChart, PieChart } from "echarts/charts";
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from "echarts/components";
|
||||
import VChart from "vue-echarts";
|
||||
import { BarChart3 } from "@lucide/vue";
|
||||
import { BarChart3, ChevronDown } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { axisColumnLabel, chartableColumnIndexes, toChartNumber } from "@/lib/chartData";
|
||||
|
||||
use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||
|
||||
|
|
@ -23,41 +25,58 @@ const { isDark } = useTheme();
|
|||
|
||||
type ChartType = "line" | "bar" | "pie";
|
||||
const chartType = ref<ChartType>("bar");
|
||||
const xColumn = ref("");
|
||||
const yColumns = ref<string[]>([]);
|
||||
const xColumnIndex = ref(0);
|
||||
const yColumnIndexes = ref<number[]>([]);
|
||||
|
||||
const numericColumns = computed(() => props.result.columns.filter((_, idx) => props.result.rows.some((row) => typeof row[idx] === "number")));
|
||||
const numericColumnIndexes = computed(() => chartableColumnIndexes(props.result));
|
||||
|
||||
const allColumns = computed(() => props.result.columns);
|
||||
const allColumnOptions = computed(() => props.result.columns.map((_, index) => ({ index, label: axisColumnLabel(props.result.columns, index) })));
|
||||
const numericColumnOptions = computed(() => numericColumnIndexes.value.map((index) => ({ index, label: axisColumnLabel(props.result.columns, index) })));
|
||||
const xColumnValue = computed({
|
||||
get: () => String(xColumnIndex.value),
|
||||
set: (value: string) => {
|
||||
const index = Number(value);
|
||||
if (Number.isInteger(index) && index >= 0 && index < props.result.columns.length) {
|
||||
xColumnIndex.value = index;
|
||||
}
|
||||
},
|
||||
});
|
||||
const yColumnLabel = computed(() => {
|
||||
if (yColumnIndexes.value.length === 0) return "0";
|
||||
const [first, ...rest] = yColumnIndexes.value;
|
||||
const label = axisColumnLabel(props.result.columns, first);
|
||||
return rest.length > 0 ? `${label} +${rest.length}` : label;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.result,
|
||||
() => {
|
||||
const cols = props.result.columns;
|
||||
const numCols = numericColumns.value;
|
||||
xColumn.value = cols.find((c) => !numCols.includes(c)) || cols[0] || "";
|
||||
yColumns.value = numCols.length > 0 ? [numCols[0]] : [];
|
||||
const numCols = numericColumnIndexes.value;
|
||||
xColumnIndex.value = cols.findIndex((_, index) => !numCols.includes(index));
|
||||
if (xColumnIndex.value < 0) xColumnIndex.value = cols.length > 0 ? 0 : -1;
|
||||
yColumnIndexes.value = numCols.length > 0 ? [numCols[0]] : [];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function toggleYColumn(col: string) {
|
||||
const idx = yColumns.value.indexOf(col);
|
||||
function toggleYColumn(index: number) {
|
||||
const idx = yColumnIndexes.value.indexOf(index);
|
||||
if (idx >= 0) {
|
||||
yColumns.value = yColumns.value.filter((c) => c !== col);
|
||||
yColumnIndexes.value = yColumnIndexes.value.filter((selected) => selected !== index);
|
||||
} else {
|
||||
yColumns.value = [...yColumns.value, col];
|
||||
yColumnIndexes.value = [...yColumnIndexes.value, index];
|
||||
}
|
||||
}
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const xIdx = props.result.columns.indexOf(xColumn.value);
|
||||
if (xIdx < 0 || yColumns.value.length === 0) return null;
|
||||
const xIdx = xColumnIndex.value;
|
||||
if (xIdx < 0 || yColumnIndexes.value.length === 0) return null;
|
||||
|
||||
const xData = props.result.rows.map((row) => String(row[xIdx] ?? ""));
|
||||
|
||||
if (chartType.value === "pie") {
|
||||
const yIdx = props.result.columns.indexOf(yColumns.value[0]);
|
||||
const yIdx = yColumnIndexes.value[0];
|
||||
if (yIdx < 0) return null;
|
||||
return {
|
||||
tooltip: { trigger: "item" },
|
||||
|
|
@ -68,14 +87,14 @@ const chartOption = computed(() => {
|
|||
radius: ["30%", "60%"],
|
||||
data: xData.map((name, i) => ({
|
||||
name,
|
||||
value: Number(props.result.rows[i][yIdx]) || 0,
|
||||
value: toChartNumber(props.result.rows[i][yIdx]) ?? 0,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const yIndices = yColumns.value.map((c) => props.result.columns.indexOf(c)).filter((i) => i >= 0);
|
||||
const yIndices = yColumnIndexes.value.filter((index) => index >= 0 && index < props.result.columns.length);
|
||||
|
||||
return {
|
||||
tooltip: { trigger: "axis" },
|
||||
|
|
@ -94,15 +113,15 @@ const chartOption = computed(() => {
|
|||
axisLabel: { color: isDark.value ? "#aaa" : "#666" },
|
||||
},
|
||||
series: yIndices.map((yIdx) => ({
|
||||
name: props.result.columns[yIdx],
|
||||
name: axisColumnLabel(props.result.columns, yIdx),
|
||||
type: chartType.value,
|
||||
data: props.result.rows.map((row) => Number(row[yIdx]) || 0),
|
||||
data: props.result.rows.map((row) => toChartNumber(row[yIdx]) ?? 0),
|
||||
smooth: chartType.value === "line",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const hasData = computed(() => props.result.rows.length > 0 && numericColumns.value.length > 0);
|
||||
const hasData = computed(() => props.result.rows.length > 0 && numericColumnIndexes.value.length > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -124,23 +143,31 @@ const hasData = computed(() => props.result.rows.length > 0 && numericColumns.va
|
|||
<span class="h-4 w-px bg-border" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground">X</span>
|
||||
<Select :model-value="xColumn" @update:model-value="(v: any) => (xColumn = v)">
|
||||
<Select v-model="xColumnValue">
|
||||
<SelectTrigger class="h-6 w-auto max-w-40 border-0 bg-transparent px-1 text-xs shadow-none focus:ring-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="col in allColumns" :key="col" :value="col">{{ col }}</SelectItem>
|
||||
<SelectItem v-for="col in allColumnOptions" :key="col.index" :value="String(col.index)">{{ col.label }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<span class="h-4 w-px bg-border" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground">Y</span>
|
||||
<div class="flex gap-0.5">
|
||||
<Button v-for="col in numericColumns" :key="col" size="sm" :variant="yColumns.includes(col) ? 'secondary' : 'ghost'" class="h-6 px-2 text-xs" @click="toggleYColumn(col)">
|
||||
{{ col }}
|
||||
</Button>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-6 max-w-48 gap-1 px-2 text-xs">
|
||||
<span class="truncate">{{ yColumnLabel }}</span>
|
||||
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-56" align="start" @close-auto-focus.prevent>
|
||||
<DropdownMenuCheckboxItem v-for="col in numericColumnOptions" :key="col.index" :checked="yColumnIndexes.includes(col.index)" class="text-xs" @select.prevent @click="toggleYColumn(col.index)">
|
||||
<span class="truncate">{{ col.label }}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 p-2">
|
||||
|
|
|
|||
|
|
@ -365,6 +365,8 @@ const customSaveHandler = computed<CustomSaveHandler>(() => ({
|
|||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
const previousSelectedIdx = selectedIdx.value;
|
||||
const previousSelectedId = previousSelectedIdx === null ? null : documentIdentity(documents.value[previousSelectedIdx]);
|
||||
try {
|
||||
const filter = currentDocumentFilter();
|
||||
const sort = sortInput.value.trim() || undefined;
|
||||
|
|
@ -382,6 +384,7 @@ async function load() {
|
|||
lastGridColumns.value = [...keySet];
|
||||
}
|
||||
total.value = result.total;
|
||||
syncSelectedDocumentAfterLoad(previousSelectedIdx, previousSelectedId);
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
|
|
@ -414,6 +417,33 @@ function asRecord(value: unknown): JsonRecord {
|
|||
return {};
|
||||
}
|
||||
|
||||
function documentIdentity(doc: JsonRecord | undefined): string | null {
|
||||
const id = doc?._id;
|
||||
if (id === null || id === undefined) return null;
|
||||
return typeof id === "object" ? JSON.stringify(id) : String(id);
|
||||
}
|
||||
|
||||
function syncSelectedDocumentAfterLoad(previousSelectedIdx: number | null, previousSelectedId: string | null) {
|
||||
if (isNew.value || previousSelectedIdx === null) return;
|
||||
if (!documents.value.length) {
|
||||
selectedIdx.value = null;
|
||||
if (!isEditing.value) editJson.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIdx = previousSelectedId ? documents.value.findIndex((doc) => documentIdentity(doc) === previousSelectedId) : previousSelectedIdx < documents.value.length ? previousSelectedIdx : -1;
|
||||
if (nextIdx < 0) {
|
||||
selectedIdx.value = null;
|
||||
if (!isEditing.value) editJson.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
selectedIdx.value = nextIdx;
|
||||
if (!isEditing.value) {
|
||||
editJson.value = JSON.stringify(documents.value[nextIdx], null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function selectDoc(idx: number) {
|
||||
selectedIdx.value = idx;
|
||||
editJson.value = JSON.stringify(documents.value[idx], null, 2);
|
||||
|
|
|
|||
|
|
@ -3414,6 +3414,16 @@ const canvasDevicePixelRatio = ref(typeof window === "undefined" ? 1 : window.de
|
|||
const canvasBackingPixelRatio = computed(() => Math.min(4, Math.max(1, canvasDevicePixelRatio.value * settingsStore.editorSettings.uiScale)));
|
||||
const useCanvasGridRows = computed(() => dataGridRenderMode.value === "canvas");
|
||||
const canvasContentHeight = computed(() => Math.max(1, displayRowCount.value * CANVAS_DATA_GRID_ROW_HEIGHT));
|
||||
// Clamp the sticky canvas/overlay to the content width. A viewport-wide sticky surface inflates the
|
||||
// scroller's scrollWidth up to clientWidth, so with few columns it sits right on the overflow threshold
|
||||
// and the custom horizontal scrollbar flickers while the pane shrinks (canvas width lags clientWidth).
|
||||
const canvasSurfaceWidth = computed(() => {
|
||||
const total = totalWidth.value;
|
||||
const vw = canvasViewportWidth.value;
|
||||
if (total <= 0) return Math.max(0, vw);
|
||||
if (vw <= 0) return total;
|
||||
return Math.min(vw, total);
|
||||
});
|
||||
const canvasRenderStyleKey = computed(() => `${settingsStore.editorSettings.theme}:${settingsStore.editorSettings.uiScale}:${canvasBackingPixelRatio.value}:${isDark.value}`);
|
||||
const CANVAS_MOUSE_WHEEL_SCROLL_MULTIPLIER = 1.5;
|
||||
const CANVAS_TRACKPAD_DELTA_THRESHOLD = 40;
|
||||
|
|
@ -3774,10 +3784,9 @@ function canvasEffectiveViewportHeight(): number {
|
|||
}
|
||||
|
||||
const canvasOverlayStyle = computed(() => {
|
||||
const vw = canvasEffectiveViewportWidth();
|
||||
const vh = canvasEffectiveViewportHeight();
|
||||
return {
|
||||
width: `${vw}px`,
|
||||
width: `${canvasSurfaceWidth.value}px`,
|
||||
height: `${vh}px`,
|
||||
marginTop: `-${vh}px`,
|
||||
};
|
||||
|
|
@ -3833,7 +3842,7 @@ function drawCanvasGrid() {
|
|||
drawCanvasDataGrid({
|
||||
canvas,
|
||||
scroller,
|
||||
width: Math.max(1, canvasViewportWidth.value || scroller.clientWidth),
|
||||
width: Math.max(1, canvasSurfaceWidth.value || scroller.clientWidth),
|
||||
height: Math.max(1, canvasViewportHeight.value || scroller.clientHeight),
|
||||
pixelRatio: canvasBackingPixelRatio.value,
|
||||
isDark: isDark.value,
|
||||
|
|
@ -6716,7 +6725,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
<canvas
|
||||
ref="canvasRef"
|
||||
class="canvas-grid-surface sticky left-0 top-0 z-0 block text-xs font-sans font-normal"
|
||||
:style="{ width: `${canvasViewportWidth}px`, height: `${canvasViewportHeight}px` }"
|
||||
:style="{ width: `${canvasSurfaceWidth}px`, height: `${canvasViewportHeight}px` }"
|
||||
@mousemove="onCanvasMouseMove"
|
||||
@mouseleave="onCanvasMouseLeave"
|
||||
@mousedown="onCanvasMouseDown"
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { isTableDataEditable } from "@/lib/tableEditing";
|
|||
import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
|
||||
import { formatShortcut } from "@/lib/shortcutRegistry";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
|
||||
import { chartableColumnIndexes } from "@/lib/chartData";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
import type { QueryTab, ConnectionConfig } from "@/types/database";
|
||||
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
|
|
@ -203,7 +204,7 @@ const resultTabsScrollbarThumbStyle = computed<CSSProperties>(() => ({
|
|||
const hasNumericData = computed(() => {
|
||||
const r = props.activeTab.result;
|
||||
if (!r || r.rows.length === 0) return false;
|
||||
return r.columns.some((_, idx) => r.rows.some((row) => typeof row[idx] === "number"));
|
||||
return chartableColumnIndexes(r).length > 0;
|
||||
});
|
||||
|
||||
const activeQueryError = computed(() => {
|
||||
|
|
@ -424,8 +425,8 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
<div class="flex flex-col flex-1 min-h-0">
|
||||
<!-- Query mode: editor + results -->
|
||||
<template v-if="activeTab.mode === 'query'">
|
||||
<Splitpanes horizontal class="flex-1">
|
||||
<Pane :size="resultsPaneOpen ? 40 : 100" :min-size="resultsPaneOpen ? 15 : 100">
|
||||
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden">
|
||||
<Pane class="min-h-0" :size="resultsPaneOpen ? 40 : 100" :min-size="resultsPaneOpen ? 15 : 100">
|
||||
<div class="h-full flex flex-col relative">
|
||||
<QueryEditor
|
||||
ref="queryEditorRef"
|
||||
|
|
@ -460,7 +461,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
</Button>
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane v-if="resultsPaneOpen" :size="60" :min-size="20">
|
||||
<Pane v-if="resultsPaneOpen" class="min-h-0" :size="60" :min-size="20">
|
||||
<div class="h-full flex flex-col">
|
||||
<div v-if="hasQueryOutput" class="flex h-10 shrink-0 items-center gap-1 border-b bg-muted/20 px-2">
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
|
|
@ -860,6 +861,15 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
.query-output-splitpanes {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.query-output-splitpanes :deep(> .splitpanes__splitter) {
|
||||
z-index: 1;
|
||||
flex: 0 0 3px;
|
||||
}
|
||||
|
||||
.result-tab-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const connectionStore = useConnectionStore();
|
|||
const settingsStore = useSettingsStore();
|
||||
const editorFontFamilyStyle = useEditorFontFamilyStyle();
|
||||
|
||||
type RedisSearchMode = "key" | "value";
|
||||
type RedisSearchMode = "key" | "value" | "all";
|
||||
type RedisCreateKeyType = "string" | "hash" | "list" | "set" | "zset" | "stream" | "json";
|
||||
|
||||
interface CreateKeyEntry {
|
||||
|
|
@ -100,10 +100,14 @@ let redisBrowserIsActive = true;
|
|||
let redisDbFlushedListenerRegistered = false;
|
||||
|
||||
const valueQuery = computed(() => searchPattern.value.trim());
|
||||
const isValueSearchMode = computed(() => searchMode.value === "value" || searchMode.value === "all");
|
||||
const effectivePattern = computed(() => (searchMode.value === "key" ? redisKeySearchPattern(searchPattern.value, fuzzyKeySearch.value) : "*"));
|
||||
const isSearchMode = computed(() => (searchMode.value === "key" ? effectivePattern.value !== "*" : valueQuery.value !== ""));
|
||||
const searchPlaceholder = computed(() => (searchMode.value === "key" ? (fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern")) : t("redis.valueSearchPlaceholder")));
|
||||
const loadingEmptyText = computed(() => (searchMode.value === "value" && valueQuery.value ? t("redis.searchingValues") : t("redis.loadingKeys")));
|
||||
const searchPlaceholder = computed(() => {
|
||||
if (searchMode.value === "key") return fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern");
|
||||
return searchMode.value === "all" ? t("redis.allSearchPlaceholder") : t("redis.valueSearchPlaceholder");
|
||||
});
|
||||
const loadingEmptyText = computed(() => (isValueSearchMode.value && valueQuery.value ? t(searchMode.value === "all" ? "redis.searchingAll" : "redis.searchingValues") : t("redis.loadingKeys")));
|
||||
const redisKeySeparator = computed(() => connectionStore.getConfig(props.connectionId)?.redis_key_separator ?? ":");
|
||||
watch(redisKeySeparator, () => {
|
||||
if (flatKeys.value.length > 0) rebuildTree(false);
|
||||
|
|
@ -192,7 +196,7 @@ function mergeTree(newKeys: RedisKeyInfo[]) {
|
|||
|
||||
async function fetchScanPage(): Promise<RedisScanResult> {
|
||||
const pageSize = settingsStore.editorSettings.redisScanPageSize;
|
||||
return searchMode.value === "value" ? await api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize) : await api.redisScanKeys(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize);
|
||||
return isValueSearchMode.value ? await api.redisScanValues(props.connectionId, props.db, scanCursor.value, "*", valueQuery.value, pageSize, searchMode.value === "all") : await api.redisScanKeys(props.connectionId, props.db, scanCursor.value, effectivePattern.value, pageSize);
|
||||
}
|
||||
|
||||
function appendScanResult(result: RedisScanResult) {
|
||||
|
|
@ -223,7 +227,7 @@ async function scanNextPage(requestId = searchRequestId): Promise<boolean> {
|
|||
}
|
||||
|
||||
async function streamValueSearch(requestId: number) {
|
||||
while (requestId === searchRequestId && searchMode.value === "value" && valueQuery.value && hasMore.value) {
|
||||
while (requestId === searchRequestId && isValueSearchMode.value && valueQuery.value && hasMore.value) {
|
||||
const applied = await scanNextPage(requestId);
|
||||
if (!applied) return;
|
||||
}
|
||||
|
|
@ -255,13 +259,13 @@ async function loadKeys() {
|
|||
expandedGroupIds.value = new Set();
|
||||
scanCursor.value = 0;
|
||||
try {
|
||||
if (searchMode.value === "value" && !valueQuery.value) {
|
||||
if (isValueSearchMode.value && !valueQuery.value) {
|
||||
hasMore.value = false;
|
||||
return;
|
||||
}
|
||||
const applied = await scanNextPage(requestId);
|
||||
if (applied) {
|
||||
if (searchMode.value === "value") {
|
||||
if (isValueSearchMode.value) {
|
||||
await streamValueSearch(requestId);
|
||||
} else {
|
||||
await fillInitialKeyBatch(requestId);
|
||||
|
|
@ -849,6 +853,9 @@ defineExpose({ focusSearch });
|
|||
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'value' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('value')">
|
||||
{{ t("redis.searchByValue") }}
|
||||
</button>
|
||||
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'all' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('all')">
|
||||
{{ t("redis.searchByAll") }}
|
||||
</button>
|
||||
</div>
|
||||
<Input v-model="searchPattern" data-redis-search-input class="h-6 text-xs border-0 shadow-none focus-visible:ring-0" :placeholder="searchPlaceholder" @input="onSearchInput" @keydown="onSearchKeydown" />
|
||||
<Button v-if="searchMode === 'key'" variant="ghost" size="sm" class="h-6 shrink-0 px-2 text-xs" :class="fuzzyKeySearch ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" :title="t('redis.fuzzyMatchTitle')" :aria-pressed="fuzzyKeySearch" @click="toggleFuzzyKeySearch">
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { tableMetaForDataTab } from "@/lib/tableDataTabMeta";
|
|||
import * as api from "@/lib/api";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
|
||||
import { connectionObjectTreeQuerySchema, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect";
|
||||
|
||||
export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>) {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -46,6 +46,24 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
|||
});
|
||||
}
|
||||
|
||||
async function refreshDataTabTableMeta(tab: QueryTab): Promise<void> {
|
||||
if (tab.mode !== "data" || !tab.connectionId || !tab.database) return;
|
||||
const tableMeta = tableMetaForDataTab(tab);
|
||||
if (!tableMeta?.tableName) return;
|
||||
|
||||
await connectionStore.ensureConnected(tab.connectionId);
|
||||
const config = connectionStore.getConfig(tab.connectionId);
|
||||
const querySchema = connectionObjectTreeQuerySchema(config, tab.database, tableMeta.schema);
|
||||
const columns = await api.getColumns(tab.connectionId, tab.database, querySchema, tableMeta.tableName);
|
||||
const primaryKeys = editablePrimaryKeys(effectiveDatabaseTypeForConnection(config), columns);
|
||||
queryStore.setTableMeta(tab.id, {
|
||||
schema: tableMeta.schema,
|
||||
tableName: tableMeta.tableName,
|
||||
columns,
|
||||
primaryKeys,
|
||||
});
|
||||
}
|
||||
|
||||
async function onExecuteSql(sql: string) {
|
||||
const tab = activeTab.value;
|
||||
if (!tab) return;
|
||||
|
|
@ -60,6 +78,11 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
|||
tab.whereInput = whereInput ?? "";
|
||||
const pageLimit = limit ?? settingsStore.editorSettings.pageSize;
|
||||
const pageOffset = offset ?? 0;
|
||||
try {
|
||||
await refreshDataTabTableMeta(tab);
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
}
|
||||
const nextSql = await buildTableSql(tab, { whereInput, orderBy, limit: pageLimit, offset: pageOffset });
|
||||
queryStore.updateSql(tab.id, nextSql);
|
||||
await queryStore.executeTabSql(tab.id, nextSql, {
|
||||
|
|
|
|||
|
|
@ -1391,11 +1391,14 @@
|
|||
fuzzyMatch: "Fuzzy",
|
||||
fuzzyMatchTitle: "Fuzzy match: search keys by plain text contains",
|
||||
valueSearchPlaceholder: "value contains...",
|
||||
allSearchPlaceholder: "key or value contains...",
|
||||
searchByKey: "Key",
|
||||
searchByValue: "Value",
|
||||
searchByAll: "All",
|
||||
keys: "{count} keys",
|
||||
loadingKeys: "Loading keys...",
|
||||
searchingValues: "Searching values...",
|
||||
searchingAll: "Searching keys and values...",
|
||||
loadMoreKeys: "Load more keys",
|
||||
fetchAllKeys: "Fetch all",
|
||||
stopFetchAll: "Stop",
|
||||
|
|
|
|||
|
|
@ -1171,11 +1171,14 @@
|
|||
fuzzyMatch: "Difusa",
|
||||
fuzzyMatchTitle: "Coincidencia difusa: busca claves que contengan texto plano",
|
||||
valueSearchPlaceholder: "el valor contiene...",
|
||||
allSearchPlaceholder: "la clave o el valor contiene...",
|
||||
searchByKey: "Clave",
|
||||
searchByValue: "Valor",
|
||||
searchByAll: "Todo",
|
||||
keys: "{count} claves",
|
||||
loadingKeys: "Cargando claves...",
|
||||
searchingValues: "Buscando por valor...",
|
||||
searchingAll: "Buscando claves y valores...",
|
||||
loadMoreKeys: "Cargar más claves",
|
||||
fetchAllKeys: "Cargar todas",
|
||||
stopFetchAll: "Detener",
|
||||
|
|
|
|||
|
|
@ -1281,11 +1281,14 @@
|
|||
fuzzyMatch: "Fuzzy",
|
||||
fuzzyMatchTitle: "Corrispondenza fuzzy: cerca le chiavi per testo semplice contenuto",
|
||||
valueSearchPlaceholder: "il valore contiene...",
|
||||
allSearchPlaceholder: "la chiave o il valore contiene...",
|
||||
searchByKey: "Chiave",
|
||||
searchByValue: "Valore",
|
||||
searchByAll: "Tutto",
|
||||
keys: "{count} chiavi",
|
||||
loadingKeys: "Caricamento chiavi...",
|
||||
searchingValues: "Ricerca nei valori...",
|
||||
searchingAll: "Ricerca in chiavi e valori...",
|
||||
loadMoreKeys: "Carica altre chiavi",
|
||||
fetchAllKeys: "Recupera tutto",
|
||||
stopFetchAll: "Interrompi",
|
||||
|
|
|
|||
|
|
@ -1281,11 +1281,14 @@
|
|||
fuzzyMatch: "Aproximado",
|
||||
fuzzyMatchTitle: "Correspondência aproximada: pesquisa chaves por texto contido",
|
||||
valueSearchPlaceholder: "o valor contém...",
|
||||
allSearchPlaceholder: "a chave ou o valor contém...",
|
||||
searchByKey: "Chave",
|
||||
searchByValue: "Valor",
|
||||
searchByAll: "Tudo",
|
||||
keys: "{count} chaves",
|
||||
loadingKeys: "Carregando chaves...",
|
||||
searchingValues: "Pesquisando valores...",
|
||||
searchingAll: "Pesquisando chaves e valores...",
|
||||
loadMoreKeys: "Carregar mais chaves",
|
||||
fetchAllKeys: "Buscar todas",
|
||||
stopFetchAll: "Parar",
|
||||
|
|
|
|||
|
|
@ -1390,11 +1390,14 @@
|
|||
fuzzyMatch: "模糊",
|
||||
fuzzyMatchTitle: "模糊匹配:自动按包含关系搜索 key",
|
||||
valueSearchPlaceholder: "按值内容搜索...",
|
||||
allSearchPlaceholder: "按 key 或值搜索...",
|
||||
searchByKey: "键",
|
||||
searchByValue: "值",
|
||||
searchByAll: "全部",
|
||||
keys: "{count} 个 key",
|
||||
loadingKeys: "正在加载 key...",
|
||||
searchingValues: "正在按值搜索...",
|
||||
searchingAll: "正在搜索 key 和值...",
|
||||
loadMoreKeys: "加载更多",
|
||||
fetchAllKeys: "获取全部",
|
||||
stopFetchAll: "停止",
|
||||
|
|
|
|||
|
|
@ -1260,11 +1260,14 @@
|
|||
fuzzyMatch: "模糊",
|
||||
fuzzyMatchTitle: "模糊匹配:自動按包含關係搜尋 key",
|
||||
valueSearchPlaceholder: "值包含……",
|
||||
allSearchPlaceholder: "鍵或值包含……",
|
||||
searchByKey: "鍵",
|
||||
searchByValue: "值",
|
||||
searchByAll: "全部",
|
||||
keys: "{count} 個 key",
|
||||
loadingKeys: "正在載入 key……",
|
||||
searchingValues: "正在按值搜尋……",
|
||||
searchingAll: "正在搜尋鍵和值……",
|
||||
loadMoreKeys: "載入更多",
|
||||
fetchAllKeys: "取得全部",
|
||||
stopFetchAll: "停止",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { axisColumnLabel, chartableColumnIndexes, toChartNumber } from "@/lib/chartData";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
function result(columns: string[], rows: QueryResult["rows"]): QueryResult {
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
affected_rows: rows.length,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("chartData", () => {
|
||||
it("accepts finite numbers and numeric strings", () => {
|
||||
expect(toChartNumber(42)).toBe(42);
|
||||
expect(toChartNumber("42.5")).toBe(42.5);
|
||||
expect(toChartNumber(" 1e3 ")).toBe(1000);
|
||||
});
|
||||
|
||||
it("rejects non-finite and non-numeric values", () => {
|
||||
expect(toChartNumber("")).toBeNull();
|
||||
expect(toChartNumber("abc")).toBeNull();
|
||||
expect(toChartNumber(null)).toBeNull();
|
||||
expect(toChartNumber(true)).toBeNull();
|
||||
});
|
||||
|
||||
it("finds numeric columns returned as strings", () => {
|
||||
expect(
|
||||
chartableColumnIndexes(
|
||||
result(
|
||||
["name", "decimal_total", "status"],
|
||||
[
|
||||
["a", "12.34", "ok"],
|
||||
["b", "56.78", "ok"],
|
||||
],
|
||||
),
|
||||
),
|
||||
).toEqual([1]);
|
||||
});
|
||||
|
||||
it("disambiguates duplicate axis labels by index", () => {
|
||||
expect(axisColumnLabel(["amount", "amount"], 0)).toBe("amount #1");
|
||||
expect(axisColumnLabel(["amount", "amount"], 1)).toBe("amount #2");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
export function toChartNumber(value: QueryResult["rows"][number][number]): number | null {
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
if (typeof value !== "string") return null;
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return null;
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function isChartableValue(value: QueryResult["rows"][number][number]): boolean {
|
||||
return toChartNumber(value) !== null;
|
||||
}
|
||||
|
||||
export function chartableColumnIndexes(result: QueryResult): number[] {
|
||||
return result.columns.map((_, index) => index).filter((index) => result.rows.some((row) => isChartableValue(row[index])));
|
||||
}
|
||||
|
||||
export function axisColumnLabel(columns: string[], index: number): string {
|
||||
const name = columns[index] ?? `#${index + 1}`;
|
||||
if (columns.filter((column) => column === name).length <= 1) return name;
|
||||
return `${name} #${index + 1}`;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ConnectionConfig } from "@/types/database";
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
|
||||
export const CONNECTION_ATTEMPT_TIMEOUT_BUFFER_MS = 2_000;
|
||||
export const MONGO_LEGACY_FALLBACK_TIMEOUT_BUFFER_MS = 30_000;
|
||||
|
|
|
|||
|
|
@ -1200,8 +1200,8 @@ export async function redisScanKeys(connectionId: string, db: number, cursor: nu
|
|||
return post("/api/redis/scan-keys", { connectionId, db, cursor, pattern, count });
|
||||
}
|
||||
|
||||
export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number): Promise<RedisScanResult> {
|
||||
return post("/api/redis/scan-values", { connectionId, db, cursor, pattern, query, count });
|
||||
export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number, includeKeyMatches = false): Promise<RedisScanResult> {
|
||||
return post("/api/redis/scan-values", { connectionId, db, cursor, pattern, query, includeKeyMatches, count });
|
||||
}
|
||||
|
||||
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {
|
||||
|
|
|
|||
|
|
@ -1032,8 +1032,8 @@ export async function redisScanKeys(connectionId: string, db: number, cursor: nu
|
|||
return invoke("redis_scan_keys", { connectionId, db, cursor, pattern, count });
|
||||
}
|
||||
|
||||
export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number): Promise<RedisScanResult> {
|
||||
return invoke("redis_scan_values", { connectionId, db, cursor, pattern, query, count });
|
||||
export async function redisScanValues(connectionId: string, db: number, cursor: number, pattern: string, query: string, count: number, includeKeyMatches = false): Promise<RedisScanResult> {
|
||||
return invoke("redis_scan_values", { connectionId, db, cursor, pattern, query, includeKeyMatches, count });
|
||||
}
|
||||
|
||||
export async function redisGetValue(connectionId: string, db: number, keyRaw: string): Promise<RedisValue> {
|
||||
|
|
|
|||
|
|
@ -420,6 +420,7 @@ function normalizeCustomColumnFormatters(value: unknown): Record<string, CustomC
|
|||
|
||||
function normalizeSqlSnippets(value: unknown, existing?: SqlSnippet[]): SqlSnippet[] {
|
||||
if (!Array.isArray(value)) return existing ?? DEFAULT_SQL_SNIPPETS;
|
||||
if (value.length === 0) return [];
|
||||
const valid: SqlSnippet[] = [];
|
||||
const seenPrefixes = new Set<string>();
|
||||
for (const item of value) {
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ pub fn agent_connect_params(config: &ConnectionConfig, host: &str, port: u16, da
|
|||
};
|
||||
let connection_string = if config.db_type == DatabaseType::MongoDb {
|
||||
config.connection_url_with_host(host, port)
|
||||
} else if matches!(config.db_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle) {
|
||||
} else if config.db_type == DatabaseType::Oracle {
|
||||
oracle_jdbc_connection_string(config, host, port, database)
|
||||
} else if config.db_type == DatabaseType::OceanbaseOracle {
|
||||
oceanbase_oracle_jdbc_connection_string(config, host, port, database)
|
||||
} else if matches!(config.db_type, DatabaseType::Kingbase | DatabaseType::Highgo | DatabaseType::Vastbase) {
|
||||
postgres_like_agent_jdbc_connection_string(config, host, port, database)
|
||||
} else if config.db_type == DatabaseType::SapHana {
|
||||
|
|
@ -205,6 +207,64 @@ pub fn oracle_error_with_driver_hint(config: &ConnectionConfig, err: &str) -> St
|
|||
)
|
||||
}
|
||||
|
||||
pub fn oracle_alternate_connect_configs(config: &ConnectionConfig, err: &str) -> Vec<ConnectionConfig> {
|
||||
if config.db_type != DatabaseType::Oracle {
|
||||
return Vec::new();
|
||||
}
|
||||
if config.driver_profile.as_deref() == Some("oracle-10g") {
|
||||
return Vec::new();
|
||||
}
|
||||
if config.connection_string.as_deref().is_some_and(|value| !value.trim().is_empty()) {
|
||||
return Vec::new();
|
||||
}
|
||||
if !oracle_listener_error_can_retry(err) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let database = config.effective_database().unwrap_or("").trim();
|
||||
if database.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let host = config.host.trim();
|
||||
let port = config.port;
|
||||
let current_url = oracle_jdbc_connection_string(config, host, port, database);
|
||||
let service_url = oracle_service_jdbc_url(host, port, database);
|
||||
let sid_url = oracle_sid_jdbc_url(host, port, database);
|
||||
let legacy_service_url = oracle_legacy_service_jdbc_url(host, port, database);
|
||||
let descriptor_service_url = oracle_descriptor_jdbc_url(host, port, database, "SERVICE_NAME");
|
||||
let descriptor_sid_url = oracle_descriptor_jdbc_url(host, port, database, "SID");
|
||||
|
||||
let normalized = err.to_lowercase();
|
||||
let candidates = if normalized.contains("ora-12505") {
|
||||
vec![service_url, descriptor_service_url, legacy_service_url]
|
||||
} else if normalized.contains("ora-12514") {
|
||||
vec![sid_url, descriptor_sid_url, legacy_service_url]
|
||||
} else {
|
||||
match config.oracle_connection_type.as_deref() {
|
||||
Some("sid") => vec![service_url, legacy_service_url, descriptor_sid_url, descriptor_service_url],
|
||||
_ => vec![sid_url, legacy_service_url, descriptor_service_url, descriptor_sid_url],
|
||||
}
|
||||
};
|
||||
|
||||
let mut urls = Vec::new();
|
||||
for url in candidates {
|
||||
if url == current_url || urls.iter().any(|seen| seen == &url) {
|
||||
continue;
|
||||
}
|
||||
urls.push(url);
|
||||
}
|
||||
|
||||
urls.into_iter()
|
||||
.map(|url| {
|
||||
let mut retry = config.clone();
|
||||
retry.oracle_connection_type = None;
|
||||
retry.connection_string = Some(url);
|
||||
retry
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn oracle_jdbc_connection_string(config: &ConnectionConfig, host: &str, port: u16, database: &str) -> String {
|
||||
if let Some(connection_string) = config.connection_string.as_deref().filter(|value| !value.trim().is_empty()) {
|
||||
let connection_string = connection_string.trim();
|
||||
|
|
@ -220,12 +280,54 @@ fn oracle_jdbc_connection_string(config: &ConnectionConfig, host: &str, port: u1
|
|||
}
|
||||
|
||||
if config.oracle_connection_type.as_deref() == Some("sid") {
|
||||
format!("jdbc:oracle:thin:@{host}:{port}:{database}")
|
||||
oracle_sid_jdbc_url(host, port, database)
|
||||
} else {
|
||||
format!("jdbc:oracle:thin:@//{host}:{port}/{database}")
|
||||
oracle_service_jdbc_url(host, port, database)
|
||||
}
|
||||
}
|
||||
|
||||
fn oracle_listener_error_can_retry(err: &str) -> bool {
|
||||
let normalized = err.to_lowercase();
|
||||
normalized.contains("ora-12505")
|
||||
|| normalized.contains("ora-12514")
|
||||
|| normalized.contains("ora-12541")
|
||||
|| normalized.contains("no listener")
|
||||
|| err.contains("没有监听程序")
|
||||
}
|
||||
|
||||
fn oracle_service_jdbc_url(host: &str, port: u16, database: &str) -> String {
|
||||
format!("jdbc:oracle:thin:@//{host}:{port}/{database}")
|
||||
}
|
||||
|
||||
fn oracle_sid_jdbc_url(host: &str, port: u16, database: &str) -> String {
|
||||
format!("jdbc:oracle:thin:@{host}:{port}:{database}")
|
||||
}
|
||||
|
||||
fn oracle_legacy_service_jdbc_url(host: &str, port: u16, database: &str) -> String {
|
||||
format!("jdbc:oracle:thin:@{host}:{port}/{database}")
|
||||
}
|
||||
|
||||
fn oracle_descriptor_jdbc_url(host: &str, port: u16, database: &str, key: &str) -> String {
|
||||
format!("jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST={host})(PORT={port}))(CONNECT_DATA=({key}={database})))")
|
||||
}
|
||||
|
||||
fn oceanbase_oracle_jdbc_connection_string(config: &ConnectionConfig, host: &str, port: u16, database: &str) -> String {
|
||||
if let Some(connection_string) = config.connection_string.as_deref().filter(|value| !value.trim().is_empty()) {
|
||||
let connection_string = connection_string.trim();
|
||||
if host == config.host && port == config.port {
|
||||
return connection_string.to_string();
|
||||
}
|
||||
return crate::models::connection::rewrite_jdbc_url_host(connection_string, host, port);
|
||||
}
|
||||
|
||||
let database = database.trim();
|
||||
if database.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
append_agent_url_params(format!("jdbc:oceanbase://{host}:{port}/{database}"), config.url_params.as_deref())
|
||||
}
|
||||
|
||||
fn postgres_like_agent_jdbc_connection_string(
|
||||
config: &ConnectionConfig,
|
||||
host: &str,
|
||||
|
|
@ -262,24 +364,35 @@ pub fn oracle_auth_fallback_profiles(config: &ConnectionConfig, err: &str) -> Ve
|
|||
}
|
||||
|
||||
pub fn oracle_alternate_connect_config(config: &ConnectionConfig, err: &str) -> Option<ConnectionConfig> {
|
||||
if config.db_type != DatabaseType::Oracle {
|
||||
return None;
|
||||
}
|
||||
if config.driver_profile.as_deref() == Some("oracle-10g") {
|
||||
return None;
|
||||
}
|
||||
if config.connection_string.as_deref().is_some_and(|value| !value.trim().is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let normalized = err.to_lowercase();
|
||||
if !normalized.contains("ora-12505") && !normalized.contains("ora-12514") {
|
||||
return None;
|
||||
}
|
||||
oracle_alternate_connect_configs(config, err).into_iter().next()
|
||||
}
|
||||
|
||||
let mut retry = config.clone();
|
||||
retry.oracle_connection_type =
|
||||
Some(if config.oracle_connection_type.as_deref() == Some("sid") { "service_name" } else { "sid" }.to_string());
|
||||
Some(retry)
|
||||
pub fn oracle_alternate_connect_config_labels(configs: &[ConnectionConfig]) -> Vec<String> {
|
||||
configs
|
||||
.iter()
|
||||
.map(|config| {
|
||||
config
|
||||
.connection_string
|
||||
.as_deref()
|
||||
.map(oracle_connection_string_label)
|
||||
.unwrap_or_else(|| config.oracle_connection_type.as_deref().unwrap_or("service_name").to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn oracle_connection_string_label(connection_string: &str) -> String {
|
||||
let upper = connection_string.to_ascii_uppercase();
|
||||
if upper.contains("(SERVICE_NAME=") {
|
||||
"descriptor service name".to_string()
|
||||
} else if upper.contains("(SID=") {
|
||||
"descriptor SID".to_string()
|
||||
} else if connection_string.starts_with("jdbc:oracle:thin:@//") {
|
||||
"service name".to_string()
|
||||
} else if connection_string.contains(':') && !connection_string.contains('/') {
|
||||
"SID".to_string()
|
||||
} else {
|
||||
"legacy service name".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn sap_hana_jdbc_connection_string(config: &ConnectionConfig, host: &str, port: u16, database: &str) -> String {
|
||||
|
|
@ -575,7 +688,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn oceanbase_oracle_uses_oracle_jdbc_connection_string_for_agent_protocol() {
|
||||
fn oceanbase_oracle_uses_oceanbase_jdbc_connection_string_for_agent_protocol() {
|
||||
let mut cfg = config(DatabaseType::OceanbaseOracle, Some("sys"));
|
||||
cfg.host = "oceanbase.example.com".to_string();
|
||||
cfg.port = 2881;
|
||||
|
|
@ -584,7 +697,19 @@ mod tests {
|
|||
|
||||
assert_eq!(params["database"], "sys");
|
||||
assert_eq!(params["sysdba"], false);
|
||||
assert_eq!(params["connection_string"], "jdbc:oracle:thin:@//oceanbase.example.com:2881/sys");
|
||||
assert_eq!(params["connection_string"], "jdbc:oceanbase://oceanbase.example.com:2881/sys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oceanbase_oracle_jdbc_url_appends_params_and_rewrites_forwarded_host() {
|
||||
let mut cfg = config(DatabaseType::OceanbaseOracle, Some("sys"));
|
||||
cfg.host = "oceanbase.example.com".to_string();
|
||||
cfg.port = 2881;
|
||||
cfg.url_params = Some("useSSL=false".to_string());
|
||||
|
||||
let params = agent_connect_params(&cfg, "127.0.0.1", 12881, "sys");
|
||||
|
||||
assert_eq!(params["connection_string"], "jdbc:oceanbase://127.0.0.1:12881/sys?useSSL=false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -631,9 +756,9 @@ mod tests {
|
|||
|
||||
let retry = oracle_alternate_connect_config(&cfg, "ORA-12514: listener does not know service").unwrap();
|
||||
|
||||
assert_eq!(retry.oracle_connection_type.as_deref(), Some("sid"));
|
||||
assert_eq!(retry.connection_string.as_deref(), Some("jdbc:oracle:thin:@127.0.0.1:3306:ORCL"));
|
||||
assert!(oracle_alternate_connect_config(&retry, "ORA-01017: invalid username/password").is_none());
|
||||
assert!(oracle_alternate_connect_config(&cfg, "ORA-12541: TNS:no listener").is_none());
|
||||
assert!(oracle_alternate_connect_config(&cfg, "ORA-12541: TNS:no listener").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -666,6 +791,28 @@ mod tests {
|
|||
assert!(oracle_alternate_connect_config(&cfg, "ORA-12514: listener does not know service").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_no_listener_errors_try_common_jdbc_url_variants() {
|
||||
let mut cfg = config(DatabaseType::Oracle, Some("ORCL"));
|
||||
cfg.host = "oracle.example.com".to_string();
|
||||
cfg.port = 1521;
|
||||
cfg.driver_profile = Some("oracle".to_string());
|
||||
cfg.oracle_connection_type = Some("service_name".to_string());
|
||||
|
||||
let retries = oracle_alternate_connect_configs(&cfg, "ORA-12541: TNS:no listener");
|
||||
let urls: Vec<_> = retries.iter().filter_map(|retry| retry.connection_string.as_deref()).collect();
|
||||
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec![
|
||||
"jdbc:oracle:thin:@oracle.example.com:1521:ORCL",
|
||||
"jdbc:oracle:thin:@oracle.example.com:1521/ORCL",
|
||||
"jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=oracle.example.com)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=ORCL)))",
|
||||
"jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=oracle.example.com)(PORT=1521))(CONNECT_DATA=(SID=ORCL)))",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sap_hana_url_includes_selected_database_and_params() {
|
||||
let mut cfg = config(DatabaseType::SapHana, Some("TENANT1"));
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ use mysql_async::Row as MysqlRow;
|
|||
|
||||
use crate::agent_connection::{
|
||||
agent_connect_params, h2_file_path_from_jdbc_url, is_h2_file_connection, mongo_legacy_error_with_auth_hint,
|
||||
oracle_alternate_connect_config, oracle_auth_fallback_profiles, oracle_error_with_driver_hint,
|
||||
should_retry_oracle_with_10g_driver,
|
||||
oracle_alternate_connect_config_labels, oracle_alternate_connect_configs, oracle_auth_fallback_profiles,
|
||||
oracle_error_with_driver_hint, should_retry_oracle_with_10g_driver,
|
||||
};
|
||||
use crate::agent_manager::{JavaRuntimeMode, DEFAULT_JRE_KEY};
|
||||
use crate::database_capabilities;
|
||||
|
|
@ -412,7 +412,7 @@ impl AppState {
|
|||
))
|
||||
} else {
|
||||
db::redis_driver::RedisConnection::Direct(tokio::sync::Mutex::new(
|
||||
db::redis_driver::connect(&url, connect_timeout).await?,
|
||||
db::redis_driver::connect_standalone(&db_config, &host, port, connect_timeout).await?,
|
||||
))
|
||||
};
|
||||
PoolKind::Redis(con)
|
||||
|
|
@ -553,27 +553,48 @@ impl AppState {
|
|||
let connect_result =
|
||||
client.call_method::<serde_json::Value>(AgentMethod::Connect, connect_params.clone()).await;
|
||||
if let Err(err) = connect_result {
|
||||
if let Some(alternate_config) = oracle_alternate_connect_config(&db_config, &err) {
|
||||
let alternate_configs = oracle_alternate_connect_configs(&db_config, &err);
|
||||
if !alternate_configs.is_empty() {
|
||||
log::warn!(
|
||||
"Oracle connect failed with {:?} descriptor: {}. Retrying with {:?} descriptor.",
|
||||
"Oracle connect failed with {:?} descriptor: {}. Retrying with Oracle JDBC URL variants: {:?}.",
|
||||
db_config.oracle_connection_type,
|
||||
err,
|
||||
alternate_config.oracle_connection_type
|
||||
oracle_alternate_connect_config_labels(&alternate_configs)
|
||||
);
|
||||
client
|
||||
.call_method::<serde_json::Value>(
|
||||
AgentMethod::Connect,
|
||||
agent_connect_params(
|
||||
&alternate_config,
|
||||
&host,
|
||||
port,
|
||||
alternate_config.effective_database().unwrap_or(""),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|alternate_err| {
|
||||
format!("{err}\n\nFallback with alternate Oracle descriptor failed: {alternate_err}")
|
||||
})?;
|
||||
let mut fallback_errors = Vec::new();
|
||||
let mut connected = false;
|
||||
for alternate_config in alternate_configs {
|
||||
let label = oracle_alternate_connect_config_labels(std::slice::from_ref(&alternate_config))
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_else(|| "alternate".to_string());
|
||||
match client
|
||||
.call_method::<serde_json::Value>(
|
||||
AgentMethod::Connect,
|
||||
agent_connect_params(
|
||||
&alternate_config,
|
||||
&host,
|
||||
port,
|
||||
alternate_config.effective_database().unwrap_or(""),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
connected = true;
|
||||
break;
|
||||
}
|
||||
Err(alternate_err) => {
|
||||
fallback_errors.push(format!("{label}: {alternate_err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !connected {
|
||||
return Err(format!(
|
||||
"{err}\n\nFallback with alternate Oracle JDBC URLs failed: {}",
|
||||
fallback_errors.join("\n")
|
||||
));
|
||||
}
|
||||
} else if should_retry_oracle_with_10g_driver(&db_config, &err) {
|
||||
log::warn!(
|
||||
"Oracle connect failed with profile {:?}: {}. Retrying with legacy Oracle profiles.",
|
||||
|
|
@ -1480,16 +1501,18 @@ mod tests {
|
|||
)
|
||||
.expect("listener errors should allow alternate descriptor retry");
|
||||
assert_eq!(retry.driver_profile.as_deref(), Some("oracle"));
|
||||
assert_eq!(retry.oracle_connection_type.as_deref(), Some("sid"));
|
||||
assert_eq!(retry.connection_string.as_deref(), Some("jdbc:oracle:thin:@127.0.0.1:3306:ORCL"));
|
||||
|
||||
let mut sid_config = config.clone();
|
||||
sid_config.oracle_connection_type = Some("sid".to_string());
|
||||
let service_retry = oracle_alternate_connect_config(
|
||||
&retry,
|
||||
&sid_config,
|
||||
"Agent RPC error (-1): ORA-12505: listener does not currently know of SID given",
|
||||
)
|
||||
.expect("SID listener errors should allow service-name retry");
|
||||
assert_eq!(service_retry.oracle_connection_type.as_deref(), Some("service_name"));
|
||||
assert_eq!(service_retry.connection_string.as_deref(), Some("jdbc:oracle:thin:@//127.0.0.1:3306/ORCL"));
|
||||
|
||||
assert!(oracle_alternate_connect_config(&config, "ORA-12541: TNS:no listener").is_none());
|
||||
assert!(oracle_alternate_connect_config(&config, "ORA-12541: TNS:no listener").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -82,6 +82,12 @@ pub struct RedisClusterPool {
|
|||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct RedisAuthCandidate {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct RedisNodeEndpoint {
|
||||
pub host: String,
|
||||
|
|
@ -90,15 +96,56 @@ pub struct RedisNodeEndpoint {
|
|||
|
||||
pub async fn connect(url: &str, timeout: std::time::Duration) -> Result<redis::aio::MultiplexedConnection, String> {
|
||||
let client = redis::Client::open(url).map_err(|e| format!("Redis connection failed: {e}"))?;
|
||||
connect_client_with_timeout(client, timeout, "Redis").await
|
||||
}
|
||||
|
||||
pub async fn connect_standalone(
|
||||
config: &ConnectionConfig,
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<redis::aio::MultiplexedConnection, String> {
|
||||
let mut last_error = None;
|
||||
for auth in redis_auth_candidates(&config.username, &config.password) {
|
||||
let client = redis::Client::open(connection_info(
|
||||
host,
|
||||
port,
|
||||
config.ssl,
|
||||
config.redis_tls_insecure(),
|
||||
&auth.username,
|
||||
&auth.password,
|
||||
redis_database_index(config),
|
||||
))
|
||||
.map_err(|e| format!("Redis connection failed: {e}"))?;
|
||||
match connect_client_with_timeout(client, timeout, "Redis").await {
|
||||
Ok(con) => return Ok(con),
|
||||
Err(err) if last_error.is_none() || is_redis_auth_error(&err) => {
|
||||
let should_retry = is_redis_auth_error(&err);
|
||||
last_error = Some(err);
|
||||
if !should_retry {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| "Redis connection failed".to_string()))
|
||||
}
|
||||
|
||||
async fn connect_client_with_timeout(
|
||||
client: redis::Client,
|
||||
timeout: std::time::Duration,
|
||||
label: &str,
|
||||
) -> Result<redis::aio::MultiplexedConnection, String> {
|
||||
let mut con = tokio::time::timeout(timeout, client.get_multiplexed_async_connection())
|
||||
.await
|
||||
.map_err(|_| format!("Redis connection timed out ({}s)", timeout.as_secs()))?
|
||||
.map_err(|e| format!("Redis connection failed: {e}"))?;
|
||||
.map_err(|_| format!("{label} connection timed out ({}s)", timeout.as_secs()))?
|
||||
.map_err(|e| format!("{label} connection failed: {e}"))?;
|
||||
|
||||
tokio::time::timeout(timeout, redis::cmd("PING").query_async::<String>(&mut con))
|
||||
.await
|
||||
.map_err(|_| format!("Redis ping timed out ({}s)", timeout.as_secs()))?
|
||||
.map_err(|e| format!("Redis authentication failed or command rejected: {e}"))?;
|
||||
.map_err(|_| format!("{label} ping timed out ({}s)", timeout.as_secs()))?
|
||||
.map_err(|e| format!("{label} authentication failed or command rejected: {e}"))?;
|
||||
|
||||
Ok(con)
|
||||
}
|
||||
|
|
@ -128,39 +175,66 @@ pub async fn connect_sentinel(config: &ConnectionConfig) -> Result<redis::aio::M
|
|||
|
||||
pub async fn connect_cluster(config: &ConnectionConfig) -> Result<RedisClusterPool, String> {
|
||||
let seed_nodes = redis_cluster_seed_nodes(config)?;
|
||||
let cluster_nodes: Vec<ConnectionInfo> = seed_nodes
|
||||
.iter()
|
||||
.map(|endpoint| {
|
||||
connection_info(
|
||||
&endpoint.host,
|
||||
endpoint.port,
|
||||
config.ssl,
|
||||
config.redis_tls_insecure(),
|
||||
&config.username,
|
||||
&config.password,
|
||||
0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let client = ClusterClient::new(cluster_nodes).map_err(|e| format!("Redis cluster connection failed: {e}"))?;
|
||||
let mut con = tokio::time::timeout(super::connection_timeout(), client.get_async_connection())
|
||||
.await
|
||||
.map_err(|_| format!("Redis cluster connection timed out ({}s)", super::CONNECTION_TIMEOUT_SECS))?
|
||||
.map_err(|e| format!("Redis cluster connection failed: {e}"))?;
|
||||
let mut last_error = None;
|
||||
for auth in redis_auth_candidates(&config.username, &config.password) {
|
||||
let cluster_nodes: Vec<ConnectionInfo> = seed_nodes
|
||||
.iter()
|
||||
.map(|endpoint| {
|
||||
connection_info(
|
||||
&endpoint.host,
|
||||
endpoint.port,
|
||||
config.ssl,
|
||||
config.redis_tls_insecure(),
|
||||
&auth.username,
|
||||
&auth.password,
|
||||
0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let client = ClusterClient::new(cluster_nodes).map_err(|e| format!("Redis cluster connection failed: {e}"))?;
|
||||
let mut con = match tokio::time::timeout(super::connection_timeout(), client.get_async_connection())
|
||||
.await
|
||||
.map_err(|_| format!("Redis cluster connection timed out ({}s)", super::CONNECTION_TIMEOUT_SECS))?
|
||||
.map_err(|e| format!("Redis cluster connection failed: {e}"))
|
||||
{
|
||||
Ok(con) => con,
|
||||
Err(err) if last_error.is_none() || is_redis_auth_error(&err) => {
|
||||
let should_retry = is_redis_auth_error(&err);
|
||||
last_error = Some(err);
|
||||
if should_retry {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
tokio::time::timeout(super::connection_timeout(), redis::cmd("PING").query_async::<String>(&mut con))
|
||||
.await
|
||||
.map_err(|_| format!("Redis cluster ping timed out ({}s)", super::CONNECTION_TIMEOUT_SECS))?
|
||||
.map_err(|e| format!("Redis cluster authentication failed or command rejected: {e}"))?;
|
||||
|
||||
Ok(RedisClusterPool {
|
||||
connection: Mutex::new(con),
|
||||
seed_nodes,
|
||||
tls: config.ssl,
|
||||
tls_insecure: config.redis_tls_insecure(),
|
||||
username: config.username.clone(),
|
||||
password: config.password.clone(),
|
||||
})
|
||||
match tokio::time::timeout(super::connection_timeout(), redis::cmd("PING").query_async::<String>(&mut con))
|
||||
.await
|
||||
.map_err(|_| format!("Redis cluster ping timed out ({}s)", super::CONNECTION_TIMEOUT_SECS))?
|
||||
.map_err(|e| format!("Redis cluster authentication failed or command rejected: {e}"))
|
||||
{
|
||||
Ok(_) => {
|
||||
return Ok(RedisClusterPool {
|
||||
connection: Mutex::new(con),
|
||||
seed_nodes,
|
||||
tls: config.ssl,
|
||||
tls_insecure: config.redis_tls_insecure(),
|
||||
username: auth.username,
|
||||
password: auth.password,
|
||||
});
|
||||
}
|
||||
Err(err) if last_error.is_none() || is_redis_auth_error(&err) => {
|
||||
let should_retry = is_redis_auth_error(&err);
|
||||
last_error = Some(err);
|
||||
if !should_retry {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| "Redis cluster connection failed".to_string()))
|
||||
}
|
||||
|
||||
fn redis_sentinel_nodes(config: &ConnectionConfig) -> Result<Vec<ConnectionInfo>, String> {
|
||||
|
|
@ -273,6 +347,25 @@ fn redis_connection_info(username: &str, password: &str, db: i64) -> RedisConnec
|
|||
}
|
||||
}
|
||||
|
||||
fn redis_auth_candidates(username: &str, password: &str) -> Vec<RedisAuthCandidate> {
|
||||
let username = username.trim();
|
||||
let password = password.trim();
|
||||
let mut candidates = vec![RedisAuthCandidate { username: username.to_string(), password: password.to_string() }];
|
||||
if !username.is_empty() && !password.is_empty() {
|
||||
candidates.push(RedisAuthCandidate { username: String::new(), password: format!("{username}@{password}") });
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn redis_database_index(config: &ConnectionConfig) -> i64 {
|
||||
config.effective_database().and_then(|database| database.parse::<i64>().ok()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn is_redis_auth_error(error: &str) -> bool {
|
||||
let error = error.to_ascii_lowercase();
|
||||
error.contains("auth") || error.contains("wrongpass") || error.contains("invalid username-password")
|
||||
}
|
||||
|
||||
fn non_empty_string(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
|
|
@ -489,6 +582,7 @@ pub async fn scan_cluster_values_page(
|
|||
cursor: u64,
|
||||
pattern: &str,
|
||||
query: &str,
|
||||
include_key_matches: bool,
|
||||
count: usize,
|
||||
) -> Result<RedisScanResult, String> {
|
||||
let master_nodes = cluster_master_nodes(pool).await?;
|
||||
|
|
@ -507,7 +601,7 @@ pub async fn scan_cluster_values_page(
|
|||
let mut con =
|
||||
connect_direct_node(endpoint, pool.tls, pool.tls_insecure, &pool.username, &pool.password).await?;
|
||||
let current_cursor = if index == node_index { node_cursor } else { 0 };
|
||||
let result = scan_values_page(&mut con, current_cursor, pattern, query, count).await?;
|
||||
let result = scan_values_page(&mut con, current_cursor, pattern, query, include_key_matches, count).await?;
|
||||
if !result.keys.is_empty() {
|
||||
let next_cursor = if result.cursor != 0 {
|
||||
encode_cluster_cursor(index, result.cursor)?
|
||||
|
|
@ -869,6 +963,7 @@ pub async fn scan_values_page<C>(
|
|||
cursor: u64,
|
||||
pattern: &str,
|
||||
query: &str,
|
||||
include_key_matches: bool,
|
||||
count: usize,
|
||||
) -> Result<RedisScanResult, String>
|
||||
where
|
||||
|
|
@ -892,7 +987,47 @@ where
|
|||
|
||||
let (next_cursor, keys) = parse_scan_keys(raw)?;
|
||||
let mut result = Vec::new();
|
||||
for key in keys {
|
||||
let keys: Vec<_> = keys
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let key_display = redis_key_bytes_to_display(&key);
|
||||
let key_raw = redis_key_bytes_to_raw(&key);
|
||||
let key_matches = include_key_matches && redis_key_matches_query(&key_display, &key_raw, query);
|
||||
(key, key_display, key_raw, key_matches)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut key_match_types = Vec::new();
|
||||
if include_key_matches {
|
||||
let mut pipe = redis::pipe();
|
||||
let mut key_match_count = 0usize;
|
||||
for (key, _, _, key_matches) in &keys {
|
||||
if *key_matches {
|
||||
pipe.cmd("TYPE").arg(key);
|
||||
key_match_count += 1;
|
||||
}
|
||||
}
|
||||
if key_match_count > 0 {
|
||||
key_match_types = pipe.query_async(con).await.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
let mut key_match_type_index = 0usize;
|
||||
for (key, key_display, key_raw, key_matches) in keys {
|
||||
if key_matches {
|
||||
let key_type = key_match_types.get(key_match_type_index).cloned().unwrap_or_else(|| "unknown".to_string());
|
||||
key_match_type_index += 1;
|
||||
result.push(RedisKeyInfo {
|
||||
key_display,
|
||||
key_raw,
|
||||
value_preview: redis_key_value_preview(&key_type),
|
||||
key_type,
|
||||
ttl: -2,
|
||||
size: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(value) = get_value(con, &key).await else {
|
||||
continue;
|
||||
};
|
||||
|
|
@ -983,6 +1118,15 @@ fn redis_value_matches_query(value: &serde_json::Value, query: &str) -> bool {
|
|||
redis_search_value_text(value).to_lowercase().contains(&query.to_lowercase())
|
||||
}
|
||||
|
||||
fn redis_key_matches_query(key_display: &str, key_raw: &str, query: &str) -> bool {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let query = query.to_lowercase();
|
||||
key_display.to_lowercase().contains(&query) || key_raw.to_lowercase().contains(&query)
|
||||
}
|
||||
|
||||
fn redis_search_value_text(value: &serde_json::Value) -> String {
|
||||
match value {
|
||||
serde_json::Value::String(text) => text.clone(),
|
||||
|
|
@ -1530,11 +1674,13 @@ mod tests {
|
|||
use super::{
|
||||
classify_command, connection_info, decode_cluster_cursor, encode_cluster_cursor, is_redis_json_type,
|
||||
parse_cluster_slots, parse_command_argv, parse_database_count, parse_redis_endpoint, parse_scan_keys,
|
||||
parse_stream_entries, redis_command_raw_to_json, redis_json_raw_to_json, redis_json_value_preview,
|
||||
redis_key_bytes_to_display, redis_key_bytes_to_raw, redis_key_raw_to_bytes, redis_key_value_preview,
|
||||
redis_raw_to_json, redis_value_contains_binary, redis_value_matches_query, RedisCommandSafety,
|
||||
parse_stream_entries, redis_auth_candidates, redis_command_raw_to_json, redis_database_index,
|
||||
redis_json_raw_to_json, redis_json_value_preview, redis_key_bytes_to_display, redis_key_bytes_to_raw,
|
||||
redis_key_matches_query, redis_key_raw_to_bytes, redis_key_value_preview, redis_raw_to_json,
|
||||
redis_value_contains_binary, redis_value_matches_query, RedisAuthCandidate, RedisCommandSafety,
|
||||
RedisNodeEndpoint, RedisRawValue,
|
||||
};
|
||||
use crate::models::connection::ConnectionConfig;
|
||||
use redis::ConnectionAddr;
|
||||
|
||||
fn bulk(value: &str) -> RedisRawValue {
|
||||
|
|
@ -1673,6 +1819,14 @@ mod tests {
|
|||
assert!(!redis_value_matches_query(&serde_json::json!("Hello Redis"), "mysql"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_redis_keys_case_insensitively() {
|
||||
assert!(redis_key_matches_query("User:42:Profile", "User:42:Profile", "profile"));
|
||||
assert!(redis_key_matches_query("binary key", "ff75736572", "FF75"));
|
||||
assert!(!redis_key_matches_query("User:42:Profile", "User:42:Profile", ""));
|
||||
assert!(!redis_key_matches_query("User:42:Profile", "User:42:Profile", "order"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_hash_field_name_in_value_search() {
|
||||
let hash_value = serde_json::json!([
|
||||
|
|
@ -1754,6 +1908,76 @@ mod tests {
|
|||
assert!(matches!(info.addr, ConnectionAddr::TcpTls { insecure: true, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redis_connection_info_preserves_acl_username_and_password() {
|
||||
let info = connection_info("cache.example.com", 6379, false, false, "app-user", "secret", 0);
|
||||
|
||||
assert_eq!(info.redis.username.as_deref(), Some("app-user"));
|
||||
assert_eq!(info.redis.password.as_deref(), Some("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redis_auth_candidates_try_username_at_password_fallback() {
|
||||
let candidates = redis_auth_candidates("app-user", "secret");
|
||||
|
||||
assert_eq!(
|
||||
candidates,
|
||||
vec![
|
||||
RedisAuthCandidate { username: "app-user".to_string(), password: "secret".to_string() },
|
||||
RedisAuthCandidate { username: String::new(), password: "app-user@secret".to_string() },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redis_database_index_uses_numeric_database_only() {
|
||||
let mut config = ConnectionConfig {
|
||||
id: "redis".to_string(),
|
||||
name: "Redis".to_string(),
|
||||
db_type: crate::models::connection::DatabaseType::Redis,
|
||||
driver_profile: None,
|
||||
driver_label: None,
|
||||
url_params: None,
|
||||
host: "cache.example.com".to_string(),
|
||||
port: 6379,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
database: Some("4".to_string()),
|
||||
visible_databases: None,
|
||||
attached_databases: Vec::new(),
|
||||
color: None,
|
||||
transport_layers: Vec::new(),
|
||||
connect_timeout_secs: crate::models::connection::default_connect_timeout_secs(),
|
||||
query_timeout_secs: crate::models::connection::default_query_timeout_secs(),
|
||||
idle_timeout_secs: crate::models::connection::default_idle_timeout_secs(),
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: None,
|
||||
redis_connection_mode: None,
|
||||
redis_sentinel_master: String::new(),
|
||||
redis_sentinel_nodes: String::new(),
|
||||
redis_sentinel_username: String::new(),
|
||||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
redis_key_separator: crate::models::connection::default_redis_key_separator(),
|
||||
etcd_endpoints: String::new(),
|
||||
external_config: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
read_only: false,
|
||||
};
|
||||
|
||||
assert_eq!(redis_database_index(&config), 4);
|
||||
config.database = Some("not-a-number".to_string());
|
||||
assert_eq!(redis_database_index(&config), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_and_decodes_cluster_scan_cursor() {
|
||||
let encoded = encode_cluster_cursor(12, 3456).unwrap();
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ fn has_top_level_select_into(sql: &str) -> bool {
|
|||
}
|
||||
|
||||
fn add_sql_server_top(sql: &str, limit: usize) -> String {
|
||||
if has_top_level_select_top(sql) {
|
||||
if has_top_level_select_top(sql) || has_top_level_offset_fetch_next(sql) {
|
||||
return sql.to_string();
|
||||
}
|
||||
if sql.len() >= 6 && sql[..6].eq_ignore_ascii_case("SELECT") {
|
||||
|
|
@ -415,6 +415,13 @@ fn has_top_level_fetch_first(sql: &str) -> bool {
|
|||
tokens.windows(2).any(|w| w[0].text == "FETCH" && w[1].text == "FIRST")
|
||||
}
|
||||
|
||||
fn has_top_level_offset_fetch_next(sql: &str) -> bool {
|
||||
let tokens = top_level_sql_tokens(sql);
|
||||
let has_offset = tokens.iter().any(|token| token.text == "OFFSET");
|
||||
let has_fetch_next = tokens.windows(2).any(|w| w[0].text == "FETCH" && w[1].text == "NEXT");
|
||||
has_offset && has_fetch_next
|
||||
}
|
||||
|
||||
fn add_fetch_first_limit(statement: &str, limit: usize, offset: usize) -> String {
|
||||
if has_top_level_fetch_first(statement) {
|
||||
return format!("{statement};");
|
||||
|
|
@ -787,6 +794,19 @@ mod tests {
|
|||
assert_eq!(result, err("unsupported"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_sqlserver_offset_fetch_next_when_offset_is_zero() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
original_sql: "SELECT * FROM TABLE_NAME ORDER BY id OFFSET 1 ROWS FETCH NEXT 10 ROWS ONLY".to_string(),
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
assert!(result.ok);
|
||||
assert_eq!(result.sql.unwrap(), "SELECT * FROM TABLE_NAME ORDER BY id OFFSET 1 ROWS FETCH NEXT 10 ROWS ONLY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_pagination_skips_sql_clause() {
|
||||
let result = build_paginated_query_sql(PaginatedQuerySqlOptions {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ pub async fn redis_scan_values_core(
|
|||
cursor: u64,
|
||||
pattern: &str,
|
||||
query: &str,
|
||||
include_key_matches: bool,
|
||||
count: usize,
|
||||
) -> Result<RedisScanResult, String> {
|
||||
let connections = state.connections.read().await;
|
||||
|
|
@ -63,11 +64,12 @@ pub async fn redis_scan_values_core(
|
|||
RedisConnection::Direct(con) => {
|
||||
let mut con = con.lock().await;
|
||||
redis_driver::select_db(&mut *con, db).await?;
|
||||
redis_driver::scan_values_page(&mut *con, cursor, pattern, query, count).await
|
||||
redis_driver::scan_values_page(&mut *con, cursor, pattern, query, include_key_matches, count).await
|
||||
}
|
||||
RedisConnection::Cluster(cluster) => {
|
||||
redis_driver::ensure_cluster_db(db)?;
|
||||
redis_driver::scan_cluster_values_page(cluster, cursor, pattern, query, count).await
|
||||
redis_driver::scan_cluster_values_page(cluster, cursor, pattern, query, include_key_matches, count)
|
||||
.await
|
||||
}
|
||||
},
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ pub struct RedisValueScanRequest {
|
|||
pub cursor: u64,
|
||||
pub pattern: String,
|
||||
pub query: String,
|
||||
pub include_key_matches: Option<bool>,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
|
|
@ -192,6 +193,7 @@ pub async fn scan_values(
|
|||
req.cursor,
|
||||
&req.pattern,
|
||||
&req.query,
|
||||
req.include_key_matches.unwrap_or(false),
|
||||
req.count,
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -34,9 +34,20 @@ pub async fn redis_scan_values(
|
|||
cursor: u64,
|
||||
pattern: String,
|
||||
query: String,
|
||||
include_key_matches: Option<bool>,
|
||||
count: usize,
|
||||
) -> Result<RedisScanResult, String> {
|
||||
dbx_core::redis_ops::redis_scan_values_core(&state, &connection_id, db, cursor, &pattern, &query, count).await
|
||||
dbx_core::redis_ops::redis_scan_values_core(
|
||||
&state,
|
||||
&connection_id,
|
||||
db,
|
||||
cursor,
|
||||
&pattern,
|
||||
&query,
|
||||
include_key_matches.unwrap_or(false),
|
||||
count,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
|
|||
Loading…
Reference in New Issue