Validate schema in DevTools extension

This commit is contained in:
Simon Binder 2023-11-11 14:49:51 +01:00
parent f9012fc05c
commit a9379a85b1
No known key found for this signature in database
GPG Key ID: 7891917E4147B8C0
17 changed files with 413 additions and 43 deletions

View File

@ -606,3 +606,15 @@ extension on TransactionExecutor {
}
}
}
/// Exposes the private `_runConnectionZoned` method for other parts of drift.
///
/// This is only used by the DevTools extension.
@internal
extension RunWithEngine on DatabaseConnectionUser {
/// Call the private [_runConnectionZoned] method.
Future<T> runConnectionZoned<T>(
DatabaseConnectionUser user, Future<T> Function() calculation) {
return _runConnectionZoned(user, calculation);
}
}

View File

@ -2,9 +2,10 @@ import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:drift/drift.dart';
import 'package:drift/src/remote/protocol.dart';
import 'package:drift/src/runtime/executor/transactions.dart';
import '../query_builder/query_builder.dart';
import 'devtools.dart';
/// A service extension making asynchronous requests on drift databases
@ -26,7 +27,7 @@ class DriftServiceExtension {
final stream = tracked.database.tableUpdates();
final id = _subscriptionId++;
stream.listen((event) {
_activeSubscriptions[id] = stream.listen((event) {
postEvent('event', {
'subscription': id,
'payload':
@ -60,6 +61,16 @@ class DriftServiceExtension {
};
return _protocol.encodePayload(result);
case 'collect-expected-schema':
final executor = _CollectCreateStatements();
await tracked.database.runConnectionZoned(
BeforeOpenRunner(tracked.database, executor), () async {
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
final migrator = tracked.database.createMigrator();
await migrator.createAll();
});
return executor.statements;
default:
throw UnsupportedError('Method $action');
}
@ -96,3 +107,52 @@ class DriftServiceExtension {
static const _protocol = DriftProtocol();
}
class _CollectCreateStatements extends QueryExecutor {
final List<String> statements = [];
@override
TransactionExecutor beginTransaction() {
throw UnimplementedError();
}
@override
SqlDialect get dialect => SqlDialect.sqlite;
@override
Future<bool> ensureOpen(QueryExecutorUser user) {
return Future.value(true);
}
@override
Future<void> runBatched(BatchedStatements statements) {
throw UnimplementedError();
}
@override
Future<void> runCustom(String statement, [List<Object?>? args]) {
statements.add(statement);
return Future.value();
}
@override
Future<int> runDelete(String statement, List<Object?> args) {
throw UnimplementedError();
}
@override
Future<int> runInsert(String statement, List<Object?> args) {
throw UnimplementedError();
}
@override
Future<List<Map<String, Object?>>> runSelect(
String statement, List<Object?> args) {
throw UnimplementedError();
}
@override
Future<int> runUpdate(String statement, List<Object?> args) {
throw UnimplementedError();
}
}

View File

@ -93,6 +93,7 @@ class EntityDescription {
return EntityDescription(
name: entity.entityName,
type: switch (entity) {
VirtualTableInfo() => 'virtual_table',
TableInfo() => 'table',
ViewInfo() => 'view',
Index() => 'index',

View File

@ -37,3 +37,4 @@ dev_dependencies:
shelf: ^1.3.0
stack_trace: ^1.10.0
test_descriptor: ^2.0.1
vm_service: ^13.0.0

View File

@ -0,0 +1,11 @@
import 'package:drift/native.dart';
import '../../generated/todos.dart';
void main() {
TodoDb(NativeDatabase.memory());
print('database created');
// Keep the process alive
Stream<void>.periodic(const Duration(seconds: 10)).listen(null);
}

View File

@ -0,0 +1,73 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:test/test.dart';
import 'package:vm_service/vm_service.dart';
import 'package:path/path.dart' as p;
import 'package:vm_service/vm_service_io.dart';
void main() {
late Process child;
late VmService vm;
late String isolateId;
setUpAll(() async {
final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final port = socket.port;
await socket.close();
String sdk = p.dirname(p.dirname(Platform.resolvedExecutable));
child = await Process.start(p.join(sdk, 'bin', 'dart'), [
'run',
'--enable-vm-service=$port',
'--disable-service-auth-codes',
'--enable-asserts',
'test/integration_tests/devtools/app.dart',
]);
final vmServiceListening = Completer<void>();
final databaseOpened = Completer<void>();
child.stdout
.map(utf8.decode)
.transform(const LineSplitter())
.listen((line) {
if (line.startsWith('The Dart VM service is listening')) {
vmServiceListening.complete();
} else if (line == 'database created') {
databaseOpened.complete();
} else if (!line.startsWith('The Dart DevTools')) {
print('[child]: $line');
}
});
await vmServiceListening.future;
vm = await vmServiceConnectUri('ws://localhost:$port/ws');
final state = await vm.getVM();
isolateId = state.isolates!.single.id!;
await databaseOpened.future;
});
tearDownAll(() async {
child.kill();
});
test('can list create statements', () async {
final response = await vm.callServiceExtension(
'ext.drift.database',
args: {'action': 'collect-expected-schema', 'db': '0'},
isolateId: isolateId,
);
expect(
response.json!['r'],
containsAll([
'CREATE TABLE IF NOT EXISTS "categories" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "desc" TEXT NOT NULL UNIQUE, "priority" INTEGER NOT NULL DEFAULT 0, "description_in_upper_case" TEXT NOT NULL GENERATED ALWAYS AS (UPPER("desc")) VIRTUAL);',
'CREATE TABLE IF NOT EXISTS "todos" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "title" TEXT NULL, "content" TEXT NOT NULL, "target_date" INTEGER NULL UNIQUE, "category" INTEGER NULL REFERENCES categories (id), "status" TEXT NULL, UNIQUE ("title", "category"), UNIQUE ("title", "target_date"));',
'CREATE TABLE IF NOT EXISTS "shared_todos" ("todo" INTEGER NOT NULL, "user" INTEGER NOT NULL, PRIMARY KEY ("todo", "user"), FOREIGN KEY (todo) REFERENCES todos(id), FOREIGN KEY (user) REFERENCES users(id));'
]));
});
}

View File

@ -2,10 +2,13 @@ import 'package:drift/drift.dart';
import 'package:drift/internal/migrations.dart';
import 'package:drift/native.dart';
import 'package:drift_dev/src/services/schema/verifier_impl.dart';
import 'package:drift_dev/src/services/schema/verifier_common.dart';
import 'package:meta/meta.dart';
import 'package:sqlite3/sqlite3.dart';
export 'package:drift/internal/migrations.dart';
export 'package:drift_dev/src/services/schema/verifier_common.dart'
show SchemaMismatch;
abstract class SchemaVerifier {
factory SchemaVerifier(SchemaInstantiationHelper helper) =
@ -130,18 +133,6 @@ class _GenerateFromScratch extends GeneratedDatabase {
int get schemaVersion => 1;
}
/// Thrown when the actual schema differs from the expected schema.
class SchemaMismatch implements Exception {
final String explanation;
SchemaMismatch(this.explanation);
@override
String toString() {
return 'Schema does not match\n$explanation';
}
}
/// Contains an initialized schema with all tables, views, triggers and indices.
///
/// You can use the [newConnection] for your database class and the

View File

@ -1,6 +1,5 @@
import 'package:analyzer/dart/element/element.dart';
import 'package:drift_dev/src/analysis/options.dart';
import 'package:drift_dev/src/services/schema/verifier_impl.dart';
import 'package:logging/logging.dart';
import 'package:sqlite3/common.dart';
import 'package:sqlparser/sqlparser.dart';
@ -8,6 +7,7 @@ import 'package:sqlparser/sqlparser.dart';
import '../../analysis/backend.dart';
import '../../analysis/driver/driver.dart';
import '../../analysis/results/results.dart';
import 'verifier_common.dart';
/// Extracts drift elements from the schema of an existing database.
///

View File

@ -0,0 +1,39 @@
import 'find_differences.dart';
/// Attempts to recognize whether [name] is likely the name of an internal
/// sqlite3 table (like `sqlite3_sequence`) that we should not consider when
/// comparing schemas.
bool isInternalElement(String name, List<String> virtualTables) {
// Skip sqlite-internal tables, https://www.sqlite.org/fileformat2.html#intschema
if (name.startsWith('sqlite_')) return true;
if (virtualTables.any((v) => name.startsWith('${v}_'))) return true;
// This file is added on some Android versions when using the native Android
// database APIs, https://github.com/simolus3/drift/discussions/2042
if (name == 'android_metadata') return true;
return false;
}
void verify(List<Input> referenceSchema, List<Input> actualSchema,
bool validateDropped) {
final result =
FindSchemaDifferences(referenceSchema, actualSchema, validateDropped)
.compare();
if (!result.noChanges) {
throw SchemaMismatch(result.describe());
}
}
/// Thrown when the actual schema differs from the expected schema.
class SchemaMismatch implements Exception {
final String explanation;
SchemaMismatch(this.explanation);
@override
String toString() {
return 'Schema does not match\n$explanation';
}
}

View File

@ -6,6 +6,7 @@ import 'package:drift_dev/api/migrations.dart';
import 'package:sqlite3/sqlite3.dart';
import 'find_differences.dart';
import 'verifier_common.dart';
Expando<List<Input>> expectedSchema = Expando();
@ -94,21 +95,6 @@ Input? _parseInputFromSchemaRow(
return Input(name, row['sql'] as String);
}
/// Attempts to recognize whether [name] is likely the name of an internal
/// sqlite3 table (like `sqlite3_sequence`) that we should not consider when
/// comparing schemas.
bool isInternalElement(String name, List<String> virtualTables) {
// Skip sqlite-internal tables, https://www.sqlite.org/fileformat2.html#intschema
if (name.startsWith('sqlite_')) return true;
if (virtualTables.any((v) => name.startsWith('${v}_'))) return true;
// This file is added on some Android versions when using the native Android
// database APIs, https://github.com/simolus3/drift/discussions/2042
if (name == 'android_metadata') return true;
return false;
}
extension CollectSchemaDb on DatabaseConnectionUser {
Future<List<Input>> collectSchemaInput(List<String> virtualTables) async {
final result = await customSelect('SELECT * FROM sqlite_master;').get();
@ -141,17 +127,6 @@ extension CollectSchema on QueryExecutor {
}
}
void verify(List<Input> referenceSchema, List<Input> actualSchema,
bool validateDropped) {
final result =
FindSchemaDifferences(referenceSchema, actualSchema, validateDropped)
.compare();
if (!result.noChanges) {
throw SchemaMismatch(result.describe());
}
}
class _DelegatingUser extends QueryExecutorUser {
@override
final int schemaVersion;

View File

@ -52,7 +52,10 @@ class ViewerDatabase implements DbViewerDatabase {
@override
List<String> get entityNames => [
for (final entity in database.description.entities)
if (entity.type == 'table') entity.name,
if (entity.type == 'table' ||
entity.type == 'virtual_table' ||
entity.type == 'view')
entity.name,
];
@override

View File

@ -1,4 +1,5 @@
import 'package:devtools_app_shared/service.dart';
import 'package:drift_devtools_extension/src/schema_validator.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -55,6 +56,10 @@ class _DatabaseDetailsState extends ConsumerState<DatabaseDetails> {
child: ListView(
controller: controller,
children: [
const Padding(
padding: EdgeInsets.all(8),
child: DatabaseSchemaCheck(),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(

View File

@ -71,6 +71,11 @@ class RemoteDatabase {
await _executeQuery<void>(ExecuteQuery(StatementMethod.custom, sql, args));
}
Future<List<String>> get createStatements async {
final res = await _driftRequest('collect-expected-schema');
return (res as List).cast();
}
Future<int> _newTableSubscription() async {
final result = await _driftRequest('subscribe-to-tables');
return result as int;

View File

@ -0,0 +1,184 @@
import 'package:devtools_app_shared/ui.dart';
// ignore: implementation_imports
import 'package:drift_dev/src/services/schema/find_differences.dart';
// ignore: implementation_imports
import 'package:drift_dev/src/services/schema/verifier_common.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sqlite3/wasm.dart' hide Row;
import 'package:url_launcher/url_launcher.dart';
import 'details.dart';
import 'remote_database.dart';
import 'service.dart';
sealed class SchemaStatus {}
final class DidNotValidateYet implements SchemaStatus {
const DidNotValidateYet();
}
final class SchemaComparisonResult implements SchemaStatus {
final bool schemaValid;
final String message;
SchemaComparisonResult({required this.schemaValid, required this.message});
}
final schemaStateProvider =
AsyncNotifierProvider.autoDispose<SchemaVerifier, SchemaStatus>(
SchemaVerifier._);
class SchemaVerifier extends AutoDisposeAsyncNotifier<SchemaStatus> {
RemoteDatabase? _database;
CommonSqlite3? _sqlite3;
SchemaVerifier._();
@override
Future<SchemaStatus> build() async {
_database = await ref.read(loadedDatabase.future);
_sqlite3 = await ref.read(sqliteProvider.future);
return const DidNotValidateYet();
}
Future<void> validate() async {
state = const AsyncLoading();
state = await AsyncValue.guard<SchemaStatus>(() async {
final database = _database!;
final virtualTables = database.description.entities
.where((e) => e.type == 'virtual_table')
.map((e) => e.name)
.toList();
final expected = await _inputFromNewDatabase(virtualTables);
final actual = <Input>[];
for (final row in await database
.select('SELECT name, sql FROM sqlite_schema;', [])) {
final name = row['name'] as String;
final sql = row['sql'] as String;
if (!isInternalElement(name, virtualTables)) {
actual.add(Input(name, sql));
}
}
try {
verify(expected, actual, true);
return SchemaComparisonResult(
schemaValid: true,
message: 'The schema of the database matches its Dart and .drift '
'definitions, meaning that migrations are likely correct.',
);
} on SchemaMismatch catch (e) {
return SchemaComparisonResult(
schemaValid: false,
message: e.toString(),
);
}
});
}
Future<List<Input>> _inputFromNewDatabase(List<String> virtuals) async {
final expectedStatements = await _database!.createStatements;
final newDatabase = _sqlite3!.openInMemory();
final inputs = <Input>[];
for (var statement in expectedStatements) {
newDatabase.execute(statement);
}
for (final row
in newDatabase.select('SELECT name, sql FROM sqlite_schema;', [])) {
final name = row['name'] as String;
final sql = row['sql'] as String;
if (!isInternalElement(name, virtuals)) {
inputs.add(Input(name, sql));
}
}
newDatabase.dispose();
return inputs;
}
}
class DatabaseSchemaCheck extends ConsumerWidget {
const DatabaseSchemaCheck({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(schemaStateProvider);
final description = switch (state) {
AsyncData(
value: SchemaComparisonResult(schemaValid: true, :var message)
) =>
Text.rich(TextSpan(
children: [
const TextSpan(
text: 'Success! ', style: TextStyle(color: Colors.green)),
TextSpan(text: message),
],
)),
AsyncData(
value: SchemaComparisonResult(schemaValid: false, :var message)
) =>
Text.rich(TextSpan(
children: [
const TextSpan(
text: 'Mismatch detected! ',
style: TextStyle(color: Colors.red)),
TextSpan(text: message),
],
)),
AsyncError(:var error) =>
Text('The schema could not be validated due to an error: $error'),
_ => Text.rich(TextSpan(
text: 'By validating your schema, you can ensure that the current '
'state of the database in your app (after migrations ran) '
'matches the expected state of tables as defined in your sources. ',
children: [
TextSpan(
text: 'Learn more',
style: const TextStyle(
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () async {
await launchUrl(Uri.parse(
'https://drift.simonbinder.eu/docs/migrations/#verifying-a-database-schema-at-runtime'));
},
),
],
)),
};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: description,
),
DevToolsButton(
label: switch (state) {
AsyncError() ||
AsyncData(value: SchemaComparisonResult()) =>
'Validate again',
_ => 'Validate schema',
},
onPressed: () {
if (state is! AsyncLoading) {
ref.read(schemaStateProvider.notifier).validate();
}
},
)
],
);
}
}

View File

@ -5,6 +5,7 @@ import 'package:devtools_extensions/devtools_extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:rxdart/transformers.dart';
import 'package:sqlite3/wasm.dart';
import 'package:vm_service/vm_service.dart';
final _serviceConnection = StreamController<VmService>.broadcast();
@ -48,3 +49,9 @@ final hotRestartEventProvider =
return notifier;
});
final sqliteProvider = FutureProvider((ref) async {
final sqlite = await WasmSqlite3.loadFromUrl(Uri.parse('sqlite3.wasm'));
sqlite.registerVirtualFileSystem(InMemoryFileSystem(), makeDefault: true);
return sqlite;
});

View File

@ -15,12 +15,14 @@ dependencies:
devtools_app_shared: '>=0.0.5 <0.0.6' # 0.0.6 requires unstable Flutter
db_viewer: ^1.0.3
rxdart: ^0.27.7
flutter_riverpod: ^2.4.4
flutter_riverpod: ^3.0.0-dev.0
vm_service: ^11.10.0
path: ^1.8.3
drift: ^2.12.1
logging: ^1.2.0
url_launcher: ^6.1.14
drift_dev: ^2.13.1
sqlite3: ^2.1.0
dev_dependencies:
flutter_test:

View File

@ -0,0 +1 @@
../../assets/sqlite3.wasm