From 47e53b384b089f9ea1738a364b850914d11448ff Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 16:41:21 +0300 Subject: [PATCH] fix(mongo): fail update/delete when 0 documents match --- CHANGELOG.md | 1 + lib/core/database/mongodb_service.dart | 46 ++++++++++++++++++++-- test/core/database/mongo_service_test.dart | 25 ++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75c45c16..3e44376d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Mongo 0-match write (#776)** — `updateOne` / `replaceOne` / `deleteOne` throw when `nMatched` / `nRemoved` is 0 (wrong `_id` type, deleted doc). Inspector and JSON editor surface Save Failed instead of a success toast. An identical `$set` (`nModified == 0`) still counts as a match. - **Mongo full-document Save (#778)** — JSON editor Save uses `replaceOne` (whole document, `_id` locked) instead of `$set`, so fields deleted in JSON — including nested keys — are removed on the server. - **Mongo document editor Back (#782)** — Dirty JSON in `MongoDocumentEditor` is registered with `UnsavedWorkRegistry`. Breadcrumb Back, Home, Close, and tree navigation confirm before discarding; Cancel keeps the editor. - **Mongo JSON filter ObjectId / DateTime (#783)** — Document list filter parses Extended JSON (`$oid`, `$date`) and wraps a 24-character hex `_id` as `ObjectId`. A leftover string `_id` with zero matches shows how to write `{ "_id": { "$oid": "…" } }`. diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 7d4c59f3..85f89a45 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -3,6 +3,31 @@ import 'package:mongo_dart/mongo_dart.dart'; import '../storage/local_db.dart'; import 'mongodb_connection.dart'; +/// Throws if a single-document write matched nothing (wrong `_id` type, +/// concurrent delete). Same class of lie as 0-row SQL DML. +/// +/// [nModified] is ignored: an identical `$set` still matched the document. +void expectMongoDocumentMatched(int matched, {required String operation}) { + if (matched >= 1) return; + throw StateError( + '$operation failed: matched 0 documents. ' + 'The document may have been deleted or the _id type does not match.', + ); +} + +void _throwIfMongoWriteFailed( + WriteResult result, { + required String operation, + required int matched, +}) { + if (result.hasWriteErrors) { + throw StateError( + '$operation failed: ${result.writeError?.errmsg ?? 'write error'}', + ); + } + expectMongoDocumentMatched(matched, operation: operation); +} + /// Service for managing MongoDB connections. class MongoService { MongoService._(); @@ -241,7 +266,12 @@ class MongoService { ) async { return _withDb(connection, database, (db) async { final coll = db.collection(collection); - await coll.updateOne(filter, update); + final result = await coll.updateOne(filter, update); + _throwIfMongoWriteFailed( + result, + operation: 'updateOne', + matched: result.nMatched, + ); }); } @@ -255,7 +285,12 @@ class MongoService { ) async { return _withDb(connection, database, (db) async { final coll = db.collection(collection); - await coll.replaceOne(filter, replacement); + final result = await coll.replaceOne(filter, replacement); + _throwIfMongoWriteFailed( + result, + operation: 'replaceOne', + matched: result.nMatched, + ); }); } @@ -268,7 +303,12 @@ class MongoService { ) async { return _withDb(connection, database, (db) async { final coll = db.collection(collection); - await coll.deleteOne(filter); + final result = await coll.deleteOne(filter); + _throwIfMongoWriteFailed( + result, + operation: 'deleteOne', + matched: result.nRemoved, + ); }); } diff --git a/test/core/database/mongo_service_test.dart b/test/core/database/mongo_service_test.dart index 71bcd4e4..84feb5ca 100644 --- a/test/core/database/mongo_service_test.dart +++ b/test/core/database/mongo_service_test.dart @@ -3,6 +3,31 @@ import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; void main() { + group('expectMongoDocumentMatched', () { + test('allows 1+ matched documents', () { + expectMongoDocumentMatched(1, operation: 'updateOne'); + expectMongoDocumentMatched(2, operation: 'replaceOne'); + }); + + test('throws on 0 matches so Save is a failure', () { + expect( + () => expectMongoDocumentMatched(0, operation: 'updateOne'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('matched 0 documents'), + ), + ), + ); + }); + + test('nModified 0 is not a failure when the filter matched', () { + // Identical $set: nMatched=1, nModified=0 — still a successful save. + expectMongoDocumentMatched(1, operation: 'updateOne'); + }); + }); + group('MongoService.createConnection', () { test('creates MongoConnection from ConnectionRow', () { const row = ConnectionRow(