diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 7168c50f9a..91796059ea 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -169,7 +169,7 @@ import { canGoNextDataGridPage } from "@/lib/dataGrid/dataGridPagination"; import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions"; import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, restoredDataGridScrollLeft, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll"; import { CANVAS_DATA_GRID_ROW_HEIGHT, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer"; -import { dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi"; +import { dataGridPreviewLabelKey, dataGridSaveActionMode, dataGridSaveToolbarState } from "@/lib/dataGrid/dataGridSaveUi"; import type { QueryEditabilityReason } from "@/lib/sql/sqlAnalysis"; import { EDITOR_FONT_FAMILY_CSS_VAR } from "@/lib/editor/editorThemes"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; @@ -4235,6 +4235,7 @@ const saveActionMode = computed(() => useTransaction: !!useTransaction.value, }), ); +const previewLabelKey = computed(() => dataGridPreviewLabelKey(resolvedDatabaseType.value)); const saveToolbarState = computed(() => dataGridSaveToolbarState({ editable: props.editable, @@ -7270,8 +7271,12 @@ function prepareTransposeCellMouseDown(rowIndex: number, actualColIdx: number) { if (item) prepareDataCellMouseDown(item, actualColIdx); } +function canSaveGridChangesFromShortcut() { + return saveToolbarState.value.showActions && !saveToolbarState.value.actionsDisabled; +} + async function saveGridChangesFromShortcut() { - if (!saveToolbarState.value.showActions || saveToolbarState.value.actionsDisabled) return false; + if (!canSaveGridChangesFromShortcut()) return false; await onToolbarCommit(); return true; } @@ -7345,9 +7350,10 @@ async function onGridKeydown(event: KeyboardEvent) { return; } if (isSaveShortcut(event, settingsStore.editorSettings.shortcuts)) { - if (await saveGridChangesFromShortcut()) { + if (canSaveGridChangesFromShortcut()) { event.preventDefault(); event.stopPropagation(); + await saveGridChangesFromShortcut(); } return; } @@ -9672,11 +9678,11 @@ const gridContextMenuItems = computed(() => { > - {{ t("toolbar.previewSql") }} + {{ t(previewLabelKey) }} - {{ t("toolbar.previewSql") }} + {{ t(previewLabelKey) }} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8b3ad89fe6..684533b40f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -42,6 +42,7 @@ export default { rollback: "Rollback", txnAutoRolledBack: "Transaction auto-rolled back after 5 minutes of inactivity", previewSql: "Preview SQL", + previewQuery: "Preview query", hidePreviewSql: "Hide SQL Preview", saveSql: "Save to SQL Library", openSql: "Open SQL file", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 6401dc5d7e..fdef639c0c 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -44,6 +44,7 @@ export default withEnglishFallback({ rollback: "Revertir", txnAutoRolledBack: "Transacción revertida automáticamente tras 5 minutos de inactividad", previewSql: "Vista previa de SQL", + previewQuery: "Vista previa de la consulta", hidePreviewSql: "Ocultar vista previa de SQL", saveSql: "Guardar en biblioteca SQL", openSql: "Abrir archivo SQL", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 6ba6f8cf42..81f9a17aa9 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -43,6 +43,7 @@ export default withEnglishFallback({ rollback: "Rollback", txnAutoRolledBack: "Transazione annullata automaticamente dopo 5 minuti di inattività", previewSql: "Anteprima SQL", + previewQuery: "Anteprima query", hidePreviewSql: "Nascondi anteprima SQL", saveSql: "Salva nella Libreria SQL", openSql: "Apri file SQL", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index bf9a8ea774..6ac1a12e6d 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -44,6 +44,7 @@ export default withEnglishFallback({ rollback: "ロールバック", txnAutoRolledBack: "5分間操作がなかったため、トランザクションは自動的にロールバックされました", previewSql: "SQLをプレビュー", + previewQuery: "クエリをプレビュー", hidePreviewSql: "SQLプレビューを非表示", saveSql: "SQLライブラリに保存", openSql: "SQLファイルを開く", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 4435ca6e02..233db49825 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -44,6 +44,7 @@ export default withEnglishFallback({ rollback: "Reverter", txnAutoRolledBack: "Transação revertida automaticamente após 5 minutos de inatividade", previewSql: "Visualizar SQL", + previewQuery: "Visualizar consulta", hidePreviewSql: "Ocultar Visualização SQL", saveSql: "Salvar na Biblioteca SQL", openSql: "Abrir arquivo SQL", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 30b787a97c..8bc23407ae 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -44,6 +44,7 @@ export default withEnglishFallback({ rollback: "回滚", txnAutoRolledBack: "事务已因 5 分钟无操作而自动回滚", previewSql: "预览 SQL", + previewQuery: "预览查询", hidePreviewSql: "隐藏 SQL 预览", saveSql: "保存到 SQL 库", openSql: "打开 SQL 文件", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 4380ec7dd4..7277a2d931 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -44,6 +44,7 @@ export default withEnglishFallback({ rollback: "回溯", txnAutoRolledBack: "事務已因 5 分鐘無操作而自動回溯", previewSql: "預覽 SQL", + previewQuery: "預覽查詢", hidePreviewSql: "隱藏 SQL 預覽", saveSql: "儲存到 SQL 庫", openSql: "開啟 SQL 檔案", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridSaveUi.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSaveUi.spec.ts new file mode 100644 index 0000000000..cafbe9cef7 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSaveUi.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { dataGridPreviewLabelKey } from "@/lib/dataGrid/dataGridSaveUi"; + +describe("dataGridPreviewLabelKey", () => { + it("offers a query preview for document databases that never emit SQL", () => { + expect(dataGridPreviewLabelKey("mongodb")).toBe("toolbar.previewQuery"); + expect(dataGridPreviewLabelKey("elasticsearch")).toBe("toolbar.previewQuery"); + }); + + it("keeps the SQL wording for SQL databases", () => { + expect(dataGridPreviewLabelKey("mysql")).toBe("toolbar.previewSql"); + expect(dataGridPreviewLabelKey("postgres")).toBe("toolbar.previewSql"); + expect(dataGridPreviewLabelKey("sqlite")).toBe("toolbar.previewSql"); + }); + + it("falls back to the SQL wording when the database type is unknown", () => { + expect(dataGridPreviewLabelKey(undefined)).toBe("toolbar.previewSql"); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridSaveUi.ts b/apps/desktop/src/lib/dataGrid/dataGridSaveUi.ts index e67ecf9415..7dba246515 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridSaveUi.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridSaveUi.ts @@ -1,3 +1,11 @@ +import type { DatabaseType } from "@/types/database"; + +const NON_SQL_PREVIEW_DATABASE_TYPES: ReadonlySet = new Set(["mongodb", "elasticsearch"]); + +export function dataGridPreviewLabelKey(databaseType?: DatabaseType): "toolbar.previewQuery" | "toolbar.previewSql" { + return databaseType && NON_SQL_PREVIEW_DATABASE_TYPES.has(databaseType) ? "toolbar.previewQuery" : "toolbar.previewSql"; +} + export interface DataGridSaveActionMode { labelKey: "grid.commit" | "grid.save"; tooltipKey: "grid.transactionSaveHint" | "grid.nonTransactionalSaveHint"; diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 09e487d335..3825858bef 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -960,7 +960,7 @@ pub async fn update_document( return Ok(result.modified_count); } } - return Ok(0); + return Err(no_matching_document_error(id)); } for filter in document_id_filters(id) { @@ -973,7 +973,12 @@ pub async fn update_document( return Ok(result.modified_count); } } - Ok(0) + Err(no_matching_document_error(id)) +} + +fn no_matching_document_error(id: &str) -> String { + let display = decode_string_document_id(id).unwrap_or_else(|| id.to_string()); + format!("No document matched _id {display}. It may have been deleted or its _id changed since the query ran.") } fn is_update_operator_document(doc: &Document) -> bool { @@ -1227,11 +1232,19 @@ pub async fn delete_document(client: &Client, database: &str, collection: &str, fn document_id_filters(id: &str) -> Vec { if let Some(string_id) = decode_string_document_id(id) { + // The marker is emitted for an explicitly typed BSON string; do not reinterpret it as ObjectId. return vec![doc! { "_id": Bson::String(string_id) }]; } if let Some(filter) = extended_json_document_id_filter(id) { return vec![filter]; } + if let Some(numeric) = numeric_document_id(id) { + return vec![doc! { "_id": numeric }, doc! { "_id": Bson::String(id.to_string()) }]; + } + object_id_then_string_filters(id) +} + +fn object_id_then_string_filters(id: &str) -> Vec { let string_filter = doc! { "_id": Bson::String(id.to_string()) }; match ObjectId::parse_str(id) { Ok(oid) => vec![doc! { "_id": Bson::ObjectId(oid) }, string_filter], @@ -1239,6 +1252,19 @@ fn document_id_filters(id: &str) -> Vec { } } +fn numeric_document_id(id: &str) -> Option { + if id.trim() != id || id.is_empty() { + return None; + } + if let Ok(value) = id.parse::() { + return Some(Bson::Int64(value)); + } + match id.parse::() { + Ok(value) if value.is_finite() => Some(Bson::Double(value)), + _ => None, + } +} + fn decode_string_document_id(id: &str) -> Option { id.strip_prefix("__dbx_mongo_string_id__").and_then(|payload| serde_json::from_str::(payload).ok()) } @@ -1690,6 +1716,35 @@ mod tests { ); } + #[test] + fn document_id_filters_keep_explicit_hex_string_ids_as_strings() { + let hex = "507f1f77bcf86cd799439011"; + let id = format!("__dbx_mongo_string_id__{}", serde_json::to_string(hex).unwrap()); + let filters = document_id_filters(&id); + + assert_eq!(filters.len(), 1); + assert!(matches!(filters[0].get("_id"), Some(Bson::String(value)) if value == hex)); + } + + #[test] + fn document_id_filters_keep_explicit_object_ids_as_object_ids() { + let filters = document_id_filters(r#"{"$oid":"507f1f77bcf86cd799439011"}"#); + + assert_eq!(filters.len(), 1); + assert!( + matches!(filters[0].get("_id"), Some(Bson::ObjectId(oid)) if oid.to_hex() == "507f1f77bcf86cd799439011") + ); + } + + #[test] + fn document_id_filters_match_numeric_ids_before_string() { + let filters = document_id_filters("42"); + + assert_eq!(filters.len(), 2); + assert!(matches!(filters[0].get("_id"), Some(Bson::Int64(42)))); + assert!(matches!(filters[1].get("_id"), Some(Bson::String(value)) if value == "42")); + } + #[test] fn json_filter_to_document_preserves_extended_json_int64_values() { let filter = serde_json::json!({ "snowflake": { "$numberLong": "2048938405781032962" } }); diff --git a/packages/app-tests/mongoDocumentValues.test.ts b/packages/app-tests/mongoDocumentValues.test.ts index d9fdec74f0..ed9ec19e2f 100644 --- a/packages/app-tests/mongoDocumentValues.test.ts +++ b/packages/app-tests/mongoDocumentValues.test.ts @@ -228,6 +228,7 @@ test("serializes typed Mongo document ids while keeping their grid display compa const id = { $numberLong: "2048938405781032962" }; assert.equal(serializeMongoDocumentId(id), '{"$numberLong":"2048938405781032962"}'); assert.equal(mongoDocumentIdForGrid(id), "2048938405781032962"); + assert.equal(serializeMongoDocumentId({ $oid: "6743e4bfa3f6f84bc3fff6c8" }), '{"$oid":"6743e4bfa3f6f84bc3fff6c8"}'); assert.equal(serializeMongoDocumentId("2048938405781032962"), '__dbx_mongo_string_id__"2048938405781032962"'); assert.equal(serializeMongoDocumentId('{"$numberLong":"2048938405781032962"}'), '__dbx_mongo_string_id__"{\\"$numberLong\\":\\"2048938405781032962\\"}"'); });