diff --git a/.gitignore b/.gitignore index ae6e4136..f7932aa5 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ flutter_export_environment.sh docs/**/*.g.dart */build/ +drift/extension/devtools/build **/pubspec_overrides.yaml diff --git a/drift/extension/devtools/.pubignore b/drift/extension/devtools/.pubignore new file mode 100644 index 00000000..85d9a327 --- /dev/null +++ b/drift/extension/devtools/.pubignore @@ -0,0 +1 @@ +!./build diff --git a/drift/extension/devtools/config.yaml b/drift/extension/devtools/config.yaml index e80f7d75..fba44287 100644 --- a/drift/extension/devtools/config.yaml +++ b/drift/extension/devtools/config.yaml @@ -2,3 +2,7 @@ name: drift issue_tracker: https://github.com/simolus3/drift/issues version: 0.0.1 material_icon_code_point: '0xf41e' + +# ??? https://github.com/flutter/devtools/issues/6539 +issueTracker: https://github.com/simolus3/drift/issues +materialIconCodePoint: "0xf41e" diff --git a/drift/lib/src/runtime/api/db_base.dart b/drift/lib/src/runtime/api/db_base.dart index ffe7fbfa..f13158ce 100644 --- a/drift/lib/src/runtime/api/db_base.dart +++ b/drift/lib/src/runtime/api/db_base.dart @@ -143,6 +143,7 @@ abstract class GeneratedDatabase extends DatabaseConnectionUser @override Future close() async { await super.close(); + devtools.handleClosed(this); assert(() { if (_openedDbCount[runtimeType] != null) { diff --git a/drift/lib/src/runtime/devtools/devtools.dart b/drift/lib/src/runtime/devtools/devtools.dart index 6588d9b9..30ec368d 100644 --- a/drift/lib/src/runtime/devtools/devtools.dart +++ b/drift/lib/src/runtime/devtools/devtools.dart @@ -50,6 +50,14 @@ void handleCreated(GeneratedDatabase database) { } } +void handleClosed(GeneratedDatabase database) { + if (_enable) { + final tracked = TrackedDatabase.byDatabase[database]; + TrackedDatabase.all.remove(tracked); + _postChangedEvent(); + } +} + String describe(GeneratedDatabase database) { return json.encode(DatabaseDescription.fromDrift(database)); } diff --git a/drift/lib/src/runtime/devtools/service_extension.dart b/drift/lib/src/runtime/devtools/service_extension.dart index 3762aee0..1b4d332b 100644 --- a/drift/lib/src/runtime/devtools/service_extension.dart +++ b/drift/lib/src/runtime/devtools/service_extension.dart @@ -34,7 +34,7 @@ class DriftServiceExtension { }); }); - return id.toString(); + return id; case 'unsubscribe-from-tables': _activeSubscriptions.remove(int.parse(parameters['id']!))?.cancel(); return null; diff --git a/drift/lib/src/runtime/devtools/shared.dart b/drift/lib/src/runtime/devtools/shared.dart index 162c5dc0..060ddd24 100644 --- a/drift/lib/src/runtime/devtools/shared.dart +++ b/drift/lib/src/runtime/devtools/shared.dart @@ -64,6 +64,11 @@ class EntityDescription { final String type; final List? columns; + late Map columnsByName = { + for (final column in columns ?? const []) + column.name: column, + }; + EntityDescription( {required this.name, required this.type, required this.columns}); @@ -99,6 +104,10 @@ class DatabaseDescription { final bool dateTimeAsText; final List entities; + late Map entitiesByName = { + for (final entity in entities) entity.name: entity, + }; + DatabaseDescription({required this.dateTimeAsText, required this.entities}); factory DatabaseDescription.fromDrift(GeneratedDatabase database) { diff --git a/examples/app/devtools_options.yaml b/examples/app/devtools_options.yaml new file mode 100644 index 00000000..e0b5c914 --- /dev/null +++ b/examples/app/devtools_options.yaml @@ -0,0 +1,2 @@ +extensions: + - drift: true \ No newline at end of file diff --git a/extras/drift_devtools_extension/build.sh b/extras/drift_devtools_extension/build.sh new file mode 100755 index 00000000..6b610743 --- /dev/null +++ b/extras/drift_devtools_extension/build.sh @@ -0,0 +1,3 @@ +dart run devtools_extensions build_and_copy \ + --source=. \ + --dest=../../drift/extension/devtools diff --git a/extras/drift_devtools_extension/lib/main.dart b/extras/drift_devtools_extension/lib/main.dart index 07f2d63a..acb47390 100644 --- a/extras/drift_devtools_extension/lib/main.dart +++ b/extras/drift_devtools_extension/lib/main.dart @@ -59,7 +59,7 @@ class DriftDevtoolsBody extends ConsumerWidget { roundedTopBorder: false, includeTopBorder: false, title: selected != null - ? Text(selected.typeName) + ? Text('Inspecting ${selected.typeName}') : const Text('No database selected'), ), if (selected != null) const Expanded(child: DatabaseDetails()) diff --git a/extras/drift_devtools_extension/lib/src/db_viewer/database.dart b/extras/drift_devtools_extension/lib/src/db_viewer/database.dart new file mode 100644 index 00000000..c36450b0 --- /dev/null +++ b/extras/drift_devtools_extension/lib/src/db_viewer/database.dart @@ -0,0 +1,154 @@ +import 'package:db_viewer/db_viewer.dart'; +import 'package:drift/drift.dart'; +// ignore: invalid_use_of_internal_member, implementation_imports +import 'package:drift/src/runtime/devtools/shared.dart'; +import 'package:flutter/widgets.dart'; +import 'package:rxdart/streams.dart'; + +import '../remote_database.dart'; + +class ViewerDatabase implements DbViewerDatabase { + final RemoteDatabase database; + + final Map _cachedFilters = {}; + + ViewerDatabase({required this.database}); + + @override + Widget buildWhereWidget( + {required VoidCallback onAddClicked, + required List whereClauses}) { + return Container(); + } + + @override + Stream count(String entityName) { + return customSelectStream('SELECT COUNT(*) AS r FROM "$entityName"') + .map((rows) => rows.first['r'] as int); + } + + @override + Future>> customSelect(String query, + {Set? fromEntityNames}) { + return database.select(query, const []); + } + + @override + Stream>> customSelectStream(String query, + {Set? fromEntityNames}) { + fromEntityNames ??= const {}; + + final updates = database.tableUpdates + .where( + (e) => e.any((updated) => fromEntityNames!.contains(updated.table))) + .asyncMap((event) => customSelect(query)); + + return ConcatStream([ + Stream.fromFuture(customSelect(query)), + updates, + ]); + } + + @override + List get entityNames => [ + for (final entity in database.description.entities) + if (entity.type == 'table') entity.name, + ]; + + @override + FilterData getCachedFilterData(String entityName) { + return _cachedFilters.putIfAbsent( + entityName, () => getFilterData(entityName)); + } + + @override + List getColumnNamesByEntityName(String entityName) { + return database.description.entitiesByName[entityName]!.columnsByName.keys + .toList(); + } + + @override + FilterData getFilterData(String entityName) { + return DriftFilterData( + entity: database.description.entitiesByName[entityName]!); + } + + @override + String getType(String entityName, String columnName) { + final type = database.description.entitiesByName[entityName]! + .columnsByName[columnName]!.type!; + final genContext = GenerationContext( + DriftDatabaseOptions( + storeDateTimeAsText: database.description.dateTimeAsText), + null, + ); + + return type.type?.sqlTypeName(genContext) ?? type.customTypeName!; + } + + @override + List> remapData( + String entityName, List> data) { + // ignore: invalid_use_of_internal_member + final types = SqlTypes(database.description.dateTimeAsText); + final mapped = >[]; + final entity = database.description.entitiesByName[entityName]!; + + for (final row in data) { + final mappedRow = {}; + + for (var MapEntry(key: column, :value) in row.entries) { + final resolvedColumn = entity.columnsByName[column]; + + if (resolvedColumn != null) { + final type = resolvedColumn.type?.type ?? DriftSqlType.any; + + mappedRow[column] = types.read(type, value); + } else { + mappedRow[column] = value; + } + } + + mapped.add(mappedRow); + } + + return mapped; + } + + @override + Future runCustomStatement(String query) { + return database.execute(query, const []); + } + + @override + void updateFilterData(String entityName, FilterData filterData) { + _cachedFilters[entityName] = filterData; + } +} + +class DriftFilterData extends FilterData { + final EntityDescription entity; + + DriftFilterData({required this.entity}); + + @override + DriftFilterData copy() { + return DriftFilterData(entity: entity); + } + + @override + Map getSelectedColumns() { + return { + for (final column in entity.columns ?? const []) + column.name: false, + }; + } + + @override + WhereClause? getWhereClause(String columnName) { + return null; + } + + @override + String get tableName => entity.name; +} diff --git a/extras/drift_devtools_extension/lib/src/db_viewer/viewer.dart b/extras/drift_devtools_extension/lib/src/db_viewer/viewer.dart new file mode 100644 index 00000000..96da3734 --- /dev/null +++ b/extras/drift_devtools_extension/lib/src/db_viewer/viewer.dart @@ -0,0 +1,30 @@ +import 'package:db_viewer/db_viewer.dart'; +import 'package:flutter/material.dart'; + +import '../remote_database.dart'; +import 'database.dart'; + +class DatabaseViewer extends StatefulWidget { + final RemoteDatabase database; + + const DatabaseViewer({super.key, required this.database}); + + @override + State createState() => _DatabaseViewerState(); +} + +class _DatabaseViewerState extends State { + @override + void initState() { + DbViewerDatabase.initDb(ViewerDatabase(database: widget.database)); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 800), + child: const DbViewerNavigator(), + ); + } +} diff --git a/extras/drift_devtools_extension/lib/src/details.dart b/extras/drift_devtools_extension/lib/src/details.dart index ff414536..7b0c0ed1 100644 --- a/extras/drift_devtools_extension/lib/src/details.dart +++ b/extras/drift_devtools_extension/lib/src/details.dart @@ -2,6 +2,7 @@ import 'package:devtools_app_shared/service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'db_viewer/viewer.dart'; import 'list.dart'; import 'remote_database.dart'; import 'service.dart'; @@ -20,16 +21,6 @@ final loadedDatabase = AutoDisposeFutureProvider((ref) async { return null; }); -final _testQuery = AutoDisposeFutureProvider((ref) async { - final database = await ref.watch(loadedDatabase.future); - - if (database != null) { - return await database.select('SELECT 1, 2, 3', []); - } else { - return null; - } -}); - class DatabaseDetails extends ConsumerStatefulWidget { const DatabaseDetails({super.key}); @@ -51,21 +42,28 @@ class _DatabaseDetailsState extends ConsumerState { @override Widget build(BuildContext context) { final database = ref.watch(loadedDatabase); - final query = ref.watch(_testQuery); return database.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Text('unknown error: $err\n$stack'), data: (database) { if (database != null) { + final textTheme = Theme.of(context).textTheme; + return Scrollbar( controller: controller, child: ListView( controller: controller, children: [ - for (final entity in database.description.entities) - Text('${entity.name}: ${entity.type}'), - Text(query.toString()), + Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Text('Database viewer', style: textTheme.headlineMedium), + ], + ), + ), + DatabaseViewer(database: database), ], ), ); diff --git a/extras/drift_devtools_extension/lib/src/list.dart b/extras/drift_devtools_extension/lib/src/list.dart index 789ae6dc..2ae95d81 100644 --- a/extras/drift_devtools_extension/lib/src/list.dart +++ b/extras/drift_devtools_extension/lib/src/list.dart @@ -155,7 +155,9 @@ class _DatabaseEntry extends ConsumerWidget { return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () {}, + onTap: () { + ref.read(selectedDatabase.notifier).state = database; + }, child: Container( color: isSelected ? colorScheme.selectedRowBackgroundColor : null, padding: _DatabaseListState._tilePadding, diff --git a/extras/drift_devtools_extension/lib/src/remote_database.dart b/extras/drift_devtools_extension/lib/src/remote_database.dart index afbd34f8..38cb904d 100644 --- a/extras/drift_devtools_extension/lib/src/remote_database.dart +++ b/extras/drift_devtools_extension/lib/src/remote_database.dart @@ -62,12 +62,13 @@ class RemoteDatabase { Future>> select( String sql, List args) async { - final result = await _driftRequest('execute-query', payload: { - 'query': json.encode(_protocol - .encodePayload(ExecuteQuery(StatementMethod.select, sql, args))) - }); + final result = await _executeQuery( + ExecuteQuery(StatementMethod.select, sql, args)); + return result.rows; + } - return (_protocol.decodePayload(result) as SelectResult).rows; + Future execute(String sql, List args) async { + await _executeQuery(ExecuteQuery(StatementMethod.custom, sql, args)); } Future _newTableSubscription() async { @@ -82,6 +83,14 @@ class RemoteDatabase { _tableNotifications = null; } + Future _executeQuery(ExecuteQuery e) async { + final result = await _driftRequest('execute-query', payload: { + 'query': json.encode(_protocol.encodePayload(e)), + }); + + return _protocol.decodePayload(result) as T; + } + Future _driftRequest(String method, {Map payload = const {}}) async { final response = await serviceManager.callServiceExtensionOnMainIsolate( diff --git a/extras/drift_devtools_extension/pubspec.yaml b/extras/drift_devtools_extension/pubspec.yaml index 2ba89230..07469611 100644 --- a/extras/drift_devtools_extension/pubspec.yaml +++ b/extras/drift_devtools_extension/pubspec.yaml @@ -1,40 +1,16 @@ name: drift_devtools_extension -description: A new Flutter project. -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +description: A Flutter web app contribution drift tools to DevTools +publish_to: 'none' -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. version: 1.0.0+1 environment: sdk: '>=3.1.0 <4.0.0' -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 devtools_extensions: ^0.0.8 devtools_app_shared: ^0.0.5 db_viewer: ^1.0.3 @@ -48,52 +24,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^2.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages