feat(meilisearch): add native document database support

This commit is contained in:
t8y2 committed 2026-08-13 02:59:56 +08:00
1 parent 292d0c8b54
commit a740e2e9ce
79 files changed
+1801 -170

No files matched your search

@@ -36,7 +36,7 @@ import {
flattenDocumentFieldPathTree,
searchDocumentFieldPathTree,
documentFilterModeNeedsValue,
documentFilterModeOptions,
documentFilterModeOptionsFor,
documentFilterValueTypeOptions,
documentStoreProviderFor,
elasticsearchBoolClauseOptions,
@@ -60,12 +60,13 @@ import {
parseDocumentStoreInputValue,
parseDocumentStoreJsonDocument,
planDocumentStoreIdentityMigration,
prepareDocumentStoreWriteDocument,
resolveDocumentStoreWriteRouting,
serializeDocumentStoreId,
stringifyDocumentStoreValue,
documentStoreValueForGrid,
} from "@/lib/app/documentJsonValues";
import { applyDocumentStoreIdentityPlan, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave";
import { applyDocumentStoreIdentityPlan, formatMeilisearchDocumentOperationPreview, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave";
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
import {
@@ -299,8 +300,8 @@ const deleteDetails = computed(() => {
if (pending.kind === "document") {
const id = documents.value[pending.index]?._id ?? "";
const displayId = mongoDocumentIdForGrid(id);
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch") {
const product = props.databaseType === "easysearch" ? "Easysearch" : "Elasticsearch";
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch") {
const product = props.databaseType === "easysearch" ? "Easysearch" : props.databaseType === "meilisearch" ? "Meilisearch" : "Elasticsearch";
return `${product} index: ${props.collection}\nDocument _id: ${String(displayId)}`;
}
return t("dangerDialog.mongoDocumentDetails", { collection: props.collection, id: String(displayId) });
@@ -335,7 +336,7 @@ const gridResult = computed<QueryResult>(() => {
columns.map((col) => {
const val = mongoDocumentDisplayValue(doc[col]);
if (val === undefined || val === null) return null;
if (col === "_id") return documentStoreProvider.value.kind === "elasticsearch" ? documentStoreValueForGrid(val, "elasticsearch") : mongoDocumentIdForGrid(val);
if (col === "_id") return documentStoreProvider.value.kind === "mongodb" ? mongoDocumentIdForGrid(val) : documentStoreValueForGrid(val, documentStoreProvider.value.kind);
if (typeof val === "object") return documentStoreValueForGrid(val, documentStoreProvider.value.kind);
if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") return val;
return String(val);
@@ -437,6 +438,7 @@ const documentStructuredFilterCount = computed(() => {
return count + (Array.isArray(rules) ? rules.length : 0);
}, 0);
});
const currentDocumentFilterModeOptions = computed(() => documentFilterModeOptionsFor(documentStoreProvider.value.kind));
const documentLoadingLabelKey = computed(() => (documentLoadCancelling.value ? "common.stopping" : "common.loading"));
let documentLoadingTimer: ReturnType<typeof setInterval> | undefined;
@@ -765,28 +767,60 @@ async function gridSave(changes: DocumentGridChanges) {
const cols = changes.columns;
const idColIdx = cols.indexOf("_id");
if (idColIdx < 0) throw new Error("No _id column");
const isEs = documentStoreProvider.value.kind === "elasticsearch";
const kind = documentStoreProvider.value.kind;
const isPathIdentityStore = kind !== "mongodb";
const isEs = kind === "elasticsearch";
if (kind === "meilisearch") {
const updates: Array<{ id: string; docJson: string }> = [];
const deleteIds: string[] = [];
const inserts: string[] = [];
for (const [rowIdx, dirtyCols] of changes.dirtyRows) {
const row = changes.rows[rowIdx];
const id = row?.[idColIdx];
const doc = documents.value[rowIdx];
if (id == null || !doc) continue;
const updated = buildPathIdentityUpdatedDocument(doc, dirtyCols, cols, kind);
const writeDocument = prepareDocumentStoreWriteDocument(updated, { kind, mode: "update" });
updates.push({
id: serializeDocumentStoreId(doc._id ?? id, kind),
docJson: stringifyDocumentStoreValue(writeDocument, kind),
});
}
for (const rowIdx of changes.deletedRows) {
const row = changes.rows[rowIdx];
const id = row?.[idColIdx];
if (id == null) continue;
deleteIds.push(serializeDocumentStoreId(documents.value[rowIdx]?._id ?? id, kind));
}
for (const newRow of changes.newRows) {
const doc = buildPathIdentityInsertDocument(newRow, cols, kind);
const idValue = newRow[idColIdx];
if (idValue !== null && idValue !== undefined && idValue !== "") doc._id = parseDocumentStoreInputValue(idValue, kind);
inserts.push(stringifyDocumentStoreValue(doc, kind));
}
await api.documentSaveMeilisearchBatch(props.connectionId, props.collection, updates, deleteIds, inserts);
await load();
return;
}
for (const [rowIdx, dirtyCols] of changes.dirtyRows) {
const row = changes.rows[rowIdx];
const id = row?.[idColIdx];
if (id == null) continue;
if (isEs) {
if (isPathIdentityStore) {
const doc = documents.value[rowIdx];
if (!doc) continue;
const routing = documentRoutingFromDocument(doc);
const updated = { ...doc };
for (const [colIdx, newVal] of dirtyCols) {
const col = cols[colIdx];
if (col === "_id" || col === "_routing") continue;
if (newVal === null) {
delete updated[col];
} else {
updated[col] = parseDocumentStoreInputValue(newVal, "elasticsearch");
}
}
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, String(id), stringifyDocumentStoreValue(updated, "elasticsearch"), routing);
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);
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, documentId, stringifyDocumentStoreValue(writeDocument, kind), routing);
continue;
}
@@ -803,20 +837,21 @@ async function gridSave(changes: DocumentGridChanges) {
const document = documents.value[rowIdx];
const routing = isEs ? documentRoutingFromDocument(document) : undefined;
const documentType = isEs ? documentTypeFromDocument(document) : undefined;
const documentId = isEs ? id : (document?._id ?? id);
await api.documentDeleteDocument(props.connectionId, props.database, props.collection, isEs ? String(documentId) : serializeMongoDocumentId(documentId), routing, documentType);
const documentId = document?._id ?? id;
await api.documentDeleteDocument(props.connectionId, props.database, props.collection, isPathIdentityStore ? serializeDocumentStoreId(documentId, kind) : serializeMongoDocumentId(documentId), routing, documentType);
}
for (const [newRowIndex, newRow] of changes.newRows.entries()) {
const newRowMeta = changes.newRowMeta[newRowIndex];
const doc = isEs ? buildElasticsearchInsertDocument(newRow, cols) : buildMongoGridInsertDocument(newRow, cols, newRowMeta);
if (isEs) {
const id = documentIdFromGridValue(newRow[idColIdx]);
const routing = documentRoutingFromGridRow(newRow, cols);
const doc = isPathIdentityStore ? buildPathIdentityInsertDocument(newRow, cols, kind) : buildMongoGridInsertDocument(newRow, cols, newRowMeta);
if (isPathIdentityStore) {
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) {
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, stringifyDocumentStoreValue(doc, "elasticsearch"), routing);
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, "elasticsearch"), routing);
await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(doc, kind), routing);
}
continue;
}
@@ -829,17 +864,28 @@ async function gridSave(changes: DocumentGridChanges) {
await load();
}
function buildElasticsearchInsertDocument(row: MongoInputValue[], columns: string[]): JsonRecord {
function buildPathIdentityInsertDocument(row: MongoInputValue[], columns: string[], kind: Exclude<DocumentStoreKind, "mongodb">): JsonRecord {
const doc: JsonRecord = {};
for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) {
const column = columns[columnIndex];
if (!column || column === "_id" || column === "_routing") continue;
if (!column || column === "_id" || (kind === "elasticsearch" && column === "_routing")) continue;
const value = row[columnIndex];
if (value !== null) doc[column] = parseDocumentStoreInputValue(value, "elasticsearch");
if (value !== null) doc[column] = parseDocumentStoreInputValue(value, kind);
}
return doc;
}
function buildPathIdentityUpdatedDocument(document: JsonRecord, changes: Map<number, MongoInputValue>, columns: string[], kind: Exclude<DocumentStoreKind, "mongodb">): JsonRecord {
const updated = { ...document };
for (const [columnIndex, newValue] of changes) {
const column = columns[columnIndex];
if (!column || column === "_id" || (kind === "elasticsearch" && column === "_routing")) continue;
if (newValue === null) delete updated[column];
else updated[column] = parseDocumentStoreInputValue(newValue, kind);
}
return updated;
}
function buildMongoGridInsertDocument(row: MongoInputValue[], columns: string[], meta?: GridNewRowMeta): Record<string, unknown> {
const sourceIndex = meta?.sourceIndex;
const sourceDocument = sourceIndex === undefined ? undefined : copyDocuments.value[sourceIndex];
@@ -879,16 +925,27 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
const idColIdx = columns.indexOf("_id");
const stmts: string[] = [];
const coll = props.collection;
const isEs = documentStoreProvider.value.kind === "elasticsearch";
const kind = documentStoreProvider.value.kind;
const isPathIdentityStore = kind !== "mongodb";
const isEs = kind === "elasticsearch";
for (const [rowIdx, dirtyCols] of dirtyRows) {
const row = rows[rowIdx];
const id = row?.[idColIdx];
if (id == null) continue;
if (isEs) {
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)}`);
if (isPathIdentityStore) {
if (isEs) {
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 {
const sourceDocument = documents.value[rowIdx];
if (!sourceDocument) continue;
const documentId = sourceDocument._id ?? id;
const updated = buildPathIdentityUpdatedDocument(sourceDocument, dirtyCols, columns, "meilisearch");
const writeDocument = prepareDocumentStoreWriteDocument(updated, { kind: "meilisearch", mode: "update" });
stmts.push(formatMeilisearchDocumentOperationPreview({ action: "update", index: coll, id: documentId, document: writeDocument }));
}
} else {
const updateDoc = buildMongoUpdateDocument(dirtyCols, columns, documents.value[rowIdx]);
stmts.push(`db.${coll}.updateOne({_id: ${formatMongoShellLiteral(documents.value[rowIdx]?._id ?? id)}}, ${formatMongoShellLiteral(updateDoc)})`);
@@ -899,7 +956,11 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
const row = rows[rowIdx];
const id = row?.[idColIdx];
if (id == null) continue;
if (isEs) {
if (isPathIdentityStore) {
if (!isEs) {
stmts.push(formatMeilisearchDocumentOperationPreview({ action: "delete", index: coll, id: documents.value[rowIdx]?._id ?? id }));
continue;
}
const routing = documentRoutingFromGridRow(row, columns);
stmts.push(`DELETE /${coll}/_doc/${elasticsearchPathIdPreview(String(id))}${elasticsearchRoutingPreview(routing)}`);
} else {
@@ -908,8 +969,14 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
}
for (const [newRowIndex, newRow] of newRows.entries()) {
const doc = isEs ? buildElasticsearchInsertDocument(newRow, columns) : buildMongoGridInsertDocument(newRow, columns, newRowMeta[newRowIndex]);
if (isEs) {
const doc = isPathIdentityStore ? buildPathIdentityInsertDocument(newRow, columns, kind) : buildMongoGridInsertDocument(newRow, columns, newRowMeta[newRowIndex]);
if (isPathIdentityStore) {
if (!isEs) {
const idValue = idColIdx >= 0 ? newRow[idColIdx] : null;
const id = idValue === null || idValue === undefined || idValue === "" ? undefined : parseDocumentStoreInputValue(idValue, "meilisearch");
stmts.push(formatMeilisearchDocumentOperationPreview({ action: id === undefined ? "insert" : "upsert", index: coll, id, document: doc }));
continue;
}
const id = idColIdx >= 0 ? documentIdFromGridValue(newRow[idColIdx]) : null;
if (id) {
stmts.push(`PUT /${coll}/_doc/${elasticsearchPathIdPreview(id)}\n${stringifyDocumentStoreValue(doc, "elasticsearch", 2)}`);
@@ -1515,7 +1582,7 @@ async function saveDoc() {
if (isNew.value) {
const apis = documentStoreWriteApis();
const explicitId = kind === "elasticsearch" ? documentIdFromGridValue(documentStoreValueForGrid(doc._id, "elasticsearch")) : null;
const explicitId = kind === "mongodb" || doc._id === undefined || doc._id === null || doc._id === "" ? null : resolveDocumentStorePathId(doc._id);
await insertDocumentStoreDocumentCore({
kind,
document: doc,
@@ -1573,7 +1640,7 @@ async function saveDoc() {
async function applyDeleteDoc(idx: number) {
const doc = documents.value[idx];
const id = doc._id;
if (!id) return;
if (id === undefined || id === null || id === "") return;
error.value = "";
try {
await api.documentDeleteDocument(props.connectionId, props.database, props.collection, serializeDocumentStoreId(id, documentStoreProvider.value.kind), documentRoutingFromDocument(doc), documentStoreProvider.value.kind === "elasticsearch" ? documentTypeFromDocument(doc) : undefined);
@@ -2086,7 +2153,7 @@ defineExpose({ focusSearch });
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="option in documentFilterModeOptions" :key="option.value" :value="option.value">
<SelectItem v-for="option in currentDocumentFilterModeOptions" :key="option.value" :value="option.value">
{{ t(option.labelKey) }}
</SelectItem>
</SelectContent>
@@ -9,6 +9,7 @@ const backend = vi.hoisted(() => ({
cancelQuery: vi.fn(),
ensureConnected: vi.fn(),
documentDeleteDocument: vi.fn(),
documentSaveMeilisearchBatch: vi.fn(),
}));
const documentJsonEditor = vi.hoisted(() => ({
@@ -27,6 +28,11 @@ const dataGrid = vi.hoisted(() => ({
| undefined
>)
| undefined,
customSaveHandler: undefined as
| {
save: (changes: { dirtyRows: Map<number, Map<number, unknown>>; deletedRows: Set<number>; newRows: unknown[][]; newRowMeta: unknown[]; columns: string[]; rows: unknown[][] }) => Promise<void>;
}
| undefined,
}));
const settings = vi.hoisted(() => ({
@@ -56,6 +62,7 @@ vi.mock("@/lib/backend/api", () => ({
documentFindDocuments: backend.documentFindDocuments,
cancelQuery: backend.cancelQuery,
documentDeleteDocument: backend.documentDeleteDocument,
documentSaveMeilisearchBatch: backend.documentSaveMeilisearchBatch,
}));
vi.mock("@/stores/connectionStore", () => ({
@@ -82,9 +89,11 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
database: { type: String, default: "" },
columnLayoutScopeKey: { type: String, default: "" },
fullExportResult: { type: Function, default: undefined },
customSaveHandler: { type: Object, default: undefined },
},
setup(props, { expose, slots }) {
dataGrid.fullExportResult = props.fullExportResult as typeof dataGrid.fullExportResult;
dataGrid.customSaveHandler = props.customSaveHandler as typeof dataGrid.customSaveHandler;
expose({
visibleColumnCount: 2,
displayableColumnCount: 2,
@@ -278,9 +287,12 @@ beforeEach(async () => {
backend.cancelQuery.mockReset();
backend.ensureConnected.mockReset();
backend.documentDeleteDocument.mockReset();
backend.documentSaveMeilisearchBatch.mockReset();
dataGrid.fullExportResult = undefined;
dataGrid.customSaveHandler = undefined;
documentJsonEditor.openSearch.mockClear();
backend.documentDeleteDocument.mockResolvedValue(undefined);
backend.documentSaveMeilisearchBatch.mockResolvedValue(0);
settings.editorSettings.mongoViewMode = "table";
settings.editorSettings.columnWidthDensity = "standard";
settings.editorSettings.dataGridRenderMode = "canvas";
@@ -757,4 +769,71 @@ describe("DocumentBrowser MongoDB filter value types", () => {
expect(backend.documentDeleteDocument).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("dangerDialog.deleteMessage");
});
it("saves Meilisearch grid changes in one batch request", async () => {
app?.unmount();
backend.getColumns.mockResolvedValue([
{ name: "id", data_type: "string", is_primary_key: true },
{ name: "title", data_type: "string" },
{ name: "rating", data_type: "number" },
{ name: "obsolete", data_type: "boolean" },
]);
backend.documentFindDocuments.mockResolvedValue({
documents: [
{ _id: "001", title: "One", rating: 1, obsolete: true },
{ _id: 2, title: "Two", rating: 2, obsolete: false },
{ _id: 3, title: "Three", rating: 3, obsolete: false },
],
raw_documents: [],
total: 3,
total_is_exact: true,
});
backend.documentSaveMeilisearchBatch.mockResolvedValue(4);
app = createApp(DocumentBrowser, {
connectionId: "meili-1",
database: "default",
collection: "movies",
databaseType: "meilisearch",
});
app.mount(root!);
await flushUi();
expect(dataGrid.customSaveHandler).toBeDefined();
await dataGrid.customSaveHandler!.save({
dirtyRows: new Map([
[
0,
new Map([
[1, "One revised"],
[3, null],
]),
],
]),
deletedRows: new Set([2]),
newRows: [
["004", "Four", 4, false],
[null, "Generated", 5, true],
],
newRowMeta: [{}, {}],
columns: ["_id", "title", "rating", "obsolete"],
rows: [
["001", "One", 1, true],
[2, "Two", 2, false],
[3, "Three", 3, false],
],
});
expect(backend.documentSaveMeilisearchBatch).toHaveBeenCalledOnce();
const [connectionId, collection, updates, deleteIds, inserts] = backend.documentSaveMeilisearchBatch.mock.calls[0]!;
expect(connectionId).toBe("meili-1");
expect(collection).toBe("movies");
expect(updates).toHaveLength(1);
expect(updates[0].id).toBe('__dbx_meilisearch_string_id__"001"');
expect(JSON.parse(updates[0].docJson)).toEqual({ title: "One revised", rating: 1 });
expect(deleteIds).toEqual(["3"]);
expect(inserts.map((value: string) => JSON.parse(value))).toEqual([
{ title: "Four", rating: 4, obsolete: false, _id: "004" },
{ title: "Generated", rating: 5, obsolete: true },
]);
});
});