diff --git a/CHANGELOG.md b/CHANGELOG.md index bf42d0f..75c45c1 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 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": "…" } }`. - **Postgres open-transaction probe (#787)** — `BEGIN` + `SELECT` does not assign an XID, so `pg_current_xact_id_if_assigned` stayed NULL (SQL-tab badge off; autocommit-off prepended a second `BEGIN`). The session tracks BEGIN/COMMIT/ROLLBACK and otherwise probes `pg_stat_activity.xact_start` (PG 9+). Implicit BEGIN when autocommit is off stays a separate `execute`. diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 4b09bab..7d4c59f 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -231,7 +231,7 @@ class MongoService { }); } - /// Updates a single document matched by [filter]. + /// Updates a single document matched by [filter] (`$set` / `$unset` / …). Future updateDocument( MongoConnection connection, String database, @@ -245,6 +245,20 @@ class MongoService { }); } + /// Replaces a single document matched by [filter] (full-document Save). + Future replaceDocument( + MongoConnection connection, + String database, + String collection, + Map filter, + Map replacement, + ) async { + return _withDb(connection, database, (db) async { + final coll = db.collection(collection); + await coll.replaceOne(filter, replacement); + }); + } + /// Deletes a single document matched by [filter]. Future deleteDocument( MongoConnection connection, diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart index 6e6e52c..f183485 100644 --- a/lib/features/mongodb/mongo_document_editor.dart +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -150,10 +150,12 @@ class _MongoDocumentEditorState extends material.State { return; } - // Remove _id from the update payload (can't change _id). Filter uses the - // original BSON _id, not a stringified copy from the editor. - final updateDoc = Map.from(parsed); - updateDoc.remove('_id'); + // replaceOne drops keys the user deleted. `_id` stays the original BSON + // value even if the editor JSON changed it. + final replacement = mongoFullDocumentReplacement( + parsed: parsed, + originalId: id, + ); setState(() { _saving = true; @@ -162,12 +164,12 @@ class _MongoDocumentEditorState extends material.State { }); try { - await MongoService.instance.updateDocument( + await MongoService.instance.replaceDocument( widget.connection, widget.database, widget.collection, {'_id': id}, - {r'$set': updateDoc}, + replacement, ); if (!mounted) return; setState(() { diff --git a/lib/features/mongodb/mongo_ejson.dart b/lib/features/mongodb/mongo_ejson.dart index f20f002..d8afb8b 100644 --- a/lib/features/mongodb/mongo_ejson.dart +++ b/lib/features/mongodb/mongo_ejson.dart @@ -53,3 +53,17 @@ Map mongoFilterFromJson(String text) { bool mongoFilterNeedsObjectIdHint(Map filter) { return filter['_id'] is String; } + +/// Full-document Save payload for [DbCollection.replaceOne]. +/// +/// Parsed JSON minus any `_id` the user typed, then `_id` locked to +/// [originalId]. Keys absent from [parsed] (including nested ones) are not in +/// the replacement, so they are dropped — unlike `$set`, which only merges. +Map mongoFullDocumentReplacement({ + required Map parsed, + required Object originalId, +}) { + final out = Map.from(parsed); + out['_id'] = originalId; + return out; +} diff --git a/test/features/mongodb/mongo_ejson_test.dart b/test/features/mongodb/mongo_ejson_test.dart index 0b74c83..2bca49b 100644 --- a/test/features/mongodb/mongo_ejson_test.dart +++ b/test/features/mongodb/mongo_ejson_test.dart @@ -98,4 +98,39 @@ void main() { expect((filter['age'] as Map)[r'$gt'], 5); }); }); + + group('mongoFullDocumentReplacement', () { + test('start {a:1,b:2}, save {a:1} drops b and keeps original _id', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + final replacement = mongoFullDocumentReplacement( + parsed: {'a': 1}, + originalId: id, + ); + expect(replacement.containsKey('b'), isFalse); + expect(replacement['a'], 1); + expect(replacement['_id'], same(id)); + }); + + test('nested keys deleted in JSON are absent from the replacement', () { + final id = ObjectId.fromHexString('507f191e810c19729de860ea'); + final replacement = mongoFullDocumentReplacement( + parsed: { + 'nested': {'x': 1}, + }, + originalId: id, + ); + expect(replacement['nested'], {'x': 1}); + expect((replacement['nested'] as Map).containsKey('y'), isFalse); + }); + + test('edited _id in JSON is overwritten with the original BSON id', () { + final id = ObjectId.fromHexString('507f1f77bcf86cd799439011'); + final replacement = mongoFullDocumentReplacement( + parsed: {'_id': 'tampered', 'a': 1}, + originalId: id, + ); + expect(replacement['_id'], same(id)); + expect(replacement['a'], 1); + }); + }); }