Skip to content

Commit

Permalink
Validate schema in DevTools extension
Browse files Browse the repository at this point in the history
  • Loading branch information
simolus3 committed Nov 11, 2023
1 parent f9012fc commit a9379a8
Show file tree
Hide file tree
Showing 17 changed files with 413 additions and 43 deletions.
12 changes: 12 additions & 0 deletions drift/lib/src/runtime/api/connection_user.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
64 changes: 62 additions & 2 deletions drift/lib/src/runtime/devtools/service_extension.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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':
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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();
}
}
1 change: 1 addition & 0 deletions drift/lib/src/runtime/devtools/shared.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ class EntityDescription {
return EntityDescription(
name: entity.entityName,
type: switch (entity) {
VirtualTableInfo() => 'virtual_table',
TableInfo() => 'table',
ViewInfo() => 'view',
Index() => 'index',
Expand Down
1 change: 1 addition & 0 deletions drift/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions drift/test/integration_tests/devtools/app.dart
Original file line number Diff line number Diff line change
@@ -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);
}
73 changes: 73 additions & 0 deletions drift/test/integration_tests/devtools/devtools_test.dart
Original file line number Diff line number Diff line change
@@ -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));'
]));
});
}
15 changes: 3 additions & 12 deletions drift_dev/lib/api/migrations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) =
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion drift_dev/lib/src/services/schema/sqlite_to_drift.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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';

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.
///
Expand Down
39 changes: 39 additions & 0 deletions drift_dev/lib/src/services/schema/verifier_common.dart
Original file line number Diff line number Diff line change
@@ -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';
}
}
27 changes: 1 addition & 26 deletions drift_dev/lib/src/services/schema/verifier_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions extras/drift_devtools_extension/lib/src/details.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions extras/drift_devtools_extension/lib/src/remote_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading

0 comments on commit a9379a8

Please sign in to comment.