feat(dynamodb): support querying, editing, and exporting
This commit is contained in:
73 files changed
+4493
-134
No files matched your search
@@ -1075,6 +1075,14 @@ const driverProfiles: Record<
|
||||
access: { type: "access", port: 0, user: "", label: "Microsoft Access", icon: "access" },
|
||||
mongodb: { type: "mongodb", port: 27017, user: "", label: "MongoDB", icon: "mongodb" },
|
||||
"mongodb-legacy": { type: "mongodb", port: 27017, user: "", label: "MongoDB (Legacy)", icon: "mongodb" },
|
||||
dynamodb: {
|
||||
type: "dynamodb",
|
||||
port: 443,
|
||||
user: "",
|
||||
label: "Amazon DynamoDB",
|
||||
icon: "dynamodb",
|
||||
host: "dynamodb.us-east-1.amazonaws.com",
|
||||
},
|
||||
clickhouse: {
|
||||
type: "clickhouse",
|
||||
port: 8123,
|
||||
@@ -2363,6 +2371,11 @@ function applyProfile(val: string, preserveConnectionFields = false) {
|
||||
if (profile.type === "sqlite") {
|
||||
form.value.database = undefined;
|
||||
}
|
||||
if (profile.type === "dynamodb") {
|
||||
form.value.database = "us-east-1";
|
||||
form.value.connection_string = undefined;
|
||||
form.value.ssl = true;
|
||||
}
|
||||
if (profile.type === "h2") {
|
||||
h2ConnectionMode.value = "file";
|
||||
form.value.host = "";
|
||||
@@ -2781,6 +2794,7 @@ const iconTypeMap: Record<string, string> = {
|
||||
access: "access",
|
||||
redis: "redis",
|
||||
mongodb: "mongodb",
|
||||
dynamodb: "dynamodb",
|
||||
duckdb: "duckdb",
|
||||
clickhouse: "clickhouse",
|
||||
sqlserver: "sqlserver",
|
||||
@@ -2871,6 +2885,7 @@ const dbOptions: DbOption[] = [
|
||||
{ value: "opentenbase", label: "OpenTenBase" },
|
||||
{ value: "mysql", label: "MySQL" },
|
||||
{ value: "mongodb", label: "MongoDB" },
|
||||
{ value: "dynamodb", label: "Amazon DynamoDB" },
|
||||
{ value: "redis", label: "Redis" },
|
||||
{ value: "oracle", label: "Oracle" },
|
||||
{ value: "sqlite", label: "SQLite" },
|
||||
@@ -2987,7 +3002,7 @@ const dbCategoryDefinitions: Array<{
|
||||
{
|
||||
key: "document",
|
||||
titleKey: "connection.databaseCategoryDocument",
|
||||
optionValues: ["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "hbase", "manticoresearch", "cassandra"],
|
||||
optionValues: ["mongodb", "dynamodb", "redis", "elasticsearch", "easysearch", "meilisearch", "hbase", "manticoresearch", "cassandra"],
|
||||
},
|
||||
{
|
||||
key: "graph_ai",
|
||||
@@ -3799,6 +3814,15 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
|
||||
throw new Error(t("connection.kingbaseDatabaseRequired"));
|
||||
}
|
||||
}
|
||||
if (config.db_type === "dynamodb") {
|
||||
config.database = config.database?.trim() || "us-east-1";
|
||||
config.username = config.username.trim();
|
||||
config.password = config.password.trim();
|
||||
config.connection_string = config.connection_string?.trim() || undefined;
|
||||
if (!config.username || !config.password) {
|
||||
throw new Error(t("connection.dynamodbCredentialsRequired"));
|
||||
}
|
||||
}
|
||||
if (config.db_type === "gaussdb") {
|
||||
const serialized = serializeGaussdbHosts(gaussdbHostEntries.value);
|
||||
config.host = serialized.host;
|
||||
@@ -6659,6 +6683,38 @@ function openExternalUrl(url: string) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- DynamoDB: endpoint, region, AWS credentials -->
|
||||
<template v-else-if="form.db_type === 'dynamodb'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.dynamodbEndpoint") }}</Label>
|
||||
<Input v-model="form.host" class="col-span-2" placeholder="dynamodb.us-east-1.amazonaws.com" />
|
||||
<Input v-model.number="form.port" type="number" class="col-span-1" min="1" max="65535" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<label class="col-span-3 flex items-center gap-2 text-sm">
|
||||
<input v-model="form.ssl" type="checkbox" />
|
||||
<span>{{ t("connection.sslEnable") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.dynamodbRegion") }}</Label>
|
||||
<Input v-model="form.database" class="col-span-3" placeholder="us-east-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.dynamodbAccessKeyId") }}</Label>
|
||||
<Input v-model="form.username" class="col-span-3" autocomplete="username" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.dynamodbSecretAccessKey") }}</Label>
|
||||
<PasswordInput v-model="form.password" class="col-span-3" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.dynamodbSessionToken") }}</Label>
|
||||
<PasswordInput v-model="form.connection_string" class="col-span-3" :placeholder="t('connection.dynamodbSessionTokenPlaceholder')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- MongoDB: URL or form -->
|
||||
<template v-else-if="form.db_type === 'mongodb'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
|
||||
@@ -18,6 +18,7 @@ import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { DynamoDbIndexInfo, DynamoDbTableDescription } from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
|
||||
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
type ElasticsearchQueryType,
|
||||
} from "@/lib/app/documentStoreProvider";
|
||||
import {
|
||||
formatDocumentStoreIdLabel,
|
||||
isDocumentStoreIdentityField,
|
||||
normalizeDocumentStoreRouting,
|
||||
parseDocumentStoreInputValue,
|
||||
@@ -87,6 +89,7 @@ import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
||||
import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibilityToLayout } from "@/lib/document/documentGridColumnVisibilityStorage";
|
||||
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import JsonEditNode from "./JsonEditNode.vue";
|
||||
import type { EditNode } from "@/types/editor";
|
||||
import type { ColumnInfo, DatabaseType, QueryResult, QueryTab } from "@/types/database";
|
||||
@@ -95,6 +98,7 @@ import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
@@ -108,6 +112,7 @@ const props = defineProps<{
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type ViewMode = "document" | "table";
|
||||
const DYNAMODB_DEFAULT_EXPORT_ROW_LIMIT = 10_000;
|
||||
|
||||
const documents = ref<JsonRecord[]>([]);
|
||||
const copyDocuments = ref<JsonRecord[]>([]);
|
||||
@@ -224,14 +229,15 @@ watch(
|
||||
|
||||
const pageTotal = computed(() => paginationTotal.value);
|
||||
const documentPageCount = computed(() => (pageTotal.value === undefined ? undefined : Math.max(1, Math.ceil(pageTotal.value / pageSize.value))));
|
||||
const canGoNextPage = computed(() =>
|
||||
canGoNextDocumentPage({
|
||||
const canGoNextPage = computed(() => {
|
||||
if (documentStoreProvider.value.kind === "dynamodb") return dynamodbHasNextCursor.value;
|
||||
return canGoNextDocumentPage({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
rowCount: documents.value.length,
|
||||
paginationTotal: pageTotal.value,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
const documentRequestLimit = computed(() => {
|
||||
if (documentStoreProvider.value.kind !== "elasticsearch" || paginationTotal.value === undefined) return pageSize.value;
|
||||
return documentPageRequestLimit(page.value, pageSize.value, paginationTotal.value);
|
||||
@@ -244,6 +250,8 @@ const tableFindPaneStyle = computed(() => {
|
||||
const documentFontStyle = computed(() => documentViewerFontStyle(settingsStore.editorSettings));
|
||||
const documentStoreLabels = computed(() => ({
|
||||
documentsLabel: documentStoreProvider.value.documentsLabel({ total: total.value ?? 0, totalIsExact: totalIsExact.value, t }),
|
||||
filterInputLabel: documentStoreProvider.value.kind === "dynamodb" ? t("dynamodb.filter") : documentStoreProvider.value.filterInputLabel,
|
||||
sortInputLabel: documentStoreProvider.value.kind === "dynamodb" ? t("dynamodb.sortKey") : documentStoreProvider.value.sortInputLabel,
|
||||
queryPreview: documentQueryPreview.value,
|
||||
}));
|
||||
|
||||
@@ -269,6 +277,39 @@ const documentFilterFieldSearch = ref<Record<string, string>>({});
|
||||
const documentFilterRules = ref<DocumentFilterRule[]>([]);
|
||||
const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
|
||||
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
|
||||
const dynamodbTableDescription = ref<DynamoDbTableDescription | null>(null);
|
||||
const dynamodbIndexName = ref("__table__");
|
||||
const dynamodbPageCursors = ref<Array<string | undefined>>([undefined]);
|
||||
const dynamodbHasNextCursor = ref(false);
|
||||
const dynamodbExactTotal = ref<number | undefined>();
|
||||
let dynamodbExactCountKey: string | null = null;
|
||||
|
||||
const dynamodbIndexOptions = computed<Array<{ value: string; label: string; index?: DynamoDbIndexInfo }>>(() => [
|
||||
{ value: "__table__", label: t("dynamodb.baseTable") },
|
||||
...(dynamodbTableDescription.value?.indexes ?? []).map((index) => ({
|
||||
value: index.name,
|
||||
label: `${index.name} (${index.kind === "global" ? "GSI" : "LSI"} · ${index.projectionType})`,
|
||||
index,
|
||||
})),
|
||||
]);
|
||||
|
||||
const dynamodbSelectedIndex = computed(() => {
|
||||
if (dynamodbIndexName.value === "__table__") return undefined;
|
||||
return dynamodbTableDescription.value?.indexes.find((index) => index.name === dynamodbIndexName.value);
|
||||
});
|
||||
|
||||
const dynamodbPartialProjectionReadOnly = computed(() => documentStoreProvider.value.kind === "dynamodb" && !!dynamodbSelectedIndex.value && dynamodbSelectedIndex.value.projectionType !== "ALL");
|
||||
const documentStoreEditable = computed(() => !dynamodbPartialProjectionReadOnly.value);
|
||||
const documentStoreEditDisabledReason = computed(() => (dynamodbPartialProjectionReadOnly.value ? t("dynamodb.partialProjectionReadOnly", { projection: dynamodbSelectedIndex.value?.projectionType ?? "UNKNOWN" }) : undefined));
|
||||
|
||||
const dynamodbSelectedKey = computed(() => {
|
||||
const table = dynamodbTableDescription.value;
|
||||
if (!table) return null;
|
||||
if (dynamodbIndexName.value === "__table__") {
|
||||
return { partitionKey: table.partitionKey, sortKey: table.sortKey };
|
||||
}
|
||||
return dynamodbSelectedIndex.value ?? null;
|
||||
});
|
||||
|
||||
const pendingDelete = ref<PendingDelete | null>(null);
|
||||
const documentFilterComposingEditors = new Set<string>();
|
||||
@@ -281,9 +322,7 @@ const selectedDoc = computed(() => {
|
||||
});
|
||||
const selectedDocumentIdLabel = computed(() => {
|
||||
if (isNew.value) return "New";
|
||||
const id = selectedDoc.value?._id;
|
||||
if (id === undefined || id === null) return "";
|
||||
return typeof id === "object" ? stringifyDocumentStoreValue(id, documentStoreProvider.value.kind) : String(id);
|
||||
return formatDocumentStoreIdLabel(selectedDoc.value?._id, documentStoreProvider.value.kind);
|
||||
});
|
||||
const selectedDocumentIdWidth = computed(() => `${Math.min(Math.max(Array.from(selectedDocumentIdLabel.value).length + 2, 5), 52)}ch`);
|
||||
|
||||
@@ -299,6 +338,12 @@ const deleteDetails = computed(() => {
|
||||
if (!pending) return "";
|
||||
if (pending.kind === "document") {
|
||||
const id = documents.value[pending.index]?._id ?? "";
|
||||
if (documentStoreProvider.value.kind === "dynamodb") {
|
||||
return t("dynamodb.documentDetails", {
|
||||
table: props.collection,
|
||||
id: formatDocumentStoreIdLabel(id, "dynamodb"),
|
||||
});
|
||||
}
|
||||
const displayId = mongoDocumentIdForGrid(id);
|
||||
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch") {
|
||||
const product = props.databaseType === "easysearch" ? "Easysearch" : props.databaseType === "meilisearch" ? "Meilisearch" : "Elasticsearch";
|
||||
@@ -346,8 +391,9 @@ const gridResult = computed<QueryResult>(() => {
|
||||
return { columns, column_types: columnTypes, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
|
||||
});
|
||||
|
||||
async function exportAllMongoDocuments(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise<QueryResult | undefined> {
|
||||
if (documentStoreProvider.value.kind !== "mongodb") return undefined;
|
||||
async function exportAllDocumentStoreDocuments(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise<QueryResult | undefined> {
|
||||
const kind = documentStoreProvider.value.kind;
|
||||
if (kind !== "mongodb" && kind !== "dynamodb") return undefined;
|
||||
|
||||
const connectionId = props.connectionId;
|
||||
const database = props.database;
|
||||
@@ -356,21 +402,23 @@ async function exportAllMongoDocuments(onProgress?: (info: { rowsExported: numbe
|
||||
const sort = currentDocumentSortJson(sortInput.value);
|
||||
const exportSettings = settingsStore.editorSettings;
|
||||
const batchSize = Math.max(1, Math.trunc(exportSettings.exportBatchSize));
|
||||
const rowLimit = exportSettings.exportRowLimitEnabled ? Math.max(0, Math.trunc(exportSettings.exportRowLimit)) : Number.POSITIVE_INFINITY;
|
||||
const rowLimit = exportSettings.exportRowLimitEnabled ? Math.max(0, Math.trunc(exportSettings.exportRowLimit)) : kind === "dynamodb" ? DYNAMODB_DEFAULT_EXPORT_ROW_LIMIT : Number.POSITIVE_INFINITY;
|
||||
const exportExecutionId = uuid();
|
||||
const exportStartedAt = performance.now();
|
||||
const exportedDocuments: JsonRecord[] = [];
|
||||
let exportedCopyDocuments: JsonRecord[] | undefined = [];
|
||||
let exportedCopyDocuments: JsonRecord[] | undefined = kind === "mongodb" ? [] : undefined;
|
||||
let totalRows: number | null = null;
|
||||
let cursor: string | undefined;
|
||||
const seenCursors = new Set<string>();
|
||||
|
||||
while (exportedDocuments.length < rowLimit) {
|
||||
const requestLimit = Math.min(batchSize, rowLimit - exportedDocuments.length);
|
||||
const requestLimit = Math.min(batchSize, kind === "dynamodb" ? 1000 : Number.POSITIVE_INFINITY, rowLimit - exportedDocuments.length);
|
||||
if (requestLimit <= 0) break;
|
||||
const result = await api.documentFindDocuments(connectionId, database, collection, exportedDocuments.length, requestLimit, filter, undefined, sort, undefined, exportExecutionId);
|
||||
const result = await api.documentFindDocuments(connectionId, database, collection, kind === "dynamodb" ? 0 : exportedDocuments.length, requestLimit, filter, undefined, sort, undefined, exportExecutionId, cursor);
|
||||
const pageDocuments = result.documents.slice(0, requestLimit).map(asRecord);
|
||||
exportedDocuments.push(...pageDocuments);
|
||||
|
||||
if (exportedCopyDocuments) {
|
||||
if (kind === "mongodb" && exportedCopyDocuments) {
|
||||
if (result.extended_documents?.length === result.documents.length) {
|
||||
exportedCopyDocuments.push(...result.extended_documents.slice(0, pageDocuments.length).map(asRecord));
|
||||
} else {
|
||||
@@ -378,19 +426,36 @@ async function exportAllMongoDocuments(onProgress?: (info: { rowsExported: numbe
|
||||
}
|
||||
}
|
||||
|
||||
if (result.total_is_exact !== false) totalRows = Math.min(result.total, rowLimit);
|
||||
if (kind === "mongodb" && result.total_is_exact !== false) totalRows = Math.min(result.total, rowLimit);
|
||||
onProgress?.({ rowsExported: exportedDocuments.length, totalRows });
|
||||
|
||||
if (kind === "dynamodb") {
|
||||
cursor = result.next_cursor;
|
||||
if (!cursor) break;
|
||||
if (seenCursors.has(cursor)) throw new Error(t("dynamodb.repeatedCursor"));
|
||||
seenCursors.add(cursor);
|
||||
continue;
|
||||
}
|
||||
|
||||
const reachedExactTotal = result.total_is_exact !== false && exportedDocuments.length >= result.total;
|
||||
if (pageDocuments.length === 0 || pageDocuments.length < requestLimit || reachedExactTotal) break;
|
||||
}
|
||||
|
||||
if (kind === "dynamodb") {
|
||||
const truncatedByLimit = !!cursor && exportedDocuments.length >= rowLimit;
|
||||
totalRows = truncatedByLimit ? null : exportedDocuments.length;
|
||||
onProgress?.({ rowsExported: exportedDocuments.length, totalRows });
|
||||
if (truncatedByLimit) {
|
||||
toast(t("dynamodb.exportLimitReached", { count: rowLimit }), 6000);
|
||||
}
|
||||
}
|
||||
|
||||
const result = mongoDocumentsToQueryResult(exportedDocuments, performance.now() - exportStartedAt, totalRows ?? exportedDocuments.length, exportedCopyDocuments, totalRows !== null);
|
||||
if (result.columns.length === 0) result.columns = gridResult.value.columns;
|
||||
result.column_types = mongoDocumentGridColumnTypes(exportedDocuments, result.columns);
|
||||
result.column_types = kind === "mongodb" ? mongoDocumentGridColumnTypes(exportedDocuments, result.columns) : undefined;
|
||||
result.affected_rows = exportedDocuments.length;
|
||||
result.truncated = false;
|
||||
result.has_more = false;
|
||||
result.truncated = kind === "dynamodb" && !!cursor && exportedDocuments.length >= rowLimit;
|
||||
result.has_more = result.truncated;
|
||||
return result;
|
||||
}
|
||||
const expandedDocumentFilterFieldPaths = ref<Set<string>>(new Set());
|
||||
@@ -616,8 +681,38 @@ function resetDocumentFilterBuilder() {
|
||||
documentFilterRules.value = documentFilterFieldOptions.value.length > 0 ? [createDocumentFilterRule()] : [];
|
||||
}
|
||||
|
||||
function dynamodbCountFilterKey(filter: string | undefined): string {
|
||||
return JSON.stringify([props.connectionId, props.collection, filter ?? ""]);
|
||||
}
|
||||
|
||||
function resetDynamoDbExactCount() {
|
||||
dynamodbExactCountKey = null;
|
||||
dynamodbExactTotal.value = undefined;
|
||||
}
|
||||
|
||||
function resetDynamoDbPagination(options: { preserveExactCount?: boolean } = {}) {
|
||||
dynamodbPageCursors.value = [undefined];
|
||||
dynamodbHasNextCursor.value = false;
|
||||
paginationTotal.value = undefined;
|
||||
if (!options.preserveExactCount) resetDynamoDbExactCount();
|
||||
}
|
||||
|
||||
function currentDocumentFilter(): string | undefined {
|
||||
return currentDocumentFilterJson(filterInput.value, appliedDocumentFilter.value, documentStoreProvider.value.kind);
|
||||
const filter = currentDocumentFilterJson(filterInput.value, appliedDocumentFilter.value, documentStoreProvider.value.kind);
|
||||
if (documentStoreProvider.value.kind !== "dynamodb" || dynamodbIndexName.value === "__table__") return filter;
|
||||
const parsed = filter ? JSON.parse(filter) : {};
|
||||
return JSON.stringify({ ...parsed, $index: dynamodbIndexName.value });
|
||||
}
|
||||
|
||||
function selectDynamoDbIndex(value: unknown) {
|
||||
const next = typeof value === "string" && value ? value : "__table__";
|
||||
if (dynamodbIndexName.value === next) return;
|
||||
dynamodbIndexName.value = next;
|
||||
if (dynamodbPartialProjectionReadOnly.value && isEditing.value) cancelEdit();
|
||||
sortInput.value = "";
|
||||
page.value = 0;
|
||||
resetDynamoDbPagination();
|
||||
void load({ page: 0 });
|
||||
}
|
||||
|
||||
function resizeDocumentQueryInput(el: HTMLTextAreaElement | undefined) {
|
||||
@@ -763,7 +858,30 @@ function documentStoreWriteApis(documentType?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function prepareDynamoDbDocumentIdentity(document: JsonRecord): { document: JsonRecord; id: string } {
|
||||
const table = dynamodbTableDescription.value;
|
||||
if (!table) throw new Error(t("dynamodb.tableMetadataUnavailable"));
|
||||
|
||||
const next = { ...document };
|
||||
const metadataId = next._id && typeof next._id === "object" && !Array.isArray(next._id) ? (next._id as JsonRecord) : undefined;
|
||||
const identity: JsonRecord = {};
|
||||
const keys = [table.partitionKey, table.sortKey].filter((value): value is NonNullable<typeof value> => !!value);
|
||||
for (const key of keys) {
|
||||
const value = next[key.name] ?? metadataId?.[key.name];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
throw new Error(t("dynamodb.keyRequired", { key: key.name }));
|
||||
}
|
||||
next[key.name] = value;
|
||||
identity[key.name] = value;
|
||||
}
|
||||
next._id = identity;
|
||||
return { document: next, id: serializeDocumentStoreId(identity, "dynamodb") };
|
||||
}
|
||||
|
||||
async function gridSave(changes: DocumentGridChanges) {
|
||||
if (!documentStoreEditable.value) {
|
||||
throw new Error(documentStoreEditDisabledReason.value);
|
||||
}
|
||||
const cols = changes.columns;
|
||||
const idColIdx = cols.indexOf("_id");
|
||||
if (idColIdx < 0) throw new Error("No _id column");
|
||||
@@ -818,8 +936,14 @@ async function gridSave(changes: DocumentGridChanges) {
|
||||
if (!doc) continue;
|
||||
const routing = isEs ? documentRoutingFromDocument(doc) : undefined;
|
||||
const updated = buildPathIdentityUpdatedDocument(doc, dirtyCols, cols, kind);
|
||||
const writeDocument = prepareDocumentStoreWriteDocument(updated, { kind, mode: "update" });
|
||||
const documentId = serializeDocumentStoreId(doc._id ?? id, kind);
|
||||
if (kind === "dynamodb") {
|
||||
const normalized = prepareDynamoDbDocumentIdentity(updated);
|
||||
const writeDocument = prepareDocumentStoreWriteDocument(normalized.document, { kind, mode: "update" });
|
||||
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, documentId, stringifyDocumentStoreValue(writeDocument, kind));
|
||||
continue;
|
||||
}
|
||||
const writeDocument = prepareDocumentStoreWriteDocument(updated, { kind, mode: "update" });
|
||||
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, documentId, stringifyDocumentStoreValue(writeDocument, kind), routing);
|
||||
continue;
|
||||
}
|
||||
@@ -848,7 +972,12 @@ async function gridSave(changes: DocumentGridChanges) {
|
||||
const idValue = newRow[idColIdx];
|
||||
const id = idValue === null || idValue === undefined || idValue === "" ? null : serializeDocumentStoreId(parseDocumentStoreInputValue(idValue, kind), kind);
|
||||
const routing = isEs ? documentRoutingFromGridRow(newRow, cols) : undefined;
|
||||
if (id) {
|
||||
if (kind === "dynamodb") {
|
||||
if (idValue !== null && idValue !== undefined && idValue !== "") doc._id = parseDocumentStoreInputValue(idValue, kind);
|
||||
const normalized = prepareDynamoDbDocumentIdentity(doc);
|
||||
const writeDocument = prepareDocumentStoreWriteDocument(normalized.document, { kind, mode: "insert" });
|
||||
await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(writeDocument, kind));
|
||||
} else if (id) {
|
||||
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, stringifyDocumentStoreValue(doc, kind), routing);
|
||||
} else {
|
||||
await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(doc, kind), routing);
|
||||
@@ -861,6 +990,10 @@ async function gridSave(changes: DocumentGridChanges) {
|
||||
}
|
||||
|
||||
if (isEs) resetElasticsearchTotals({ preservePaginationTotal: true });
|
||||
if (kind === "dynamodb") {
|
||||
page.value = 0;
|
||||
resetDynamoDbPagination();
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -920,6 +1053,14 @@ function buildElasticsearchPartialUpdateDocument(changes: Map<number, MongoInput
|
||||
return document;
|
||||
}
|
||||
|
||||
function formatDynamoDbOperationPreview(action: "insert" | "put" | "delete", id: unknown, document?: Record<string, unknown>): string {
|
||||
const operation = action === "insert" ? "INSERT ITEM" : action === "put" ? "PUT ITEM" : "DELETE ITEM";
|
||||
const lines = [`DBX DYNAMODB ${operation}`, `table: ${JSON.stringify(props.collection)}`];
|
||||
if (id !== undefined) lines.push("key:", stringifyDocumentStoreValue(id, "dynamodb", 2));
|
||||
if (document) lines.push("item:", stringifyDocumentStoreValue(document, "dynamodb", 2));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function previewDocumentChanges(changes: DocumentGridChanges): Promise<string[]> {
|
||||
const { dirtyRows, deletedRows, newRows, newRowMeta, columns, rows } = changes;
|
||||
const idColIdx = columns.indexOf("_id");
|
||||
@@ -938,6 +1079,14 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
|
||||
const updateDoc = buildElasticsearchPartialUpdateDocument(dirtyCols, columns);
|
||||
const routing = documentRoutingFromGridRow(row, columns);
|
||||
stmts.push(`POST /${coll}/_update/${elasticsearchPathIdPreview(String(id))}${elasticsearchRoutingPreview(routing)}\n${stringifyDocumentStoreValue({ doc: updateDoc.$set ?? updateDoc }, "elasticsearch", 2)}`);
|
||||
} else if (kind === "dynamodb") {
|
||||
const sourceDocument = documents.value[rowIdx];
|
||||
if (!sourceDocument) continue;
|
||||
const documentId = sourceDocument._id ?? id;
|
||||
const updated = buildPathIdentityUpdatedDocument(sourceDocument, dirtyCols, columns, "dynamodb");
|
||||
const normalized = prepareDynamoDbDocumentIdentity(updated);
|
||||
const writeDocument = prepareDocumentStoreWriteDocument(normalized.document, { kind: "dynamodb", mode: "update" });
|
||||
stmts.push(formatDynamoDbOperationPreview("put", documentId, writeDocument));
|
||||
} else {
|
||||
const sourceDocument = documents.value[rowIdx];
|
||||
if (!sourceDocument) continue;
|
||||
@@ -957,6 +1106,10 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
|
||||
const id = row?.[idColIdx];
|
||||
if (id == null) continue;
|
||||
if (isPathIdentityStore) {
|
||||
if (kind === "dynamodb") {
|
||||
stmts.push(formatDynamoDbOperationPreview("delete", documents.value[rowIdx]?._id ?? id));
|
||||
continue;
|
||||
}
|
||||
if (!isEs) {
|
||||
stmts.push(formatMeilisearchDocumentOperationPreview({ action: "delete", index: coll, id: documents.value[rowIdx]?._id ?? id }));
|
||||
continue;
|
||||
@@ -971,6 +1124,14 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
|
||||
for (const [newRowIndex, newRow] of newRows.entries()) {
|
||||
const doc = isPathIdentityStore ? buildPathIdentityInsertDocument(newRow, columns, kind) : buildMongoGridInsertDocument(newRow, columns, newRowMeta[newRowIndex]);
|
||||
if (isPathIdentityStore) {
|
||||
if (kind === "dynamodb") {
|
||||
const idValue = idColIdx >= 0 ? newRow[idColIdx] : undefined;
|
||||
if (idValue !== null && idValue !== undefined && idValue !== "") doc._id = parseDocumentStoreInputValue(idValue, "dynamodb");
|
||||
const normalized = prepareDynamoDbDocumentIdentity(doc);
|
||||
const writeDocument = prepareDocumentStoreWriteDocument(normalized.document, { kind: "dynamodb", mode: "insert" });
|
||||
stmts.push(formatDynamoDbOperationPreview("insert", JSON.parse(normalized.id), writeDocument));
|
||||
continue;
|
||||
}
|
||||
if (!isEs) {
|
||||
const idValue = idColIdx >= 0 ? newRow[idColIdx] : null;
|
||||
const id = idValue === null || idValue === undefined || idValue === "" ? undefined : parseDocumentStoreInputValue(idValue, "meilisearch");
|
||||
@@ -1132,13 +1293,24 @@ async function load(options: { page?: number } = {}) {
|
||||
const collection = props.collection;
|
||||
const storeKind = documentStoreProvider.value.kind;
|
||||
const filter = currentDocumentFilter();
|
||||
if (storeKind === "dynamodb") {
|
||||
const countKey = dynamodbCountFilterKey(filter);
|
||||
if (dynamodbExactCountKey !== countKey) {
|
||||
dynamodbExactCountKey = countKey;
|
||||
dynamodbExactTotal.value = undefined;
|
||||
}
|
||||
}
|
||||
const countRequest: LoadedDocumentQueryTotalCountRequest = { connectionId, database, collection, filter, generation: requestGeneration, storeKind };
|
||||
if (storeKind === "elasticsearch" && elasticsearchCountKey !== null && elasticsearchCountKey !== elasticsearchCountFilterKey(filter)) {
|
||||
resetElasticsearchTotals();
|
||||
}
|
||||
const sort = currentDocumentSortJson(sortInput.value);
|
||||
const skip = requestPage * pageSize.value;
|
||||
const result = await api.documentFindDocuments(connectionId, database, collection, skip, documentRequestLimit.value, filter, undefined, sort, undefined, executionId);
|
||||
const cursor = storeKind === "dynamodb" ? dynamodbPageCursors.value[requestPage] : undefined;
|
||||
if (storeKind === "dynamodb" && requestPage > 0 && !cursor) {
|
||||
throw new Error(t("dynamodb.pageCursorUnavailable"));
|
||||
}
|
||||
const skip = storeKind === "dynamodb" ? 0 : requestPage * pageSize.value;
|
||||
const result = await api.documentFindDocuments(connectionId, database, collection, skip, documentRequestLimit.value, filter, undefined, sort, undefined, executionId, cursor);
|
||||
if (documentLoadExecutionId.value !== executionId) return;
|
||||
if (connectionId !== props.connectionId || database !== props.database || collection !== props.collection || storeKind !== documentStoreProvider.value.kind) return;
|
||||
const nextDocuments =
|
||||
@@ -1159,6 +1331,12 @@ async function load(options: { page?: number } = {}) {
|
||||
copyDocuments.value = nextCopyDocuments;
|
||||
mongoCopyDocumentsAvailable.value = hasTypePreservingCopyDocuments;
|
||||
loadedDocumentQueryTotalCountRequest = countRequest;
|
||||
if (storeKind === "dynamodb") {
|
||||
const nextCursors = dynamodbPageCursors.value.slice(0, requestPage + 1);
|
||||
nextCursors[requestPage + 1] = result.next_cursor;
|
||||
dynamodbPageCursors.value = nextCursors;
|
||||
dynamodbHasNextCursor.value = !!result.next_cursor;
|
||||
}
|
||||
if (nextDocuments.length > 0) {
|
||||
const keySet = new Set<string>();
|
||||
keySet.add("_id");
|
||||
@@ -1172,6 +1350,13 @@ async function load(options: { page?: number } = {}) {
|
||||
}
|
||||
if (storeKind === "elasticsearch") {
|
||||
applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter);
|
||||
} else if (storeKind === "dynamodb") {
|
||||
cancelElasticsearchCount();
|
||||
const lowerBound = requestPage * pageSize.value + nextDocuments.length + (result.next_cursor ? 1 : 0);
|
||||
const exactTotal = dynamodbExactTotal.value ?? (!result.next_cursor ? lowerBound : undefined);
|
||||
total.value = exactTotal ?? lowerBound;
|
||||
totalIsExact.value = exactTotal !== undefined;
|
||||
paginationTotal.value = exactTotal;
|
||||
} else {
|
||||
cancelElasticsearchCount();
|
||||
const totals = resolveDocumentQueryTotals(result.total, result.total_is_exact !== false, {
|
||||
@@ -1212,11 +1397,15 @@ async function countExactDocumentTotal(): Promise<number | undefined> {
|
||||
paginationTotal.value = totals.paginationTotal;
|
||||
return exactCount;
|
||||
}
|
||||
const exactCount = await api.mongoCountDocuments(request.connectionId, request.database, request.collection, request.filter, "accurate");
|
||||
const exactCount = request.storeKind === "dynamodb" ? await api.documentCountDocuments(request.connectionId, request.collection, request.filter) : await api.mongoCountDocuments(request.connectionId, request.database, request.collection, request.filter, "accurate");
|
||||
if (!isCurrentDocumentQueryTotalCountRequest(request)) return undefined;
|
||||
if (!Number.isFinite(exactCount) || exactCount < 0) {
|
||||
throw new Error("invalid count");
|
||||
}
|
||||
if (request.storeKind === "dynamodb") {
|
||||
dynamodbExactCountKey = dynamodbCountFilterKey(request.filter);
|
||||
dynamodbExactTotal.value = exactCount;
|
||||
}
|
||||
const totals = resolveDocumentQueryTotals(exactCount, true);
|
||||
total.value = totals.total;
|
||||
totalIsExact.value = totals.totalIsExact;
|
||||
@@ -1226,6 +1415,7 @@ async function countExactDocumentTotal(): Promise<number | undefined> {
|
||||
|
||||
async function refreshDocuments() {
|
||||
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
|
||||
if (documentStoreProvider.value.kind === "dynamodb") resetDynamoDbExactCount();
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -1248,20 +1438,49 @@ async function cancelDocumentLoad() {
|
||||
function applyFilter() {
|
||||
page.value = 0;
|
||||
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals();
|
||||
if (documentStoreProvider.value.kind === "dynamodb") resetDynamoDbPagination();
|
||||
void load();
|
||||
}
|
||||
|
||||
function paginate(offset: number, limit: number) {
|
||||
async function paginate(offset: number, limit: number) {
|
||||
const normalizedLimit = normalizeResultPageSize(limit, pageSize.value);
|
||||
const pageSizeChanged = normalizedLimit !== pageSize.value;
|
||||
pageSize.value = normalizedLimit;
|
||||
if (documentStoreProvider.value.kind === "dynamodb" && pageSizeChanged) {
|
||||
page.value = 0;
|
||||
resetDynamoDbPagination({ preserveExactCount: true });
|
||||
await load({ page: 0 });
|
||||
return;
|
||||
}
|
||||
const requestedPage = Math.floor(Math.max(0, offset) / normalizedLimit);
|
||||
const nextPage = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value);
|
||||
void load({ page: nextPage });
|
||||
if (documentStoreProvider.value.kind !== "dynamodb") {
|
||||
await load({ page: nextPage });
|
||||
return;
|
||||
}
|
||||
for (let cursorPage = 0; cursorPage <= nextPage; cursorPage += 1) {
|
||||
if (cursorPage > 0 && !dynamodbPageCursors.value[cursorPage]) {
|
||||
error.value = t("dynamodb.pageCursorUnavailable");
|
||||
return;
|
||||
}
|
||||
if (cursorPage === nextPage) {
|
||||
await load({ page: cursorPage });
|
||||
return;
|
||||
}
|
||||
if (!dynamodbPageCursors.value[cursorPage + 1]) {
|
||||
await load({ page: cursorPage });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onSort(column: string, _columnIndex: number, direction: "asc" | "desc" | null) {
|
||||
if (documentStoreProvider.value.kind === "dynamodb" && direction && dynamodbSelectedKey.value?.sortKey?.name !== column) {
|
||||
error.value = t("dynamodb.sortKeyOnly", { key: dynamodbSelectedKey.value?.sortKey?.name || t("dynamodb.none") });
|
||||
return;
|
||||
}
|
||||
sortInput.value = documentStoreProvider.value.sortInputForColumn(column, direction);
|
||||
page.value = 0;
|
||||
if (documentStoreProvider.value.kind === "dynamodb") resetDynamoDbPagination({ preserveExactCount: true });
|
||||
void load();
|
||||
}
|
||||
|
||||
@@ -1275,7 +1494,7 @@ function asRecord(value: unknown): JsonRecord {
|
||||
function documentIdentity(doc: JsonRecord | undefined): string | null {
|
||||
const id = doc?._id;
|
||||
if (id === null || id === undefined) return null;
|
||||
return typeof id === "object" ? JSON.stringify(id) : String(id);
|
||||
return serializeDocumentStoreId(id, documentStoreProvider.value.kind);
|
||||
}
|
||||
|
||||
function syncSelectedDocumentAfterLoad(previousSelectedIdx: number | null, previousSelectedId: string | null) {
|
||||
@@ -1385,6 +1604,7 @@ function selectDoc(idx: number) {
|
||||
}
|
||||
|
||||
function startNew() {
|
||||
if (!documentStoreEditable.value) return;
|
||||
selectedIdx.value = null;
|
||||
editJson.value = emptyDocumentJson();
|
||||
editFields.value = [createEditNode("", "", false, false)];
|
||||
@@ -1395,6 +1615,7 @@ function startNew() {
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
if (!documentStoreEditable.value) return;
|
||||
const doc = selectedDoc.value;
|
||||
if (!doc) return;
|
||||
// Issue #2952: open whole-document JSON editing by default (DBeaver-style), not field tree.
|
||||
@@ -1563,6 +1784,9 @@ function resolveDocumentStorePathId(id: unknown): string | null {
|
||||
|
||||
function resolveWriteIdentityFromEditor(doc: JsonRecord, currentId: unknown, currentRouting: string | undefined): { writeId: string; writeRouting?: string } | null {
|
||||
const kind = documentStoreProvider.value.kind;
|
||||
if (kind === "dynamodb") {
|
||||
return { writeId: prepareDynamoDbDocumentIdentity(doc).id };
|
||||
}
|
||||
const hasPayloadId = Object.prototype.hasOwnProperty.call(doc, "_id");
|
||||
const writeId = hasPayloadId ? resolveDocumentStorePathId(doc._id) : resolveDocumentStorePathId(currentId);
|
||||
if (!writeId) return null;
|
||||
@@ -1571,7 +1795,7 @@ function resolveWriteIdentityFromEditor(doc: JsonRecord, currentId: unknown, cur
|
||||
}
|
||||
|
||||
async function saveDoc() {
|
||||
if (isSavingDocument.value) return;
|
||||
if (isSavingDocument.value || !documentStoreEditable.value) return;
|
||||
error.value = "";
|
||||
isSavingDocument.value = true;
|
||||
try {
|
||||
@@ -1579,13 +1803,14 @@ async function saveDoc() {
|
||||
if (!doc) return;
|
||||
|
||||
const kind = documentStoreProvider.value.kind;
|
||||
const writeDocument = kind === "dynamodb" ? prepareDynamoDbDocumentIdentity(doc).document : doc;
|
||||
|
||||
if (isNew.value) {
|
||||
const apis = documentStoreWriteApis();
|
||||
const explicitId = kind === "mongodb" || doc._id === undefined || doc._id === null || doc._id === "" ? null : resolveDocumentStorePathId(doc._id);
|
||||
const explicitId = kind === "mongodb" || writeDocument._id === undefined || writeDocument._id === null || writeDocument._id === "" ? null : resolveDocumentStorePathId(writeDocument._id);
|
||||
await insertDocumentStoreDocumentCore({
|
||||
kind,
|
||||
document: doc,
|
||||
document: writeDocument,
|
||||
explicitId,
|
||||
routing: normalizeDocumentStoreRouting(doc._routing),
|
||||
apis,
|
||||
@@ -1605,7 +1830,7 @@ async function saveDoc() {
|
||||
}
|
||||
const currentRouting = documentRoutingFromDocument(current);
|
||||
const apis = documentStoreWriteApis(kind === "elasticsearch" ? documentTypeFromDocument(current) : undefined);
|
||||
const write = resolveWriteIdentityFromEditor(doc, currentId, currentRouting);
|
||||
const write = resolveWriteIdentityFromEditor(writeDocument, currentId, currentRouting);
|
||||
if (!write) {
|
||||
error.value = t("mongo.jsonIdRequired");
|
||||
return;
|
||||
@@ -1615,8 +1840,7 @@ async function saveDoc() {
|
||||
write: { id: write.writeId, routing: write.writeRouting },
|
||||
current: { id: deleteId, routing: kind === "elasticsearch" ? currentRouting : undefined },
|
||||
});
|
||||
// Rekey writes first then deletes; write failure leaves the old document intact.
|
||||
await applyDocumentStoreIdentityPlan({ kind, plan, document: doc, apis });
|
||||
await applyDocumentStoreIdentityPlan({ kind, plan, document: writeDocument, apis });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -1626,6 +1850,10 @@ async function saveDoc() {
|
||||
documentEditMode.value = "fields";
|
||||
editFields.value = [];
|
||||
if (kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
|
||||
if (kind === "dynamodb") {
|
||||
page.value = 0;
|
||||
resetDynamoDbPagination();
|
||||
}
|
||||
await load();
|
||||
if (selectedIdx.value !== null && documents.value[selectedIdx.value]) {
|
||||
editJson.value = stringifyDocumentStoreValue(documents.value[selectedIdx.value], documentStoreProvider.value.kind, 2);
|
||||
@@ -1649,6 +1877,10 @@ async function applyDeleteDoc(idx: number) {
|
||||
editJson.value = "";
|
||||
}
|
||||
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
|
||||
if (documentStoreProvider.value.kind === "dynamodb") {
|
||||
page.value = 0;
|
||||
resetDynamoDbPagination();
|
||||
}
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
@@ -1656,6 +1888,7 @@ async function applyDeleteDoc(idx: number) {
|
||||
}
|
||||
|
||||
function requestDeleteDoc(idx: number) {
|
||||
if (!documentStoreEditable.value) return;
|
||||
if (!settingsStore.editorSettings.confirmDangerousSqlExecution) {
|
||||
void applyDeleteDoc(idx);
|
||||
return;
|
||||
@@ -1688,7 +1921,7 @@ function nextPage() {
|
||||
}
|
||||
|
||||
function docPreview(doc: JsonRecord): string {
|
||||
const id = doc._id || "";
|
||||
const id = formatDocumentStoreIdLabel(doc._id, documentStoreProvider.value.kind);
|
||||
const keys = Object.keys(doc)
|
||||
.filter((k) => k !== "_id")
|
||||
.slice(0, 3);
|
||||
@@ -1724,6 +1957,15 @@ watch([viewMode, isEditing, selectedIdx], ([mode, editing, index]) => {
|
||||
documentViewerSearchActive.value = false;
|
||||
});
|
||||
|
||||
async function loadDynamoDbTableDescription() {
|
||||
if (documentStoreProvider.value.kind !== "dynamodb") return;
|
||||
try {
|
||||
dynamodbTableDescription.value = await api.dynamodbDescribeTable(props.connectionId, props.collection);
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
||||
try {
|
||||
@@ -1731,6 +1973,7 @@ onMounted(async () => {
|
||||
} catch (e) {
|
||||
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
|
||||
}
|
||||
await loadDynamoDbTableDescription();
|
||||
// Mapping metadata enriches the filter builder, but it must not delay the
|
||||
// first page of documents when the mapping endpoint is slow.
|
||||
void loadElasticsearchMappingFields();
|
||||
@@ -1807,7 +2050,26 @@ defineExpose({ focusSearch });
|
||||
|
||||
<span class="shrink-0 ml-1">{{ documentStoreLabels.documentsLabel }}</span>
|
||||
|
||||
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="startNew"><Plus class="h-3 w-3" /></Button>
|
||||
<div v-if="documentStoreProvider.kind === 'dynamodb' && dynamodbTableDescription" class="ml-1 flex min-w-0 items-center gap-1.5">
|
||||
<Select :model-value="dynamodbIndexName" @update:model-value="selectDynamoDbIndex">
|
||||
<SelectTrigger class="h-6 w-44 min-w-0 text-xs" :title="t('dynamodb.index')">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in dynamodbIndexOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span v-if="dynamodbSelectedKey" class="max-w-64 truncate font-mono text-[11px] text-muted-foreground" :title="t('dynamodb.keySummary', { partitionKey: dynamodbSelectedKey.partitionKey.name, sortKey: dynamodbSelectedKey.sortKey?.name || t('dynamodb.none') })">
|
||||
PK: {{ dynamodbSelectedKey.partitionKey.name }}<template v-if="dynamodbSelectedKey.sortKey"> · SK: {{ dynamodbSelectedKey.sortKey.name }}</template>
|
||||
</span>
|
||||
<Badge v-if="dynamodbPartialProjectionReadOnly" variant="outline" class="h-5 rounded border-amber-500/50 px-1.5 text-[10px] text-amber-600 dark:text-amber-400" :title="documentStoreEditDisabledReason">
|
||||
{{ dynamodbSelectedIndex?.projectionType }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" :disabled="!documentStoreEditable" :title="documentStoreEditDisabledReason" @click="startNew"><Plus class="h-3 w-3" /></Button>
|
||||
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="refreshDocuments"><RefreshCw class="h-3 w-3" :class="{ 'animate-spin': loading }" /></Button>
|
||||
|
||||
<div v-if="viewMode === 'document'" class="flex items-center gap-1 ml-1">
|
||||
@@ -1988,7 +2250,7 @@ defineExpose({ focusSearch });
|
||||
context="results"
|
||||
:database-type="props.databaseType"
|
||||
:mongo-update-target="mongoUpdateTarget"
|
||||
editable
|
||||
:editable="documentStoreEditable"
|
||||
:custom-save-handler="customSaveHandler"
|
||||
:loading="loading"
|
||||
:sql="documentStoreLabels.queryPreview"
|
||||
@@ -1999,7 +2261,7 @@ defineExpose({ focusSearch });
|
||||
:inexact-total-row-count-mode="documentStoreProvider.kind === 'mongodb' ? 'estimated' : 'at-least'"
|
||||
:pagination-total-row-count="pageTotal"
|
||||
:count-total-rows="countExactDocumentTotal"
|
||||
:full-export-result="documentStoreProvider.kind === 'mongodb' ? exportAllMongoDocuments : undefined"
|
||||
:full-export-result="documentStoreProvider.kind === 'mongodb' || documentStoreProvider.kind === 'dynamodb' ? exportAllDocumentStoreDocuments : undefined"
|
||||
@sort="onSort"
|
||||
@reload="refreshDocuments"
|
||||
@paginate="(offset: number, limit: number) => paginate(offset, limit)"
|
||||
@@ -2210,7 +2472,7 @@ defineExpose({ focusSearch });
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.filterInputLabel }}</span>
|
||||
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">{{ documentStoreLabels.filterInputLabel }}</span>
|
||||
<textarea
|
||||
ref="filterInputRef"
|
||||
v-model="filterInput"
|
||||
@@ -2249,7 +2511,7 @@ defineExpose({ focusSearch });
|
||||
<span class="h-5 w-px bg-border group-hover:bg-primary/60" />
|
||||
</button>
|
||||
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0">
|
||||
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.sortInputLabel }}</span>
|
||||
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0">{{ documentStoreLabels.sortInputLabel }}</span>
|
||||
<textarea
|
||||
ref="sortInputRef"
|
||||
v-model="sortInput"
|
||||
@@ -2290,7 +2552,7 @@ defineExpose({ focusSearch });
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div v-for="(doc, idx) in documents" :key="idx" class="px-3 py-1.5 border-b text-xs font-mono cursor-pointer hover:bg-accent/50 flex items-center gap-2 group" :class="{ 'bg-accent': selectedIdx === idx }" @click="selectDoc(idx)">
|
||||
<span class="truncate flex-1">{{ docPreview(doc) }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0" @click.stop="requestDeleteDoc(idx)">
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0" :disabled="!documentStoreEditable" :title="documentStoreEditDisabledReason" @click.stop="requestDeleteDoc(idx)">
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2310,7 +2572,7 @@ defineExpose({ focusSearch });
|
||||
<input class="min-w-0 w-full cursor-text select-text appearance-none border-0 bg-transparent p-0 text-inherit outline-none focus:ring-0" :value="selectedDocumentIdLabel" :aria-label="`_id: ${selectedDocumentIdLabel}`" readonly spellcheck="false" />
|
||||
</Badge>
|
||||
<span class="flex-1" />
|
||||
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{ t("mongo.edit") }}</Button>
|
||||
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" :disabled="!documentStoreEditable" :title="documentStoreEditDisabledReason" @click="startEdit">{{ t("mongo.edit") }}</Button>
|
||||
<template v-if="isEditing">
|
||||
<div class="flex items-center border rounded-md overflow-hidden mr-1">
|
||||
<Button variant="ghost" size="sm" class="h-6 rounded-none px-2 text-xs" :class="{ 'bg-accent': documentEditMode === 'json' }" :disabled="isSavingDocument" @click="setDocumentEditMode('json')">{{ t("mongo.editModeJson") }}</Button>
|
||||
|
||||
+358
-6
@@ -6,8 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const backend = vi.hoisted(() => ({
|
||||
getColumns: vi.fn(),
|
||||
documentFindDocuments: vi.fn(),
|
||||
documentCountDocuments: vi.fn(),
|
||||
dynamodbDescribeTable: vi.fn(),
|
||||
cancelQuery: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
documentInsertDocument: vi.fn(),
|
||||
documentUpdateDocument: vi.fn(),
|
||||
documentDeleteDocument: vi.fn(),
|
||||
documentSaveMeilisearchBatch: vi.fn(),
|
||||
}));
|
||||
@@ -24,6 +28,8 @@ const dataGrid = vi.hoisted(() => ({
|
||||
column_types?: string[];
|
||||
rows: Array<Array<string | number | boolean | null>>;
|
||||
mongo_copy_documents?: unknown[];
|
||||
truncated?: boolean;
|
||||
has_more?: boolean;
|
||||
}
|
||||
| undefined
|
||||
>)
|
||||
@@ -31,8 +37,12 @@ const dataGrid = vi.hoisted(() => ({
|
||||
customSaveHandler: undefined as
|
||||
| {
|
||||
save: (changes: { dirtyRows: Map<number, Map<number, unknown>>; deletedRows: Set<number>; newRows: unknown[][]; newRowMeta: unknown[]; columns: string[]; rows: unknown[][] }) => Promise<void>;
|
||||
preview?: (changes: { dirtyRows: Map<number, Map<number, unknown>>; deletedRows: Set<number>; newRows: unknown[][]; newRowMeta: unknown[]; columns: string[]; rows: unknown[][] }) => Promise<string[]>;
|
||||
}
|
||||
| undefined,
|
||||
countTotalRows: undefined as (() => Promise<number | undefined>) | undefined,
|
||||
paginate: undefined as ((offset: number, limit: number) => Promise<void>) | undefined,
|
||||
editable: true,
|
||||
}));
|
||||
|
||||
const settings = vi.hoisted(() => ({
|
||||
@@ -54,13 +64,19 @@ const settings = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("vue-i18n", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("vue-i18n")>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => (key === "dynamodb.items" ? `${params?.count} Items` : key),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
getColumns: backend.getColumns,
|
||||
documentFindDocuments: backend.documentFindDocuments,
|
||||
documentCountDocuments: backend.documentCountDocuments,
|
||||
dynamodbDescribeTable: backend.dynamodbDescribeTable,
|
||||
cancelQuery: backend.cancelQuery,
|
||||
documentInsertDocument: backend.documentInsertDocument,
|
||||
documentUpdateDocument: backend.documentUpdateDocument,
|
||||
documentDeleteDocument: backend.documentDeleteDocument,
|
||||
documentSaveMeilisearchBatch: backend.documentSaveMeilisearchBatch,
|
||||
}));
|
||||
@@ -90,10 +106,14 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
|
||||
columnLayoutScopeKey: { type: String, default: "" },
|
||||
fullExportResult: { type: Function, default: undefined },
|
||||
customSaveHandler: { type: Object, default: undefined },
|
||||
countTotalRows: { type: Function, default: undefined },
|
||||
editable: { type: Boolean, default: false },
|
||||
},
|
||||
setup(props, { expose, slots }) {
|
||||
setup(props, { attrs, expose, slots }) {
|
||||
dataGrid.fullExportResult = props.fullExportResult as typeof dataGrid.fullExportResult;
|
||||
dataGrid.customSaveHandler = props.customSaveHandler as typeof dataGrid.customSaveHandler;
|
||||
dataGrid.countTotalRows = props.countTotalRows as typeof dataGrid.countTotalRows;
|
||||
dataGrid.paginate = attrs.onPaginate as typeof dataGrid.paginate;
|
||||
expose({
|
||||
visibleColumnCount: 2,
|
||||
displayableColumnCount: 2,
|
||||
@@ -113,8 +133,9 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
|
||||
multiRowTranspose: false,
|
||||
setMultiRowTranspose: vi.fn(),
|
||||
});
|
||||
return () =>
|
||||
h(
|
||||
return () => {
|
||||
dataGrid.editable = props.editable;
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
"data-testid": "data-grid",
|
||||
@@ -124,6 +145,7 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
|
||||
"data-result-hidden-column-keys": JSON.stringify((props.result as { local_hidden_column_keys?: string[] }).local_hidden_column_keys ?? []),
|
||||
"data-result-column-types": JSON.stringify((props.result as { column_types?: string[] }).column_types ?? []),
|
||||
"data-result-rows": JSON.stringify((props.result as { rows?: unknown[] }).rows ?? []),
|
||||
"data-editable": String(props.editable),
|
||||
},
|
||||
[
|
||||
slots["search-bar"]?.({
|
||||
@@ -134,6 +156,7 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
|
||||
}),
|
||||
],
|
||||
);
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -215,8 +238,9 @@ vi.mock("@/components/ui/select", async () => {
|
||||
props: {
|
||||
modelValue: { type: String, default: "" },
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () => h("div", { "data-testid": "select", "data-model-value": props.modelValue }, slots.default?.());
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit, slots }) {
|
||||
return () => h("div", { "data-testid": "select", "data-model-value": props.modelValue, onClick: () => props.modelValue === "__table__" && emit("update:modelValue", "by_status") }, slots.default?.());
|
||||
},
|
||||
});
|
||||
const passthrough = (name: string) =>
|
||||
@@ -284,14 +308,23 @@ beforeEach(async () => {
|
||||
});
|
||||
backend.getColumns.mockReset();
|
||||
backend.documentFindDocuments.mockReset();
|
||||
backend.documentCountDocuments.mockReset();
|
||||
backend.dynamodbDescribeTable.mockReset();
|
||||
backend.cancelQuery.mockReset();
|
||||
backend.ensureConnected.mockReset();
|
||||
backend.documentInsertDocument.mockReset();
|
||||
backend.documentUpdateDocument.mockReset();
|
||||
backend.documentDeleteDocument.mockReset();
|
||||
backend.documentSaveMeilisearchBatch.mockReset();
|
||||
dataGrid.fullExportResult = undefined;
|
||||
dataGrid.customSaveHandler = undefined;
|
||||
dataGrid.countTotalRows = undefined;
|
||||
dataGrid.paginate = undefined;
|
||||
dataGrid.editable = true;
|
||||
documentJsonEditor.openSearch.mockClear();
|
||||
backend.documentDeleteDocument.mockResolvedValue(undefined);
|
||||
backend.documentInsertDocument.mockResolvedValue("created");
|
||||
backend.documentUpdateDocument.mockResolvedValue(1);
|
||||
backend.documentSaveMeilisearchBatch.mockResolvedValue(0);
|
||||
settings.editorSettings.mongoViewMode = "table";
|
||||
settings.editorSettings.columnWidthDensity = "standard";
|
||||
@@ -306,6 +339,16 @@ beforeEach(async () => {
|
||||
settings.updateEditorSettings.mockReset();
|
||||
settings.updateEditorSettings.mockImplementation((partial: Partial<typeof settings.editorSettings>) => Object.assign(settings.editorSettings, partial));
|
||||
backend.ensureConnected.mockResolvedValue(undefined);
|
||||
backend.documentCountDocuments.mockResolvedValue(0);
|
||||
backend.dynamodbDescribeTable.mockResolvedValue({
|
||||
name: "orders",
|
||||
status: "ACTIVE",
|
||||
itemCount: 0,
|
||||
sizeBytes: 0,
|
||||
partitionKey: { name: "tenant_id", attributeType: "S" },
|
||||
sortKey: { name: "order_id", attributeType: "S" },
|
||||
indexes: [],
|
||||
});
|
||||
backend.getColumns.mockResolvedValue([
|
||||
{ name: "buyers", data_type: "nested" },
|
||||
{ name: "buyers.email", data_type: "text" },
|
||||
@@ -443,6 +486,315 @@ describe("DocumentBrowser Elasticsearch field search", () => {
|
||||
});
|
||||
|
||||
describe("DocumentBrowser MongoDB filter value types", () => {
|
||||
it("loads DynamoDB table metadata before requesting the first page", async () => {
|
||||
app?.unmount();
|
||||
let resolveDescription!: (value: { name: string; status: string; itemCount: number; sizeBytes: number; partitionKey: { name: string; attributeType: string }; sortKey: { name: string; attributeType: string }; indexes: never[] }) => void;
|
||||
backend.dynamodbDescribeTable.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveDescription = resolve;
|
||||
}),
|
||||
);
|
||||
backend.documentFindDocuments.mockClear();
|
||||
app = createApp(DocumentBrowser, {
|
||||
connectionId: "dynamodb-1",
|
||||
database: "us-east-1",
|
||||
collection: "orders",
|
||||
databaseType: "dynamodb",
|
||||
});
|
||||
app.mount(root!);
|
||||
await flushUi();
|
||||
|
||||
expect(backend.dynamodbDescribeTable).toHaveBeenCalledOnce();
|
||||
expect(backend.documentFindDocuments).not.toHaveBeenCalled();
|
||||
|
||||
resolveDescription({
|
||||
name: "orders",
|
||||
status: "ACTIVE",
|
||||
itemCount: 0,
|
||||
sizeBytes: 0,
|
||||
partitionKey: { name: "tenant_id", attributeType: "S" },
|
||||
sortKey: { name: "order_id", attributeType: "S" },
|
||||
indexes: [],
|
||||
});
|
||||
await flushUi();
|
||||
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("labels new DynamoDB rows as conditional inserts in the executable preview", async () => {
|
||||
app?.unmount();
|
||||
backend.documentFindDocuments.mockResolvedValue({
|
||||
documents: [{ _id: { tenant_id: "tenant-04", order_id: "ORD-000994" }, tenant_id: "tenant-04", order_id: "ORD-000994", note: "existing" }],
|
||||
total: 1,
|
||||
total_is_exact: true,
|
||||
});
|
||||
app = createApp(DocumentBrowser, {
|
||||
connectionId: "dynamodb-1",
|
||||
database: "us-east-1",
|
||||
collection: "orders",
|
||||
databaseType: "dynamodb",
|
||||
});
|
||||
app.mount(root!);
|
||||
await flushUi();
|
||||
|
||||
const preview = await dataGrid.customSaveHandler!.preview!({
|
||||
dirtyRows: new Map(),
|
||||
deletedRows: new Set(),
|
||||
newRows: [['{"tenant_id":"tenant-04","order_id":"ORD-new"}', "tenant-04", "ORD-new", "new item"]],
|
||||
newRowMeta: [{}],
|
||||
columns: ["_id", "tenant_id", "order_id", "note"],
|
||||
rows: [['{"tenant_id":"tenant-04","order_id":"ORD-000994"}', "tenant-04", "ORD-000994", "existing"]],
|
||||
});
|
||||
|
||||
expect(preview).toHaveLength(1);
|
||||
expect(preview[0]).toContain('DBX DYNAMODB INSERT ITEM\ntable: "orders"');
|
||||
expect(preview[0]).toContain('"order_id": "ORD-new"');
|
||||
});
|
||||
|
||||
it("uses one backend update for a DynamoDB key migration and previews the old source key", async () => {
|
||||
app?.unmount();
|
||||
backend.documentFindDocuments.mockResolvedValue({
|
||||
documents: [{ _id: { tenant_id: "tenant-04", order_id: "ORD-old" }, tenant_id: "tenant-04", order_id: "ORD-old", note: "existing" }],
|
||||
total: 1,
|
||||
total_is_exact: true,
|
||||
});
|
||||
app = createApp(DocumentBrowser, {
|
||||
connectionId: "dynamodb-1",
|
||||
database: "us-east-1",
|
||||
collection: "orders",
|
||||
databaseType: "dynamodb",
|
||||
});
|
||||
app.mount(root!);
|
||||
await flushUi();
|
||||
|
||||
const changes = {
|
||||
dirtyRows: new Map([[0, new Map([[2, "ORD-new"]])]]),
|
||||
deletedRows: new Set<number>(),
|
||||
newRows: [],
|
||||
newRowMeta: [],
|
||||
columns: ["_id", "tenant_id", "order_id", "note"],
|
||||
rows: [['{"tenant_id":"tenant-04","order_id":"ORD-old"}', "tenant-04", "ORD-old", "existing"]],
|
||||
};
|
||||
const preview = await dataGrid.customSaveHandler!.preview!(changes);
|
||||
|
||||
expect(preview).toHaveLength(1);
|
||||
expect(preview[0]).toContain('key:\n{\n "tenant_id": "tenant-04",\n "order_id": "ORD-old"\n}');
|
||||
expect(preview[0]).toContain('"order_id": "ORD-new"');
|
||||
|
||||
await dataGrid.customSaveHandler!.save(changes);
|
||||
expect(backend.documentUpdateDocument).toHaveBeenCalledTimes(1);
|
||||
expect(backend.documentUpdateDocument.mock.calls[0]?.slice(0, 4)).toEqual(["dynamodb-1", "us-east-1", "orders", '{"tenant_id":"tenant-04","order_id":"ORD-old"}']);
|
||||
expect(JSON.parse(backend.documentUpdateDocument.mock.calls[0]?.[4])).toMatchObject({ tenant_id: "tenant-04", order_id: "ORD-new", note: "existing" });
|
||||
expect(backend.documentInsertDocument).not.toHaveBeenCalled();
|
||||
expect(backend.documentDeleteDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("makes partial-projection DynamoDB index results read-only", async () => {
|
||||
app?.unmount();
|
||||
backend.dynamodbDescribeTable.mockResolvedValue({
|
||||
name: "orders",
|
||||
status: "ACTIVE",
|
||||
itemCount: 1,
|
||||
sizeBytes: 100,
|
||||
partitionKey: { name: "tenant_id", attributeType: "S" },
|
||||
sortKey: { name: "order_id", attributeType: "S" },
|
||||
indexes: [
|
||||
{
|
||||
name: "by_status",
|
||||
kind: "global",
|
||||
partitionKey: { name: "status", attributeType: "S" },
|
||||
sortKey: { name: "created_at", attributeType: "N" },
|
||||
projectionType: "KEYS_ONLY",
|
||||
nonKeyAttributes: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
app = createApp(DocumentBrowser, {
|
||||
connectionId: "dynamodb-1",
|
||||
database: "us-east-1",
|
||||
collection: "orders",
|
||||
databaseType: "dynamodb",
|
||||
});
|
||||
app.mount(root!);
|
||||
await flushUi();
|
||||
|
||||
expect(dataGrid.editable).toBe(true);
|
||||
root!.querySelector<HTMLElement>('[data-testid="select"][data-model-value="__table__"]')!.click();
|
||||
await flushUi();
|
||||
|
||||
expect(dataGrid.editable).toBe(false);
|
||||
expect(root!.textContent).toContain("KEYS_ONLY");
|
||||
await expect(
|
||||
dataGrid.customSaveHandler!.save({
|
||||
dirtyRows: new Map(),
|
||||
deletedRows: new Set(),
|
||||
newRows: [],
|
||||
newRowMeta: [],
|
||||
columns: ["_id"],
|
||||
rows: [],
|
||||
}),
|
||||
).rejects.toThrow("dynamodb.partialProjectionReadOnly");
|
||||
});
|
||||
|
||||
it("keeps ALL-projection DynamoDB index results editable", async () => {
|
||||
app?.unmount();
|
||||
backend.dynamodbDescribeTable.mockResolvedValue({
|
||||
name: "orders",
|
||||
status: "ACTIVE",
|
||||
itemCount: 1,
|
||||
sizeBytes: 100,
|
||||
partitionKey: { name: "tenant_id", attributeType: "S" },
|
||||
sortKey: { name: "order_id", attributeType: "S" },
|
||||
indexes: [
|
||||
{
|
||||
name: "by_status",
|
||||
kind: "global",
|
||||
partitionKey: { name: "status", attributeType: "S" },
|
||||
sortKey: { name: "created_at", attributeType: "N" },
|
||||
projectionType: "ALL",
|
||||
nonKeyAttributes: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
app = createApp(DocumentBrowser, {
|
||||
connectionId: "dynamodb-1",
|
||||
database: "us-east-1",
|
||||
collection: "orders",
|
||||
databaseType: "dynamodb",
|
||||
});
|
||||
app.mount(root!);
|
||||
await flushUi();
|
||||
|
||||
root!.querySelector<HTMLElement>('[data-testid="select"][data-model-value="__table__"]')!.click();
|
||||
await flushUi();
|
||||
expect(dataGrid.editable).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an exact DynamoDB count while moving between cursor pages", async () => {
|
||||
app?.unmount();
|
||||
backend.documentCountDocuments.mockResolvedValue(250);
|
||||
backend.documentFindDocuments.mockReset();
|
||||
backend.documentFindDocuments
|
||||
.mockResolvedValueOnce({
|
||||
documents: Array.from({ length: 100 }, (_, index) => ({ _id: { tenant_id: "tenant-a", order_id: String(index) } })),
|
||||
total: 101,
|
||||
total_is_exact: false,
|
||||
next_cursor: "cursor-1",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
documents: Array.from({ length: 100 }, (_, index) => ({ _id: { tenant_id: "tenant-a", order_id: String(index + 100) } })),
|
||||
total: 101,
|
||||
total_is_exact: false,
|
||||
next_cursor: "cursor-2",
|
||||
});
|
||||
app = createApp(DocumentBrowser, {
|
||||
connectionId: "dynamodb-1",
|
||||
database: "us-east-1",
|
||||
collection: "orders",
|
||||
databaseType: "dynamodb",
|
||||
});
|
||||
app.mount(root!);
|
||||
await flushUi();
|
||||
|
||||
expect(await dataGrid.countTotalRows!()).toBe(250);
|
||||
await flushUi();
|
||||
expect(root!.textContent).toContain("250 Items");
|
||||
|
||||
await dataGrid.paginate!(100, 100);
|
||||
await flushUi();
|
||||
expect(backend.documentFindDocuments.mock.calls.at(-1)?.[10]).toBe("cursor-1");
|
||||
expect(root!.textContent).toContain("250 Items");
|
||||
});
|
||||
|
||||
it("exports all DynamoDB pages with opaque cursors instead of offsets", async () => {
|
||||
app?.unmount();
|
||||
settings.editorSettings.exportBatchSize = 2;
|
||||
backend.documentFindDocuments.mockReset();
|
||||
backend.documentFindDocuments
|
||||
.mockResolvedValueOnce({
|
||||
documents: [{ _id: { tenant_id: "tenant-a", order_id: "visible" }, tenant_id: "tenant-a", order_id: "visible" }],
|
||||
total: 2,
|
||||
total_is_exact: false,
|
||||
next_cursor: "visible-cursor",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
documents: [
|
||||
{ _id: { tenant_id: "tenant-a", order_id: "001" }, tenant_id: "tenant-a", order_id: "001", amount: { $dbxDynamoDb: { version: 1, type: "number", value: "9007199254740993" } } },
|
||||
{ _id: { tenant_id: "tenant-a", order_id: "002" }, tenant_id: "tenant-a", order_id: "002", tags: { $dbxDynamoDb: { version: 1, type: "stringSet", value: ["new", "paid"] } } },
|
||||
],
|
||||
total: 3,
|
||||
total_is_exact: false,
|
||||
next_cursor: "cursor-1",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
documents: [{ _id: { tenant_id: "tenant-b", order_id: "003" }, tenant_id: "tenant-b", order_id: "003" }],
|
||||
total: 1,
|
||||
total_is_exact: true,
|
||||
});
|
||||
app = createApp(DocumentBrowser, {
|
||||
connectionId: "dynamodb-1",
|
||||
database: "us-east-1",
|
||||
collection: "orders",
|
||||
databaseType: "dynamodb",
|
||||
});
|
||||
app.mount(root!);
|
||||
await flushUi();
|
||||
|
||||
expect(dataGrid.fullExportResult).toBeTypeOf("function");
|
||||
const progress = vi.fn();
|
||||
const result = await dataGrid.fullExportResult!(progress);
|
||||
|
||||
expect(backend.documentFindDocuments.mock.calls.slice(1).map((call) => [call[3], call[4], call[10]])).toEqual([
|
||||
[0, 2, undefined],
|
||||
[0, 2, "cursor-1"],
|
||||
]);
|
||||
expect(result?.rows).toHaveLength(3);
|
||||
expect(result?.rows[0]).toContain('{"$dbxDynamoDb":{"version":1,"type":"number","value":"9007199254740993"}}');
|
||||
expect(result?.rows[1]).toContain('{"$dbxDynamoDb":{"version":1,"type":"stringSet","value":["new","paid"]}}');
|
||||
expect(progress).toHaveBeenLastCalledWith({ rowsExported: 3, totalRows: 3 });
|
||||
});
|
||||
|
||||
it("caps an otherwise unlimited DynamoDB export at 10,000 items", async () => {
|
||||
app?.unmount();
|
||||
settings.editorSettings.exportBatchSize = 1000;
|
||||
backend.documentFindDocuments.mockReset();
|
||||
backend.documentFindDocuments.mockResolvedValueOnce({
|
||||
documents: [{ _id: { tenant_id: "visible", order_id: "visible" } }],
|
||||
total: 2,
|
||||
total_is_exact: false,
|
||||
next_cursor: "visible-cursor",
|
||||
});
|
||||
for (let pageIndex = 0; pageIndex < 10; pageIndex += 1) {
|
||||
backend.documentFindDocuments.mockResolvedValueOnce({
|
||||
documents: Array.from({ length: 1000 }, (_, rowIndex) => ({
|
||||
_id: { tenant_id: `tenant-${pageIndex}`, order_id: String(rowIndex) },
|
||||
tenant_id: `tenant-${pageIndex}`,
|
||||
order_id: String(rowIndex),
|
||||
})),
|
||||
total: 1001,
|
||||
total_is_exact: false,
|
||||
next_cursor: `cursor-${pageIndex + 1}`,
|
||||
});
|
||||
}
|
||||
app = createApp(DocumentBrowser, {
|
||||
connectionId: "dynamodb-1",
|
||||
database: "us-east-1",
|
||||
collection: "orders",
|
||||
databaseType: "dynamodb",
|
||||
});
|
||||
app.mount(root!);
|
||||
await flushUi();
|
||||
|
||||
const progress = vi.fn();
|
||||
const result = await dataGrid.fullExportResult!(progress);
|
||||
|
||||
expect(result?.rows).toHaveLength(10_000);
|
||||
expect(result?.truncated).toBe(true);
|
||||
expect(result?.has_more).toBe(true);
|
||||
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(11);
|
||||
expect(progress).toHaveBeenLastCalledWith({ rowsExported: 10_000, totalRows: null });
|
||||
});
|
||||
|
||||
it("exports all matching MongoDB documents without changing the visible page", async () => {
|
||||
app?.unmount();
|
||||
settings.editorSettings.exportBatchSize = 2;
|
||||
|
||||
@@ -22,6 +22,7 @@ const assetIcons: Record<string, string> = {
|
||||
redis: "redis",
|
||||
mongodb: "mongodb",
|
||||
mongodb_legacy: "mongodb",
|
||||
dynamodb: "dynamodb",
|
||||
clickhouse: "clickhouse",
|
||||
duckdb: "duckdb",
|
||||
mariadb: "mariadb",
|
||||
|
||||
@@ -397,7 +397,7 @@ const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
|
||||
connection: ["connection"],
|
||||
database: ["database", "redis-db", "mq-tenant", "nacos-namespace", "consul-root", "mongo-db"],
|
||||
schema: ["schema"],
|
||||
table: ["table", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"],
|
||||
table: ["table", "mongo-collection", "mongo-bucket", "dynamodb-table", "vector-collection", "elasticsearch-index"],
|
||||
view: ["view"],
|
||||
};
|
||||
|
||||
@@ -679,7 +679,7 @@ const sidebarCommentLabelWidths = shallowRef(new Map<string, number>());
|
||||
let sidebarCommentMeasureFrame = 0;
|
||||
const sidebarTreeContentWidth = ref(0);
|
||||
let sidebarTreeContentMeasureFrame = 0;
|
||||
const sidebarTableNameDisplayTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "mongo-collection", "vector-collection", "elasticsearch-index"]);
|
||||
const sidebarTableNameDisplayTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "mongo-collection", "dynamodb-table", "vector-collection", "elasticsearch-index"]);
|
||||
const sidebarStorageDisplayTypes = new Set<TreeNodeType>(["database", "table", "materialized_view"]);
|
||||
|
||||
function sidebarCommentLabel(node: TreeNode): string {
|
||||
@@ -1365,6 +1365,8 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
|
||||
await store.loadRedisDatabases(connId);
|
||||
} else if (config.db_type === "mongodb") {
|
||||
await store.loadMongoDatabases(connId);
|
||||
} else if (config.db_type === "dynamodb") {
|
||||
await store.loadDynamoDbTables(connId);
|
||||
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch") {
|
||||
await store.loadElasticsearchIndices(connId);
|
||||
} else if (config.db_type === "qdrant" || config.db_type === "milvus" || config.db_type === "weaviate" || config.db_type === "chromadb") {
|
||||
|
||||
@@ -752,6 +752,8 @@ async function toggle(requestId = beginNavigationRequest()) {
|
||||
await connectionStore.loadConsulRoot(node.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "dynamodb") {
|
||||
await connectionStore.loadDynamoDbTables(node.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
|
||||
// Expand: list indices (like other db types list databases).
|
||||
await connectionStore.loadElasticsearchIndices(node.connectionId);
|
||||
@@ -1317,6 +1319,18 @@ function openMongoTreeData(node: TreeNode) {
|
||||
queryStore.openMongoBucket(node.connectionId, node.database, node.label);
|
||||
return;
|
||||
}
|
||||
if (node.type === "dynamodb-table") {
|
||||
const tab = queryStore.createTab(node.connectionId, node.database, `${node.database}.${node.label}`, "mongo");
|
||||
queryStore.updateSql(tab, node.label);
|
||||
queryStore.setTableMeta(tab, {
|
||||
database: node.database,
|
||||
tableName: node.label,
|
||||
tableType: "DYNAMODB TABLE",
|
||||
columns: [],
|
||||
primaryKeys: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (node.type !== "mongo-collection") return;
|
||||
const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo");
|
||||
queryStore.updateSql(tab, node.label);
|
||||
@@ -3765,7 +3779,7 @@ const canOpenSqlFileExecution = computed(() => {
|
||||
const canExportAllDatabases = computed(() => {
|
||||
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return false;
|
||||
const dbType = connectionStore.getConfig(activeNode.value.connectionId)?.db_type;
|
||||
return !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(dbType || "");
|
||||
return !["redis", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(dbType || "");
|
||||
});
|
||||
|
||||
const canOpenDiagram = computed(() => {
|
||||
@@ -4889,6 +4903,13 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.type === "dynamodb-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: toggle, icon: TableProperties });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.type === "mongo-collection") {
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
items.push({ label: "", separator: true });
|
||||
|
||||
@@ -304,6 +304,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
return { icon: Archive, colorClass: "text-cyan-400" };
|
||||
case "mongo-collection":
|
||||
return { icon: Table, colorClass: "text-green-400" };
|
||||
case "dynamodb-table":
|
||||
return { icon: Table, colorClass: "text-amber-500" };
|
||||
case "vector-collection":
|
||||
return { icon: TableProperties, colorClass: "text-cyan-400" };
|
||||
case "elasticsearch-index":
|
||||
@@ -385,7 +387,7 @@ function treeNodeSecondaryValue(node: TreeNode): string | undefined {
|
||||
|
||||
function visibleLabel(node: TreeNode): string {
|
||||
const withValidity = (label: string) => (node.valid === false ? `${label} · INVALID` : label);
|
||||
if (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "vector-collection" || node.type === "elasticsearch-index") {
|
||||
if (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "dynamodb-table" || node.type === "vector-collection" || node.type === "elasticsearch-index") {
|
||||
return withValidity(sidebarDisplayTableName(node.label, settingsStore.editorSettings.sidebarHiddenTablePrefixes));
|
||||
}
|
||||
return withValidity(displayLabel(node));
|
||||
|
||||
@@ -44,7 +44,7 @@ async function copy() {
|
||||
<!-- card: 卡片类报错信息面板 -->
|
||||
<div v-if="variant === 'card'" class="mx-3 my-2 rounded-lg border border-destructive/30 bg-destructive/10 p-3 shadow-sm shrink-0 select-text flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-2 border-b border-destructive/20 pb-2">
|
||||
<div class="flex items-center gap-2 font-semibold text-xs text-destructive">
|
||||
<div data-native-clipboard class="flex items-center gap-2 font-semibold text-xs text-destructive">
|
||||
<TriangleAlert class="h-4 w-4 text-destructive shrink-0" aria-hidden="true" />
|
||||
<span>{{ displayTitle }}</span>
|
||||
</div>
|
||||
@@ -58,14 +58,14 @@ async function copy() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-h-40 min-h-[56px] overflow-y-auto rounded bg-background/80 dark:bg-background/50 border border-destructive/20 p-2.5 text-xs font-mono leading-relaxed text-destructive break-words whitespace-pre-wrap select-text cursor-text" @mousedown.stop @click.stop>
|
||||
<div data-native-clipboard class="max-h-40 min-h-[56px] overflow-y-auto rounded bg-background/80 dark:bg-background/50 border border-destructive/20 p-2.5 text-xs font-mono leading-relaxed text-destructive break-words whitespace-pre-wrap select-text cursor-text" @mousedown.stop @click.stop>
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- banner: 紧凑内联横幅 -->
|
||||
<div v-else-if="variant === 'banner'" class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
|
||||
<span class="flex-1 min-w-0 break-all">{{ message }}</span>
|
||||
<span data-native-clipboard class="flex-1 min-w-0 break-all">{{ message }}</span>
|
||||
<button v-if="copyMode === 'label'" type="button" class="shrink-0 hover:underline" :aria-label="t('grid.copy')" @click.stop="copy">
|
||||
{{ t("grid.copy") }}
|
||||
</button>
|
||||
@@ -78,7 +78,7 @@ async function copy() {
|
||||
<!-- centered: 居中占满 -->
|
||||
<div v-else class="flex-1 min-h-0 flex flex-col items-center justify-center gap-2 px-6 py-4 text-center">
|
||||
<TriangleAlert class="h-8 w-8 text-destructive/50" aria-hidden="true" />
|
||||
<div class="min-h-0 max-h-48 max-w-lg overflow-auto space-y-1 select-text text-destructive" @mousedown.stop @click.stop>
|
||||
<div data-native-clipboard class="min-h-0 max-h-48 max-w-lg overflow-auto space-y-1 select-text text-destructive" @mousedown.stop @click.stop>
|
||||
<div class="text-sm font-medium">{{ displayTitle }}</div>
|
||||
<div class="text-xs break-all cursor-text text-destructive/80 select-text">{{ message }}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
|
||||
import { eventTargetAllowsNativeClipboard } from "@/lib/common/clipboard";
|
||||
|
||||
const mountedContainers: HTMLDivElement[] = [];
|
||||
|
||||
function findNativeClipboardRegion(container: HTMLElement, text: string): HTMLElement {
|
||||
const region = Array.from(container.querySelectorAll<HTMLElement>("[data-native-clipboard]")).find((element) => element.textContent?.trim() === text);
|
||||
if (!region) throw new Error(`Native clipboard region not found: ${text}`);
|
||||
return region;
|
||||
}
|
||||
|
||||
function selectRegion(region: HTMLElement) {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(region);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
async function mountCardBanner() {
|
||||
const container = document.createElement("div");
|
||||
mountedContainers.push(container);
|
||||
document.body.append(container);
|
||||
|
||||
const app = createApp(ErrorBanner, {
|
||||
variant: "card",
|
||||
title: "Save failed",
|
||||
message: "Conditional request failed",
|
||||
});
|
||||
app.use(
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: { en: { grid: { copy: "Copy", dismiss: "Dismiss" } } },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
}),
|
||||
);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
return container;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
for (const container of mountedContainers.splice(0)) container.remove();
|
||||
});
|
||||
|
||||
describe("ErrorBanner native clipboard selection", () => {
|
||||
it.each(["Save failed", "Conditional request failed"])("preserves native copy for selected %s text", async (text) => {
|
||||
const container = await mountCardBanner();
|
||||
selectRegion(findNativeClipboardRegion(container, text));
|
||||
|
||||
expect(
|
||||
eventTargetAllowsNativeClipboard({
|
||||
key: "c",
|
||||
metaKey: true,
|
||||
target: container,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves grid copy shortcuts active without a text selection", async () => {
|
||||
const container = await mountCardBanner();
|
||||
|
||||
expect(
|
||||
eventTargetAllowsNativeClipboard({
|
||||
key: "c",
|
||||
metaKey: true,
|
||||
target: container,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user