diff --git a/docs/README.md b/docs/README.md index c44fa3ce..38ba660e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,8 @@ dart run build_runner serve web:8080 --live-reload To build the website into a directory `out`, use: ``` -dart run drift_dev schema steps lib/snippets/migrations/exported_eschema/ lib/database/schema_versions.dart +dart run drift_dev schema steps lib/snippets/migrations/exported_eschema/ lib/snippets/migrations/schema_versions.dart +dart run drift_dev schema generate --data-classes --companions lib/snippets/migrations/exported_eschema/ lib/snippets/migrations/tests/generated_migrations/ + dart run build_runner build --release --output web:out ``` diff --git a/docs/build.yaml b/docs/build.yaml index ab3f84e9..cef5ad34 100644 --- a/docs/build.yaml +++ b/docs/build.yaml @@ -122,6 +122,8 @@ targets: environment: "preview" build_web_compilers:entrypoint: generate_for: + include: + - "web/**" exclude: - "web/drift_worker.dart" release_options: diff --git a/docs/lib/snippets/migrations/migrations.dart b/docs/lib/snippets/migrations/migrations.dart index 062e7b74..49b43259 100644 --- a/docs/lib/snippets/migrations/migrations.dart +++ b/docs/lib/snippets/migrations/migrations.dart @@ -1,13 +1,5 @@ -import 'dart:math' as math; - import 'package:drift/drift.dart'; -// #docregion stepbystep -// This file was generated by `drift_dev schema steps drift_schemas lib/database/schema_versions.dart` -import 'schema_versions.dart'; - -// #enddocregion stepbystep - part 'migrations.g.dart'; const kDebugMode = false; @@ -25,8 +17,8 @@ class Todos extends Table { // #enddocregion table @DriftDatabase(tables: [Todos]) -class Example extends _$Example { - Example(QueryExecutor e) : super(e); +class MyDatabase extends _$MyDatabase { + MyDatabase(QueryExecutor e) : super(e); // #docregion start @override @@ -99,121 +91,3 @@ class Example extends _$Example { // #enddocregion change_type } } - -class StepByStep { - // #docregion stepbystep - MigrationStrategy get migration { - return MigrationStrategy( - onCreate: (Migrator m) async { - await m.createAll(); - }, - onUpgrade: stepByStep( - from1To2: (m, schema) async { - // we added the dueDate property in the change from version 1 to - // version 2 - await m.addColumn(schema.todos, schema.todos.dueDate); - }, - from2To3: (m, schema) async { - // we added the priority property in the change from version 1 or 2 - // to version 3 - await m.addColumn(schema.todos, schema.todos.priority); - }, - ), - ); - } - // #enddocregion stepbystep -} - -extension StepByStep2 on GeneratedDatabase { - MigrationStrategy get migration { - return MigrationStrategy( - onCreate: (Migrator m) async { - await m.createAll(); - }, - // #docregion stepbystep2 - onUpgrade: (m, from, to) async { - // Run migration steps without foreign keys and re-enable them later - // (https://drift.simonbinder.eu/docs/advanced-features/migrations/#tips) - await customStatement('PRAGMA foreign_keys = OFF'); - - await m.runMigrationSteps( - from: from, - to: to, - steps: migrationSteps( - from1To2: (m, schema) async { - // we added the dueDate property in the change from version 1 to - // version 2 - await m.addColumn(schema.todos, schema.todos.dueDate); - }, - from2To3: (m, schema) async { - // we added the priority property in the change from version 1 or 2 - // to version 3 - await m.addColumn(schema.todos, schema.todos.priority); - }, - ), - ); - - if (kDebugMode) { - // Fail if the migration broke foreign keys - final wrongForeignKeys = - await customSelect('PRAGMA foreign_key_check').get(); - assert(wrongForeignKeys.isEmpty, - '${wrongForeignKeys.map((e) => e.data)}'); - } - - await customStatement('PRAGMA foreign_keys = ON;'); - }, - // #enddocregion stepbystep2 - ); - } -} - -extension StepByStep3 on Example { - MigrationStrategy get migration { - return MigrationStrategy( - onCreate: (Migrator m) async { - await m.createAll(); - }, - // #docregion stepbystep3 - onUpgrade: (m, from, to) async { - // Run migration steps without foreign keys and re-enable them later - // (https://drift.simonbinder.eu/docs/advanced-features/migrations/#tips) - await customStatement('PRAGMA foreign_keys = OFF'); - - // Manually running migrations up to schema version 2, after which we've - // enabled step-by-step migrations. - if (from < 2) { - // we added the dueDate property in the change from version 1 to - // version 2 - before switching to step-by-step migrations. - await m.addColumn(todos, todos.dueDate); - } - - // At this point, we should be migrated to schema 3. For future schema - // changes, we will "start" at schema 3. - await m.runMigrationSteps( - from: math.max(2, from), - to: to, - // ignore: missing_required_argument - steps: migrationSteps( - from2To3: (m, schema) async { - // we added the priority property in the change from version 1 or - // 2 to version 3 - await m.addColumn(schema.todos, schema.todos.priority); - }, - ), - ); - - if (kDebugMode) { - // Fail if the migration broke foreign keys - final wrongForeignKeys = - await customSelect('PRAGMA foreign_key_check').get(); - assert(wrongForeignKeys.isEmpty, - '${wrongForeignKeys.map((e) => e.data)}'); - } - - await customStatement('PRAGMA foreign_keys = ON;'); - }, - // #enddocregion stepbystep3 - ); - } -} diff --git a/docs/lib/snippets/migrations/step_by_step.dart b/docs/lib/snippets/migrations/step_by_step.dart new file mode 100644 index 00000000..7c569a8c --- /dev/null +++ b/docs/lib/snippets/migrations/step_by_step.dart @@ -0,0 +1,129 @@ +import 'dart:math' as math; + +import 'package:drift/drift.dart'; + +import 'migrations.dart'; + +// #docregion stepbystep +// This file was generated by `drift_dev schema steps drift_schemas/ lib/database/schema_versions.dart` +import 'schema_versions.dart'; + +// #enddocregion stepbystep + +class StepByStep { + // #docregion stepbystep + MigrationStrategy get migration { + return MigrationStrategy( + onCreate: (Migrator m) async { + await m.createAll(); + }, + onUpgrade: stepByStep( + from1To2: (m, schema) async { + // we added the dueDate property in the change from version 1 to + // version 2 + await m.addColumn(schema.todos, schema.todos.dueDate); + }, + from2To3: (m, schema) async { + // we added the priority property in the change from version 1 or 2 + // to version 3 + await m.addColumn(schema.todos, schema.todos.priority); + }, + ), + ); + } + // #enddocregion stepbystep +} + +extension StepByStep2 on GeneratedDatabase { + MigrationStrategy get migration { + return MigrationStrategy( + onCreate: (Migrator m) async { + await m.createAll(); + }, + // #docregion stepbystep2 + onUpgrade: (m, from, to) async { + // Run migration steps without foreign keys and re-enable them later + // (https://drift.simonbinder.eu/docs/advanced-features/migrations/#tips) + await customStatement('PRAGMA foreign_keys = OFF'); + + await m.runMigrationSteps( + from: from, + to: to, + steps: migrationSteps( + from1To2: (m, schema) async { + // we added the dueDate property in the change from version 1 to + // version 2 + await m.addColumn(schema.todos, schema.todos.dueDate); + }, + from2To3: (m, schema) async { + // we added the priority property in the change from version 1 or 2 + // to version 3 + await m.addColumn(schema.todos, schema.todos.priority); + }, + ), + ); + + if (kDebugMode) { + // Fail if the migration broke foreign keys + final wrongForeignKeys = + await customSelect('PRAGMA foreign_key_check').get(); + assert(wrongForeignKeys.isEmpty, + '${wrongForeignKeys.map((e) => e.data)}'); + } + + await customStatement('PRAGMA foreign_keys = ON;'); + }, + // #enddocregion stepbystep2 + ); + } +} + +extension StepByStep3 on MyDatabase { + MigrationStrategy get migration { + return MigrationStrategy( + onCreate: (Migrator m) async { + await m.createAll(); + }, + // #docregion stepbystep3 + onUpgrade: (m, from, to) async { + // Run migration steps without foreign keys and re-enable them later + // (https://drift.simonbinder.eu/docs/advanced-features/migrations/#tips) + await customStatement('PRAGMA foreign_keys = OFF'); + + // Manually running migrations up to schema version 2, after which we've + // enabled step-by-step migrations. + if (from < 2) { + // we added the dueDate property in the change from version 1 to + // version 2 - before switching to step-by-step migrations. + await m.addColumn(todos, todos.dueDate); + } + + // At this point, we should be migrated to schema 3. For future schema + // changes, we will "start" at schema 3. + await m.runMigrationSteps( + from: math.max(2, from), + to: to, + // ignore: missing_required_argument + steps: migrationSteps( + from2To3: (m, schema) async { + // we added the priority property in the change from version 1 or + // 2 to version 3 + await m.addColumn(schema.todos, schema.todos.priority); + }, + ), + ); + + if (kDebugMode) { + // Fail if the migration broke foreign keys + final wrongForeignKeys = + await customSelect('PRAGMA foreign_key_check').get(); + assert(wrongForeignKeys.isEmpty, + '${wrongForeignKeys.map((e) => e.data)}'); + } + + await customStatement('PRAGMA foreign_keys = ON;'); + }, + // #enddocregion stepbystep3 + ); + } +} diff --git a/docs/lib/snippets/migrations/tests/generated_migrations/schema.dart b/docs/lib/snippets/migrations/tests/generated_migrations/schema.dart new file mode 100644 index 00000000..1c9347e9 --- /dev/null +++ b/docs/lib/snippets/migrations/tests/generated_migrations/schema.dart @@ -0,0 +1,24 @@ +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +//@dart=2.12 +import 'package:drift/drift.dart'; +import 'package:drift/internal/migrations.dart'; +import 'schema_v1.dart' as v1; +import 'schema_v2.dart' as v2; +import 'schema_v3.dart' as v3; + +class GeneratedHelper implements SchemaInstantiationHelper { + @override + GeneratedDatabase databaseForVersion(QueryExecutor db, int version) { + switch (version) { + case 1: + return v1.DatabaseAtV1(db); + case 2: + return v2.DatabaseAtV2(db); + case 3: + return v3.DatabaseAtV3(db); + default: + throw MissingSchemaException(version, const {1, 2, 3}); + } + } +} diff --git a/docs/lib/snippets/migrations/tests/generated_migrations/schema_v1.dart b/docs/lib/snippets/migrations/tests/generated_migrations/schema_v1.dart new file mode 100644 index 00000000..9c383740 --- /dev/null +++ b/docs/lib/snippets/migrations/tests/generated_migrations/schema_v1.dart @@ -0,0 +1,232 @@ +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +//@dart=2.12 +import 'package:drift/drift.dart'; + +class Todos extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Todos(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + late final GeneratedColumn title = GeneratedColumn( + 'title', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 6, maxTextLength: 10), + type: DriftSqlType.string, + requiredDuringInsert: true); + late final GeneratedColumn content = GeneratedColumn( + 'body', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn category = GeneratedColumn( + 'category', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + @override + List get $columns => [id, title, content, category]; + @override + String get aliasedName => _alias ?? 'todos'; + @override + String get actualTableName => 'todos'; + @override + Set get $primaryKey => {id}; + @override + TodosData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TodosData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + title: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}title'])!, + content: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}body'])!, + category: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}category']), + ); + } + + @override + Todos createAlias(String alias) { + return Todos(attachedDatabase, alias); + } +} + +class TodosData extends DataClass implements Insertable { + final int id; + final String title; + final String content; + final int? category; + const TodosData( + {required this.id, + required this.title, + required this.content, + this.category}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['body'] = Variable(content); + if (!nullToAbsent || category != null) { + map['category'] = Variable(category); + } + return map; + } + + TodosCompanion toCompanion(bool nullToAbsent) { + return TodosCompanion( + id: Value(id), + title: Value(title), + content: Value(content), + category: category == null && nullToAbsent + ? const Value.absent() + : Value(category), + ); + } + + factory TodosData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TodosData( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + content: serializer.fromJson(json['content']), + category: serializer.fromJson(json['category']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'content': serializer.toJson(content), + 'category': serializer.toJson(category), + }; + } + + TodosData copyWith( + {int? id, + String? title, + String? content, + Value category = const Value.absent()}) => + TodosData( + id: id ?? this.id, + title: title ?? this.title, + content: content ?? this.content, + category: category.present ? category.value : this.category, + ); + @override + String toString() { + return (StringBuffer('TodosData(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('content: $content, ') + ..write('category: $category') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, title, content, category); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TodosData && + other.id == this.id && + other.title == this.title && + other.content == this.content && + other.category == this.category); +} + +class TodosCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value content; + final Value category; + const TodosCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.content = const Value.absent(), + this.category = const Value.absent(), + }); + TodosCompanion.insert({ + this.id = const Value.absent(), + required String title, + required String content, + this.category = const Value.absent(), + }) : title = Value(title), + content = Value(content); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? content, + Expression? category, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (content != null) 'body': content, + if (category != null) 'category': category, + }); + } + + TodosCompanion copyWith( + {Value? id, + Value? title, + Value? content, + Value? category}) { + return TodosCompanion( + id: id ?? this.id, + title: title ?? this.title, + content: content ?? this.content, + category: category ?? this.category, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (content.present) { + map['body'] = Variable(content.value); + } + if (category.present) { + map['category'] = Variable(category.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TodosCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('content: $content, ') + ..write('category: $category') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV1 extends GeneratedDatabase { + DatabaseAtV1(QueryExecutor e) : super(e); + late final Todos todos = Todos(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [todos]; + @override + int get schemaVersion => 1; +} diff --git a/docs/lib/snippets/migrations/tests/generated_migrations/schema_v2.dart b/docs/lib/snippets/migrations/tests/generated_migrations/schema_v2.dart new file mode 100644 index 00000000..b4a27a3d --- /dev/null +++ b/docs/lib/snippets/migrations/tests/generated_migrations/schema_v2.dart @@ -0,0 +1,262 @@ +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +//@dart=2.12 +import 'package:drift/drift.dart'; + +class Todos extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Todos(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + late final GeneratedColumn title = GeneratedColumn( + 'title', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 6, maxTextLength: 10), + type: DriftSqlType.string, + requiredDuringInsert: true); + late final GeneratedColumn content = GeneratedColumn( + 'body', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn category = GeneratedColumn( + 'category', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn dueDate = GeneratedColumn( + 'due_date', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + @override + List get $columns => [id, title, content, category, dueDate]; + @override + String get aliasedName => _alias ?? 'todos'; + @override + String get actualTableName => 'todos'; + @override + Set get $primaryKey => {id}; + @override + TodosData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TodosData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + title: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}title'])!, + content: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}body'])!, + category: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}category']), + dueDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}due_date']), + ); + } + + @override + Todos createAlias(String alias) { + return Todos(attachedDatabase, alias); + } +} + +class TodosData extends DataClass implements Insertable { + final int id; + final String title; + final String content; + final int? category; + final DateTime? dueDate; + const TodosData( + {required this.id, + required this.title, + required this.content, + this.category, + this.dueDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['body'] = Variable(content); + if (!nullToAbsent || category != null) { + map['category'] = Variable(category); + } + if (!nullToAbsent || dueDate != null) { + map['due_date'] = Variable(dueDate); + } + return map; + } + + TodosCompanion toCompanion(bool nullToAbsent) { + return TodosCompanion( + id: Value(id), + title: Value(title), + content: Value(content), + category: category == null && nullToAbsent + ? const Value.absent() + : Value(category), + dueDate: dueDate == null && nullToAbsent + ? const Value.absent() + : Value(dueDate), + ); + } + + factory TodosData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TodosData( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + content: serializer.fromJson(json['content']), + category: serializer.fromJson(json['category']), + dueDate: serializer.fromJson(json['dueDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'content': serializer.toJson(content), + 'category': serializer.toJson(category), + 'dueDate': serializer.toJson(dueDate), + }; + } + + TodosData copyWith( + {int? id, + String? title, + String? content, + Value category = const Value.absent(), + Value dueDate = const Value.absent()}) => + TodosData( + id: id ?? this.id, + title: title ?? this.title, + content: content ?? this.content, + category: category.present ? category.value : this.category, + dueDate: dueDate.present ? dueDate.value : this.dueDate, + ); + @override + String toString() { + return (StringBuffer('TodosData(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('content: $content, ') + ..write('category: $category, ') + ..write('dueDate: $dueDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, title, content, category, dueDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TodosData && + other.id == this.id && + other.title == this.title && + other.content == this.content && + other.category == this.category && + other.dueDate == this.dueDate); +} + +class TodosCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value content; + final Value category; + final Value dueDate; + const TodosCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.content = const Value.absent(), + this.category = const Value.absent(), + this.dueDate = const Value.absent(), + }); + TodosCompanion.insert({ + this.id = const Value.absent(), + required String title, + required String content, + this.category = const Value.absent(), + this.dueDate = const Value.absent(), + }) : title = Value(title), + content = Value(content); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? content, + Expression? category, + Expression? dueDate, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (content != null) 'body': content, + if (category != null) 'category': category, + if (dueDate != null) 'due_date': dueDate, + }); + } + + TodosCompanion copyWith( + {Value? id, + Value? title, + Value? content, + Value? category, + Value? dueDate}) { + return TodosCompanion( + id: id ?? this.id, + title: title ?? this.title, + content: content ?? this.content, + category: category ?? this.category, + dueDate: dueDate ?? this.dueDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (content.present) { + map['body'] = Variable(content.value); + } + if (category.present) { + map['category'] = Variable(category.value); + } + if (dueDate.present) { + map['due_date'] = Variable(dueDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TodosCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('content: $content, ') + ..write('category: $category, ') + ..write('dueDate: $dueDate') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV2 extends GeneratedDatabase { + DatabaseAtV2(QueryExecutor e) : super(e); + late final Todos todos = Todos(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [todos]; + @override + int get schemaVersion => 2; +} diff --git a/docs/lib/snippets/migrations/tests/generated_migrations/schema_v3.dart b/docs/lib/snippets/migrations/tests/generated_migrations/schema_v3.dart new file mode 100644 index 00000000..0aab5157 --- /dev/null +++ b/docs/lib/snippets/migrations/tests/generated_migrations/schema_v3.dart @@ -0,0 +1,294 @@ +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +//@dart=2.12 +import 'package:drift/drift.dart'; + +class Todos extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Todos(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + late final GeneratedColumn title = GeneratedColumn( + 'title', aliasedName, false, + additionalChecks: + GeneratedColumn.checkTextLength(minTextLength: 6, maxTextLength: 10), + type: DriftSqlType.string, + requiredDuringInsert: true); + late final GeneratedColumn content = GeneratedColumn( + 'body', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn category = GeneratedColumn( + 'category', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn dueDate = GeneratedColumn( + 'due_date', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn priority = GeneratedColumn( + 'priority', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + @override + List get $columns => + [id, title, content, category, dueDate, priority]; + @override + String get aliasedName => _alias ?? 'todos'; + @override + String get actualTableName => 'todos'; + @override + Set get $primaryKey => {id}; + @override + TodosData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TodosData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + title: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}title'])!, + content: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}body'])!, + category: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}category']), + dueDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}due_date']), + priority: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}priority']), + ); + } + + @override + Todos createAlias(String alias) { + return Todos(attachedDatabase, alias); + } +} + +class TodosData extends DataClass implements Insertable { + final int id; + final String title; + final String content; + final int? category; + final DateTime? dueDate; + final int? priority; + const TodosData( + {required this.id, + required this.title, + required this.content, + this.category, + this.dueDate, + this.priority}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['body'] = Variable(content); + if (!nullToAbsent || category != null) { + map['category'] = Variable(category); + } + if (!nullToAbsent || dueDate != null) { + map['due_date'] = Variable(dueDate); + } + if (!nullToAbsent || priority != null) { + map['priority'] = Variable(priority); + } + return map; + } + + TodosCompanion toCompanion(bool nullToAbsent) { + return TodosCompanion( + id: Value(id), + title: Value(title), + content: Value(content), + category: category == null && nullToAbsent + ? const Value.absent() + : Value(category), + dueDate: dueDate == null && nullToAbsent + ? const Value.absent() + : Value(dueDate), + priority: priority == null && nullToAbsent + ? const Value.absent() + : Value(priority), + ); + } + + factory TodosData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TodosData( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + content: serializer.fromJson(json['content']), + category: serializer.fromJson(json['category']), + dueDate: serializer.fromJson(json['dueDate']), + priority: serializer.fromJson(json['priority']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'content': serializer.toJson(content), + 'category': serializer.toJson(category), + 'dueDate': serializer.toJson(dueDate), + 'priority': serializer.toJson(priority), + }; + } + + TodosData copyWith( + {int? id, + String? title, + String? content, + Value category = const Value.absent(), + Value dueDate = const Value.absent(), + Value priority = const Value.absent()}) => + TodosData( + id: id ?? this.id, + title: title ?? this.title, + content: content ?? this.content, + category: category.present ? category.value : this.category, + dueDate: dueDate.present ? dueDate.value : this.dueDate, + priority: priority.present ? priority.value : this.priority, + ); + @override + String toString() { + return (StringBuffer('TodosData(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('content: $content, ') + ..write('category: $category, ') + ..write('dueDate: $dueDate, ') + ..write('priority: $priority') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, title, content, category, dueDate, priority); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TodosData && + other.id == this.id && + other.title == this.title && + other.content == this.content && + other.category == this.category && + other.dueDate == this.dueDate && + other.priority == this.priority); +} + +class TodosCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value content; + final Value category; + final Value dueDate; + final Value priority; + const TodosCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.content = const Value.absent(), + this.category = const Value.absent(), + this.dueDate = const Value.absent(), + this.priority = const Value.absent(), + }); + TodosCompanion.insert({ + this.id = const Value.absent(), + required String title, + required String content, + this.category = const Value.absent(), + this.dueDate = const Value.absent(), + this.priority = const Value.absent(), + }) : title = Value(title), + content = Value(content); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? content, + Expression? category, + Expression? dueDate, + Expression? priority, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (content != null) 'body': content, + if (category != null) 'category': category, + if (dueDate != null) 'due_date': dueDate, + if (priority != null) 'priority': priority, + }); + } + + TodosCompanion copyWith( + {Value? id, + Value? title, + Value? content, + Value? category, + Value? dueDate, + Value? priority}) { + return TodosCompanion( + id: id ?? this.id, + title: title ?? this.title, + content: content ?? this.content, + category: category ?? this.category, + dueDate: dueDate ?? this.dueDate, + priority: priority ?? this.priority, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (content.present) { + map['body'] = Variable(content.value); + } + if (category.present) { + map['category'] = Variable(category.value); + } + if (dueDate.present) { + map['due_date'] = Variable(dueDate.value); + } + if (priority.present) { + map['priority'] = Variable(priority.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TodosCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('content: $content, ') + ..write('category: $category, ') + ..write('dueDate: $dueDate, ') + ..write('priority: $priority') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV3 extends GeneratedDatabase { + DatabaseAtV3(QueryExecutor e) : super(e); + late final Todos todos = Todos(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [todos]; + @override + int get schemaVersion => 3; +} diff --git a/docs/lib/snippets/migrations/tests/schema_test.dart b/docs/lib/snippets/migrations/tests/schema_test.dart new file mode 100644 index 00000000..81fb9bbd --- /dev/null +++ b/docs/lib/snippets/migrations/tests/schema_test.dart @@ -0,0 +1,32 @@ +// #docregion setup +import 'package:test/test.dart'; +import 'package:drift_dev/api/migrations.dart'; + +// The generated directory from before. +import 'generated_migrations/schema.dart'; + +// #enddocregion setup +import '../migrations.dart'; +// #docregion setup + +void main() { + late SchemaVerifier verifier; + + setUpAll(() { + // GeneratedHelper() was generated by drift, the verifier is an api + // provided by drift_dev. + verifier = SchemaVerifier(GeneratedHelper()); + }); + + test('upgrade from v1 to v2', () async { + // Use startAt(1) to obtain a database connection with all tables + // from the v1 schema. + final connection = await verifier.startAt(1); + final db = MyDatabase(connection); + + // Use this to run a migration to v2 and then validate that the + // database has the expected schema. + await verifier.migrateAndValidate(db, 2); + }); +} +// #enddocregion setup diff --git a/docs/lib/snippets/migrations/tests/verify_data_integrity_test.dart b/docs/lib/snippets/migrations/tests/verify_data_integrity_test.dart new file mode 100644 index 00000000..fa63835c --- /dev/null +++ b/docs/lib/snippets/migrations/tests/verify_data_integrity_test.dart @@ -0,0 +1,49 @@ +import 'package:test/test.dart'; +import 'package:drift_dev/api/migrations.dart'; + +import '../migrations.dart'; +import 'generated_migrations/schema.dart'; + +// #docregion imports +import 'generated_migrations/schema_v1.dart' as v1; +import 'generated_migrations/schema_v2.dart' as v2; +// #enddocregion imports + +// #docregion main +void main() { +// #enddocregion main + late SchemaVerifier verifier; + + setUpAll(() { + // GeneratedHelper() was generated by drift, the verifier is an api + // provided by drift_dev. + verifier = SchemaVerifier(GeneratedHelper()); + }); + +// #docregion main + // ... + test('upgrade from v1 to v2', () async { + final schema = await verifier.schemaAt(1); + + // Add some data to the table being migrated + final oldDb = v1.DatabaseAtV1(schema.newConnection()); + await oldDb.into(oldDb.todos).insert(v1.TodosCompanion.insert( + title: 'my first todo entry', + content: 'should still be there after the migration', + )); + await oldDb.close(); + + // Run the migration and verify that it adds the name column. + final db = MyDatabase(schema.newConnection()); + await verifier.migrateAndValidate(db, 2); + await db.close(); + + // Make sure the entry is still here + final migratedDb = v2.DatabaseAtV2(schema.newConnection()); + final entry = await migratedDb.select(migratedDb.todos).getSingle(); + expect(entry.id, 1); + expect(entry.dueDate, isNull); // default from the migration + await migratedDb.close(); + }); +} +// #enddocregion main \ No newline at end of file diff --git a/docs/pages/docs/Advanced Features/migrations.md b/docs/pages/docs/Advanced Features/migrations.md deleted file mode 100644 index 07bc0767..00000000 --- a/docs/pages/docs/Advanced Features/migrations.md +++ /dev/null @@ -1,528 +0,0 @@ ---- -data: - title: "Migrations" - weight: 10 - description: Define what happens when your database gets created or updated -aliases: - - /migrations -template: layouts/docs/single ---- - -As your app grows, you may want to change the table structure for your drift database: -New features need new columns or tables, and outdated columns may have to be altered or -removed altogether. -When making changes to your database schema, you need to write migrations enabling users with -an old version of your app to convert to the database expected by the latest version. -With incorrect migrations, your database ends up in an inconsistent state which can cause crashes -or data loss. This is why drift provides dedicated test tools and APIs to make writing migrations -easy and safe. - -{% assign snippets = 'package:drift_docs/snippets/migrations/migrations.dart.excerpt.json' | readString | json_decode %} - -## Manual setup {#basics} - -Drift provides a migration API that can be used to gradually apply schema changes after bumping -the `schemaVersion` getter inside the `Database` class. To use it, override the `migration` -getter. - -Here's an example: Let's say you wanted to add a due date to your todo entries (`v2` of the schema). -Later, you decide to also add a priority column (`v3` of the schema). - -{% include "blocks/snippet" snippets = snippets name = 'table' %} - -We can now change the `database` class like this: - -{% include "blocks/snippet" snippets = snippets name = 'start' %} - -You can also add individual tables or drop them - see the reference of [Migrator](https://pub.dev/documentation/drift/latest/drift/Migrator-class.html) -for all the available options. - -You can also use higher-level query APIs like `select`, `update` or `delete` inside a migration callback. -However, be aware that drift expects the latest schema when creating SQL statements or mapping results. -For instance, when adding a new column to your database, you shouldn't run a `select` on that table before -you've actually added the column. In general, try to avoid running queries in migration callbacks if possible. - -`sqlite` can feel a bit limiting when it comes to migrations - there only are methods to create tables and columns. -Existing columns can't be altered or removed. A workaround is described [here](https://stackoverflow.com/a/805508), it -can be used together with `customStatement` to run the statements. -Alternatively, [complex migrations](#complex-migrations) described on this page help automating this. - -### Tips - -To ensure your schema stays consistent during a migration, you can wrap it in a `transaction` block. -However, be aware that some pragmas (including `foreign_keys`) can't be changed inside transactions. -Still, it can be useful to: - -- always re-enable foreign keys before using the database, by enabling them in [`beforeOpen`](#post-migration-callbacks). -- disable foreign-keys before migrations -- run migrations inside a transaction -- make sure your migrations didn't introduce any inconsistencies with `PRAGMA foreign_key_check`. - -With all of this combined, a migration callback can look like this: - -{% include "blocks/snippet" snippets = snippets name = 'structured' %} - -## Migration workflow - -While migrations can be written manually without additional help from drift, dedicated tools testing your migrations help -to ensure that they are correct and aren't loosing any data. - -Drift's migration tooling consists of the following steps: - -1. After each change to your schema, use a tool to export the current schema into a separate file. -2. Use a drift tool to generate test code able to verify that your migrations are bringing the database - into the expected schema. -3. Use generated code to make writing schema migrations easier. - -### Setup - -As described by the first step, you can export the schema of your database into a JSON file. -It is recommended to do this once intially, and then again each time you change your schema -and increase the `schemaVersion` getter in the database. - -You should store these exported files in your repository and include them in source control. -This guide assumes a top-level `drift_schemas/` folder in your project, like this: - -``` -my_app - .../ - lib/ - database/ - database.dart - database.g.dart - test/ - generated_migrations/ - schema.dart - schema_v1.dart - schema_v2.dart - drift_schemas/ - drift_schema_v1.json - drift_schema_v2.json - pubspec.yaml -``` - -Of course, you can also use another folder or a subfolder somewhere if that suits your workflow -better. - -{% block "blocks/alert" title="Examples available" %} -Exporting schemas and generating code for them can't be done with `build_runner` alone, which is -why this setup described here is necessary. - -We hope it's worth it though! Verifying migrations can give you confidence that you won't run -into issues after changing your database. -If you get stuck along the way, don't hesitate to [open a discussion about it](https://github.com/simolus3/drift/discussions). - -Also there are two examples in the drift repository which may be useful as a reference: - -- A [Flutter app](https://github.com/simolus3/drift/tree/latest-release/examples/app) -- An [example specific to migrations](https://github.com/simolus3/drift/tree/latest-release/examples/migrations_example). -{% endblock %} - -#### Exporting the schema - -To begin, lets create the first schema representation: - -``` -$ mkdir drift_schemas -$ dart run drift_dev schema dump lib/database/database.dart drift_schemas/ -``` - -This instructs the generator to look at the database defined in `lib/database/database.dart` and extract -its schema into the new folder. - -After making a change to your database schema, you can run the command again. For instance, let's say we -made a change to our tables and increased the `schemaVersion` to `2`. To dump the new schema, just run the -command again: - -``` -$ dart run drift_dev schema dump lib/database/database.dart drift_schemas/ -``` - -You'll need to run this command every time you change the schema of your database and increment the `schemaVersion`. - -Drift will name the files in the folder `drift_schema_vX.json`, where `X` is the current `schemaVersion` of your -database. -If drift is unable to extract the version from your `schemaVersion` getter, provide the full path explicitly: - -``` -$ dart run drift_dev schema dump lib/database/database.dart drift_schemas/drift_schema_v3.json -``` - -{% block "blocks/alert" title=' Dumping a database' color="success" %} -If, instead of exporting the schema of a database class, you want to export the schema of an existing sqlite3 -database file, you can do that as well! `drift_dev schema dump` recognizes a sqlite3 database file as its first -argument and can extract the relevant schema from there. -{% endblock %} - -### Generating step-by-step migrations {#step-by-step} - -With all your database schemas exported into a folder, drift can generate code that makes it much -easier to write schema migrations "step-by-step" (incrementally from each version to the next one). - -This code is stored in a single-file, which you can generate like this: - -``` -$ dart run drift_dev schema steps drift_schemas/ lib/database/schema_versions.dart -``` - -The generated code contains a `stepByStep` method which you can use as a callback to the `onUpgrade` -parameter of your `MigrationStrategy`. -As an example, here is the [initial](#basics) migration shown at the top of this page, but rewritten using -the generated `stepByStep` function: - -{% include "blocks/snippet" snippets = snippets name = 'stepbystep' %} - -`stepByStep` expects a callback for each schema upgrade responsible for running the partial migration. -That callback receives two parameters: A migrator `m` (similar to the regular migrator you'd get for -`onUpgrade` callbacks) and a `schema` parameter that gives you access to the schema at the version you're -migrating to. -For instance, in the `from1To2` function, `schema` provides getters for the database schema at version 2. -The migrator passed to the function is also set up to consider that specific version by default. -A call to `m.recreateAllViews()` would re-create views at the expected state of schema version 2, for instance. - -#### Customizing step-by-step migrations - -The `stepByStep` function generated by the `drift_dev schema steps` command gives you an -`OnUpgrade` callback. -But you might want to customize the upgrade behavior, for instance by adding foreign key -checks afterwards (as described in [tips](#tips)). - -The `Migrator.runMigrationSteps` helper method can be used for that, as this example -shows: - -{% include "blocks/snippet" snippets = snippets name = 'stepbystep2' %} - -Here, foreign keys are disabled before runnign the migration and re-enabled afterwards. -A check ensuring no inconsistencies occurred helps catching issues with the migration -in debug modes. - -#### Moving to step-by-step migrations - -If you've been using drift before `stepByStep` was added to the library, or if you've never exported a schema, -you can move to step-by-step migrations by pinning the `from` value in `Migrator.runMigrationSteps` to a known -starting point. - -This allows you to perform all prior migration work to get the database to the "starting" point for -`stepByStep` migrations, and then use `stepByStep` migrations beyond that schema version. - -{% include "blocks/snippet" snippets = snippets name = 'stepbystep3' %} - -Here, we give a "floor" to the `from` value of `2`, since we've performed all other migration work to get to -this point. From now on, you can generate step-by-step migrations for each schema change. - -If you did not do this, a user migrating from schema 1 directly to schema 3 would not properly walk migrations -and apply all migration changes required. - -### Writing tests - -After you've exported the database schemas into a folder, you can generate old versions of your database class -based on those schema files. -For verifications, drift will generate a much smaller database implementation that can only be used to -test migrations. - -You can put this test code whereever you want, but it makes sense to put it in a subfolder of `test/`. -If we wanted to write them to `test/generated_migrations/`, we could use - -``` -$ dart run drift_dev schema generate drift_schemas/ test/generated_migrations/ -``` - -After that setup, it's finally time to write some tests! For instance, a test could look like this: - -```dart -import 'package:my_app/database/database.dart'; - -import 'package:test/test.dart'; -import 'package:drift_dev/api/migrations.dart'; - -// The generated directory from before. -import 'generated_migrations/schema.dart'; - -void main() { - late SchemaVerifier verifier; - - setUpAll(() { - // GeneratedHelper() was generated by drift, the verifier is an api - // provided by drift_dev. - verifier = SchemaVerifier(GeneratedHelper()); - }); - - test('upgrade from v1 to v2', () async { - // Use startAt(1) to obtain a database connection with all tables - // from the v1 schema. - final connection = await verifier.startAt(1); - final db = MyDatabase(connection); - - // Use this to run a migration to v2 and then validate that the - // database has the expected schema. - await verifier.migrateAndValidate(db, 2); - }); -} -``` - -In general, a test looks like this: - -1. Use `verifier.startAt()` to obtain a [connection](https://drift.simonbinder.eu/api/drift/databaseconnection-class) - to a database with an initial schema. - This database contains all your tables, indices and triggers from that version, created by using `Migrator.createAll`. -2. Create your application database with that connection. For this, create a constructor in your database class that - accepts a `QueryExecutor` and forwards it to the super constructor in `GeneratedDatabase`. - Then, you can pass the result of calling `newConnection()` to that constructor to create a test instance of your - datbaase. -3. Call `verifier.migrateAndValidate(db, version)`. This will initiate a migration towards the target version (here, `2`). - Unlike the database created by `startAt`, this uses the migration logic you wrote for your database. - -`migrateAndValidate` will extract all `CREATE` statement from the `sqlite_schema` table and semantically compare them. -If it sees anything unexpected, it will throw a `SchemaMismatch` exception to fail your test. - -{% block "blocks/alert" title="Writing testable migrations" %} -To test migrations _towards_ an old schema version (e.g. from `v1` to `v2` if your current version is `v3`), -you're `onUpgrade` handler must be capable of upgrading to a version older than the current `schemaVersion`. -For this, check the `to` parameter of the `onUpgrade` callback to run a different migration if necessary. -{% endblock %} - -#### Verifying data integrity - -In addition to the changes made in your table structure, its useful to ensure that data that was present before a migration -is still there after it ran. -You can use `schemaAt` to obtain a raw `Database` from the `sqlite3` package in addition to a connection. -This can be used to insert data before a migration. After the migration ran, you can then check that the data is still there. - -Note that you can't use the regular database class from you app for this, since its data classes always expect the latest -schema. However, you can instruct drift to generate older snapshots of your data classes and companions for this purpose. -To enable this feature, pass the `--data-classes` and `--companions` command-line arguments to the `drift_dev schema generate` -command: - -``` -$ dart run drift_dev schema generate --data-classes --companions drift_schemas/ test/generated_migrations/ -``` - -Then, you can import the generated classes with an alias: - -```dart -import 'generated_migrations/schema_v1.dart' as v1; -import 'generated_migrations/schema_v2.dart' as v2; -``` - -This can then be used to manually create and verify data at a specific version: - -```dart -void main() { - // ... - test('upgrade from v1 to v2', () async { - final schema = await verifier.schemaAt(1); - - // Add some data to the users table, which only has an id column at v1 - final oldDb = v1.DatabaseAtV1(schema.newConnection()); - await oldDb.into(oldDb.users).insert(const v1.UsersCompanion(id: Value(1))); - await oldDb.close(); - - // Run the migration and verify that it adds the name column. - final db = Database(schema.newConnection()); - await verifier.migrateAndValidate(db, 2); - await db.close(); - - // Make sure the user is still here - final migratedDb = v2.DatabaseAtV2(schema.newConnection()); - final user = await migratedDb.select(migratedDb.users).getSingle(); - expect(user.id, 1); - expect(user.name, 'no name'); // default from the migration - await migratedDb.close(); - }); -} -``` - -## Complex migrations - -Sqlite has builtin statements for simple changes, like adding columns or dropping entire tables. -More complex migrations require a [12-step procedure](https://www.sqlite.org/lang_altertable.html#otheralter) that -involves creating a copy of the table and copying over data from the old table. -Drift 3.4 introduced the `TableMigration` api to automate most of this procedure, making it easier and safer to use. - -To start the migration, drift will create a new instance of the table with the current schema. Next, it will copy over -rows from the old table. -In most cases, for instance when changing column types, we can't just copy over each row without changing its content. -Here, you can use a `columnTransformer` to apply a per-row transformation. -The `columnTransformer` is a map from columns to the sql expression that will be used to copy the column from the -old table. -For instance, if we wanted to cast a column before copying it, we could use: - -```dart -columnTransformer: { - todos.category: todos.category.cast(), -} -``` - -Internally, drift will use a `INSERT INTO SELECT` statement to copy old data. In this case, it would look like -`INSERT INTO temporary_todos_copy SELECT id, title, content, CAST(category AS INT) FROM todos`. -As you can see, drift will use the expression from the `columnTransformer` map and fall back to just copying the column -otherwise. -If you're introducing new columns in a table migration, be sure to include them in the `newColumns` parameter of -`TableMigration`. Drift will ensure that those columns have a default value or a transformation in `columnTransformer`. -Of course, drift won't attempt to copy `newColumns` from the old table either. - -Regardless of whether you're implementing complex migrations with `TableMigration` or by running a custom sequence -of statements, we strongly recommend to write integration tests covering your migrations. This helps to avoid data -loss caused by errors in a migration. - -Here are some examples demonstrating common usages of the table migration api: - -### Changing the type of a column - -Let's say the `category` column in `Todos` used to be a non-nullable `text()` column that we're now changing to a -nullable int. For simplicity, we assume that `category` always contained integers, they were just stored in a text -column that we now want to adapt. - -```patch -class Todos extends Table { - IntColumn get id => integer().autoIncrement()(); - TextColumn get title => text().withLength(min: 6, max: 10)(); - TextColumn get content => text().named('body')(); -- IntColumn get category => text()(); -+ IntColumn get category => integer().nullable()(); -} -``` - -After re-running your build and incrementing the schema version, you can write a migration: - -{% include "blocks/snippet" snippets = snippets name = 'change_type' %} - -The important part here is the `columnTransformer` - a map from columns to expressions that will -be used to copy the old data. The values in that map refer to the old table, so we can use -`todos.category.cast()` to copy old rows and transform their `category`. -All columns that aren't present in `columnTransformer` will be copied from the old table without -any transformation. - -### Changing column constraints - -When you're changing columns constraints in a way that's compatible to existing data (e.g. changing -non-nullable columns to nullable columns), you can just copy over data without applying any -transformation: - -```dart -await m.alterTable(TableMigration(todos)); -``` - -### Deleting columns - -Deleting a column that's not referenced by a foreign key constraint is easy too: - -```dart -await m.alterTable(TableMigration(yourTable)); -``` - -To delete a column referenced by a foreign key, you'd have to migrate the referencing -tables first. - -### Renaming columns - -If you're renaming a column in Dart, note that the easiest way is to just rename the getter and use -`named`: `TextColumn newName => text().named('old_name')()`. That is fully backwards compatible and -doesn't require a migration. - -If you know your app runs on sqlite 3.25.0 or later (it does if you're using `sqlite3_flutter_libs`), -you can also use the `renameColumn` api in `Migrator`: - -```dart -m.renameColumn(yourTable, 'old_column_name', yourTable.newColumn); -``` - -If you do want to change the actual column name in a table, you can write a `columnTransformer` to -use an old column with a different name: - -```dart -await m.alterTable( - TableMigration( - yourTable, - columnTransformer: { - yourTable.newColumn: const CustomExpression('old_column_name') - }, - ) -) -``` - -## Migrating views, triggers and indices - -When changing the definition of a view, a trigger or an index, the easiest way -to update the database schema is to drop and re-create the element. -With the `Migrator` API, this is just a matter of calling `await drop(element)` -followed by `await create(element)`, where `element` is the trigger, view or index -to update. - -Note that the definition of a Dart-defined view might change without modifications -to the view class itself. This is because columns from a table are referenced with -a getter. When renaming a column through `.named('name')` in a table definition -without renaming the getter, the view definition in Dart stays the same but the -`CREATE VIEW` statement changes. - -A headache-free solution to this problem is to just re-create all views in a -migration, for which the `Migrator` provides the `recreateAllViews` method. - -## Post-migration callbacks - -The `beforeOpen` parameter in `MigrationStrategy` can be used to populate data after the database has been created. -It runs after migrations, but before any other query. Note that it will be called whenever the database is opened, -regardless of whether a migration actually ran or not. You can use `details.hadUpgrade` or `details.wasCreated` to -check whether migrations were necessary: - -```dart -beforeOpen: (details) async { - if (details.wasCreated) { - final workId = await into(categories).insert(Category(description: 'Work')); - - await into(todos).insert(TodoEntry( - content: 'A first todo entry', - category: null, - targetDate: DateTime.now(), - )); - - await into(todos).insert( - TodoEntry( - content: 'Rework persistence code', - category: workId, - targetDate: DateTime.now().add(const Duration(days: 4)), - )); - } -}, -``` - -You could also activate pragma statements that you need: - -```dart -beforeOpen: (details) async { - if (details.wasCreated) { - // ... - } - await customStatement('PRAGMA foreign_keys = ON'); -} -``` - -## During development - -During development, you might be changing your schema very often and don't want to write migrations for that -yet. You can just delete your apps' data and reinstall the app - the database will be deleted and all tables -will be created again. Please note that uninstalling is not enough sometimes - Android might have backed up -the database file and will re-create it when installing the app again. - -You can also delete and re-create all tables every time your app is opened, see [this comment](https://github.com/simolus3/drift/issues/188#issuecomment-542682912) -on how that can be achieved. - -## Verifying a database schema at runtime - -Instead (or in addition to) [writing tests](#verifying-migrations) to ensure your migrations work as they should, -you can use a new API from `drift_dev` 1.5.0 to verify the current schema without any additional setup. - -{% assign runtime_snippet = 'package:drift_docs/snippets/migrations/runtime_verification.dart.excerpt.json' | readString | json_decode %} - -{% include "blocks/snippet" snippets = runtime_snippet name = '' %} - -When you use `validateDatabaseSchema`, drift will transparently: - -- collect information about your database by reading from `sqlite3_schema`. -- create a fresh in-memory instance of your database and create a reference schema with `Migrator.createAll()`. -- compare the two. Ideally, your actual schema at runtime should be identical to the fresh one even though it - grew through different versions of your app. - -When a mismatch is found, an exception with a message explaining exactly where another value was expected will -be thrown. -This allows you to find issues with your schema migrations quickly. diff --git a/docs/pages/docs/CLI.md b/docs/pages/docs/CLI.md index 2d571703..cf2f2ff3 100644 --- a/docs/pages/docs/CLI.md +++ b/docs/pages/docs/CLI.md @@ -69,4 +69,4 @@ The generated file (`schema.json` in this case) contains information about all - dependencies thereof Exporting a schema can be used to generate test code for your schema migrations. For details, -see [the guide]({{ "Advanced Features/migrations.md#verifying-migrations" | pageUrl }}). \ No newline at end of file +see [the guide]({{ "Migrations/tests.md" | pageUrl }}). \ No newline at end of file diff --git a/docs/pages/docs/Dart API/tables.md b/docs/pages/docs/Dart API/tables.md index f8820aa6..291cddc4 100644 --- a/docs/pages/docs/Dart API/tables.md +++ b/docs/pages/docs/Dart API/tables.md @@ -232,7 +232,7 @@ should happen when the target row gets updated or deleted. Be aware that, in sqlite3, foreign key references aren't enabled by default. They need to be enabled with `PRAGMA foreign_keys = ON`. -A suitable place to issue that pragma with drift is in a [post-migration callback]({{ '../Advanced Features/migrations.md#post-migration-callbacks' | pageUrl }}). +A suitable place to issue that pragma with drift is in a [post-migration callback]({{ '../Migrations/index.md#post-migration-callbacks' | pageUrl }}). ## Default values @@ -286,7 +286,7 @@ In Dart, the `check` method on the column builder adds a check constraint to the ``` Note that these `CHECK` constraints are part of the `CREATE TABLE` statement. -If you want to change or remove a `check` constraint, write a [schema migration]({{ '../Advanced Features/migrations.md#changing-column-constraints' | pageUrl }}) to re-create the table without the constraint. +If you want to change or remove a `check` constraint, write a [schema migration]({{ '../Migrations/api.md#changing-column-constraints' | pageUrl }}) to re-create the table without the constraint. ### Unique column diff --git a/docs/pages/docs/Examples/index.md b/docs/pages/docs/Examples/index.md index a0600258..7e64e4a9 100644 --- a/docs/pages/docs/Examples/index.md +++ b/docs/pages/docs/Examples/index.md @@ -51,5 +51,5 @@ Additional patterns are also shown and explained on this website: [web_worker]: https://github.com/simolus3/drift/tree/develop/examples/web_worker_example [flutter_web_worker]: https://github.com/simolus3/drift/tree/develop/examples/flutter_web_worker_example [migration]: https://github.com/simolus3/drift/tree/develop/examples/migrations_example -[migration tooling]: {{ '../Advanced Features/migrations.md#verifying-migrations' | pageUrl }} +[migration tooling]: {{ '../Migrations/tests.md#verifying-migrations' | pageUrl }} [with_built_value]: https://github.com/simolus3/drift/tree/develop/examples/with_built_value diff --git a/docs/pages/docs/Getting started/starting_with_sql.md b/docs/pages/docs/Getting started/starting_with_sql.md index 9256184b..05b4eb3a 100644 --- a/docs/pages/docs/Getting started/starting_with_sql.md +++ b/docs/pages/docs/Getting started/starting_with_sql.md @@ -87,7 +87,7 @@ further guides to help you learn more: - The [SQL IDE]({{ "../Using SQL/sql_ide.md" | pageUrl }}) that provides feedback on sql queries right in your editor. - [Transactions]({{ "../Dart API/transactions.md" | pageUrl }}) -- [Schema migrations]({{ "../Advanced Features/migrations.md" | pageUrl }}) +- [Schema migrations]({{ "../Migrations/index.md" | pageUrl }}) - Writing [queries]({{ "../Dart API/select.md" | pageUrl }}) and [expressions]({{ "../Dart API/expressions.md" | pageUrl }}) in Dart - A more [in-depth guide]({{ "../Using SQL/drift_files.md" | pageUrl }}) diff --git a/docs/pages/docs/Migrations/api.md b/docs/pages/docs/Migrations/api.md new file mode 100644 index 00000000..a9e78898 --- /dev/null +++ b/docs/pages/docs/Migrations/api.md @@ -0,0 +1,140 @@ +--- +data: + title: "The migrator API" + weight: 50 + description: How to run `ALTER` statements and complex table migrations. +template: layouts/docs/single +--- + +{% assign snippets = 'package:drift_docs/snippets/migrations/migrations.dart.excerpt.json' | readString | json_decode %} + +You can write migrations manually by using `customStatement()` in a migration +callback. However, the callbacks also give you an instance of `Migrator` as a +parameter. This class knows about the target schema of the database and can be +used to create, drop and alter most elements in your schema. + +## Migrating views, triggers and indices + +When changing the definition of a view, a trigger or an index, the easiest way +to update the database schema is to drop and re-create the element. +With the `Migrator` API, this is just a matter of calling `await drop(element)` +followed by `await create(element)`, where `element` is the trigger, view or index +to update. + +Note that the definition of a Dart-defined view might change without modifications +to the view class itself. This is because columns from a table are referenced with +a getter. When renaming a column through `.named('name')` in a table definition +without renaming the getter, the view definition in Dart stays the same but the +`CREATE VIEW` statement changes. + +A headache-free solution to this problem is to just re-create all views in a +migration, for which the `Migrator` provides the `recreateAllViews` method. + +## Complex migrations + +Sqlite has builtin statements for simple changes, like adding columns or dropping entire tables. +More complex migrations require a [12-step procedure](https://www.sqlite.org/lang_altertable.html#otheralter) that +involves creating a copy of the table and copying over data from the old table. +Drift 2.4 introduced the `TableMigration` API to automate most of this procedure, making it easier and safer to use. + +To start the migration, drift will create a new instance of the table with the current schema. Next, it will copy over +rows from the old table. +In most cases, for instance when changing column types, we can't just copy over each row without changing its content. +Here, you can use a `columnTransformer` to apply a per-row transformation. +The `columnTransformer` is a map from columns to the sql expression that will be used to copy the column from the +old table. +For instance, if we wanted to cast a column before copying it, we could use: + +```dart +columnTransformer: { + todos.category: todos.category.cast(), +} +``` + +Internally, drift will use a `INSERT INTO SELECT` statement to copy old data. In this case, it would look like +`INSERT INTO temporary_todos_copy SELECT id, title, content, CAST(category AS INT) FROM todos`. +As you can see, drift will use the expression from the `columnTransformer` map and fall back to just copying the column +otherwise. +If you're introducing new columns in a table migration, be sure to include them in the `newColumns` parameter of +`TableMigration`. Drift will ensure that those columns have a default value or a transformation in `columnTransformer`. +Of course, drift won't attempt to copy `newColumns` from the old table either. + +Regardless of whether you're implementing complex migrations with `TableMigration` or by running a custom sequence +of statements, we strongly recommend to write integration tests covering your migrations. This helps to avoid data +loss caused by errors in a migration. + +Here are some examples demonstrating common usages of the table migration api: + +### Changing the type of a column + +Let's say the `category` column in `Todos` used to be a non-nullable `text()` column that we're now changing to a +nullable int. For simplicity, we assume that `category` always contained integers, they were just stored in a text +column that we now want to adapt. + +```patch +class Todos extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get title => text().withLength(min: 6, max: 10)(); + TextColumn get content => text().named('body')(); +- IntColumn get category => text()(); ++ IntColumn get category => integer().nullable()(); +} +``` + +After re-running your build and incrementing the schema version, you can write a migration: + +{% include "blocks/snippet" snippets = snippets name = 'change_type' %} + +The important part here is the `columnTransformer` - a map from columns to expressions that will +be used to copy the old data. The values in that map refer to the old table, so we can use +`todos.category.cast()` to copy old rows and transform their `category`. +All columns that aren't present in `columnTransformer` will be copied from the old table without +any transformation. + +### Changing column constraints + +When you're changing columns constraints in a way that's compatible to existing data (e.g. changing +non-nullable columns to nullable columns), you can just copy over data without applying any +transformation: + +```dart +await m.alterTable(TableMigration(todos)); +``` + +### Deleting columns + +Deleting a column that's not referenced by a foreign key constraint is easy too: + +```dart +await m.alterTable(TableMigration(yourTable)); +``` + +To delete a column referenced by a foreign key, you'd have to migrate the referencing +tables first. + +### Renaming columns + +If you're renaming a column in Dart, note that the easiest way is to just rename the getter and use +`named`: `TextColumn newName => text().named('old_name')()`. That is fully backwards compatible and +doesn't require a migration. + +If you know your app runs on sqlite 3.25.0 or later (it does if you're using `sqlite3_flutter_libs`), +you can also use the `renameColumn` api in `Migrator`: + +```dart +m.renameColumn(yourTable, 'old_column_name', yourTable.newColumn); +``` + +If you do want to change the actual column name in a table, you can write a `columnTransformer` to +use an old column with a different name: + +```dart +await m.alterTable( + TableMigration( + yourTable, + columnTransformer: { + yourTable.newColumn: const CustomExpression('old_column_name') + }, + ) +) +``` diff --git a/docs/pages/docs/Migrations/exports.md b/docs/pages/docs/Migrations/exports.md new file mode 100644 index 00000000..7b7fc0a8 --- /dev/null +++ b/docs/pages/docs/Migrations/exports.md @@ -0,0 +1,103 @@ +--- +data: + title: "Exporting schemas" + weight: 10 + description: Store all schema versions of your app for validation. +template: layouts/docs/single +--- + +By design, drift's code generator can only see the current state of your database +schema. When you change it, it can be helpful to store a snapshot of the older +schema in a file. +Later, drift tools can take a look at all the schema files to validate the migrations +you write. + +We recommend exporting the initial schema once. Afterwards, each changed schema version +(that is, every time you change the `schemaVersion` in the database) should also be +stored. +This guide assumes a top-level `drift_schemas/` folder in your project to store these +schema files, like this: + +``` +my_app + .../ + lib/ + database/ + database.dart + database.g.dart + test/ + generated_migrations/ + schema.dart + schema_v1.dart + schema_v2.dart + drift_schemas/ + drift_schema_v1.json + drift_schema_v2.json + pubspec.yaml +``` + +Of course, you can also use another folder or a subfolder somewhere if that suits your workflow +better. + +{% block "blocks/alert" title="Examples available" %} +Exporting schemas and generating code for them can't be done with `build_runner` alone, which is +why this setup described here is necessary. + +We hope it's worth it though! Verifying migrations can give you confidence that you won't run +into issues after changing your database. +If you get stuck along the way, don't hesitate to [open a discussion about it](https://github.com/simolus3/drift/discussions). + +Also there are two examples in the drift repository which may be useful as a reference: + +- A [Flutter app](https://github.com/simolus3/drift/tree/latest-release/examples/app) +- An [example specific to migrations](https://github.com/simolus3/drift/tree/latest-release/examples/migrations_example). +{% endblock %} + +## Exporting the schema + +To begin, lets create the first schema representation: + +``` +$ mkdir drift_schemas +$ dart run drift_dev schema dump lib/database/database.dart drift_schemas/ +``` + +This instructs the generator to look at the database defined in `lib/database/database.dart` and extract +its schema into the new folder. + +After making a change to your database schema, you can run the command again. For instance, let's say we +made a change to our tables and increased the `schemaVersion` to `2`. To dump the new schema, just run the +command again: + +``` +$ dart run drift_dev schema dump lib/database/database.dart drift_schemas/ +``` + +You'll need to run this command every time you change the schema of your database and increment the `schemaVersion`. + +Drift will name the files in the folder `drift_schema_vX.json`, where `X` is the current `schemaVersion` of your +database. +If drift is unable to extract the version from your `schemaVersion` getter, provide the full path explicitly: + +``` +$ dart run drift_dev schema dump lib/database/database.dart drift_schemas/drift_schema_v3.json +``` + +{% block "blocks/alert" title=' Dumping a database' color="success" %} +If, instead of exporting the schema of a database class, you want to export the schema of an existing sqlite3 +database file, you can do that as well! `drift_dev schema dump` recognizes a sqlite3 database file as its first +argument and can extract the relevant schema from there. +{% endblock %} + +## What now? + +Having exported your schema versions into files like this, drift tools are able +to generate code aware of multiple schema versions. + +This enables [step-by-step migrations]({{ 'step_by_step.md' | pageUrl }}): Drift +can generate boilerplate code for every schema migration you need to write, so that +you only need to fill in what has actually changed. This makes writing migrations +much easier. + +By knowing all schema versions, drift can also [generate test code]({{'tests.md' | pageUrl}}), +which makes it easy to write unit tests for all your schema migrations. diff --git a/docs/pages/docs/Migrations/index.md b/docs/pages/docs/Migrations/index.md index 7e5f54f8..df435b4d 100644 --- a/docs/pages/docs/Migrations/index.md +++ b/docs/pages/docs/Migrations/index.md @@ -1,7 +1,130 @@ --- data: title: Migrations - description: Simple guide to get a drift project up and running. - hide_section_index: true + description: Tooling and APIs to safely change the schema of your database. template: layouts/docs/list +aliases: + - /migrations --- + +The strict schema of tables and columns is what enables type-safe queries to +the database. +But since the schema is stored in the database too, changing it needs to happen +through migrations developed as part of your app. Drift provides APIs to make most +migrations easy to write, as well as command-line and testing tools to ensure +the migrations are correct. + +{% assign snippets = 'package:drift_docs/snippets/migrations/migrations.dart.excerpt.json' | readString | json_decode %} + +## Manual setup {#basics} + +Drift provides a migration API that can be used to gradually apply schema changes after bumping +the `schemaVersion` getter inside the `Database` class. To use it, override the `migration` +getter. + +Here's an example: Let's say you wanted to add a due date to your todo entries (`v2` of the schema). +Later, you decide to also add a priority column (`v3` of the schema). + +{% include "blocks/snippet" snippets = snippets name = 'table' %} + +We can now change the `database` class like this: + +{% include "blocks/snippet" snippets = snippets name = 'start' %} + +You can also add individual tables or drop them - see the reference of [Migrator](https://pub.dev/documentation/drift/latest/drift/Migrator-class.html) +for all the available options. + +You can also use higher-level query APIs like `select`, `update` or `delete` inside a migration callback. +However, be aware that drift expects the latest schema when creating SQL statements or mapping results. +For instance, when adding a new column to your database, you shouldn't run a `select` on that table before +you've actually added the column. In general, try to avoid running queries in migration callbacks if possible. + +Writing migrations without any tooling support isn't easy. Since correct migrations are +essential for app updates to work smoothly, we strongly recommend using the tools and testing +framework provided by drift to ensure your migrations are correct. +To do that, [export old versions]({{ 'exports.md' | pageUrl }}) to then use easy +[step-by-step migrations]({{ 'step_by_step.md' | pageUrl }}) or [tests]({{ 'tests.md' | pageUrl }}). + +## General tips {#tips} + +To ensure your schema stays consistent during a migration, you can wrap it in a `transaction` block. +However, be aware that some pragmas (including `foreign_keys`) can't be changed inside transactions. +Still, it can be useful to: + +- always re-enable foreign keys before using the database, by enabling them in [`beforeOpen`](#post-migration-callbacks). +- disable foreign-keys before migrations +- run migrations inside a transaction +- make sure your migrations didn't introduce any inconsistencies with `PRAGMA foreign_key_check`. + +With all of this combined, a migration callback can look like this: + +{% include "blocks/snippet" snippets = snippets name = 'structured' %} + +## Post-migration callbacks + +The `beforeOpen` parameter in `MigrationStrategy` can be used to populate data after the database has been created. +It runs after migrations, but before any other query. Note that it will be called whenever the database is opened, +regardless of whether a migration actually ran or not. You can use `details.hadUpgrade` or `details.wasCreated` to +check whether migrations were necessary: + +```dart +beforeOpen: (details) async { + if (details.wasCreated) { + final workId = await into(categories).insert(Category(description: 'Work')); + + await into(todos).insert(TodoEntry( + content: 'A first todo entry', + category: null, + targetDate: DateTime.now(), + )); + + await into(todos).insert( + TodoEntry( + content: 'Rework persistence code', + category: workId, + targetDate: DateTime.now().add(const Duration(days: 4)), + )); + } +}, +``` + +You could also activate pragma statements that you need: + +```dart +beforeOpen: (details) async { + if (details.wasCreated) { + // ... + } + await customStatement('PRAGMA foreign_keys = ON'); +} +``` + +## During development + +During development, you might be changing your schema very often and don't want to write migrations for that +yet. You can just delete your apps' data and reinstall the app - the database will be deleted and all tables +will be created again. Please note that uninstalling is not enough sometimes - Android might have backed up +the database file and will re-create it when installing the app again. + +You can also delete and re-create all tables every time your app is opened, see [this comment](https://github.com/simolus3/drift/issues/188#issuecomment-542682912) +on how that can be achieved. + +## Verifying a database schema at runtime + +Instead (or in addition to) [writing tests](#verifying-migrations) to ensure your migrations work as they should, +you can use a new API from `drift_dev` 1.5.0 to verify the current schema without any additional setup. + +{% assign runtime_snippet = 'package:drift_docs/snippets/migrations/runtime_verification.dart.excerpt.json' | readString | json_decode %} + +{% include "blocks/snippet" snippets = runtime_snippet name = '' %} + +When you use `validateDatabaseSchema`, drift will transparently: + +- collect information about your database by reading from `sqlite3_schema`. +- create a fresh in-memory instance of your database and create a reference schema with `Migrator.createAll()`. +- compare the two. Ideally, your actual schema at runtime should be identical to the fresh one even though it + grew through different versions of your app. + +When a mismatch is found, an exception with a message explaining exactly where another value was expected will +be thrown. +This allows you to find issues with your schema migrations quickly. diff --git a/docs/pages/docs/Migrations/step_by_step.md b/docs/pages/docs/Migrations/step_by_step.md new file mode 100644 index 00000000..cf1eb3ab --- /dev/null +++ b/docs/pages/docs/Migrations/step_by_step.md @@ -0,0 +1,87 @@ +--- +data: + title: "Schema migration helpers" + weight: 20 + description: Use generated code reflecting over all schema versions to write migrations step-by-step. +template: layouts/docs/single +--- + +{% assign snippets = 'package:drift_docs/snippets/migrations/step_by_step.dart.excerpt.json' | readString | json_decode %} + +Database migrations are typically written incrementally, with one piece of code transforming +the database schema to the next version. By chaining these migrations, you can write +schema migrations even for very old app versions. + +Reliably writing migrations between app versions isn't easy though. This code needs to be +maintained and tested, but the growing complexity of the database schema shouldn't make +migrations more complex. +Let's take a look at a typical example making the incremental migrations pattern hard: + +1. In the initial database schema, we have a bunch of tables. +2. In the migration from 1 to 2, we add a column `birthDate` to one of the table (`Users`). +3. In version 3, we realize that we actually don't want to store users at all and delete + the table. + +Before version 3, the only migration could have been written as `m.addColumn(users, users.birthDate)`. +But now that the `Users` table doesn't exist in the source code anymore, that's no longer possible! +Sure, we could remember that the migration from 1 to 2 is now pointless and just skip it if a user +upgrades from 1 to 3 directly, but this adds a lot of complexity. For more complex migration scripts +spanning many versions, this can quickly lead to code that's hard to understand and maintain. + +## Generating step-by-step code + +Drift provides tools to [export old schema versions]({{ 'exports.md' | pageUrl }}). After exporting all +your schema versions, you can use the following command to generate code aiding with the implementation +of step-by-step migrations: + +``` +$ dart run drift_dev schema steps drift_schemas/ lib/database/schema_versions.dart +``` + +The first argument (`drift_schemas/`) is the folder storing exported schemas, the second argument is +the path of the file to generate. Typically, you'd generate a file next to your database class. + +The generated file contains a `stepByStep` method which can be used to write migrations easily: + +{% include "blocks/snippet" snippets = snippets name = 'stepbystep' %} + +`stepByStep` expects a callback for each schema upgrade responsible for running the partial migration. +That callback receives two parameters: A migrator `m` (similar to the regular migrator you'd get for +`onUpgrade` callbacks) and a `schema` parameter that gives you access to the schema at the version you're +migrating to. +For instance, in the `from1To2` function, `schema` provides getters for the database schema at version 2. +The migrator passed to the function is also set up to consider that specific version by default. +A call to `m.recreateAllViews()` would re-create views at the expected state of schema version 2, for instance. + +## Customizing step-by-step migrations + +The `stepByStep` function generated by the `drift_dev schema steps` command gives you an +`OnUpgrade` callback. +But you might want to customize the upgrade behavior, for instance by adding foreign key +checks afterwards (as described in [tips]({{ 'index.md#tips' | pageUrl }})). + +The `Migrator.runMigrationSteps` helper method can be used for that, as this example +shows: + +{% include "blocks/snippet" snippets = snippets name = 'stepbystep2' %} + +Here, foreign keys are disabled before runnign the migration and re-enabled afterwards. +A check ensuring no inconsistencies occurred helps catching issues with the migration +in debug modes. + +## Moving to step-by-step migrations + +If you've been using drift before `stepByStep` was added to the library, or if you've never exported a schema, +you can move to step-by-step migrations by pinning the `from` value in `Migrator.runMigrationSteps` to a known +starting point. + +This allows you to perform all prior migration work to get the database to the "starting" point for +`stepByStep` migrations, and then use `stepByStep` migrations beyond that schema version. + +{% include "blocks/snippet" snippets = snippets name = 'stepbystep3' %} + +Here, we give a "floor" to the `from` value of `2`, since we've performed all other migration work to get to +this point. From now on, you can generate step-by-step migrations for each schema change. + +If you did not do this, a user migrating from schema 1 directly to schema 3 would not properly walk migrations +and apply all migration changes required. diff --git a/docs/pages/docs/Migrations/tests.md b/docs/pages/docs/Migrations/tests.md new file mode 100644 index 00000000..78c538ea --- /dev/null +++ b/docs/pages/docs/Migrations/tests.md @@ -0,0 +1,87 @@ +--- +data: + title: "Testing migrations" + weight: 30 + description: Generate test code to write unit tests for your migrations. +template: layouts/docs/single +--- + +{% assign snippets = 'package:drift_docs/snippets/migrations/tests/schema_test.dart.excerpt.json' | readString | json_decode %} +{% assign verify = 'package:drift_docs/snippets/migrations/tests/verify_data_integrity_test.dart.excerpt.json' | readString | json_decode %} + +While migrations can be written manually without additional help from drift, dedicated tools testing +your migrations help to ensure that they are correct and aren't loosing any data. + +Drift's migration tooling consists of the following steps: + +1. After each change to your schema, use a tool to export the current schema into a separate file. +2. Use a drift tool to generate test code able to verify that your migrations are bringing the database + into the expected schema. +3. Use generated code to make writing schema migrations easier. + +This page describes steps 2 and 3. It assumes that you're already following step 1 by +[exporting your schema]({{ 'exports.md' }}) when it changes. + +## Writing tests + +After you've exported the database schemas into a folder, you can generate old versions of your database class +based on those schema files. +For verifications, drift will generate a much smaller database implementation that can only be used to +test migrations. + +You can put this test code whereever you want, but it makes sense to put it in a subfolder of `test/`. +If we wanted to write them to `test/generated_migrations/`, we could use + +``` +$ dart run drift_dev schema generate drift_schemas/ test/generated_migrations/ +``` + +After that setup, it's finally time to write some tests! For instance, a test could look like this: + +{% include "blocks/snippet" snippets = snippets name = 'setup' %} + +In general, a test looks like this: + +1. Use `verifier.startAt()` to obtain a [connection](https://drift.simonbinder.eu/api/drift/databaseconnection-class) + to a database with an initial schema. + This database contains all your tables, indices and triggers from that version, created by using `Migrator.createAll`. +2. Create your application database with that connection. For this, create a constructor in your database class that + accepts a `QueryExecutor` and forwards it to the super constructor in `GeneratedDatabase`. + Then, you can pass the result of calling `newConnection()` to that constructor to create a test instance of your + datbaase. +3. Call `verifier.migrateAndValidate(db, version)`. This will initiate a migration towards the target version (here, `2`). + Unlike the database created by `startAt`, this uses the migration logic you wrote for your database. + +`migrateAndValidate` will extract all `CREATE` statement from the `sqlite_schema` table and semantically compare them. +If it sees anything unexpected, it will throw a `SchemaMismatch` exception to fail your test. + +{% block "blocks/alert" title="Writing testable migrations" %} +To test migrations _towards_ an old schema version (e.g. from `v1` to `v2` if your current version is `v3`), +you're `onUpgrade` handler must be capable of upgrading to a version older than the current `schemaVersion`. +For this, check the `to` parameter of the `onUpgrade` callback to run a different migration if necessary. +Or, use [step-by-step migrations]({{ 'step_by_step.md' | pageUrl }}) which do this automatically. +{% endblock %} + +## Verifying data integrity + +In addition to the changes made in your table structure, its useful to ensure that data that was present before a migration +is still there after it ran. +You can use `schemaAt` to obtain a raw `Database` from the `sqlite3` package in addition to a connection. +This can be used to insert data before a migration. After the migration ran, you can then check that the data is still there. + +Note that you can't use the regular database class from you app for this, since its data classes always expect the latest +schema. However, you can instruct drift to generate older snapshots of your data classes and companions for this purpose. +To enable this feature, pass the `--data-classes` and `--companions` command-line arguments to the `drift_dev schema generate` +command: + +``` +$ dart run drift_dev schema generate --data-classes --companions drift_schemas/ test/generated_migrations/ +``` + +Then, you can import the generated classes with an alias: + +{% include "blocks/snippet" snippets = verify name = 'imports' %} + +This can then be used to manually create and verify data at a specific version: + +{% include "blocks/snippet" snippets = verify name = 'main' %} diff --git a/docs/pages/docs/faq.md b/docs/pages/docs/faq.md index b643e454..d65c6f3d 100644 --- a/docs/pages/docs/faq.md +++ b/docs/pages/docs/faq.md @@ -55,7 +55,7 @@ in your favorite dependency injection framework for flutter hence solves this pr ## Why am I getting no such table errors? -If you add another table after your app has already been installed, you need to write a [migration]({{ "Advanced Features/migrations.md" | pageUrl }}) +If you add another table after your app has already been installed, you need to write a [migration]({{ "Migrations/index.md" | pageUrl }}) that covers creating that table. If you're in the process of developing your app and want to use un- and reinstall your app instead of writing migrations, that's fine too. Please note that your apps data might be backed up on Android, so manually deleting your app's data instead of a reinstall is necessary on some devices. @@ -80,7 +80,7 @@ you can set to `true`. When enabled, drift will print the statements it runs. ## How do I insert data on the first app start? -You can populate the database on the first start of your app with a custom [migration strategy]({{ 'Advanced Features/migrations.md' | pageUrl }}). +You can populate the database on the first start of your app with a custom [migration strategy]({{ 'Migrations/index.md' | pageUrl }}). To insert data when the database is created (which usually happens when the app is first run), you can use this: ```dart diff --git a/docs/pages/docs/setup.md b/docs/pages/docs/setup.md index 4846181f..0079cd66 100644 --- a/docs/pages/docs/setup.md +++ b/docs/pages/docs/setup.md @@ -113,14 +113,11 @@ started with drift: - Writing queries: Drift-generated classes support writing the most common SQL statements, like [selects]({{ 'Dart API/select.md' | pageUrl }}) or [inserts, updates and deletes]({{ 'Dart API/writes.md' | pageUrl }}). - General [notes on how to integrate drift with your app's architecture]({{ 'Dart API/architecture.md' | pageUrl }}). -- Something to keep in mind for later: When you change the schema of your database and write migrations, drift can help you make sure they're - correct. Use [runtime checks], which don't require additional setup, or more involved [test utilities] if you want to test migrations between - any schema versions. +- Something to keep in mind for later: When changing the database, for instance by adding new columns + or tables, you need to write a migration so that existing databases are transformed to the new + format. Drift's extensive [migration tools]({{ 'Migrations/index.md' | pageUrl }}) help with that. Once you're familiar with the basics, the [overview here]({{ 'index.md' | pageUrl }}) shows what more drift has to offer. This includes transactions, automated tooling to help with migrations, multi-platform support and more. - -[runtime checks]: {{ 'Advanced Features/migrations.md#verifying-a-database-schema-at-runtime' | pageUrl }} -[test utilities]: {{ 'Advanced Features/migrations.md#verifying-migrations' | pageUrl }} diff --git a/docs/pages/docs/testing.md b/docs/pages/docs/testing.md index 13e426e7..cd114109 100644 --- a/docs/pages/docs/testing.md +++ b/docs/pages/docs/testing.md @@ -116,4 +116,4 @@ test('stream emits a new user when the name updates', () async { ## Testing migrations Drift can help you generate code for schema migrations. For more details, see -[this guide]({{ "Advanced Features/migrations.md#verifying-migrations" | pageUrl }}). +[this guide]({{ "Migrations/tests.md" | pageUrl }}). diff --git a/docs/pages/docs/upgrading.md b/docs/pages/docs/upgrading.md index 738d681d..2a6209b6 100644 --- a/docs/pages/docs/upgrading.md +++ b/docs/pages/docs/upgrading.md @@ -110,7 +110,7 @@ Also, you may have to - Format your sources again: Run `dart format .`. - Re-run the build: Run `dart run build_runner build -d`. - - If you have been using generated [migration test files]({{ 'Advanced Features/migrations.md#exporting-the-schema' | pageUrl }}), + - If you have been using generated [migration test files]({{ 'Migrations/exports.md' | pageUrl }}), re-generate them as well with `dart run drift_dev schema generate drift_schemas/ test/generated_migrations/` (you may have to adapt the command to the directories you use for schemas). - Manually fix the changed order of imports caused by the migration. diff --git a/docs/pages/index.html b/docs/pages/index.html index 6ae4d48d..15171b0b 100644 --- a/docs/pages/index.html +++ b/docs/pages/index.html @@ -51,7 +51,7 @@ and easy process. Further, drift provides a complete test toolkit to help you test migrations between all your revisions. -[All about schema migrations]({{ "docs/Advanced Features/migrations.md" | pageUrl }}) +[All about schema migrations]({{ "docs/Migrations/index.md" | pageUrl }}) {% endblock %} {% endblock %} diff --git a/docs/pubspec.yaml b/docs/pubspec.yaml index 645e8e8d..7c19be4d 100644 --- a/docs/pubspec.yaml +++ b/docs/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: picocss: hosted: https://simonbinder.eu version: ^1.5.10 + test: ^1.18.0 dev_dependencies: lints: ^2.0.0 @@ -43,7 +44,6 @@ dev_dependencies: shelf: ^1.2.0 shelf_static: ^1.1.0 source_span: ^1.9.1 - test: ^1.18.0 sqlparser: zap_dev: ^0.2.3+1