feat(hbase): add HBase REST support
This commit is contained in:
parent
1cdbf4fa3d
commit
44cb4acbb7
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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: "履歴を検索...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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: "搜索历史...",
|
||||
|
|
|
|||
|
|
@ -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: "搜尋歷史……",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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` +
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"]],
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(_)
|
||||
|
|
|
|||
|
|
@ -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![]),
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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![]),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(()))
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue