Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
19 changes: 15 additions & 4 deletions lib/features/mongodb/mongo_documents_view.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,6 +47,7 @@ class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {

final _filterController = material.TextEditingController();
Map<String, dynamic>? _activeFilter;
String? _emptyFilterHint;

@override
void initState() {
Expand Down Expand Up @@ -94,6 +95,11 @@ class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {
_totalCount = count;
_documents = docs;
_loading = false;
_emptyFilterHint = docs.isEmpty &&
_activeFilter != null &&
mongoFilterNeedsObjectIdHint(_activeFilter!)
? kMongoFilterIdStringHint
: null;
});
} catch (e) {
if (mounted) {
Expand All @@ -111,21 +117,23 @@ class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {
_activeFilter = null;
} else {
try {
_activeFilter = json.decode(text) as Map<String, dynamic>;
_activeFilter = mongoFilterFromJson(text);
} catch (e) {
setState(() {
_error = 'Invalid JSON filter: $e';
});
return;
}
}
_emptyFilterHint = null;
_skip = 0;
_load();
}

void _clearFilter() {
_filterController.clear();
_activeFilter = null;
_emptyFilterHint = null;
_skip = 0;
_load();
}
Expand Down Expand Up @@ -258,7 +266,8 @@ class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {
? 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(
Expand Down Expand Up @@ -303,7 +312,9 @@ class _MongoDocumentsViewState extends material.State<MongoDocumentsView> {
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(),
),
),
Expand Down
31 changes: 31 additions & 0 deletions lib/features/mongodb/mongo_ejson.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,34 @@ Map<String, dynamic> mongoDocumentFromEjson(String text) {
}
return EJsonCodec.eJson2Doc(Map<String, dynamic>.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<String, dynamic> mongoFilterFromJson(String text) {
final decoded = json.decode(text);
if (decoded is! Map) {
throw const FormatException('Filter JSON must be an object');
}
final raw = Map<String, dynamic>.from(decoded);
Map<String, dynamic> doc;
try {
doc = EJsonCodec.eJson2Doc(Map<String, dynamic>.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<String, dynamic> filter) {
return filter['_id'] is String;
}
43 changes: 43 additions & 0 deletions test/features/mongodb/mongo_ejson_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ObjectId>());
expect(filter['_id'], isNot(isA<String>()));
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<ObjectId>());
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<DateTime>());
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<Map>());
expect((filter['age'] as Map)[r'$gt'], 5);
});
});
}
Loading