feat(hbase): add HBase REST support

This commit is contained in:
zipg 2026-07-28 11:28:02 +08:00 committed by GitHub
parent 1cdbf4fa3d
commit 44cb4acbb7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 2455 additions and 19 deletions

View File

@ -826,6 +826,7 @@ const driverProfiles: Record<
label: "Elasticsearch",
icon: "elasticsearch",
},
hbase: { type: "hbase", port: 8080, user: "", label: "Apache HBase", icon: "hbase" },
qdrant: { type: "qdrant", port: 6333, user: "", label: "Qdrant", icon: "qdrant" },
milvus: { type: "milvus", port: 19530, user: "root", label: "Milvus", icon: "milvus" },
weaviate: { type: "weaviate", port: 8080, user: "", label: "Weaviate", icon: "weaviate" },
@ -2200,6 +2201,7 @@ const iconTypeMap: Record<string, string> = {
sqlserver: "sqlserver",
oracle: "oracle",
elasticsearch: "elasticsearch",
hbase: "hbase",
qdrant: "qdrant",
milvus: "milvus",
weaviate: "weaviate",
@ -2279,6 +2281,7 @@ const dbOptions: DbOption[] = [
{ value: "sqlite", label: "SQLite" },
{ value: "sqlserver", label: "SQL Server" },
{ value: "elasticsearch", label: "Elasticsearch" },
{ value: "hbase", label: "Apache HBase" },
{ value: "qdrant", label: "Qdrant" },
{ value: "milvus", label: "Milvus" },
{ value: "weaviate", label: "Weaviate" },
@ -2380,7 +2383,7 @@ const dbCategoryDefinitions: Array<{
{
key: "document",
titleKey: "connection.databaseCategoryDocument",
optionValues: ["mongodb", "redis", "elasticsearch", "manticoresearch", "cassandra"],
optionValues: ["mongodb", "redis", "elasticsearch", "hbase", "manticoresearch", "cassandra"],
},
{
key: "graph_ai",
@ -2495,10 +2498,10 @@ const sqliteExtensionPaths = computed({
form.value.url_params = setSqliteExtensionPaths(form.value.url_params, value);
},
});
const tlsCapableDatabaseTypes = new Set<DatabaseType>(["mysql", "starrocks", "postgres", "redshift", "gaussdb", "kwdb", "opengauss", "questdb", "dameng", "redis", "etcd", "clickhouse", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "influxdb"]);
const tlsCapableDatabaseTypes = new Set<DatabaseType>(["mysql", "starrocks", "postgres", "redshift", "gaussdb", "kwdb", "opengauss", "questdb", "dameng", "redis", "etcd", "clickhouse", "elasticsearch", "hbase", "qdrant", "milvus", "weaviate", "chromadb", "influxdb"]);
const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type));
const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse");
const supportsGenericUrlParams = computed(() => form.value.db_type !== "manticoresearch");
const supportsGenericUrlParams = computed(() => form.value.db_type !== "manticoresearch" && form.value.db_type !== "hbase");
const bareMysqlProfiles = new Set(["doris", "selectdb", "oceanbase"]);
const supportsMysqlTlsOptions = computed(() => form.value.db_type === "starrocks" || (form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value)));
const supportsMysqlCleartextPasswordAuth = computed(() => form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value));
@ -5902,7 +5905,7 @@ function openExternalUrl(url: string) {
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div v-if="form.db_type !== 'hbase'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ databaseLabel }}</Label>
<Input v-model="form.database" class="col-span-3" :placeholder="databasePlaceholder" />
</div>

View File

@ -0,0 +1,508 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { Braces, DatabaseZap, ListFilter, Loader2, Plus, RefreshCw, ScanSearch, TableProperties, Trash2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import DataGrid from "@/components/grid/DataGrid.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import { useToast } from "@/composables/useToast";
import * as api from "@/lib/backend/api";
import type { CellValue } from "@/lib/dataGrid/cellValue";
import { encodeHBaseTextInput, hbaseCellInput } from "@/lib/hbase/hbaseValues";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import type { QueryResult } from "@/types/database";
import type { HBaseCellInput, HBasePutRowInput, HBaseRow, HBaseTableSchema, HBaseValueEncoding } from "@/types/hbase";
const props = defineProps<{
tabId: string;
connectionId: string;
namespace: string;
table: string;
createTableOnOpen?: boolean;
}>();
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
type LookupMode = "prefix" | "exact";
const rows = ref<HBaseRow[]>([]);
const loading = ref(false);
const error = ref("");
const lookupMode = ref<LookupMode>("prefix");
const rowKeyInput = ref("");
const rowLimit = ref("100");
const truncated = ref(false);
const elapsedMs = ref(0);
const schema = ref<HBaseTableSchema>();
const schemaLoading = ref(false);
const schemaDialogOpen = ref(false);
const writeDialogOpen = ref(false);
const writeJson = ref("");
const writeLoading = ref(false);
const writeError = ref("");
const createTableDialogOpen = ref(false);
const createTableName = ref("");
const createColumnFamilies = ref("");
const createTableLoading = ref(false);
const createTableError = ref("");
const deleteTableDialogOpen = ref(false);
const deleteTableLoading = ref(false);
const deleteTableError = ref("");
const hasTable = computed(() => props.table.trim().length > 0);
const readOnly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only === true);
const qualifiedTableLabel = computed(() => {
if (!hasTable.value) return props.namespace;
return props.namespace && props.namespace !== "default" ? `${props.namespace}:${props.table}` : props.table;
});
const gridColumns = computed(() => {
const columns = new Set<string>();
for (const row of rows.value) {
for (const cell of row.cells) columns.add(cell.column);
}
return ["_row_key", ...Array.from(columns).sort((left, right) => left.localeCompare(right))];
});
const gridResult = computed<QueryResult>(() => {
const columns = gridColumns.value;
const data = rows.value.map((row) => {
const cells = new Map(row.cells.map((cell) => [cell.column, cell.value]));
return columns.map((column) => (column === "_row_key" ? row.rowKey : (cells.get(column) ?? null)));
});
return {
columns,
column_types: columns.map((column) => (column === "_row_key" ? "ROW_KEY" : "HBASE_CELL")),
column_sortables: columns.map(() => false),
rows: data,
affected_rows: data.length,
execution_time_ms: elapsedMs.value,
total_is_exact: !truncated.value,
};
});
const customSaveHandler = computed<CustomSaveHandler>(() => ({
save: saveGridChanges,
preview: previewGridChanges,
supportsInsert: false,
canInsert: false,
canDelete: !readOnly.value,
readonlyColumns: ["_row_key"],
targetLabel: qualifiedTableLabel.value,
}));
watch(
() => [props.connectionId, props.namespace, props.table] as const,
() => {
rows.value = [];
schema.value = undefined;
rowKeyInput.value = "";
if (hasTable.value) void refreshRows();
},
{ immediate: true },
);
watch(
() => props.createTableOnOpen,
(requested) => {
if (!requested) return;
openCreateTableDialog();
const tab = queryStore.tabs.find((candidate) => candidate.id === props.tabId);
if (tab) tab.hbaseCreateTableOnOpen = undefined;
},
{ immediate: true },
);
async function refreshRows() {
if (loading.value || !hasTable.value) return;
loading.value = true;
error.value = "";
const startedAt = performance.now();
try {
if (lookupMode.value === "exact" && rowKeyInput.value) {
const rowKey = encodeHBaseTextInput(rowKeyInput.value);
const row = await api.hbaseGetRow(props.connectionId, props.namespace, props.table, rowKey.value, rowKey.encoding);
rows.value = row ? [row] : [];
truncated.value = false;
} else {
const result = await api.hbaseScanRows(props.connectionId, props.namespace, props.table, rowKeyInput.value || undefined, Number(rowLimit.value));
rows.value = result.rows;
truncated.value = result.truncated;
}
} catch (caught) {
error.value = errorMessage(caught);
} finally {
elapsedMs.value = Math.round(performance.now() - startedAt);
loading.value = false;
}
}
async function openSchemaDialog() {
schemaDialogOpen.value = true;
if (schema.value) return;
schemaLoading.value = true;
try {
schema.value = await api.hbaseGetTableSchema(props.connectionId, props.namespace, props.table);
} catch (caught) {
error.value = errorMessage(caught);
schemaDialogOpen.value = false;
} finally {
schemaLoading.value = false;
}
}
async function openWriteDialog() {
if (schemaLoading.value) return;
error.value = "";
schemaLoading.value = true;
try {
schema.value ??= await api.hbaseGetTableSchema(props.connectionId, props.namespace, props.table);
} catch (caught) {
error.value = errorMessage(caught);
return;
} finally {
schemaLoading.value = false;
}
const family = schema.value.columnFamilies[0]?.name;
if (!family) {
error.value = t("hbase.noColumnFamilies");
return;
}
writeJson.value = JSON.stringify(
{
rowKey: "",
cells: {
[`${family}:qualifier`]: "value",
},
},
null,
2,
);
writeError.value = "";
writeDialogOpen.value = true;
}
function openCreateTableDialog() {
createTableError.value = "";
createTableName.value = "";
createColumnFamilies.value = "";
createTableDialogOpen.value = true;
}
async function writeRow() {
if (writeLoading.value) return;
writeLoading.value = true;
writeError.value = "";
try {
const input = parseWriteInput(writeJson.value);
await api.hbasePutRow(props.connectionId, props.namespace, props.table, input);
writeDialogOpen.value = false;
toast(t("hbase.rowSaved"));
await refreshRows();
} catch (caught) {
writeError.value = errorMessage(caught);
} finally {
writeLoading.value = false;
}
}
async function createTable() {
if (createTableLoading.value) return;
const families = createColumnFamilies.value
.split(",")
.map((value) => value.trim())
.filter(Boolean);
createTableLoading.value = true;
createTableError.value = "";
try {
const createdTable = createTableName.value.trim();
await api.hbaseCreateTable(props.connectionId, props.namespace, createdTable, families);
createTableDialogOpen.value = false;
toast(t("hbase.tableCreated", { table: createdTable }));
await connectionStore.loadTables(props.connectionId, props.namespace);
if (!hasTable.value) {
const tab = queryStore.tabs.find((candidate) => candidate.id === props.tabId);
if (tab) tab.title = createdTable;
queryStore.updateSql(props.tabId, createdTable);
}
} catch (caught) {
createTableError.value = errorMessage(caught);
} finally {
createTableLoading.value = false;
}
}
async function deleteTable() {
if (deleteTableLoading.value) return;
deleteTableLoading.value = true;
deleteTableError.value = "";
try {
await api.hbaseDeleteTable(props.connectionId, props.namespace, props.table);
deleteTableDialogOpen.value = false;
await connectionStore.loadTables(props.connectionId, props.namespace);
queryStore.closeTab(props.tabId);
toast(t("hbase.tableDeleted", { table: qualifiedTableLabel.value }));
} catch (caught) {
deleteTableError.value = errorMessage(caught);
} finally {
deleteTableLoading.value = false;
}
}
async function saveGridChanges(changes: { dirtyRows: Map<number, Map<number, CellValue>>; deletedRows: Set<number>; columns: string[] }) {
for (const [rowIndex, dirtyColumns] of changes.dirtyRows) {
const row = rows.value[rowIndex];
if (!row) continue;
const cells: HBaseCellInput[] = [];
for (const [columnIndex, value] of dirtyColumns) {
const column = changes.columns[columnIndex];
if (!column || column === "_row_key") continue;
cells.push(hbaseCellInput(column, value));
}
if (cells.length > 0) {
await api.hbasePutRow(props.connectionId, props.namespace, props.table, {
rowKey: row.rowKeyBase64,
rowKeyEncoding: "base64",
cells,
});
}
}
for (const rowIndex of changes.deletedRows) {
const row = rows.value[rowIndex];
if (!row) continue;
await api.hbaseDeleteRow(props.connectionId, props.namespace, props.table, row.rowKeyBase64, "base64");
}
}
async function previewGridChanges(changes: { dirtyRows: Map<number, Map<number, CellValue>>; deletedRows: Set<number>; columns: string[] }) {
const statements: string[] = [];
for (const [rowIndex, dirtyColumns] of changes.dirtyRows) {
const row = rows.value[rowIndex];
if (!row) continue;
const columns = Array.from(dirtyColumns.keys())
.map((index) => changes.columns[index])
.filter((column) => column && column !== "_row_key");
if (columns.length > 0) statements.push(`PUT ${qualifiedTableLabel.value}/${row.rowKey} (${columns.join(", ")})`);
}
for (const rowIndex of changes.deletedRows) {
const row = rows.value[rowIndex];
if (row) statements.push(`DELETE ${qualifiedTableLabel.value}/${row.rowKey}`);
}
return statements;
}
function parseWriteInput(source: string): HBasePutRowInput {
const value = JSON.parse(source) as { rowKey?: unknown; cells?: unknown };
const rowKey = encodedJsonValue(value.rowKey, "rowKey");
if (!value.cells || typeof value.cells !== "object" || Array.isArray(value.cells)) {
throw new Error(t("hbase.cellsObjectRequired"));
}
const cells = Object.entries(value.cells as Record<string, unknown>).map(([column, cellValue]) => {
const encoded = encodedJsonValue(cellValue, column);
return { column, value: encoded.value, valueEncoding: encoded.encoding };
});
if (cells.length === 0) throw new Error(t("hbase.cellRequired"));
return { rowKey: rowKey.value, rowKeyEncoding: rowKey.encoding, cells };
}
function encodedJsonValue(value: unknown, label: string): { value: string; encoding: HBaseValueEncoding } {
if (typeof value === "string") {
return value.startsWith("base64:") ? { value: value.slice(7), encoding: "base64" } : { value, encoding: "utf8" };
}
if (value && typeof value === "object" && !Array.isArray(value) && typeof (value as { base64?: unknown }).base64 === "string") {
return { value: (value as { base64: string }).base64, encoding: "base64" };
}
if (value === null || typeof value === "number" || typeof value === "boolean") {
return { value: value === null ? "" : String(value), encoding: "utf8" };
}
throw new Error(t("hbase.invalidJsonValue", { label }));
}
function errorMessage(value: unknown): string {
return value instanceof Error ? value.message : String(value);
}
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-background">
<div class="flex min-h-10 shrink-0 flex-wrap items-center gap-2 border-b px-2 py-1.5">
<div class="flex min-w-0 items-center gap-1.5 pr-1">
<DatabaseZap class="h-4 w-4 shrink-0 text-primary" />
<span class="truncate text-xs font-medium">{{ qualifiedTableLabel }}</span>
</div>
<div v-if="hasTable" class="flex h-7 shrink-0 items-center rounded border bg-muted/40 p-0.5">
<button type="button" class="flex h-6 items-center gap-1 rounded-sm px-2 text-xs transition-colors" :class="lookupMode === 'prefix' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="lookupMode = 'prefix'">
<ListFilter class="h-3.5 w-3.5" />
{{ t("hbase.prefix") }}
</button>
<button type="button" class="flex h-6 items-center gap-1 rounded-sm px-2 text-xs transition-colors" :class="lookupMode === 'exact' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="lookupMode = 'exact'">
<ScanSearch class="h-3.5 w-3.5" />
{{ t("hbase.exact") }}
</button>
</div>
<Input v-if="hasTable" v-model="rowKeyInput" class="h-7 min-w-40 flex-1 text-xs sm:max-w-80" :placeholder="lookupMode === 'prefix' ? t('hbase.rowKeyPrefix') : t('hbase.rowKey')" @keydown.enter="refreshRows" />
<Select v-if="hasTable && lookupMode === 'prefix'" v-model="rowLimit">
<SelectTrigger class="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">50 {{ t("hbase.rows") }}</SelectItem>
<SelectItem value="100">100 {{ t("hbase.rows") }}</SelectItem>
<SelectItem value="200">200 {{ t("hbase.rows") }}</SelectItem>
<SelectItem value="500">500 {{ t("hbase.rows") }}</SelectItem>
</SelectContent>
</Select>
<Button v-if="hasTable" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" :disabled="loading" @click="refreshRows">
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="h-3.5 w-3.5" />
{{ t("grid.refresh") }}
</Button>
<Button v-if="hasTable" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="openSchemaDialog">
<Braces class="h-3.5 w-3.5" />
{{ t("hbase.schema") }}
</Button>
<Button v-if="hasTable && !readOnly" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" :disabled="schemaLoading" @click="openWriteDialog">
<Loader2 v-if="schemaLoading" class="h-3.5 w-3.5 animate-spin" />
<Plus v-else class="h-3.5 w-3.5" />
{{ t("hbase.writeRow") }}
</Button>
<Button v-if="hasTable && !readOnly" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="openCreateTableDialog">
<TableProperties class="h-3.5 w-3.5" />
{{ t("hbase.createTable") }}
</Button>
<Button
v-if="hasTable && !readOnly"
variant="ghost"
size="icon-sm"
class="h-7 w-7 text-destructive"
:aria-label="t('hbase.deleteTable')"
@click="
deleteTableError = '';
deleteTableDialogOpen = true;
"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
<div v-if="hasTable" class="flex h-7 shrink-0 items-center gap-2 border-b px-3 text-[11px] text-muted-foreground">
<span>{{ t("hbase.loadedRows", { count: rows.length }) }}</span>
<span>{{ elapsedMs }} ms</span>
<span v-if="truncated" class="text-amber-600 dark:text-amber-400">{{ t("hbase.resultTruncated") }}</span>
</div>
<ErrorBanner v-if="error" :message="error" dismissible @dismiss="error = ''" />
<DataGrid
v-if="hasTable"
class="min-h-0 flex-1"
:result="gridResult"
context="results"
database-type="hbase"
:editable="!readOnly"
:custom-save-handler="customSaveHandler"
:allow-insert-rows="false"
:allow-delete-rows="!readOnly"
:loading="loading"
:page-limit="Number(rowLimit)"
:total-row-count="rows.length"
:total-row-count-is-exact="!truncated"
@reload="refreshRows"
/>
<div v-else class="flex min-h-0 flex-1 items-center justify-center">
<Button v-if="!readOnly" variant="outline" class="gap-2" @click="openCreateTableDialog">
<TableProperties class="h-4 w-4" />
{{ t("hbase.createTable") }}
</Button>
</div>
<Dialog v-model:open="schemaDialogOpen">
<DialogContent class="max-h-[80vh] max-w-2xl overflow-hidden p-0">
<DialogHeader class="border-b px-4 py-3">
<DialogTitle class="text-sm">{{ t("hbase.tableSchema", { table: qualifiedTableLabel }) }}</DialogTitle>
</DialogHeader>
<div class="min-h-0 overflow-auto px-4 py-3">
<div v-if="schemaLoading" class="flex min-h-32 items-center justify-center">
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
</div>
<div v-else-if="schema" class="space-y-4 text-xs">
<section v-for="family in schema.columnFamilies" :key="family.name" class="border-b pb-3 last:border-b-0">
<div class="mb-2 font-medium">{{ family.name }}</div>
<dl class="grid grid-cols-[minmax(8rem,auto)_1fr] gap-x-4 gap-y-1 font-mono text-[11px]">
<template v-for="(value, key) in family.properties" :key="key">
<dt class="text-muted-foreground">{{ key }}</dt>
<dd class="break-all">{{ value }}</dd>
</template>
</dl>
</section>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog v-model:open="writeDialogOpen">
<DialogContent class="max-w-xl">
<DialogHeader>
<DialogTitle class="text-sm">{{ t("hbase.writeRow") }}</DialogTitle>
</DialogHeader>
<ErrorBanner v-if="writeError" :message="writeError" dismissible @dismiss="writeError = ''" />
<textarea v-model="writeJson" class="min-h-64 w-full resize-y rounded border bg-muted/20 p-3 font-mono text-xs outline-none focus:ring-1 focus:ring-ring" spellcheck="false" />
<DialogFooter>
<Button variant="outline" :disabled="writeLoading" @click="writeDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button :disabled="writeLoading" @click="writeRow">
<Loader2 v-if="writeLoading" class="mr-1.5 h-4 w-4 animate-spin" />
{{ t("common.save") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="createTableDialogOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle class="text-sm">{{ t("hbase.createTableIn", { namespace }) }}</DialogTitle>
</DialogHeader>
<ErrorBanner v-if="createTableError" :message="createTableError" dismissible @dismiss="createTableError = ''" />
<div class="space-y-3">
<Input v-model="createTableName" :placeholder="t('hbase.tableName')" />
<Input v-model="createColumnFamilies" :placeholder="t('hbase.columnFamiliesPlaceholder')" @keydown.enter="createTable" />
</div>
<DialogFooter>
<Button variant="outline" :disabled="createTableLoading" @click="createTableDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button :disabled="createTableLoading || !createTableName.trim() || !createColumnFamilies.trim()" @click="createTable">
<Loader2 v-if="createTableLoading" class="mr-1.5 h-4 w-4 animate-spin" />
{{ t("hbase.create") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DangerConfirmDialog
v-model:open="deleteTableDialogOpen"
:title="t('hbase.deleteTable')"
:message="t('hbase.deleteTableConfirm', { table: qualifiedTableLabel })"
:details="qualifiedTableLabel"
:confirm-label="t('common.delete')"
:loading="deleteTableLoading"
:close-on-confirm="false"
@confirm="deleteTable"
>
<template #options>
<ErrorBanner v-if="deleteTableError" :message="deleteTableError" dismissible class="mb-3" @dismiss="deleteTableError = ''" />
</template>
</DangerConfirmDialog>
</div>
</template>

View File

@ -50,6 +50,7 @@ const DocumentBrowser = defineAsyncComponent(() => import("@/components/document
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
const MongoBucketBrowser = defineAsyncComponent(() => import("@/components/document/MongoBucketBrowser.vue"));
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
const HBaseBrowser = defineAsyncComponent(() => import("@/components/hbase/HBaseBrowser.vue"));
const ElasticsearchJsonResponsePanel = defineAsyncComponent(() => import("@/components/common/ElasticsearchJsonResponsePanel.vue"));
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
@ -1812,6 +1813,12 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
</div>
</template>
<template v-else-if="activeTab.mode === 'hbase'">
<div class="flex-1 min-h-0">
<HBaseBrowser :key="activeTab.id" :tab-id="activeTab.id" :connection-id="activeTab.connectionId" :namespace="activeTab.database" :table="activeTab.sql" :create-table-on-open="activeTab.hbaseCreateTableOnOpen" />
</div>
</template>
<template v-else-if="activeTab.mode === 'mq'">
<div class="flex-1 min-h-0">
<MqAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-tenant="activeTab.mqTenant" :initial-tab="activeTab.mqInitialTab" :read-only="activeConnection?.read_only ?? false" />

View File

@ -2044,7 +2044,13 @@ async function confirmBatchEmpty() {
const canCreateTable = computed(() => {
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
return (activeNode.value.type === "database" || activeNode.value.type === "schema" || activeNode.value.type === "group-tables") && !isSqlServerLinkedNode(activeNode.value) && !!activeNode.value.database && supportsTableStructureEditing(tableStructureDatabaseTypeForConnection(config));
const supportsHBaseTableCreation = config?.db_type === "hbase" && !config.read_only;
return (
(activeNode.value.type === "database" || activeNode.value.type === "schema" || activeNode.value.type === "group-tables") &&
!isSqlServerLinkedNode(activeNode.value) &&
!!activeNode.value.database &&
(supportsHBaseTableCreation || supportsTableStructureEditing(tableStructureDatabaseTypeForConnection(config)))
);
});
const canCreateDatabase = computed(() => {
@ -2905,6 +2911,12 @@ function openPasteTableDialog() {
function createTable() {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
if (connectionStore.getConfig(node.connectionId)?.db_type === "hbase") {
const tabId = queryStore.createTab(node.connectionId, node.database, node.database, "hbase", undefined, "");
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId);
if (tab) tab.hbaseCreateTableOnOpen = true;
return;
}
queryStore.openTableStructure(node.connectionId, node.database, node.schema, "");
}
@ -3694,6 +3706,20 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean {
const { node, items } = context;
// 4. Database / Schema
if (node.type === "database" || node.type === "schema") {
if (currentDatabaseType() === "hbase") {
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
if (canCreateTable.value) {
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus });
}
items.push({
label: t("contextMenu.refreshChildren"),
action: refresh,
icon: RefreshCw,
shortcut: shortcutRefresh,
});
return true;
}
if (canCloseDatabaseConnection.value) {
items.push({ label: t("contextMenu.closeDatabaseConnection"), action: closeDatabaseConnection, icon: Unplug });
items.push({ label: "", separator: true });
@ -3703,9 +3729,11 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean {
if (canOpenObjectBrowser.value) {
items.push({ label: t("contextMenu.openObjectBrowser"), action: openObjectBrowser, icon: TableProperties });
}
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
const sqlHistoryMenu = savedSqlHistorySubmenu();
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
if (supportsConnectionQueryActions(currentDatabaseType())) {
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
const sqlHistoryMenu = savedSqlHistorySubmenu();
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
}
if (node.type === "database" && currentDatabaseType() !== "cloudflare-d1") {
if (!isNodeDefaultDatabase.value) {
items.push({ label: t("contextMenu.setDefaultDatabase"), action: setNodeAsDefaultDatabase, icon: Database });
@ -3789,6 +3817,40 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean {
function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
const { node, items } = context;
// 5. Redis DB / Mongo DB
if (currentDatabaseType() === "hbase" && node.type === "group-tables") {
if (canCreateTable.value) {
items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus });
items.push({ label: "", separator: true });
}
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({
label: t("contextMenu.refreshChildren"),
action: refresh,
icon: RefreshCw,
shortcut: shortcutRefresh,
});
return true;
}
if (currentDatabaseType() === "hbase" && node.type === "table") {
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.viewData"), action: openDataImmediately, icon: TableProperties });
items.push({
label: t("contextMenu.openInNewDataTab"),
action: openDataInNewTabImmediately,
icon: CopyPlus,
shortcut: shortcutOpenDataInNewTab.value,
});
items.push({
label: t("contextMenu.refreshChildren"),
action: refresh,
icon: RefreshCw,
shortcut: shortcutRefresh,
});
return true;
}
if (node.type === "etcd-root" || node.type === "etcd-dashboard" || node.type === "zookeeper-root") {
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Database });
return true;

View File

@ -30,6 +30,12 @@ export function useSidebarDataOpenRuntime() {
async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMode: DataTabOpenMode = "default") {
if (!(node.type === "table" || node.type === "view" || node.type === "materialized_view") || !hasNodeDatabaseContext(node)) return;
const config = connectionStore.getConfig(node.connectionId);
if (config?.db_type === "hbase") {
await connectionStore.ensureConnected(node.connectionId);
const tabId = queryStore.createTab(node.connectionId, node.database, node.label, "hbase", undefined, node.label, undefined, { forceNew: openMode === "new-tab" });
queryStore.updateSql(tabId, node.label);
return;
}
const traceId = uuid().slice(0, 8);
const startedAt = performance.now();
let lastPhaseAt = startedAt;

View File

@ -3124,6 +3124,32 @@ export default {
vectorLabel: "Vector",
vectorDimensionRequired: "Collection dimensions are not available yet. Enter a vector matching the collection dimension.",
},
hbase: {
prefix: "Prefix",
exact: "Exact",
rowKeyPrefix: "Row key prefix",
rowKey: "Row key",
rows: "rows",
schema: "Schema",
writeRow: "Write row",
loadedRows: "{count} rows loaded",
resultTruncated: "More rows are available",
tableSchema: "Schema: {table}",
rowSaved: "HBase row saved",
cellsObjectRequired: "cells must be a JSON object",
cellRequired: "At least one cell is required",
noColumnFamilies: "This HBase table has no column families",
invalidJsonValue: "Invalid value for {label}; use text, a primitive, or base64:...",
createTable: "Create table",
create: "Create",
tableCreated: "HBase table {table} created",
tableDeleted: "HBase table {table} deleted",
createTableIn: "Create table in {namespace}",
tableName: "Table name",
columnFamiliesPlaceholder: "Column families, comma-separated",
deleteTable: "Delete table",
deleteTableConfirm: "Delete HBase table {table}? All rows will be permanently removed.",
},
history: {
title: "History",
search: "Search history...",

View File

@ -2981,6 +2981,32 @@ export default withEnglishFallback({
vectorLabel: "Vector",
vectorDimensionRequired: "La dimensión de la colección aún no está disponible. Introduce un vector con la dimensión correcta.",
},
hbase: {
prefix: "Prefijo",
exact: "Exacta",
rowKeyPrefix: "Prefijo de clave de fila",
rowKey: "Clave de fila",
rows: "filas",
schema: "Esquema",
writeRow: "Escribir fila",
loadedRows: "{count} filas cargadas",
resultTruncated: "Hay más filas disponibles",
tableSchema: "Esquema: {table}",
rowSaved: "Fila de HBase guardada",
cellsObjectRequired: "cells debe ser un objeto JSON",
cellRequired: "Se requiere al menos una celda",
noColumnFamilies: "Esta tabla HBase no tiene familias de columnas",
invalidJsonValue: "Valor no válido para {label}; usa texto, un valor primitivo o base64:...",
createTable: "Crear tabla",
create: "Crear",
tableCreated: "Tabla HBase {table} creada",
tableDeleted: "Tabla HBase {table} eliminada",
createTableIn: "Crear tabla en {namespace}",
tableName: "Nombre de tabla",
columnFamiliesPlaceholder: "Familias de columnas, separadas por comas",
deleteTable: "Eliminar tabla",
deleteTableConfirm: "¿Eliminar la tabla HBase {table}? Todas las filas se eliminarán permanentemente.",
},
history: {
title: "Historial",
search: "Buscar en historial...",

View File

@ -2979,6 +2979,32 @@ export default withEnglishFallback({
vectorLabel: "Vettore",
vectorDimensionRequired: "La dimensione della collezione non è ancora disponibile. Inserisci un vettore con la dimensione corretta.",
},
hbase: {
prefix: "Prefisso",
exact: "Esatta",
rowKeyPrefix: "Prefisso chiave riga",
rowKey: "Chiave riga",
rows: "righe",
schema: "Schema",
writeRow: "Scrivi riga",
loadedRows: "{count} righe caricate",
resultTruncated: "Sono disponibili altre righe",
tableSchema: "Schema: {table}",
rowSaved: "Riga HBase salvata",
cellsObjectRequired: "cells deve essere un oggetto JSON",
cellRequired: "È richiesta almeno una cella",
noColumnFamilies: "Questa tabella HBase non ha famiglie di colonne",
invalidJsonValue: "Valore non valido per {label}; usa testo, un valore primitivo o base64:...",
createTable: "Crea tabella",
create: "Crea",
tableCreated: "Tabella HBase {table} creata",
tableDeleted: "Tabella HBase {table} eliminata",
createTableIn: "Crea tabella in {namespace}",
tableName: "Nome tabella",
columnFamiliesPlaceholder: "Famiglie di colonne, separate da virgole",
deleteTable: "Elimina tabella",
deleteTableConfirm: "Eliminare la tabella HBase {table}? Tutte le righe verranno eliminate definitivamente.",
},
history: {
title: "Cronologia",
search: "Cerca nella cronologia...",

View File

@ -2980,6 +2980,32 @@ export default withEnglishFallback({
vectorLabel: "ベクトル",
vectorDimensionRequired: "コレクションの次元をまだ取得できません。次元が一致するベクトルを入力してください。",
},
hbase: {
prefix: "プレフィックス",
exact: "完全一致",
rowKeyPrefix: "行キープレフィックス",
rowKey: "行キー",
rows: "行",
schema: "スキーマ",
writeRow: "行を書き込む",
loadedRows: "{count} 行を読み込みました",
resultTruncated: "さらに行があります",
tableSchema: "スキーマ: {table}",
rowSaved: "HBase 行を保存しました",
cellsObjectRequired: "cells は JSON オブジェクトである必要があります",
cellRequired: "少なくとも 1 つのセルが必要です",
noColumnFamilies: "この HBase テーブルには列ファミリーがありません",
invalidJsonValue: "{label} の値が無効です。テキスト、プリミティブ値、または base64:... を使用してください",
createTable: "テーブルを作成",
create: "作成",
tableCreated: "HBase テーブル {table} を作成しました",
tableDeleted: "HBase テーブル {table} を削除しました",
createTableIn: "{namespace} にテーブルを作成",
tableName: "テーブル名",
columnFamiliesPlaceholder: "列ファミリー(カンマ区切り)",
deleteTable: "テーブルを削除",
deleteTableConfirm: "HBase テーブル {table} を削除しますか?すべての行が完全に削除されます。",
},
history: {
title: "履歴",
search: "履歴を検索...",

View File

@ -2981,6 +2981,32 @@ export default withEnglishFallback({
vectorLabel: "Vetor",
vectorDimensionRequired: "A dimensão da coleção ainda não está disponível. Insira um vetor com a dimensão correta.",
},
hbase: {
prefix: "Prefixo",
exact: "Exata",
rowKeyPrefix: "Prefixo da chave da linha",
rowKey: "Chave da linha",
rows: "linhas",
schema: "Esquema",
writeRow: "Gravar linha",
loadedRows: "{count} linhas carregadas",
resultTruncated: "Há mais linhas disponíveis",
tableSchema: "Esquema: {table}",
rowSaved: "Linha do HBase salva",
cellsObjectRequired: "cells deve ser um objeto JSON",
cellRequired: "É necessária pelo menos uma célula",
noColumnFamilies: "Esta tabela HBase não possui famílias de colunas",
invalidJsonValue: "Valor inválido para {label}; use texto, um valor primitivo ou base64:...",
createTable: "Criar tabela",
create: "Criar",
tableCreated: "Tabela HBase {table} criada",
tableDeleted: "Tabela HBase {table} excluída",
createTableIn: "Criar tabela em {namespace}",
tableName: "Nome da tabela",
columnFamiliesPlaceholder: "Famílias de colunas, separadas por vírgulas",
deleteTable: "Excluir tabela",
deleteTableConfirm: "Excluir a tabela HBase {table}? Todas as linhas serão removidas permanentemente.",
},
history: {
title: "Histórico",
search: "Pesquisar histórico...",

View File

@ -3124,6 +3124,32 @@ export default withEnglishFallback({
vectorLabel: "向量",
vectorDimensionRequired: "暂未获取到集合维度,请先输入与集合维度一致的向量。",
},
hbase: {
prefix: "前缀",
exact: "精确",
rowKeyPrefix: "Row Key 前缀",
rowKey: "Row Key",
rows: "行",
schema: "Schema",
writeRow: "写入行",
loadedRows: "已加载 {count} 行",
resultTruncated: "仍有更多数据",
tableSchema: "Schema{table}",
rowSaved: "HBase 行已保存",
cellsObjectRequired: "cells 必须是 JSON 对象",
cellRequired: "至少需要一个单元格",
noColumnFamilies: "当前 HBase 表没有列族",
invalidJsonValue: "{label} 的值无效,请使用文本、基础类型或 base64:...",
createTable: "新建表",
create: "创建",
tableCreated: "HBase 表 {table} 已创建",
tableDeleted: "HBase 表 {table} 已删除",
createTableIn: "在 {namespace} 中新建表",
tableName: "表名",
columnFamiliesPlaceholder: "列族,使用逗号分隔",
deleteTable: "删除表",
deleteTableConfirm: "确定删除 HBase 表 {table} 吗?其中所有行都会被永久删除。",
},
history: {
title: "历史",
search: "搜索历史...",

View File

@ -2652,6 +2652,32 @@ export default withEnglishFallback({
vectorLabel: "向量",
vectorDimensionRequired: "尚未取得集合維度,請先輸入與集合維度一致的向量。",
},
hbase: {
prefix: "前綴",
exact: "精確",
rowKeyPrefix: "Row Key 前綴",
rowKey: "Row Key",
rows: "列",
schema: "Schema",
writeRow: "寫入資料列",
loadedRows: "已載入 {count} 列",
resultTruncated: "仍有更多資料",
tableSchema: "Schema{table}",
rowSaved: "HBase 資料列已儲存",
cellsObjectRequired: "cells 必須是 JSON 物件",
cellRequired: "至少需要一個儲存格",
noColumnFamilies: "目前 HBase 資料表沒有欄族",
invalidJsonValue: "{label} 的值無效,請使用文字、基本型別或 base64:...",
createTable: "建立資料表",
create: "建立",
tableCreated: "HBase 資料表 {table} 已建立",
tableDeleted: "HBase 資料表 {table} 已刪除",
createTableIn: "在 {namespace} 中建立資料表",
tableName: "資料表名稱",
columnFamiliesPlaceholder: "欄族,以逗號分隔",
deleteTable: "刪除資料表",
deleteTableConfirm: "確定要刪除 HBase 資料表 {table} 嗎?其中所有資料列都會永久刪除。",
},
history: {
title: "歷史",
search: "搜尋歷史……",

View File

@ -98,6 +98,7 @@ describe("database property editing", () => {
expect(editableDatabasePropertyGroups({ db_type: "mysql", read_only: true }, { type: "database", database: "app" })).toEqual([]);
expect(editableDatabasePropertyGroups({ db_type: "sqlite" }, { type: "database", database: "main" })).toEqual([]);
expect(editableDatabasePropertyGroups({ db_type: "sqlserver" }, { type: "database", database: "master" })).toEqual([]);
expect(editableDatabasePropertyGroups({ db_type: "hbase" }, { type: "database", database: "default" })).toEqual([]);
expect(editableDatabasePropertyGroups({ db_type: "postgres" }, { type: "connection" })).toEqual([]);
expect(editableSchemaPropertyGroups({ db_type: "postgres", read_only: true }, { type: "schema", database: "postgres", schema: "public" })).toEqual([]);
expect(editableSchemaPropertyGroups({ db_type: "postgres" }, { type: "database", database: "postgres" })).toEqual([]);
@ -137,6 +138,7 @@ describe("database namespace creation", () => {
expect(connectionNamespaceCreationTarget({ db_type: "sqlite", read_only: true })).toBeNull();
expect(connectionNamespaceCreationTarget({ db_type: "jdbc" })).toBeNull();
expect(connectionNamespaceCreationTarget({ db_type: "oracle" })).toBeNull();
expect(connectionNamespaceCreationTarget({ db_type: "hbase" })).toBeNull();
});
it("allows schema creation only on writable database nodes with schema targets", () => {

View File

@ -6,6 +6,10 @@ describe("databaseObjectCapabilities", () => {
expect(sidebarObjectKindsForDatabase("dameng")).toContain("MATERIALIZED_VIEW");
});
it("exposes only tables for HBase namespaces", () => {
expect(sidebarObjectKindsForDatabase("hbase")).toEqual(["TABLE"]);
});
it("exposes materialized views for StarRocks only", () => {
// StarRocks has a dedicated MV listing/classification path in
// crates/dbx-core/src/db/mysql.rs (`list_starrocks_tables` +

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { encodeHBaseTextInput, hbaseCellInput } from "@/lib/hbase/hbaseValues";
describe("HBase value conversion", () => {
it("keeps UTF-8 input unchanged", () => {
expect(encodeHBaseTextInput("customer#001")).toEqual({ value: "customer#001", encoding: "utf8" });
expect(hbaseCellInput("profile:name", "Alice")).toEqual({
column: "profile:name",
value: "Alice",
valueEncoding: "utf8",
});
});
it("removes the display prefix before sending Base64 input", () => {
expect(encodeHBaseTextInput("base64:AAEC")).toEqual({ value: "AAEC", encoding: "base64" });
expect(hbaseCellInput("profile:avatar", "base64:AP8=")).toEqual({
column: "profile:avatar",
value: "AP8=",
valueEncoding: "base64",
});
});
it("writes null cells as empty UTF-8 values", () => {
expect(hbaseCellInput("profile:note", null)).toEqual({
column: "profile:note",
value: "",
valueEncoding: "utf8",
});
});
});

View File

@ -424,6 +424,15 @@ export const zookeeperGet = forward("zookeeperGet");
export const zookeeperPut = forward("zookeeperPut");
export const zookeeperDelete = forward("zookeeperDelete");
// HBase
export const hbaseGetTableSchema = forward("hbaseGetTableSchema");
export const hbaseScanRows = forward("hbaseScanRows");
export const hbaseGetRow = forward("hbaseGetRow");
export const hbasePutRow = forward("hbasePutRow");
export const hbaseDeleteRow = forward("hbaseDeleteRow");
export const hbaseCreateTable = forward("hbaseCreateTable");
export const hbaseDeleteTable = forward("hbaseDeleteTable");
// Message Queue
export const mqTestConnection = forward("mqTestConnection");
export const mqListTenants = forward("mqListTenants");

View File

@ -2295,6 +2295,38 @@ export async function nacosRawRequest(connectionId: string, req: NacosRawRequest
return post("/api/nacos/raw", { connectionId, req });
}
// ---------------------------------------------------------------------------
// HBase
// ---------------------------------------------------------------------------
export async function hbaseGetTableSchema(connectionId: string, namespace: string, table: string): Promise<import("@/types/hbase").HBaseTableSchema> {
return post("/api/hbase/table-schema", { connectionId, namespace, table });
}
export async function hbaseScanRows(connectionId: string, namespace: string, table: string, rowKeyPrefix: string | undefined, limit: number): Promise<import("@/types/hbase").HBaseScanResult> {
return post("/api/hbase/scan-rows", { connectionId, namespace, table, rowKeyPrefix, limit });
}
export async function hbaseGetRow(connectionId: string, namespace: string, table: string, rowKey: string, rowKeyEncoding?: import("@/types/hbase").HBaseValueEncoding): Promise<import("@/types/hbase").HBaseRow | null> {
return post("/api/hbase/get-row", { connectionId, namespace, table, rowKey, rowKeyEncoding });
}
export async function hbasePutRow(connectionId: string, namespace: string, table: string, input: import("@/types/hbase").HBasePutRowInput): Promise<void> {
return post("/api/hbase/put-row", { connectionId, namespace, table, input });
}
export async function hbaseDeleteRow(connectionId: string, namespace: string, table: string, rowKey: string, rowKeyEncoding?: import("@/types/hbase").HBaseValueEncoding): Promise<void> {
return post("/api/hbase/delete-row", { connectionId, namespace, table, rowKey, rowKeyEncoding });
}
export async function hbaseCreateTable(connectionId: string, namespace: string, table: string, columnFamilies: string[]): Promise<void> {
return post("/api/hbase/create-table", { connectionId, namespace, table, columnFamilies });
}
export async function hbaseDeleteTable(connectionId: string, namespace: string, table: string): Promise<void> {
return post("/api/hbase/delete-table", { connectionId, namespace, table });
}
// ---------------------------------------------------------------------------
// MongoDB
// ---------------------------------------------------------------------------

View File

@ -2062,6 +2062,35 @@ export async function zookeeperDelete(connectionId: string, key: string): Promis
return invoke("zookeeper_delete", { connectionId, key });
}
// --- HBase ---
export async function hbaseGetTableSchema(connectionId: string, namespace: string, table: string): Promise<import("@/types/hbase").HBaseTableSchema> {
return invoke("hbase_get_table_schema", { connectionId, namespace, table });
}
export async function hbaseScanRows(connectionId: string, namespace: string, table: string, rowKeyPrefix: string | undefined, limit: number): Promise<import("@/types/hbase").HBaseScanResult> {
return invoke("hbase_scan_rows", { connectionId, namespace, table, rowKeyPrefix, limit });
}
export async function hbaseGetRow(connectionId: string, namespace: string, table: string, rowKey: string, rowKeyEncoding?: import("@/types/hbase").HBaseValueEncoding): Promise<import("@/types/hbase").HBaseRow | null> {
return invoke("hbase_get_row", { connectionId, namespace, table, rowKey, rowKeyEncoding });
}
export async function hbasePutRow(connectionId: string, namespace: string, table: string, input: import("@/types/hbase").HBasePutRowInput): Promise<void> {
return invoke("hbase_put_row", { connectionId, namespace, table, input });
}
export async function hbaseDeleteRow(connectionId: string, namespace: string, table: string, rowKey: string, rowKeyEncoding?: import("@/types/hbase").HBaseValueEncoding): Promise<void> {
return invoke("hbase_delete_row", { connectionId, namespace, table, rowKey, rowKeyEncoding });
}
export async function hbaseCreateTable(connectionId: string, namespace: string, table: string, columnFamilies: string[]): Promise<void> {
return invoke("hbase_create_table", { connectionId, namespace, table, columnFamilies });
}
export async function hbaseDeleteTable(connectionId: string, namespace: string, table: string): Promise<void> {
return invoke("hbase_delete_table", { connectionId, namespace, table });
}
// --- Document stores ---
export interface DocumentQueryResult {
documents: any[];

View File

@ -74,7 +74,7 @@ export function supportsClearableQuerySchema(dbType?: DatabaseType): boolean {
}
export function supportsConnectionQueryActions(dbType?: DatabaseType): boolean {
return dbType !== "nacos";
return dbType !== "nacos" && dbType !== "hbase";
}
export function usesFetchFirst(dbType?: DatabaseType): boolean {

View File

@ -28,6 +28,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
mongodb: { connection: "special" },
oracle: { deferred: "Oracle schemas are users; database creation is not a normal connected DDL action" },
elasticsearch: { deferred: "index creation is not modeled as database creation" },
hbase: { deferred: "namespace creation needs dedicated HBase namespace options" },
qdrant: { deferred: "collection creation is separate from database creation" },
milvus: { deferred: "collection/database lifecycle needs a dedicated vector workflow" },
weaviate: { deferred: "collection creation is separate from database creation" },

View File

@ -61,6 +61,7 @@ const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
["neo4j", TABLE_VIEW_OBJECTS],
// others
["influxdb", ["TABLE"]],
["hbase", ["TABLE"]],
["questdb", ["TABLE", "VIEW", "MATERIALIZED_VIEW"]],
["manticoresearch", ["TABLE", "FUNCTION"]],
["databend", ["TABLE", "VIEW", "PROCEDURE"]],

View File

@ -29,6 +29,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
mongodb: { deferred: "database options are not modeled as SQL database properties" },
oracle: { deferred: "Oracle database properties are instance/user/tablespace administration" },
elasticsearch: { deferred: "index settings are not database properties" },
hbase: { deferred: "namespace and table properties need a dedicated HBase workflow" },
qdrant: { deferred: "collection settings are not database properties" },
milvus: { deferred: "collection/database settings need a dedicated vector workflow" },
weaviate: { deferred: "collection settings are not database properties" },

View File

@ -0,0 +1,16 @@
import type { CellValue } from "@/lib/dataGrid/cellValue";
import type { HBaseCellInput, HBaseValueEncoding } from "@/types/hbase";
export interface HBaseEncodedText {
value: string;
encoding: HBaseValueEncoding;
}
export function encodeHBaseTextInput(value: string): HBaseEncodedText {
return value.startsWith("base64:") ? { value: value.slice(7), encoding: "base64" } : { value, encoding: "utf8" };
}
export function hbaseCellInput(column: string, value: CellValue): HBaseCellInput {
const encoded = encodeHBaseTextInput(value == null ? "" : String(value));
return { column, value: encoded.value, valueEncoding: encoded.encoding };
}

View File

@ -26,6 +26,12 @@ export type ActiveTabSidebarTarget =
database: string;
collectionName: string;
}
| {
type: "hbase-table";
connectionId: string;
namespace: string;
tableName: string;
}
| {
type: "etcd-root";
connectionId: string;
@ -112,6 +118,16 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
};
}
if (tab.mode === "hbase") {
if (!tab.sql) return null;
return {
type: "hbase-table",
connectionId: tab.connectionId,
namespace: tab.database,
tableName: tab.sql || tab.title,
};
}
if (tab.mode === "etcd") {
return { type: "etcd-root", connectionId: tab.connectionId };
}
@ -176,6 +192,10 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
return node.type === "vector-collection" && node.connectionId === target.connectionId && node.database === target.database && node.label === target.collectionName;
}
if (target.type === "hbase-table") {
return node.type === "table" && node.connectionId === target.connectionId && node.database === target.namespace && node.label === target.tableName;
}
if (target.type === "query-context") {
if (target.schema) {
return node.type === "schema" && node.connectionId === target.connectionId && node.database === target.database && node.label === target.schema;

View File

@ -31,6 +31,12 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
triggers: false,
ddl: false,
},
hbase: {
indexes: false,
foreignKeys: false,
triggers: false,
ddl: false,
},
qdrant: {
indexes: false,
foreignKeys: false,

View File

@ -94,6 +94,10 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
if (compact) return tab.sql;
return `${tab.sql}@${database}`;
}
if (tab.mode === "hbase" && tab.sql) {
if (compact) return tab.sql;
return `${tab.sql}@${database}`;
}
if (tab.mode === "redis") {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@${database}`;
@ -148,6 +152,9 @@ export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; v
if (tab.mode === "vector" && tab.sql) {
lines.push({ label: t("tabs.tooltipCollection"), value: tab.sql });
}
if (tab.mode === "hbase" && tab.sql) {
lines.push({ label: t("tabs.tooltipTable"), value: tab.sql });
}
if (tab.mode === "objects" && tab.objectBrowser?.schema) {
lines.push({ label: t("tabs.tooltipSchema"), value: tab.objectBrowser.schema });
}
@ -333,6 +340,7 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
if (tab.mode === "mongo") return t("tabs.mongo");
if (tab.mode === "mongo-gridfs" || tab.mode === "mongo-bucket") return t("tabs.gridfs");
if (tab.mode === "vector") return t("tabs.vector");
if (tab.mode === "hbase") return "HBase";
if (tab.mode === "redis") return t("tabs.redis");
if (tab.mode === "etcd") return t("tabs.etcd");
if (tab.mode === "etcd-dashboard") return t("tabs.etcdDashboard");

View File

@ -12,6 +12,7 @@ export type DatabaseType =
| "mongodb"
| "oracle"
| "elasticsearch"
| "hbase"
| "qdrant"
| "milvus"
| "weaviate"
@ -906,7 +907,31 @@ export interface QueryTab {
explainClientSessionId?: string;
/** Invalidates tab-scoped completion metadata after session context changes. */
completionContextVersion?: number;
mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "etcd-dashboard" | "zookeeper" | "mq" | "nacos" | "nacos-dashboard" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist" | "mysql-dashboard" | "postgres-dashboard";
mode:
| "data"
| "query"
| "redis"
| "redis-dashboard"
| "mongo"
| "mongo-gridfs"
| "mongo-bucket"
| "vector"
| "hbase"
| "etcd"
| "etcd-dashboard"
| "zookeeper"
| "mq"
| "nacos"
| "nacos-dashboard"
| "objects"
| "structure"
| "users"
| "dameng-jobs"
| "processlist"
| "mysql-dashboard"
| "postgres-dashboard";
/** Ephemeral navigation intent; it is consumed by HBaseBrowser and is not persisted. */
hbaseCreateTableOnOpen?: boolean;
mqTenant?: string;
mqInitialTab?: "topics";
nacosNamespace?: string;

View File

@ -0,0 +1,44 @@
export type HBaseValueEncoding = "utf8" | "base64";
export interface HBaseColumnFamily {
name: string;
properties: Record<string, string>;
}
export interface HBaseTableSchema {
name: string;
columnFamilies: HBaseColumnFamily[];
properties: Record<string, string>;
}
export interface HBaseCell {
column: string;
value: string;
valueEncoding: HBaseValueEncoding;
valueBase64: string;
timestamp?: number;
}
export interface HBaseRow {
rowKey: string;
rowKeyEncoding: HBaseValueEncoding;
rowKeyBase64: string;
cells: HBaseCell[];
}
export interface HBaseScanResult {
rows: HBaseRow[];
truncated: boolean;
}
export interface HBaseCellInput {
column: string;
value: string;
valueEncoding?: HBaseValueEncoding;
}
export interface HBasePutRowInput {
rowKey: string;
rowKeyEncoding?: HBaseValueEncoding;
cells: HBaseCellInput[];
}

View File

@ -355,6 +355,35 @@
"driverManagement": false
}
},
{
"dbType": "hbase",
"label": "Apache HBase",
"runtimeMode": "native",
"mcpMode": "bridge",
"singleConnectionPool": true,
"metadataConnectionScoped": false,
"skipTcpProbe": false,
"defaultPort": 8080,
"supportLevel": "operate",
"capabilities": {
"queryExecution": false,
"metadataBrowse": true,
"objectBrowser": false,
"objectSource": false,
"schemaSearch": false,
"diagram": false,
"tableDataEdit": true,
"tableStructureEdit": false,
"tableImport": false,
"dataTransfer": false,
"sqlFileExecution": false,
"databaseCreate": false,
"fieldLineage": false,
"sqlExplain": false,
"userAdmin": false,
"driverManagement": false
}
},
{
"dbType": "qdrant",
"label": "Qdrant",

View File

@ -96,6 +96,7 @@ pub enum PoolKind {
ClickHouse(db::clickhouse_driver::ChClient),
SqlServer(Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
Elasticsearch(db::elasticsearch_driver::EsClient),
HBase(db::hbase_driver::HBaseClient),
VectorDb(db::vector_driver::VectorClient),
InfluxDb(db::influxdb_driver::InfluxdbClient),
Agent(Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>),
@ -116,6 +117,7 @@ enum ConnectionDatabaseInfoSource {
Agent(Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>),
ExternalDriver { config: Arc<ConnectionConfig>, session: Arc<PluginDriverSession> },
NativeMysql(db::mysql::MySqlPool),
NativeHBase(db::hbase_driver::HBaseClient),
}
/// Held connection for a manual transaction session
@ -1396,6 +1398,17 @@ impl AppState {
db::elasticsearch_driver::test_connection(&mut client, connect_timeout).await?;
PoolKind::Elasticsearch(client)
}
DatabaseType::Hbase => {
let client = db::hbase_driver::HBaseClient::new(
&url,
Some(&db_config.username),
Some(&db_config.password),
false,
connect_timeout,
)?;
db::hbase_driver::test_connection(&client, connect_timeout).await?;
PoolKind::HBase(client)
}
DatabaseType::Qdrant | DatabaseType::Milvus | DatabaseType::Weaviate | DatabaseType::ChromaDb => {
let kind = match db_config.db_type {
DatabaseType::Qdrant => db::vector_driver::VectorDbKind::Qdrant,
@ -2225,6 +2238,18 @@ impl AppState {
}
}
}
PoolKind::HBase(client) => {
let client = client.clone();
drop(connections);
let timeout = crate::db::connection_timeout();
match db::hbase_driver::test_connection(&client, timeout).await {
Ok(_) => false,
Err(err) => {
log::warn!("HBase connection pool '{pool_key}' is stale: {err}");
true
}
}
}
PoolKind::VectorDb(client) => {
let client = client.clone();
drop(connections);
@ -2789,6 +2814,7 @@ impl AppState {
})
}
Some(PoolKind::Mysql(pool, _)) => Some(ConnectionDatabaseInfoSource::NativeMysql(pool.clone())),
Some(PoolKind::HBase(client)) => Some(ConnectionDatabaseInfoSource::NativeHBase(client.clone())),
_ => None,
}
};
@ -2811,6 +2837,9 @@ impl AppState {
Some(ConnectionDatabaseInfoSource::NativeMysql(pool)) => {
db::mysql::database_connection_info(&pool, db::mysql::protocol_product_name(&config)).await.map(Some)
}
Some(ConnectionDatabaseInfoSource::NativeHBase(client)) => {
db::hbase_driver::database_connection_info(&client).await
}
None => Ok(None),
}
}
@ -2985,6 +3014,13 @@ impl AppState {
}
}
}
PoolKind::HBase(client) => match db::hbase_driver::test_connection(client, timeout).await {
Ok(_) => true,
Err(e) => {
log::warn!("HBase connection pool '{key}' is unhealthy: {e}");
false
}
},
PoolKind::VectorDb(client) => match db::vector_driver::test_connection(client, timeout).await {
Ok(()) => true,
Err(e) => {
@ -3226,6 +3262,7 @@ enum KeepaliveTarget {
ClickHouse(db::clickhouse_driver::ChClient),
SqlServer(Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
Elasticsearch(db::elasticsearch_driver::EsClient),
HBase(db::hbase_driver::HBaseClient),
VectorDb(db::vector_driver::VectorClient),
InfluxDb(db::influxdb_driver::InfluxdbClient),
Agent(Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>),
@ -3244,6 +3281,7 @@ fn keepalive_target_from_pool(pool: &PoolKind, config: &ConnectionConfig) -> Opt
PoolKind::ClickHouse(client) => Some(KeepaliveTarget::ClickHouse(client.clone())),
PoolKind::SqlServer(client) => Some(KeepaliveTarget::SqlServer(client.clone())),
PoolKind::Elasticsearch(client) => Some(KeepaliveTarget::Elasticsearch(client.clone())),
PoolKind::HBase(client) => Some(KeepaliveTarget::HBase(client.clone())),
PoolKind::VectorDb(client) => Some(KeepaliveTarget::VectorDb(client.clone())),
PoolKind::InfluxDb(client) => Some(KeepaliveTarget::InfluxDb(client.clone())),
PoolKind::Agent(client) => Some(KeepaliveTarget::Agent(client.clone())),
@ -3274,6 +3312,7 @@ async fn ping_keepalive_target(target: &mut KeepaliveTarget, timeout: Duration)
db::sqlserver::test_connection(&mut client).await
}
KeepaliveTarget::Elasticsearch(client) => db::elasticsearch_driver::test_connection(client, timeout).await,
KeepaliveTarget::HBase(client) => db::hbase_driver::test_connection(client, timeout).await.map(|_| ()),
KeepaliveTarget::VectorDb(client) => db::vector_driver::test_connection(client, timeout).await,
KeepaliveTarget::InfluxDb(client) => db::influxdb_driver::test_connection(client, timeout).await,
KeepaliveTarget::Agent(client) => {
@ -3466,6 +3505,7 @@ fn clone_pool_kind(pool: &PoolKind) -> PoolKind {
PoolKind::ClickHouse(client) => PoolKind::ClickHouse(client.clone()),
PoolKind::SqlServer(client) => PoolKind::SqlServer(client.clone()),
PoolKind::Elasticsearch(client) => PoolKind::Elasticsearch(client.clone()),
PoolKind::HBase(client) => PoolKind::HBase(client.clone()),
PoolKind::VectorDb(client) => PoolKind::VectorDb(client.clone()),
PoolKind::InfluxDb(client) => PoolKind::InfluxDb(client.clone()),
PoolKind::Agent(client) => PoolKind::Agent(client.clone()),
@ -3519,6 +3559,9 @@ pub async fn close_pool_kind(pool: PoolKind) {
PoolKind::Elasticsearch(client) => {
drop(client);
}
PoolKind::HBase(client) => {
drop(client);
}
PoolKind::VectorDb(client) => {
drop(client);
}

View File

@ -18,6 +18,7 @@ pub fn is_single_connection_pool(db_type: &DatabaseType) -> bool {
| DatabaseType::Turso
| DatabaseType::CloudflareD1
| DatabaseType::MongoDb
| DatabaseType::Hbase
| DatabaseType::Oracle
| DatabaseType::Dameng
| DatabaseType::Kingbase

View File

@ -0,0 +1,902 @@
use base64::{
engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD as BASE64_URL},
Engine as _,
};
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use reqwest::{Client as HttpClient, RequestBuilder, Response, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::{BTreeMap, HashMap};
use std::time::Duration;
use super::{http_client_builder, with_connection_timeout};
use crate::models::connection::DatabaseConnectionInfo;
use crate::types::{ColumnInfo, DatabaseInfo, TableInfo};
const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'/')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
const HBASE_JSON: &str = "application/json";
const HBASE_KEY_ENCODING_HEADER: &str = "Encoding";
const HBASE_KEY_ENCODING_BASE64: &str = "base64";
const DEFAULT_NAMESPACE: &str = "default";
#[derive(Clone)]
pub struct HBaseClient {
http: HttpClient,
base_url: String,
auth: Option<(String, String)>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HBaseVersionInfo {
pub version: String,
pub rest_version: Option<String>,
pub server: Option<String>,
pub jvm: Option<String>,
pub os: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HBaseColumnFamily {
pub name: String,
pub properties: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HBaseTableSchema {
pub name: String,
pub column_families: Vec<HBaseColumnFamily>,
pub properties: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HBaseCell {
pub column: String,
pub value: String,
pub value_encoding: String,
pub value_base64: String,
pub timestamp: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HBaseRow {
pub row_key: String,
pub row_key_encoding: String,
pub row_key_base64: String,
pub cells: Vec<HBaseCell>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HBaseScanResult {
pub rows: Vec<HBaseRow>,
pub truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HBaseCellInput {
pub column: String,
pub value: String,
#[serde(default)]
pub value_encoding: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HBasePutRowInput {
pub row_key: String,
#[serde(default)]
pub row_key_encoding: Option<String>,
pub cells: Vec<HBaseCellInput>,
}
#[derive(Debug, Deserialize)]
struct RestNamespaces {
#[serde(rename = "Namespace", default)]
namespaces: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RestTableList {
#[serde(rename = "table", default)]
tables: Vec<RestTableName>,
}
#[derive(Debug, Deserialize)]
struct RestTableName {
name: String,
}
#[derive(Debug, Deserialize)]
struct RestRows {
#[serde(rename = "Row", default)]
rows: Vec<RestRow>,
}
#[derive(Debug, Deserialize)]
struct RestRow {
key: String,
#[serde(rename = "Cell", default)]
cells: Vec<RestCell>,
}
#[derive(Debug, Deserialize)]
struct RestCell {
column: String,
#[serde(default)]
timestamp: Option<u64>,
#[serde(rename = "$", default)]
value: String,
}
impl HBaseClient {
pub fn new(
url: &str,
username: Option<&str>,
password: Option<&str>,
accept_invalid_certs: bool,
timeout: Duration,
) -> Result<Self, String> {
let base_url = url.trim().trim_end_matches('/').to_string();
reqwest::Url::parse(&base_url).map_err(|error| format!("Invalid HBase REST URL: {error}"))?;
let auth = username
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| (value.to_string(), password.unwrap_or_default().to_string()));
let http = http_client_builder(timeout)
.danger_accept_invalid_certs(accept_invalid_certs)
.build()
.map_err(|error| format!("Failed to create HBase HTTP client: {error}"))?;
Ok(Self { http, base_url, auth })
}
fn request(&self, method: reqwest::Method, path: &str) -> RequestBuilder {
let request = self.http.request(method, format!("{}{}", self.base_url, path));
self.with_auth(request)
}
fn scanner_request(&self, method: reqwest::Method, location: &str) -> Result<RequestBuilder, String> {
let path = scanner_resource_path(location)?;
Ok(self.request(method, &path))
}
fn with_auth(&self, request: RequestBuilder) -> RequestBuilder {
match &self.auth {
Some((username, password)) => request.basic_auth(username, Some(password)),
None => request,
}
}
}
pub async fn test_connection(client: &HBaseClient, timeout: Duration) -> Result<HBaseVersionInfo, String> {
with_connection_timeout("HBase", timeout, async { version_info(client).await }).await
}
pub async fn version_info(client: &HBaseClient) -> Result<HBaseVersionInfo, String> {
let body = send_json(client.request(reqwest::Method::GET, "/version"), "read server version").await?;
Ok(HBaseVersionInfo {
version: json_string(&body, "Version").unwrap_or_else(|| "unknown".to_string()),
rest_version: json_string(&body, "REST"),
server: json_string(&body, "Server"),
jvm: json_string(&body, "JVM"),
os: json_string(&body, "OS"),
})
}
pub async fn database_connection_info(client: &HBaseClient) -> Result<Option<DatabaseConnectionInfo>, String> {
let version = version_info(client).await?;
Ok(Some(DatabaseConnectionInfo {
product_name: Some("Apache HBase".to_string()),
product_version: Some(version.version),
server_comment: version.rest_version.map(|rest| format!("HBase REST {rest}")),
driver_name: Some("HBase REST API".to_string()),
..Default::default()
}))
}
pub async fn list_namespaces(client: &HBaseClient) -> Result<Vec<DatabaseInfo>, String> {
let response = client
.request(reqwest::Method::GET, "/namespaces")
.header(reqwest::header::ACCEPT, HBASE_JSON)
.send()
.await
.map_err(|error| format_request_error("list namespaces", error))?;
let body: RestNamespaces = parse_success_json(response, "list namespaces").await?;
let mut namespaces = body.namespaces;
namespaces.sort_by_key(|name| name.to_ascii_lowercase());
Ok(namespaces.into_iter().map(|name| DatabaseInfo { name }).collect())
}
pub async fn list_tables(client: &HBaseClient, namespace: &str) -> Result<Vec<TableInfo>, String> {
let response = client
.request(reqwest::Method::GET, "/")
.header(reqwest::header::ACCEPT, HBASE_JSON)
.send()
.await
.map_err(|error| format_request_error("list tables", error))?;
let body: RestTableList = parse_success_json(response, "list tables").await?;
let mut names: Vec<String> =
body.tables.into_iter().filter_map(|table| table_name_in_namespace(&table.name, namespace)).collect();
names.sort_by_key(|name| name.to_ascii_lowercase());
Ok(names
.into_iter()
.map(|name| TableInfo {
name,
table_type: "HBASE_TABLE".to_string(),
comment: None,
parent_schema: None,
parent_name: None,
})
.collect())
}
pub async fn get_table_schema(client: &HBaseClient, namespace: &str, table: &str) -> Result<HBaseTableSchema, String> {
let qualified = qualified_table_name(namespace, table)?;
let path = format!("/{}/schema", path_segment(&qualified));
let body = send_json(client.request(reqwest::Method::GET, &path), "read table schema").await?;
parse_table_schema(body)
}
pub async fn get_columns(client: &HBaseClient, namespace: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let schema = get_table_schema(client, namespace, table).await?;
Ok(schema
.column_families
.into_iter()
.map(|family| ColumnInfo {
name: family.name,
data_type: "COLUMN_FAMILY".to_string(),
is_nullable: true,
extra: family.properties.get("VERSIONS").map(|versions| format!("versions={versions}")),
comment: family.properties.get("COMPRESSION").map(|compression| format!("compression={compression}")),
..Default::default()
})
.collect())
}
pub async fn scan_rows(
client: &HBaseClient,
namespace: &str,
table: &str,
row_key_prefix: Option<&str>,
limit: usize,
) -> Result<HBaseScanResult, String> {
let qualified = qualified_table_name(namespace, table)?;
let limit = limit.clamp(1, 1000);
let prefix = row_key_prefix.map(str::as_bytes).filter(|value| !value.is_empty());
let mut scanner = Map::new();
scanner.insert("batch".to_string(), Value::from((limit.saturating_mul(10)).clamp(10, 1000) as u64));
scanner.insert("maxVersions".to_string(), Value::from(1));
if let Some(prefix) = prefix {
scanner.insert("startRow".to_string(), Value::String(BASE64.encode(prefix)));
if let Some(end) = prefix_range_end(prefix) {
scanner.insert("endRow".to_string(), Value::String(BASE64.encode(end)));
}
}
let create_path = format!("/{}/scanner", path_segment(&qualified));
let response = client
.request(reqwest::Method::PUT, &create_path)
.header(reqwest::header::CONTENT_TYPE, HBASE_JSON)
.json(&Value::Object(scanner))
.send()
.await
.map_err(|error| format_request_error("create scanner", error))?;
let response = ensure_success(response, "create scanner").await?;
let scanner_url = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.map(str::to_string)
.ok_or_else(|| "HBase REST did not return a scanner location".to_string())?;
let scan_result = read_scanner(client, &scanner_url, limit).await;
let close_result = close_scanner(client, &scanner_url).await;
match (scan_result, close_result) {
(Ok(result), Ok(())) => Ok(result),
(Ok(_), Err(error)) => Err(error),
(Err(error), _) => Err(error),
}
}
pub async fn get_row(
client: &HBaseClient,
namespace: &str,
table: &str,
row_key: &str,
row_key_encoding: Option<&str>,
) -> Result<Option<HBaseRow>, String> {
let qualified = qualified_table_name(namespace, table)?;
let row_key_bytes = decode_input(row_key, row_key_encoding, "row key")?;
if row_key_bytes.is_empty() {
return Err("HBase row key cannot be empty".to_string());
}
let path = encoded_row_path(&qualified, &row_key_bytes);
let response = client
.request(reqwest::Method::GET, &path)
.header(HBASE_KEY_ENCODING_HEADER, HBASE_KEY_ENCODING_BASE64)
.header(reqwest::header::ACCEPT, HBASE_JSON)
.send()
.await
.map_err(|error| format_request_error("read row", error))?;
if response.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
let body: RestRows = parse_success_json(response, "read row").await?;
let mut rows = decode_rest_rows(body.rows)?;
Ok(rows.pop())
}
pub async fn put_row(
client: &HBaseClient,
namespace: &str,
table: &str,
input: &HBasePutRowInput,
) -> Result<(), String> {
let qualified = qualified_table_name(namespace, table)?;
let row_key = decode_input(&input.row_key, input.row_key_encoding.as_deref(), "row key")?;
if row_key.is_empty() {
return Err("HBase row key cannot be empty".to_string());
}
if input.cells.is_empty() {
return Err("At least one HBase cell is required".to_string());
}
let mut cells = Vec::with_capacity(input.cells.len());
for cell in &input.cells {
validate_column_name(&cell.column)?;
let value = decode_input(&cell.value, cell.value_encoding.as_deref(), "cell value")?;
cells.push(serde_json::json!({
"column": BASE64.encode(cell.column.as_bytes()),
"$": BASE64.encode(value),
}));
}
let body = serde_json::json!({
"Row": [{
"key": BASE64.encode(&row_key),
"Cell": cells,
}]
});
let path = encoded_row_path(&qualified, &row_key);
let response = client
.request(reqwest::Method::PUT, &path)
.header(HBASE_KEY_ENCODING_HEADER, HBASE_KEY_ENCODING_BASE64)
.header(reqwest::header::CONTENT_TYPE, HBASE_JSON)
.json(&body)
.send()
.await
.map_err(|error| format_request_error("write row", error))?;
ensure_success(response, "write row").await.map(|_| ())
}
pub async fn delete_row(
client: &HBaseClient,
namespace: &str,
table: &str,
row_key: &str,
row_key_encoding: Option<&str>,
) -> Result<(), String> {
let qualified = qualified_table_name(namespace, table)?;
let row_key = decode_input(row_key, row_key_encoding, "row key")?;
if row_key.is_empty() {
return Err("HBase row key cannot be empty".to_string());
}
let path = encoded_row_path(&qualified, &row_key);
let response = client
.request(reqwest::Method::DELETE, &path)
.header(HBASE_KEY_ENCODING_HEADER, HBASE_KEY_ENCODING_BASE64)
.send()
.await
.map_err(|error| format_request_error("delete row", error))?;
ensure_success(response, "delete row").await.map(|_| ())
}
pub async fn create_table(
client: &HBaseClient,
namespace: &str,
table: &str,
column_families: &[String],
) -> Result<(), String> {
let qualified = qualified_table_name(namespace, table)?;
if column_families.is_empty() {
return Err("At least one HBase column family is required".to_string());
}
let mut families = Vec::with_capacity(column_families.len());
for family in column_families {
validate_family_name(family)?;
families.push(serde_json::json!({ "name": family.trim() }));
}
let path = format!("/{}/schema", path_segment(&qualified));
let response = client
.request(reqwest::Method::PUT, &path)
.header(reqwest::header::CONTENT_TYPE, HBASE_JSON)
.json(&serde_json::json!({ "ColumnSchema": families }))
.send()
.await
.map_err(|error| format_request_error("create table", error))?;
ensure_success(response, "create table").await.map(|_| ())
}
pub async fn delete_table(client: &HBaseClient, namespace: &str, table: &str) -> Result<(), String> {
let qualified = qualified_table_name(namespace, table)?;
let path = format!("/{}/schema", path_segment(&qualified));
let response = client
.request(reqwest::Method::DELETE, &path)
.send()
.await
.map_err(|error| format_request_error("delete table", error))?;
ensure_success(response, "delete table").await.map(|_| ())
}
async fn read_scanner(client: &HBaseClient, scanner_url: &str, limit: usize) -> Result<HBaseScanResult, String> {
let mut order = Vec::<String>::new();
let mut rows = HashMap::<String, RestRow>::new();
let mut exhausted = false;
while rows.len() <= limit {
let response = client
.scanner_request(reqwest::Method::GET, scanner_url)?
.header(reqwest::header::ACCEPT, HBASE_JSON)
.send()
.await
.map_err(|error| format_request_error("read scanner", error))?;
if response.status() == StatusCode::NO_CONTENT {
exhausted = true;
break;
}
let body: RestRows = parse_success_json(response, "read scanner").await?;
if body.rows.is_empty() {
exhausted = true;
break;
}
for row in body.rows {
let key = row.key.clone();
if let Some(existing) = rows.get_mut(&key) {
existing.cells.extend(row.cells);
} else {
order.push(key.clone());
rows.insert(key, row);
}
}
if rows.len() > limit {
break;
}
}
let truncated = !exhausted && rows.len() >= limit;
let mut ordered_rows = Vec::with_capacity(order.len().min(limit));
for key in order.into_iter().take(limit) {
if let Some(row) = rows.remove(&key) {
ordered_rows.push(row);
}
}
Ok(HBaseScanResult { rows: decode_rest_rows(ordered_rows)?, truncated })
}
async fn close_scanner(client: &HBaseClient, scanner_url: &str) -> Result<(), String> {
let response = client
.scanner_request(reqwest::Method::DELETE, scanner_url)?
.send()
.await
.map_err(|error| format_request_error("close scanner", error))?;
if response.status() == StatusCode::NOT_FOUND {
return Ok(());
}
ensure_success(response, "close scanner").await.map(|_| ())
}
fn decode_rest_rows(rows: Vec<RestRow>) -> Result<Vec<HBaseRow>, String> {
rows.into_iter()
.map(|row| {
let row_key_bytes =
BASE64.decode(&row.key).map_err(|error| format!("Invalid HBase row key Base64: {error}"))?;
let (row_key, row_key_encoding) = display_bytes(&row_key_bytes);
let mut cells = row
.cells
.into_iter()
.map(|cell| {
let column_bytes =
BASE64.decode(&cell.column).map_err(|error| format!("Invalid HBase column Base64: {error}"))?;
let column = String::from_utf8(column_bytes)
.map_err(|_| "HBase returned a non-UTF-8 column identifier".to_string())?;
let value_bytes =
BASE64.decode(&cell.value).map_err(|error| format!("Invalid HBase cell Base64: {error}"))?;
let value_base64 = BASE64.encode(&value_bytes);
let (value, value_encoding) = display_bytes(&value_bytes);
Ok(HBaseCell { column, value, value_encoding, value_base64, timestamp: cell.timestamp })
})
.collect::<Result<Vec<_>, String>>()?;
cells.sort_by(|left, right| left.column.cmp(&right.column));
Ok(HBaseRow { row_key, row_key_encoding, row_key_base64: BASE64.encode(row_key_bytes), cells })
})
.collect()
}
fn parse_table_schema(body: Value) -> Result<HBaseTableSchema, String> {
let object = body.as_object().ok_or_else(|| "Invalid HBase table schema response".to_string())?;
let name = object.get("name").and_then(Value::as_str).unwrap_or_default().to_string();
let mut column_families = Vec::new();
for raw_family in object.get("ColumnSchema").and_then(Value::as_array).into_iter().flatten() {
let family = raw_family.as_object().ok_or_else(|| "Invalid HBase column family response".to_string())?;
let family_name = family.get("name").and_then(Value::as_str).unwrap_or_default().to_string();
if family_name.is_empty() {
continue;
}
column_families.push(HBaseColumnFamily { name: family_name, properties: string_properties(family, &["name"]) });
}
column_families.sort_by(|left, right| left.name.cmp(&right.name));
Ok(HBaseTableSchema { name, column_families, properties: string_properties(object, &["name", "ColumnSchema"]) })
}
fn string_properties(object: &Map<String, Value>, excluded: &[&str]) -> BTreeMap<String, String> {
object
.iter()
.filter(|(key, _)| !excluded.contains(&key.as_str()))
.filter_map(|(key, value)| match value {
Value::String(value) => Some((key.clone(), value.clone())),
Value::Bool(value) => Some((key.clone(), value.to_string())),
Value::Number(value) => Some((key.clone(), value.to_string())),
_ => None,
})
.collect()
}
fn json_string(value: &Value, key: &str) -> Option<String> {
value.get(key).and_then(Value::as_str).map(str::to_string)
}
async fn send_json(request: RequestBuilder, action: &str) -> Result<Value, String> {
let response = request
.header(reqwest::header::ACCEPT, HBASE_JSON)
.send()
.await
.map_err(|error| format_request_error(action, error))?;
parse_success_json(response, action).await
}
async fn parse_success_json<T: serde::de::DeserializeOwned>(response: Response, action: &str) -> Result<T, String> {
let response = ensure_success(response, action).await?;
response.json::<T>().await.map_err(|error| format!("HBase failed to parse {action} response: {error}"))
}
async fn ensure_success(response: Response, action: &str) -> Result<Response, String> {
let status = response.status();
if status.is_success() {
return Ok(response);
}
let body = response.text().await.unwrap_or_default();
let detail = body.trim();
if detail.is_empty() {
Err(format!("HBase {action} failed ({status})"))
} else {
Err(format!("HBase {action} failed ({status}): {detail}"))
}
}
fn format_request_error(action: &str, error: reqwest::Error) -> String {
format!("HBase {action} request failed: {error}")
}
fn qualified_table_name(namespace: &str, table: &str) -> Result<String, String> {
let namespace = namespace.trim();
let table = table.trim();
if table.is_empty() {
return Err("HBase table name cannot be empty".to_string());
}
if table.contains(':') {
return Ok(table.to_string());
}
if namespace.is_empty() || namespace == DEFAULT_NAMESPACE {
Ok(table.to_string())
} else {
Ok(format!("{namespace}:{table}"))
}
}
fn table_name_in_namespace(qualified: &str, namespace: &str) -> Option<String> {
let namespace = namespace.trim();
match qualified.split_once(':') {
Some((table_namespace, table)) if table_namespace == namespace => Some(table.to_string()),
Some(_) => None,
None if namespace.is_empty() || namespace == DEFAULT_NAMESPACE => Some(qualified.to_string()),
None => None,
}
}
fn validate_family_name(value: &str) -> Result<(), String> {
let value = value.trim();
if value.is_empty() {
return Err("HBase column family name cannot be empty".to_string());
}
if value.contains(':') {
return Err(format!("HBase column family name cannot contain ':': {value}"));
}
Ok(())
}
fn validate_column_name(value: &str) -> Result<(), String> {
let Some((family, qualifier)) = value.split_once(':') else {
return Err(format!("HBase column must use family:qualifier syntax: {value}"));
};
validate_family_name(family)?;
if qualifier.is_empty() {
return Err(format!("HBase column qualifier cannot be empty: {value}"));
}
Ok(())
}
fn decode_input(value: &str, encoding: Option<&str>, label: &str) -> Result<Vec<u8>, String> {
let encoding = encoding.unwrap_or("utf8").trim();
if encoding.eq_ignore_ascii_case("base64") {
BASE64.decode(value.trim()).map_err(|error| format!("Invalid Base64 {label}: {error}"))
} else if encoding.eq_ignore_ascii_case("utf8") || encoding.is_empty() {
Ok(value.as_bytes().to_vec())
} else {
Err(format!("Unsupported HBase {label} encoding: {encoding}"))
}
}
fn display_bytes(bytes: &[u8]) -> (String, String) {
match std::str::from_utf8(bytes) {
Ok(text) if text.chars().all(|ch| !ch.is_control() || matches!(ch, '\n' | '\r' | '\t')) => {
(text.to_string(), "utf8".to_string())
}
_ => (format!("base64:{}", BASE64.encode(bytes)), "base64".to_string()),
}
}
fn prefix_range_end(prefix: &[u8]) -> Option<Vec<u8>> {
let mut end = prefix.to_vec();
for index in (0..end.len()).rev() {
if end[index] != u8::MAX {
end[index] += 1;
end.truncate(index + 1);
return Some(end);
}
}
None
}
fn path_segment(value: &str) -> String {
utf8_percent_encode(value, PATH_SEGMENT_ENCODE_SET).to_string()
}
fn encoded_row_path(qualified_table: &str, row_key: &[u8]) -> String {
format!("/{}/{}", path_segment(qualified_table), BASE64_URL.encode(row_key))
}
fn scanner_resource_path(location: &str) -> Result<String, String> {
let location = location.trim();
if location.is_empty() {
return Err("HBase REST returned an empty scanner location".to_string());
}
if let Ok(url) = reqwest::Url::parse(location) {
let mut path = url.path().to_string();
if let Some(query) = url.query() {
path.push('?');
path.push_str(query);
}
return Ok(path);
}
Ok(if location.starts_with('/') { location.to_string() } else { format!("/{location}") })
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn namespace_table_names_are_filtered_and_unqualified() {
assert_eq!(table_name_in_namespace("orders", "default"), Some("orders".to_string()));
assert_eq!(table_name_in_namespace("sales:orders", "sales"), Some("orders".to_string()));
assert_eq!(table_name_in_namespace("sales:orders", "default"), None);
assert_eq!(qualified_table_name("sales", "orders").unwrap(), "sales:orders");
assert_eq!(qualified_table_name("default", "orders").unwrap(), "orders");
}
#[test]
fn prefix_range_end_handles_carry_and_unbounded_prefix() {
assert_eq!(prefix_range_end(b"customer#00"), Some(b"customer#01".to_vec()));
assert_eq!(prefix_range_end(&[0x01, 0xff]), Some(vec![0x02]));
assert_eq!(prefix_range_end(&[0xff, 0xff]), None);
}
#[test]
fn binary_values_keep_base64_representation() {
assert_eq!(display_bytes(b"hello"), ("hello".to_string(), "utf8".to_string()));
assert_eq!(display_bytes(&[0, 1, 2]), ("base64:AAEC".to_string(), "base64".to_string()));
}
#[test]
fn row_paths_use_url_safe_base64_for_arbitrary_keys() {
assert_eq!(encoded_row_path("demo", b"customer#001"), "/demo/Y3VzdG9tZXIjMDAx");
assert_eq!(encoded_row_path("demo", &[0, 1, 2, 0xfb, 0xff]), "/demo/AAEC-_8");
}
#[test]
fn table_schema_properties_are_preserved() {
let schema = parse_table_schema(serde_json::json!({
"name": "demo",
"ColumnSchema": [{"name": "cf", "VERSIONS": "3", "COMPRESSION": "SNAPPY"}],
"IS_META": "false"
}))
.unwrap();
assert_eq!(schema.name, "demo");
assert_eq!(schema.column_families[0].name, "cf");
assert_eq!(schema.column_families[0].properties.get("VERSIONS").map(String::as_str), Some("3"));
assert_eq!(schema.properties.get("IS_META").map(String::as_str), Some("false"));
}
#[test]
fn scanner_location_reuses_the_configured_gateway_origin() {
assert_eq!(
scanner_resource_path("http://hbase.internal:8080/dbx/scanner/123?token=abc").unwrap(),
"/dbx/scanner/123?token=abc"
);
assert_eq!(scanner_resource_path("/dbx/scanner/123").unwrap(), "/dbx/scanner/123");
assert_eq!(scanner_resource_path("dbx/scanner/123").unwrap(), "/dbx/scanner/123");
}
#[tokio::test]
#[ignore = "requires DBX_TEST_HBASE_URL and a writable HBase REST gateway"]
async fn live_hbase_rest_crud_and_scan() {
let url = std::env::var("DBX_TEST_HBASE_URL").expect("DBX_TEST_HBASE_URL is required");
let username = std::env::var("DBX_TEST_HBASE_USERNAME").ok();
let password = std::env::var("DBX_TEST_HBASE_PASSWORD").ok();
let client = HBaseClient::new(&url, username.as_deref(), password.as_deref(), false, Duration::from_secs(5))
.expect("create HBase client");
let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis();
let table = format!("dbx_hbase_live_{suffix}");
let namespace = "default";
let version = test_connection(&client, Duration::from_secs(5)).await.expect("test connection");
assert!(!version.version.is_empty());
let namespaces = list_namespaces(&client).await.expect("list namespaces");
assert!(namespaces.iter().any(|item| item.name == namespace));
create_table(&client, namespace, &table, &["profile".to_string(), "metrics".to_string()])
.await
.expect("create table");
let test_result: Result<(), String> = async {
let tables = list_tables(&client, namespace).await?;
if !tables.iter().any(|item| item.name == table) {
return Err("created table is missing from the table list".to_string());
}
let schema = get_table_schema(&client, namespace, &table).await?;
if schema.column_families.iter().map(|family| family.name.as_str()).collect::<Vec<_>>()
!= vec!["metrics", "profile"]
{
return Err(format!("unexpected column families: {:?}", schema.column_families));
}
for (key, name, score) in
[("customer#001", "Alice", "10"), ("customer#002", "Bob", "20"), ("customer#003", "Carol", "30")]
{
put_row(
&client,
namespace,
&table,
&HBasePutRowInput {
row_key: key.to_string(),
row_key_encoding: Some("utf8".to_string()),
cells: vec![
HBaseCellInput {
column: "profile:name".to_string(),
value: name.to_string(),
value_encoding: Some("utf8".to_string()),
},
HBaseCellInput {
column: "metrics:score".to_string(),
value: score.to_string(),
value_encoding: Some("utf8".to_string()),
},
],
},
)
.await?;
}
let limited = scan_rows(&client, namespace, &table, Some("customer#00"), 2).await?;
if limited.rows.len() != 2 || !limited.truncated {
return Err(format!("unexpected limited scan result: {limited:?}"));
}
let all = scan_rows(&client, namespace, &table, Some("customer#00"), 10).await?;
if all.rows.iter().map(|row| row.row_key.as_str()).collect::<Vec<_>>()
!= vec!["customer#001", "customer#002", "customer#003"]
|| all.truncated
{
return Err(format!("unexpected full scan result: {all:?}"));
}
let row = get_row(&client, namespace, &table, "customer#002", Some("utf8"))
.await?
.ok_or_else(|| "customer#002 is missing".to_string())?;
if !row.cells.iter().any(|cell| cell.column == "profile:name" && cell.value == "Bob") {
return Err(format!("unexpected customer#002 row: {row:?}"));
}
put_row(
&client,
namespace,
&table,
&HBasePutRowInput {
row_key: "customer#002".to_string(),
row_key_encoding: Some("utf8".to_string()),
cells: vec![HBaseCellInput {
column: "metrics:score".to_string(),
value: "25".to_string(),
value_encoding: Some("utf8".to_string()),
}],
},
)
.await?;
let updated = get_row(&client, namespace, &table, "customer#002", Some("utf8"))
.await?
.ok_or_else(|| "updated customer#002 is missing".to_string())?;
if !updated.cells.iter().any(|cell| cell.column == "metrics:score" && cell.value == "25") {
return Err(format!("updated cell is missing: {updated:?}"));
}
put_row(
&client,
namespace,
&table,
&HBasePutRowInput {
row_key: "AAEC".to_string(),
row_key_encoding: Some("base64".to_string()),
cells: vec![HBaseCellInput {
column: "profile:binary".to_string(),
value: "/wA=".to_string(),
value_encoding: Some("base64".to_string()),
}],
},
)
.await?;
let binary = get_row(&client, namespace, &table, "AAEC", Some("base64"))
.await?
.ok_or_else(|| "binary row is missing".to_string())?;
if binary.row_key != "base64:AAEC"
|| !binary.cells.iter().any(|cell| {
cell.column == "profile:binary" && cell.value == "base64:/wA=" && cell.value_base64 == "/wA="
})
{
return Err(format!("unexpected binary row: {binary:?}"));
}
delete_row(&client, namespace, &table, "customer#003", Some("utf8")).await?;
if get_row(&client, namespace, &table, "customer#003", Some("utf8")).await?.is_some() {
return Err("customer#003 still exists after deletion".to_string());
}
Ok(())
}
.await;
let cleanup_result = delete_table(&client, namespace, &table).await;
if let Err(error) = test_result {
panic!("live HBase test failed: {error:?}; cleanup result: {cleanup_result:?}");
}
cleanup_result.expect("delete temporary table");
let tables = list_tables(&client, namespace).await.expect("list tables after cleanup");
assert!(!tables.iter().any(|item| item.name == table));
}
}

View File

@ -16,6 +16,7 @@ pub mod duckdb_worker_runtime;
pub mod elasticsearch_driver;
pub mod elasticsearch_sql;
pub mod file_validator;
pub mod hbase_driver;
pub mod http_tunnel;
pub mod influxdb_driver;
pub mod manticoresearch;

View File

@ -0,0 +1,82 @@
use crate::connection::{AppState, PoolKind};
use crate::db::hbase_driver::{self, HBasePutRowInput, HBaseRow, HBaseScanResult, HBaseTableSchema};
async fn client(state: &AppState, connection_id: &str) -> Result<hbase_driver::HBaseClient, String> {
let pool_key = state.get_or_create_pool(connection_id, None).await?;
let connections = state.connections.read().await;
match connections.get(&pool_key) {
Some(PoolKind::HBase(client)) => Ok(client.clone()),
_ => Err("Not an HBase connection".to_string()),
}
}
pub async fn get_table_schema_core(
state: &AppState,
connection_id: &str,
namespace: &str,
table: &str,
) -> Result<HBaseTableSchema, String> {
hbase_driver::get_table_schema(&client(state, connection_id).await?, namespace, table).await
}
pub async fn scan_rows_core(
state: &AppState,
connection_id: &str,
namespace: &str,
table: &str,
row_key_prefix: Option<&str>,
limit: usize,
) -> Result<HBaseScanResult, String> {
hbase_driver::scan_rows(&client(state, connection_id).await?, namespace, table, row_key_prefix, limit).await
}
pub async fn get_row_core(
state: &AppState,
connection_id: &str,
namespace: &str,
table: &str,
row_key: &str,
row_key_encoding: Option<&str>,
) -> Result<Option<HBaseRow>, String> {
hbase_driver::get_row(&client(state, connection_id).await?, namespace, table, row_key, row_key_encoding).await
}
pub async fn put_row_core(
state: &AppState,
connection_id: &str,
namespace: &str,
table: &str,
input: &HBasePutRowInput,
) -> Result<(), String> {
hbase_driver::put_row(&client(state, connection_id).await?, namespace, table, input).await
}
pub async fn delete_row_core(
state: &AppState,
connection_id: &str,
namespace: &str,
table: &str,
row_key: &str,
row_key_encoding: Option<&str>,
) -> Result<(), String> {
hbase_driver::delete_row(&client(state, connection_id).await?, namespace, table, row_key, row_key_encoding).await
}
pub async fn create_table_core(
state: &AppState,
connection_id: &str,
namespace: &str,
table: &str,
column_families: &[String],
) -> Result<(), String> {
hbase_driver::create_table(&client(state, connection_id).await?, namespace, table, column_families).await
}
pub async fn delete_table_core(
state: &AppState,
connection_id: &str,
namespace: &str,
table: &str,
) -> Result<(), String> {
hbase_driver::delete_table(&client(state, connection_id).await?, namespace, table).await
}

View File

@ -28,6 +28,7 @@ pub mod db_admin_sql;
pub mod document_ops;
pub mod driver_runtime;
pub mod external;
pub mod hbase_ops;
pub mod history;
pub mod jdbc;
pub mod models;

View File

@ -455,6 +455,7 @@ pub enum DatabaseType {
Oracle,
#[serde(rename = "elasticsearch")]
Elasticsearch,
Hbase,
#[serde(rename = "qdrant")]
Qdrant,
#[serde(rename = "milvus")]
@ -1001,6 +1002,7 @@ impl ConnectionConfig {
}
DatabaseType::Oracle => format!("oracle://{host}:{port}{db_part}"),
DatabaseType::Elasticsearch
| DatabaseType::Hbase
| DatabaseType::Qdrant
| DatabaseType::Milvus
| DatabaseType::Weaviate
@ -1149,6 +1151,7 @@ impl ConnectionConfig {
format!("oracle://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Elasticsearch
| DatabaseType::Hbase
| DatabaseType::Qdrant
| DatabaseType::Milvus
| DatabaseType::Weaviate
@ -2762,6 +2765,20 @@ mod tests {
assert_eq!(config.connection_url(), "https://10.1.2.3:8443");
}
#[test]
fn hbase_rest_url_uses_http_or_https_without_embedding_credentials() {
let mut config = mysql_config("hbase-user", "secret", None);
config.db_type = DatabaseType::Hbase;
config.port = 8080;
assert_eq!(config.connection_url(), "http://10.1.2.3:8080");
assert_eq!(config.redacted_connection_url(), "http://10.1.2.3:8080");
config.ssl = true;
assert_eq!(config.connection_url(), "https://10.1.2.3:8080");
assert_eq!(config.redacted_connection_url(), "https://10.1.2.3:8080");
}
#[test]
fn clickhouse_host_may_include_http_scheme() {
let mut config = mysql_config("default", "", None);

View File

@ -1696,6 +1696,7 @@ pub async fn do_execute(
.await
.map(|result| truncate_result_with_max_rows(result, max_rows))
}
PoolKind::HBase(_) => Err("SQL execution is not supported for HBase connections".to_string()),
};
result.map(normalize_query_result_for_js)
}
@ -2592,7 +2593,7 @@ pub async fn execute_statements_in_transaction_on_pool(
| PoolKind::Turso(_)
| PoolKind::SqlServer(_)
| PoolKind::Agent(_) => TxPath::Explicit,
PoolKind::MessageQueue | PoolKind::Nacos => TxPath::None,
PoolKind::MessageQueue | PoolKind::Nacos | PoolKind::HBase(_) => TxPath::None,
#[cfg(feature = "duckdb-bundled")]
PoolKind::DuckDb(_)
| PoolKind::DuckDbWorker(_)

View File

@ -1124,6 +1124,7 @@ async fn list_databases_once(state: &AppState, connection_id: &str) -> Result<Ve
PoolKind::Postgres(p) => db::postgres::list_databases(p).await,
PoolKind::Sqlite(p) => db::sqlite::list_databases(p).await,
PoolKind::Rqlite(client) => db::rqlite_driver::list_databases(client).await,
PoolKind::HBase(client) => db::hbase_driver::list_namespaces(client).await,
#[cfg(feature = "duckdb-bundled")]
PoolKind::DuckDb(con) => {
let con = con.lock().map_err(|e| e.to_string())?;
@ -2473,6 +2474,9 @@ async fn list_tables_once(
.await
.map(|names| collection_names_to_tables(names, "INDEX"))
.map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)),
PoolKind::HBase(client) => db::hbase_driver::list_tables(client, database)
.await
.map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)),
PoolKind::VectorDb(client) => db::vector_driver::list_collections(client)
.await
.map(|infos| collection_names_to_tables(infos.into_iter().map(|i| i.name).collect(), "COLLECTION"))
@ -5215,6 +5219,9 @@ pub async fn get_columns_core_for_session(
PoolKind::Elasticsearch(client) => {
db::elasticsearch_driver::get_columns(client, table).await.map(deduplicate_column_infos)
}
PoolKind::HBase(client) => {
db::hbase_driver::get_columns(client, database, table).await.map(deduplicate_column_infos)
}
_ => Ok(vec![]),
}
})

View File

@ -15,6 +15,7 @@ pub(in crate::schema) async fn list_databases(
PoolKind::Postgres(p) => db::postgres::list_databases(p).await,
PoolKind::Sqlite(p) => db::sqlite::list_databases(p).await,
PoolKind::Rqlite(client) => db::rqlite_driver::list_databases(client).await,
PoolKind::HBase(client) => db::hbase_driver::list_namespaces(client).await,
PoolKind::Turso(client) => db::turso_driver::list_databases(client).await,
_ => Ok(vec![]),
}
@ -60,6 +61,7 @@ pub(in crate::schema) async fn list_tables(
PoolKind::Elasticsearch(client) => {
db::elasticsearch_driver::list_indices(client).await.map(|names| collection_names_to_tables(names, "INDEX"))
}
PoolKind::HBase(client) => db::hbase_driver::list_tables(client, database).await,
PoolKind::VectorDb(client) => db::vector_driver::list_collections(client)
.await
.map(|infos| collection_names_to_tables(infos.into_iter().map(|i| i.name).collect(), "COLLECTION")),
@ -149,6 +151,7 @@ pub(in crate::schema) async fn get_columns(
PoolKind::Rqlite(client) => db::rqlite_driver::get_columns(client, schema, table).await,
PoolKind::Turso(client) => db::turso_driver::get_columns(client, schema, table).await,
PoolKind::Elasticsearch(client) => db::elasticsearch_driver::get_columns(client, table).await,
PoolKind::HBase(client) => db::hbase_driver::get_columns(client, database, table).await,
PoolKind::VectorDb(_) => Ok(vec![]),
_ => Ok(vec![]),
}

View File

@ -497,6 +497,14 @@ async fn main() {
.route("/zookeeper/get", post(routes::zookeeper::get))
.route("/zookeeper/put", post(routes::zookeeper::put))
.route("/zookeeper/delete", post(routes::zookeeper::delete))
// HBase REST
.route("/hbase/table-schema", post(routes::hbase::get_table_schema))
.route("/hbase/scan-rows", post(routes::hbase::scan_rows))
.route("/hbase/get-row", post(routes::hbase::get_row))
.route("/hbase/put-row", post(routes::hbase::put_row))
.route("/hbase/delete-row", post(routes::hbase::delete_row))
.route("/hbase/create-table", post(routes::hbase::create_table))
.route("/hbase/delete-table", post(routes::hbase::delete_table))
// Nacos
.route("/nacos/test-connection", post(routes::nacos::test_connection))
.route("/nacos/namespaces/list", post(routes::nacos::list_namespaces))

View File

@ -0,0 +1,181 @@
use std::sync::Arc;
use axum::extract::State;
use axum::Json;
use dbx_core::db::hbase_driver::{HBasePutRowInput, HBaseRow, HBaseScanResult, HBaseTableSchema};
use serde::Deserialize;
use crate::error::AppError;
use crate::state::WebState;
async fn ensure_writable(
app: &dbx_core::connection::AppState,
connection_id: &str,
action: &str,
) -> Result<(), AppError> {
if let Some(name) = dbx_core::query::connection_readonly_name(app, connection_id).await {
return Err(AppError::from(format!(
"Read-only mode: connection '{}' has read-only protection enabled. {} blocked.",
name, action
)));
}
Ok(())
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HBaseTableRequest {
pub connection_id: String,
pub namespace: String,
pub table: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HBaseScanRequest {
pub connection_id: String,
pub namespace: String,
pub table: String,
pub row_key_prefix: Option<String>,
pub limit: Option<usize>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HBaseRowRequest {
pub connection_id: String,
pub namespace: String,
pub table: String,
pub row_key: String,
pub row_key_encoding: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HBasePutRowRequest {
pub connection_id: String,
pub namespace: String,
pub table: String,
pub input: HBasePutRowInput,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HBaseCreateTableRequest {
pub connection_id: String,
pub namespace: String,
pub table: String,
pub column_families: Vec<String>,
}
pub async fn get_table_schema(
State(state): State<Arc<WebState>>,
Json(request): Json<HBaseTableRequest>,
) -> Result<Json<HBaseTableSchema>, AppError> {
let result = dbx_core::hbase_ops::get_table_schema_core(
&state.app,
&request.connection_id,
&request.namespace,
&request.table,
)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn scan_rows(
State(state): State<Arc<WebState>>,
Json(request): Json<HBaseScanRequest>,
) -> Result<Json<HBaseScanResult>, AppError> {
let result = dbx_core::hbase_ops::scan_rows_core(
&state.app,
&request.connection_id,
&request.namespace,
&request.table,
request.row_key_prefix.as_deref(),
request.limit.unwrap_or(100),
)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn get_row(
State(state): State<Arc<WebState>>,
Json(request): Json<HBaseRowRequest>,
) -> Result<Json<Option<HBaseRow>>, AppError> {
let result = dbx_core::hbase_ops::get_row_core(
&state.app,
&request.connection_id,
&request.namespace,
&request.table,
&request.row_key,
request.row_key_encoding.as_deref(),
)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn put_row(
State(state): State<Arc<WebState>>,
Json(request): Json<HBasePutRowRequest>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &request.connection_id, "Write HBase row").await?;
dbx_core::hbase_ops::put_row_core(
&state.app,
&request.connection_id,
&request.namespace,
&request.table,
&request.input,
)
.await
.map_err(AppError::from)?;
Ok(Json(()))
}
pub async fn delete_row(
State(state): State<Arc<WebState>>,
Json(request): Json<HBaseRowRequest>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &request.connection_id, "Delete HBase row").await?;
dbx_core::hbase_ops::delete_row_core(
&state.app,
&request.connection_id,
&request.namespace,
&request.table,
&request.row_key,
request.row_key_encoding.as_deref(),
)
.await
.map_err(AppError::from)?;
Ok(Json(()))
}
pub async fn create_table(
State(state): State<Arc<WebState>>,
Json(request): Json<HBaseCreateTableRequest>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &request.connection_id, "Create HBase table").await?;
dbx_core::hbase_ops::create_table_core(
&state.app,
&request.connection_id,
&request.namespace,
&request.table,
&request.column_families,
)
.await
.map_err(AppError::from)?;
Ok(Json(()))
}
pub async fn delete_table(
State(state): State<Arc<WebState>>,
Json(request): Json<HBaseTableRequest>,
) -> Result<Json<()>, AppError> {
ensure_writable(&state.app, &request.connection_id, "Delete HBase table").await?;
dbx_core::hbase_ops::delete_table_core(&state.app, &request.connection_id, &request.namespace, &request.table)
.await
.map_err(AppError::from)?;
Ok(Json(()))
}

View File

@ -7,6 +7,7 @@ pub mod data_compare;
pub mod database_export;
pub mod document_store;
pub mod etcd;
pub mod hbase;
pub mod history;
pub mod jdbc;
pub mod layout;

View File

@ -45,8 +45,9 @@ test("only explicitly supported query schemas can be cleared from query tabs", (
assert.equal(supportsClearableQuerySchema("jdbc"), false);
});
test("Nacos connection menus do not expose SQL-style query actions", () => {
test("non-SQL connection menus do not expose SQL-style query actions", () => {
assert.equal(supportsConnectionQueryActions("nacos"), false);
assert.equal(supportsConnectionQueryActions("hbase"), false);
assert.equal(supportsConnectionQueryActions("mysql"), true);
assert.equal(supportsConnectionQueryActions("redis"), true);
});

View File

@ -154,12 +154,7 @@ test("findNodePathForTarget handles loaded, unloaded, and MySQL schema fallback
schema: "app_dev",
tableName: "enum_info",
},
expectedPath: [
"mysql-conn-1",
"mysql-conn-1:app_dev",
"mysql-conn-1:app_dev:__tables",
"mysql-conn-1:app_dev:__tables:enum_info",
],
expectedPath: ["mysql-conn-1", "mysql-conn-1:app_dev", "mysql-conn-1:app_dev:__tables", "mysql-conn-1:app_dev:__tables:enum_info"],
},
] satisfies Array<{
name: string;
@ -225,6 +220,33 @@ test("mongo tabs target the matching visible collection node", () => {
assert.equal(findSidebarNodeForActiveTab(tab, [flat(collection)])?.id, "events-node");
});
test("HBase tabs target the matching table in their namespace", () => {
const tab: QueryTab = {
id: "tab-hbase",
title: "events",
connectionId: "conn-hbase",
database: "analytics",
sql: "events",
isExecuting: false,
mode: "hbase",
};
const table: TreeNode = {
id: "hbase-events",
label: "events",
type: "table",
connectionId: "conn-hbase",
database: "analytics",
};
assert.deepEqual(activeTabSidebarTarget(tab), {
type: "hbase-table",
connectionId: "conn-hbase",
namespace: "analytics",
tableName: "events",
});
assert.equal(findSidebarNodeForActiveTab(tab, [flat(table)])?.id, "hbase-events");
});
test("GridFS tabs target the shared GridFS sidebar entry", () => {
const managerTab: QueryTab = {
id: "tab-gridfs",

View File

@ -1,7 +1,20 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { activeResultRun, databaseDisplayNameForTab, executionSummaryItems, middleEllipsis, nextExecutionSummaryView, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, tabDisplayTitle, tabModeLabel, tabularResultItems } from "../../apps/desktop/src/lib/tabs/tabPresentation.ts";
import {
activeResultRun,
databaseDisplayNameForTab,
executionSummaryItems,
middleEllipsis,
nextExecutionSummaryView,
resultGridCacheKey,
resultRunItems,
resultSourceRange,
resultSqlForGrid,
tabDisplayTitle,
tabModeLabel,
tabularResultItems,
} from "../../apps/desktop/src/lib/tabs/tabPresentation.ts";
import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts";
import type { ConnectionConfig, QueryResult, QueryTab } from "../../apps/desktop/src/types/database.ts";
@ -125,6 +138,26 @@ test("zookeeper tabs use key browser labels", () => {
}
});
test("HBase tabs identify the table and namespace", () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
useConnectionStore().addEphemeralConnection({
...conn("conn-1"),
name: "HBase Dev",
db_type: "hbase",
port: 8080,
});
const t = (key: string) => key;
try {
const tab = queryTab({ mode: "hbase", database: "analytics", title: "events", sql: "events" });
assert.equal(tabDisplayTitle(tab, t), "events@analytics");
assert.equal(tabModeLabel(tab, t), "HBase");
} finally {
restoreStorage();
}
});
test("GridFS tabs use dedicated titles and labels", () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());

View File

@ -0,0 +1,98 @@
use std::sync::Arc;
use dbx_core::db::hbase_driver::{HBasePutRowInput, HBaseRow, HBaseScanResult, HBaseTableSchema};
use tauri::State;
use crate::commands::connection::{ensure_connection_writable, AppState};
#[tauri::command]
pub async fn hbase_get_table_schema(
state: State<'_, Arc<AppState>>,
connection_id: String,
namespace: String,
table: String,
) -> Result<HBaseTableSchema, String> {
dbx_core::hbase_ops::get_table_schema_core(&state, &connection_id, &namespace, &table).await
}
#[tauri::command]
pub async fn hbase_scan_rows(
state: State<'_, Arc<AppState>>,
connection_id: String,
namespace: String,
table: String,
row_key_prefix: Option<String>,
limit: usize,
) -> Result<HBaseScanResult, String> {
dbx_core::hbase_ops::scan_rows_core(&state, &connection_id, &namespace, &table, row_key_prefix.as_deref(), limit)
.await
}
#[tauri::command]
pub async fn hbase_get_row(
state: State<'_, Arc<AppState>>,
connection_id: String,
namespace: String,
table: String,
row_key: String,
row_key_encoding: Option<String>,
) -> Result<Option<HBaseRow>, String> {
dbx_core::hbase_ops::get_row_core(&state, &connection_id, &namespace, &table, &row_key, row_key_encoding.as_deref())
.await
}
#[tauri::command]
pub async fn hbase_put_row(
state: State<'_, Arc<AppState>>,
connection_id: String,
namespace: String,
table: String,
input: HBasePutRowInput,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Write HBase row").await?;
dbx_core::hbase_ops::put_row_core(&state, &connection_id, &namespace, &table, &input).await
}
#[tauri::command]
pub async fn hbase_delete_row(
state: State<'_, Arc<AppState>>,
connection_id: String,
namespace: String,
table: String,
row_key: String,
row_key_encoding: Option<String>,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Delete HBase row").await?;
dbx_core::hbase_ops::delete_row_core(
&state,
&connection_id,
&namespace,
&table,
&row_key,
row_key_encoding.as_deref(),
)
.await
}
#[tauri::command]
pub async fn hbase_create_table(
state: State<'_, Arc<AppState>>,
connection_id: String,
namespace: String,
table: String,
column_families: Vec<String>,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Create HBase table").await?;
dbx_core::hbase_ops::create_table_core(&state, &connection_id, &namespace, &table, &column_families).await
}
#[tauri::command]
pub async fn hbase_delete_table(
state: State<'_, Arc<AppState>>,
connection_id: String,
namespace: String,
table: String,
) -> Result<(), String> {
ensure_connection_writable(&state, &connection_id, "Delete HBase table").await?;
dbx_core::hbase_ops::delete_table_core(&state, &connection_id, &namespace, &table).await
}

View File

@ -15,6 +15,7 @@ pub mod etcd_cmd;
pub mod external_db;
pub mod external_sql;
pub mod fs_open;
pub mod hbase_cmd;
pub mod history;
pub mod keychain;
pub mod list_sql_files;

View File

@ -1630,6 +1630,13 @@ pub fn run() {
commands::mongo_cmd::mongo_update_document,
commands::mongo_cmd::mongo_update_documents,
commands::document_cmd::document_delete_document,
commands::hbase_cmd::hbase_get_table_schema,
commands::hbase_cmd::hbase_scan_rows,
commands::hbase_cmd::hbase_get_row,
commands::hbase_cmd::hbase_put_row,
commands::hbase_cmd::hbase_delete_row,
commands::hbase_cmd::hbase_create_table,
commands::hbase_cmd::hbase_delete_table,
commands::mongo_cmd::mongo_delete_document,
commands::mongo_cmd::mongo_delete_documents,
commands::mongo_cmd::mongo_find_one_and_update,