Skip to content

Commit

Permalink
Support Dart views in schema tools
Browse files Browse the repository at this point in the history
Closes #3285
  • Loading branch information
simolus3 committed Nov 2, 2024
1 parent dd1bbb9 commit 3d446ab
Show file tree
Hide file tree
Showing 15 changed files with 404 additions and 130 deletions.
75 changes: 69 additions & 6 deletions drift/lib/internal/export_schema.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:isolate';

import 'package:drift/drift.dart';
Expand All @@ -20,15 +21,36 @@ final class SchemaExporter {
/// all statements that were executed in the process.
Future<List<String>> collectOnCreateStatements(
[SqlDialect dialect = SqlDialect.sqlite]) async {
final collector = CollectCreateStatements(dialect);
final collected = await _collect(dialects: [dialect]);
return collected.collectedStatements.map((e) => e.stmt).toList();
}

Future<_CollectByDialect> _collect({
required Iterable<SqlDialect> dialects,
List<String>? elementNames,
}) async {
final interceptor = _CollectByDialect();
final collector =
CollectCreateStatements(SqlDialect.sqlite).interceptWith(interceptor);
final db = _database(collector);

await db.runConnectionZoned(BeforeOpenRunner(db, collector), () async {
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
final migrator = db.createMigrator();
await migrator.createAll();

for (final entity in db.allSchemaEntities) {
if (elementNames == null || elementNames.contains(entity.entityName)) {
interceptor.currentName = entity.entityName;
for (final dialect in dialects) {
interceptor.currentDialect = dialect;

await migrator.create(entity);
}
}
}
});

return collector.statements;
return interceptor;
}

/// Creates a [SchemaExporter] with the [database], parses the single-argument
Expand All @@ -48,8 +70,49 @@ final class SchemaExporter {
GeneratedDatabase Function(QueryExecutor) database,
) async {
final export = SchemaExporter(database);
final statements = await export
.collectOnCreateStatements(SqlDialect.values.byName(args.single));
port.send(statements);

if (args case ['v2', final options]) {
final parsedOptions = json.decode(options);
final dialects = (parsedOptions['dialects'] as List)
.map((e) => SqlDialect.values.byName(e as String));
final elements = (parsedOptions['elements'] as List).cast<String>();

final result =
await export._collect(dialects: dialects, elementNames: elements);
final serialized = [
for (final row in result.collectedStatements)
[row.element, row.dialect.name, row.stmt]
];

port.send(serialized);
} else {
final statements = await export
.collectOnCreateStatements(SqlDialect.values.byName(args.single));
port.send(statements);
}
}
}

final class _CollectByDialect extends QueryInterceptor {
SqlDialect currentDialect = SqlDialect.sqlite;
String? currentName;

final List<({String element, SqlDialect dialect, String stmt})>
collectedStatements = [];

@override
SqlDialect dialect(QueryExecutor executor) {
return currentDialect;
}

@override
Future<void> runCustom(
QueryExecutor executor, String statement, List<Object?> args) {
if (currentName != null) {
collectedStatements.add(
(element: currentName!, dialect: currentDialect, stmt: statement));
}

return executor.runCustom(statement, args);
}
}
1 change: 1 addition & 0 deletions drift_dev/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## 2.22.0-dev

- CLI options dealing with schemas now support views defined in Dart ([#3285](https://github.com/simolus3/drift/issues/3285)).
- Pass language version to dart formatter when generating code.
- Deprecate `package:drift_dev/api/migrations.dart` in favor of `package:drift_dev/api/migrations_native.dart`.
- Support [runtime schema verification](https://drift.simonbinder.eu/migrations/tests/#verifying-a-database-schema-at-runtime)
Expand Down
3 changes: 2 additions & 1 deletion drift_dev/lib/src/analysis/resolver/file_analysis.dart
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,10 @@ class FileAnalyzer {
typeMapping: typeMapping,
requiredVariables: options.variables);

result.resolvedQueries[element.id] =
final analyzed = result.resolvedQueries[element.id] =
await analyzer.analyze(element, sourceForCustomName: stmt.as)
..declaredInDriftFile = true;
element.resolved = analyzed;

for (final error in analyzer.lints) {
result.analysisErrors.add(DriftAnalysisError.fromSqlError(error));
Expand Down
2 changes: 2 additions & 0 deletions drift_dev/lib/src/analysis/results/query.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ class DefinedSqlQuery extends DriftElement implements DriftQueryDeclaration {
/// `CAST(x AS ENUMNAME(MyDartType))` expression.
final Map<String, DartType> dartTypes;

SqlQuery? resolved;

DefinedSqlQuery(
super.id,
super.declaration, {
Expand Down
2 changes: 1 addition & 1 deletion drift_dev/lib/src/cli/commands/make_migrations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ class _MigrationTestEmitter {

final writer = SchemaWriter(driftElements, options: cli.project.options);
final schemaFile = driftSchemaFile(schemaVersion);
final content = json.encode(writer.createSchemaJson());
final content = json.encode(await writer.createSchemaJson());
if (!schemaFile.existsSync()) {
cli.logger
.info('$dbName: Creating schema file for version $schemaVersion');
Expand Down
7 changes: 7 additions & 0 deletions drift_dev/lib/src/cli/commands/schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ extension ExportSchema on DriftDevCli {
final result = input.fileAnalysis!;
final databaseElement = databases.single;
final db = result.resolvedDatabases[databaseElement.id]!;

final otherSources =
db.availableElements.map((e) => e.id.libraryUri).toSet();
for (final other in otherSources) {
await driver.driver.fullyAnalyze(other);
}

return (
elements: db.availableElements,
schemaVersion: databaseElement.schemaVersion,
Expand Down
2 changes: 1 addition & 1 deletion drift_dev/lib/src/cli/commands/schema/dump.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class DumpSchemaCommand extends Command {
await parent.create(recursive: true);
}

await file.writeAsString(json.encode(writer.createSchemaJson()));
await file.writeAsString(json.encode(await writer.createSchemaJson()));
print('Wrote to $target');
}

Expand Down
113 changes: 10 additions & 103 deletions drift_dev/lib/src/cli/commands/schema/export.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';

import 'package:args/command_runner.dart';
import 'package:drift/drift.dart' show SqlDialect;

import '../../../analysis/options.dart';
import '../../../analysis/results/file_results.dart';
import '../../../analysis/results/results.dart';
import '../../../services/schema/schema_files.dart';
import '../../../writer/database_writer.dart';
import '../../../writer/import_manager.dart';
import '../../../writer/writer.dart';
import '../../../services/schema/schema_isolate.dart';
import '../schema.dart';
import '../../cli.dart';

Expand Down Expand Up @@ -53,100 +46,14 @@ class ExportSchemaCommand extends Command {
var (:elements, schemaVersion: _, db: _) =
await cli.readElementsFromSource(File(rest.single).absolute);

// The roundtrip through the schema writer ensures that irrelevant things
// like type converters that would break imports are removed.
final schemaWriter = SchemaWriter(elements, options: cli.project.options);
final json = schemaWriter.createSchemaJson();
elements = SchemaReader.readJson(json).entities.toList();

// Ok, so the way this works is that we create a Dart script containing the
// relevant definitions and then spawn that as an isolate...

final writer = Writer(
DriftOptions.fromJson({
...cli.project.options.toJson(),
'generate_manager': false,
'sql': {
'dialect': dialect.name,
},
}),
generationOptions: GenerationOptions(
forSchema: 1,
writeCompanions: false,
writeDataClasses: false,
imports: NullImportManager(),
),
);

writer.leaf().writeln('''
import 'dart:isolate';
import 'package:drift/drift.dart';
import 'package:drift/internal/export_schema.dart';
void main(List<String> args, SendPort port) {
SchemaExporter.run(args, port, DatabaseAtV1.new);
}
''');

final database = DriftDatabase(
id: DriftElementId(SchemaReader.elementUri, 'database'),
declaration: DriftDeclaration(SchemaReader.elementUri, 0, 'database'),
declaredIncludes: const [],
declaredQueries: const [],
declaredTables: const [],
declaredViews: const [],
);
final resolved = ResolvedDatabaseAccessor(const {}, const [], elements);
final input = DatabaseGenerationInput(database, resolved, const {}, null);

DatabaseWriter(input, writer.child()).write();

final receive = ReceivePort();
final receiveErrors = ReceivePort();
final isolate = await Isolate.spawnUri(
Uri.dataFromString(writer.writeGenerated()),
[dialect.name],
receive.sendPort,
errorsAreFatal: true,
onError: receiveErrors.sendPort,
);

await Future.any([
receiveErrors.firstOrNever.then((e) {
stderr
..writeln('Could not spawn isolate to print statements: $e')
..flush();
}),
receive.firstOrNever.then((statements) {
for (final statement in (statements as List).cast<String>()) {
if (statement.endsWith(';')) {
print(statement);
} else {
print('$statement;');
}
}
}),
]);

isolate.kill();
receiveErrors.close();
receive.close();
}
}

extension<T> on Stream<T> {
/// Variant of [Stream.first] that, when the stream is closed without emitting
/// an event, simply never completes instead of throwing.
Future<T> get firstOrNever {
final completer = Completer<T>.sync();
late StreamSubscription<T> subscription;
subscription = listen((data) {
subscription.cancel();
completer.complete(data);
}, onError: (Object error, StackTrace trace) {
subscription.cancel();
completer.completeError(error, trace);
});
return completer.future;
final options = (dialect: dialect, elements: elements);
final statements = await SchemaIsolate.collectAllCreateStatements(options);
for (final statement in statements) {
if (statement.endsWith(';')) {
print(statement);
} else {
print('$statement;');
}
}
}
}
61 changes: 52 additions & 9 deletions drift_dev/lib/src/services/schema/schema_files.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import '../../analysis/options.dart';
import '../../analysis/resolver/shared/data_class.dart';
import '../../analysis/results/results.dart';
import '../../writer/utils/column_constraints.dart';
import 'schema_isolate.dart';

class _ExportedSchemaVersion {
static final Version current = _supportDialectSpecificConstraints;
Expand Down Expand Up @@ -35,15 +36,49 @@ class SchemaWriter {
return _entityIds.putIfAbsent(entity, () => _maxId++);
}

Map<String, Object?> createSchemaJson() {
/// Exports analyzed drift elements into a serialized format that can be used
/// to re-construct the current database schema later.
///
/// Some drift elements, in particular Dart-defined views, are partially
/// defined at runtime and require running code. To infer the schema of these
/// elements, this method runs drift's code generator and spawns up a short-
/// lived isolate to collect the actual `CREATE` statements generated at
/// runtime.
Future<Map<String, Object?>> createSchemaJson() async {
final requiresRuntimeInformation = <DriftSchemaElement>[];
for (final element in elements) {
if (element is DriftView) {
if (element.source is! SqlViewSource) {
requiresRuntimeInformation.add(element);
}
}
}

final knownStatements = <String, List<(SqlDialect, String)>>{};
if (requiresRuntimeInformation.isNotEmpty) {
final statements = await SchemaIsolate.collectStatements(
allElements: elements,
elementFilter: requiresRuntimeInformation,
);

for (final statement in statements) {
knownStatements
.putIfAbsent(statement.elementName, () => [])
.add((statement.dialect, statement.createStatement));
}
}

return {
'_meta': {
'description': 'This file contains a serialized version of schema '
'entities for drift.',
'version': _ExportedSchemaVersion.current.toString(),
},
'options': _serializeOptions(),
'entities': elements.map(_entityToJson).whereType<Map>().toList(),
'entities': elements
.map((e) => _entityToJson(e, knownStatements))
.whereType<Map>()
.toList(),
};
}

Expand All @@ -55,7 +90,8 @@ class SchemaWriter {
return asJson;
}

Map<String, Object?>? _entityToJson(DriftElement entity) {
Map<String, Object?>? _entityToJson(DriftElement entity,
Map<String, List<(SqlDialect, String)>> knownStatements) {
String? type;
Map<String, Object?>? data;

Expand Down Expand Up @@ -85,17 +121,24 @@ class SchemaWriter {
],
};
} else if (entity is DriftView) {
final source = entity.source;
if (source is! SqlViewSource) {
throw UnsupportedError(
'Exporting Dart-defined views into a schema is not '
'currently supported');
String? sql;
if (knownStatements[entity.schemaName] case final known?) {
sql = known.firstWhere((e) => e.$1 == SqlDialect.sqlite).$2;
} else {
final source = entity.source;
if (source is! SqlViewSource) {
throw UnsupportedError(
'Exporting Dart-defined views into a schema is not '
'currently supported');
}

sql = source.sqlCreateViewStmt;
}

type = 'view';
data = {
'name': entity.schemaName,
'sql': source.sqlCreateViewStmt,
'sql': sql,
'dart_info_name': entity.entityInfoName,
'columns': [for (final column in entity.columns) _columnData(column)],
};
Expand Down
Loading

0 comments on commit 3d446ab

Please sign in to comment.