From 8b0c227c5bd1d98dd8851616c4e0b5d3f9bdfc6b Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 20 Sep 2026 16:29:16 +0300 Subject: [PATCH] fix(mongo): parse Extended JSON and hex _id in the document filter json.decode left _id as a string, so find never matched BSON ObjectId. Accept $oid / $date and wrap a 24-character hex _id. Hint when a string _id still matches nothing. --- CHANGELOG.md | 1 + .../mongodb/mongo_documents_view.dart | 19 ++++++-- lib/features/mongodb/mongo_ejson.dart | 31 +++++++++++++ test/features/mongodb/mongo_ejson_test.dart | 43 +++++++++++++++++++ 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ee59abd..3c293830 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 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`. - **Postgres Table Browser paging (#788)** — Browse stays on `PgSessionMode.readOnly`. `SELECT` uses `ORDER BY` primary-key columns when a PK exists. Row totals come from `pg_class.reltuples` instead of a blocking `COUNT(*)` before first paint (stale estimates below the current page are ignored so Next still works). Save / REFRESH stay on `tableWrite`. - **Postgres timestamptz / bytea / jsonb / arrays (#789)** — Table Browser and SQL results encode cells as PG literals (ISO timestamps, `\x` hex for bytea, JSON text for jsonb, `{1,2,3}` for arrays) instead of `Object.toString`. Schema uses `udt_name` so `ARRAY` / `USER-DEFINED` round-trip through `formatLiteral`. diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index efdcc438..98700e7c 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -1,10 +1,10 @@ import 'dart:async' show unawaited; -import 'dart:convert'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/destructive_sql_detector.dart'; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/features/mongodb/mongo_ejson.dart'; import 'package:querya_desktop/features/mongodb/mongo_field_codec.dart'; import 'package:querya_desktop/features/workspace/destructive_query_dialog.dart'; import 'package:querya_desktop/features/workspace/grid_cell_popover_inspector.dart'; @@ -47,6 +47,7 @@ class _MongoDocumentsViewState extends material.State { final _filterController = material.TextEditingController(); Map? _activeFilter; + String? _emptyFilterHint; @override void initState() { @@ -94,6 +95,11 @@ class _MongoDocumentsViewState extends material.State { _totalCount = count; _documents = docs; _loading = false; + _emptyFilterHint = docs.isEmpty && + _activeFilter != null && + mongoFilterNeedsObjectIdHint(_activeFilter!) + ? kMongoFilterIdStringHint + : null; }); } catch (e) { if (mounted) { @@ -111,7 +117,7 @@ class _MongoDocumentsViewState extends material.State { _activeFilter = null; } else { try { - _activeFilter = json.decode(text) as Map; + _activeFilter = mongoFilterFromJson(text); } catch (e) { setState(() { _error = 'Invalid JSON filter: $e'; @@ -119,6 +125,7 @@ class _MongoDocumentsViewState extends material.State { return; } } + _emptyFilterHint = null; _skip = 0; _load(); } @@ -126,6 +133,7 @@ class _MongoDocumentsViewState extends material.State { void _clearFilter() { _filterController.clear(); _activeFilter = null; + _emptyFilterHint = null; _skip = 0; _load(); } @@ -258,7 +266,8 @@ class _MongoDocumentsViewState extends material.State { ? material.Center( child: material.Padding( padding: const material.EdgeInsets.all(48), - child: const Text('No documents found').muted(), + child: Text(_emptyFilterHint ?? 'No documents found') + .muted(), ), ) : material.ListView.separated( @@ -303,7 +312,9 @@ class _MongoDocumentsViewState extends material.State { material.Expanded( child: TextField( controller: _filterController, - placeholder: const Text('Filter (JSON) e.g. {"name": "John"}'), + placeholder: const Text( + r'Filter (JSON / EJSON) e.g. {"_id": {"$oid": "…"}}', + ), onSubmitted: (_) => _applyFilter(), ), ), diff --git a/lib/features/mongodb/mongo_ejson.dart b/lib/features/mongodb/mongo_ejson.dart index 9a825299..f20f0022 100644 --- a/lib/features/mongodb/mongo_ejson.dart +++ b/lib/features/mongodb/mongo_ejson.dart @@ -22,3 +22,34 @@ Map mongoDocumentFromEjson(String text) { } return EJsonCodec.eJson2Doc(Map.from(decoded)); } + +/// Hint when a find filter's `_id` is still a JSON string (not ObjectId). +const kMongoFilterIdStringHint = + 'No documents matched. If `_id` is an ObjectId, use ' + r'{"_id": {"$oid": "…"}} or a 24-character hex `_id`.'; + +/// Parses a find filter: Extended JSON (`$oid`, `$date`, …) plus a 24-char +/// hex `_id` string wrapped as [ObjectId]. +Map mongoFilterFromJson(String text) { + final decoded = json.decode(text); + if (decoded is! Map) { + throw const FormatException('Filter JSON must be an object'); + } + final raw = Map.from(decoded); + Map doc; + try { + doc = EJsonCodec.eJson2Doc(Map.from(raw)); + } catch (_) { + doc = raw; + } + final id = doc['_id']; + if (id is String && ObjectId.isValidHexId(id)) { + doc['_id'] = ObjectId.fromHexString(id); + } + return doc; +} + +/// True when `_id` is still a JSON string (hex wrap did not apply). +bool mongoFilterNeedsObjectIdHint(Map filter) { + return filter['_id'] is String; +} diff --git a/test/features/mongodb/mongo_ejson_test.dart b/test/features/mongodb/mongo_ejson_test.dart index bb65f987..0b74c834 100644 --- a/test/features/mongodb/mongo_ejson_test.dart +++ b/test/features/mongodb/mongo_ejson_test.dart @@ -55,4 +55,47 @@ void main() { ); }); }); + + group('mongoFilterFromJson', () { + test('24-char hex _id becomes ObjectId, not a String', () { + const hex = '507f1f77bcf86cd799439011'; + final filter = mongoFilterFromJson('{"_id": "$hex"}'); + expect(filter['_id'], isA()); + expect(filter['_id'], isNot(isA())); + expect((filter['_id'] as ObjectId).oid, hex); + expect(mongoFilterNeedsObjectIdHint(filter), isFalse); + }); + + test('Extended JSON \$oid _id is ObjectId', () { + const hex = '507f191e810c19729de860ea'; + final filter = mongoFilterFromJson( + '{"_id": {"\$oid": "$hex"}}', + ); + expect(filter['_id'], isA()); + expect((filter['_id'] as ObjectId).oid, hex); + }); + + test('Extended JSON \$date is DateTime', () { + final filter = mongoFilterFromJson( + '{"created": {"\$date": "2024-01-15T12:30:00.000Z"}}', + ); + expect(filter['created'], isA()); + expect( + (filter['created'] as DateTime).toUtc(), + DateTime.utc(2024, 1, 15, 12, 30), + ); + }); + + test('non-hex _id string stays a string and needs the ObjectId hint', () { + final filter = mongoFilterFromJson('{"_id": "not-an-objectid"}'); + expect(filter['_id'], 'not-an-objectid'); + expect(mongoFilterNeedsObjectIdHint(filter), isTrue); + }); + + test('query operators like \$gt are kept', () { + final filter = mongoFilterFromJson('{"age": {"\$gt": 5}}'); + expect(filter['age'], isA()); + expect((filter['age'] as Map)[r'$gt'], 5); + }); + }); }