Skip to content

Commit

Permalink
Add support for migrations (#74)
Browse files Browse the repository at this point in the history
Resolves #8.
  • Loading branch information
vitusortner authored Mar 1, 2019
1 parent 1955c5a commit e15ffc0
Show file tree
Hide file tree
Showing 17 changed files with 332 additions and 40 deletions.
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This package is still in an early phase and the API will likely change.
1. [Transactions](#transactions)
1. [Entities](#entities)
1. [Foreign Keys](#foreign-keys)
1. [Migrations](#migrations)
1. [Examples](#examples)
1. [Naming](#naming)
1. [Bugs and Feedback](#bugs-and-feedback)
Expand Down Expand Up @@ -93,7 +94,7 @@ This package is still in an early phase and the API will likely change.
- `@insert` marks a method as an insertion method.

```dart
@Database()
@Database(version: 1)
abstract class AppDatabase extends FloorDatabase {
static Future<AppDatabase> openDatabase() async => _$open();
Expand Down Expand Up @@ -259,6 +260,46 @@ class Dog {
}
```

## Migrations
Whenever are doing changes to your entities, you're required to also migrate the old data.

First, update your entity.
Increase the database version and change the `openDatabase` method to take in a list of `Migration`s.
This parameter has to get passed to the `_$open()` method.
Define a `Migration` which specifies a `startVersion`, an `endVersion` and a function that executes SQL to migrate the data.
Lastly, call `openDatabase` with your newly created `Migration`.
Don't forget to trigger the code generator again, to create the code for handling the new entity.

```dart
// Update entity with new 'nickname' field
@Entity(tableName: 'person')
class Person {
@PrimaryKey(autoGenerate: true)
final int id;
@ColumnInfo(name: 'custom_name', nullable: false)
final String name;
final String nickname;
Person(this.id, this.name, this.nickname);
}
// Bump up database version
@Database(version: 2)
abstract class AppDatabase extends FloorDatabase {
static Future<AppDatabase> openDatabase(List<Migration> migrations) async =>
_$open(migrations);
}
// Create migration
final migration1to2 = Migration(1, 2, (database) {
database.execute('ALTER TABLE person ADD COLUMN nickname TEXT');
});
final database = await AppDatabase.openDatabase([migration1to2]);
```

## Examples
For further examples take a look at the [example](https://github.com/vitusortner/floor/tree/develop/example) and [floor_test](https://github.com/vitusortner/floor/tree/develop/floor_test) directories.

Expand Down
1 change: 1 addition & 0 deletions floor/lib/floor.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
library floor;

export 'package:floor/src/database.dart';
export 'package:floor/src/migration.dart';
export 'package:floor_annotation/floor_annotation.dart';
33 changes: 31 additions & 2 deletions floor/lib/src/database.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import 'package:floor/floor.dart';
import 'package:meta/meta.dart';
import 'package:sqflite/sqflite.dart' as sqflite;

/// Extend this class to enable database functionality.
abstract class FloorDatabase {
/// Use this for direct access to the sqflite database.
/// Use this whenever you want need direct access to the sqflite database.
sqflite.DatabaseExecutor database;

// TODO remove this
/// Opens the database to be able to query it.
Future<sqflite.Database> open();
Future<sqflite.Database> open(List<Migration> migrations);

/// Closes the database.
Future<void> close() async {
Expand All @@ -17,4 +19,31 @@ abstract class FloorDatabase {
await immutableDatabase.close();
}
}

/// Runs the given [migrations] for migrating the database schema and data.
@protected
void runMigrations(
final sqflite.Database migrationDatabase,
final int startVersion,
final int endVersion,
final List<Migration> migrations,
) {
final relevantMigrations = migrations
.where((migration) => migration.startVersion >= startVersion)
.toList()
..sort((first, second) =>
first.startVersion.compareTo(second.startVersion));

if (relevantMigrations.isEmpty ||
relevantMigrations.last.endVersion != endVersion) {
throw StateError(
'There is no migration supplied to update the database to the current version.'
' Aborting the migration.',
);
}

for (final migration in relevantMigrations) {
migration.migrate(migrationDatabase);
}
}
}
43 changes: 43 additions & 0 deletions floor/lib/src/migration.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import 'package:sqflite/sqflite.dart' as sqflite;

/// Base class for a database migration.
///
/// Each migration can move between 2 versions that are defined by
/// [startVersion] and [endVersion].
class Migration {
/// The start version of the database.
final int startVersion;

/// The start version of the database.
final int endVersion;

/// Function that performs the migration.
final void Function(sqflite.Database database) migrate;

/// Creates a new migration between [startVersion] and [endVersion].
/// [migrate] will be called by the database and performs the actual
/// migration.
Migration(this.startVersion, this.endVersion, this.migrate)
: assert(startVersion != null),
assert(endVersion != null),
assert(migrate != null),
assert(startVersion < endVersion);

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Migration &&
runtimeType == other.runtimeType &&
startVersion == other.startVersion &&
endVersion == other.endVersion &&
migrate == other.migrate;

@override
int get hashCode =>
startVersion.hashCode ^ endVersion.hashCode ^ migrate.hashCode;

@override
String toString() {
return 'Migration{startVersion: $startVersion, endVersion: $endVersion, migrate: $migrate}';
}
}
4 changes: 3 additions & 1 deletion floor/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ dependencies:
sqflite: ^1.1.1
floor_annotation:
path: ../floor_annotation/

mockito: ^4.0.0
flutter_test:
sdk: flutter
121 changes: 121 additions & 0 deletions floor/test/migration_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import 'package:floor/src/database.dart';
import 'package:floor/src/migration.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:sqflite/sqlite_api.dart';

void main() {
final floorDatabase = TestFloorDatabase();
final mockMigrationDatabase = MockSqfliteDatabase();

tearDown(() {
clearInteractions(mockMigrationDatabase);
});

test('run single migration', () {
const startVersion = 1;
const endVersion = 2;
const sql = 'FOO BAR';
final migrations = [
Migration(1, 2, (database) {
database.execute(sql);
})
];

// ignore: invalid_use_of_protected_member
floorDatabase.runMigrations(
mockMigrationDatabase,
startVersion,
endVersion,
migrations,
);

verify(mockMigrationDatabase.execute(sql));
});

test('run multiple migrations in order', () {
const startVersion = 1;
const endVersion = 4;
const sql1 = 'first';
const sql2 = 'second';
const sql3 = 'third';
final migrations = [
Migration(3, 4, (database) {
database.execute(sql3);
}),
Migration(1, 2, (database) {
database.execute(sql1);
}),
Migration(2, 3, (database) {
database.execute(sql2);
}),
];

// ignore: invalid_use_of_protected_member
floorDatabase.runMigrations(
mockMigrationDatabase,
startVersion,
endVersion,
migrations,
);

verifyInOrder([
mockMigrationDatabase.execute(sql1),
mockMigrationDatabase.execute(sql2),
mockMigrationDatabase.execute(sql3),
]);
});

test('exception when no matching start version found', () {
const startVersion = 10;
const endVersion = 20;
const sql = 'FOO BAR';
final migrations = [
Migration(1, 2, (database) {
database.execute(sql);
})
];

// ignore: invalid_use_of_protected_member
final actual = () => floorDatabase.runMigrations(
mockMigrationDatabase,
startVersion,
endVersion,
migrations,
);

expect(actual, throwsStateError);
verifyZeroInteractions(mockMigrationDatabase);
});

test('exception when no matching end version found', () {
const startVersion = 1;
const endVersion = 10;
const sql = 'FOO BAR';
final migrations = [
Migration(1, 2, (database) {
database.execute(sql);
})
];

// ignore: invalid_use_of_protected_member
final actual = () => floorDatabase.runMigrations(
mockMigrationDatabase,
startVersion,
endVersion,
migrations,
);

expect(actual, throwsStateError);
verifyZeroInteractions(mockMigrationDatabase);
});
}

class TestFloorDatabase extends FloorDatabase {
@override
Future<Database> open(List<Migration> migrations) {
return null;
}
}

class MockSqfliteDatabase extends Mock implements Database {}
7 changes: 6 additions & 1 deletion floor_annotation/lib/src/database.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import 'package:meta/meta.dart';

/// Marks a class as a FloorDatabase.
class Database {
/// The database version
final int version;

/// Marks a class as a FloorDatabase.
const Database();
const Database({@required this.version});
}
1 change: 1 addition & 0 deletions floor_generator/lib/misc/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ abstract class AnnotationField {
static const QUERY_VALUE = 'value';
static const PRIMARY_KEY_AUTO_GENERATE = 'autoGenerate';
static const ON_CONFLICT = 'onConflict';
static const DATABASE_VERSION = 'version';

static const COLUMN_INFO_NAME = 'name';
static const COLUMN_INFO_NULLABLE = 'nullable';
Expand Down
16 changes: 16 additions & 0 deletions floor_generator/lib/model/database.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:analyzer/dart/element/element.dart';
import 'package:floor_generator/misc/constants.dart';
import 'package:floor_generator/misc/type_utils.dart';
import 'package:floor_generator/model/delete_method.dart';
import 'package:floor_generator/model/entity.dart';
Expand All @@ -15,6 +16,21 @@ class Database {

String get name => clazz.displayName;

int get version {
final databaseVersion = clazz.metadata
.firstWhere(isDatabaseAnnotation)
.computeConstantValue()
.getField(AnnotationField.DATABASE_VERSION)
?.toIntValue();

return databaseVersion != null
? databaseVersion
: throw InvalidGenerationSourceError(
'No version for this database specified even though it is required.',
element: clazz,
);
}

List<MethodElement> get methods => clazz.methods;

List<QueryMethod> get queryMethods {
Expand Down
2 changes: 1 addition & 1 deletion floor_generator/lib/model/foreign_key.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class ForeignKey {
return 'CASCADE';
case ForeignKeyAction.NO_ACTION:
default:
return 'NO_ACTION';
return 'NO ACTION';
}
}

Expand Down
Loading

0 comments on commit e15ffc0

Please sign in to comment.