Improve testing structure for sally

This commit is contained in:
Simon Binder 2019-02-14 16:53:52 +01:00
parent b2736421d8
commit b7e37857ec
16 changed files with 460 additions and 238 deletions

View File

@ -467,13 +467,6 @@
</list>
</value>
</entry>
<entry key="sqlite2">
<value>
<list>
<option value="$USER_HOME$/.pub-cache/hosted/pub.dartlang.org/sqlite2-0.5.1/lib" />
</list>
</value>
</entry>
<entry key="stack_trace">
<value>
<list>
@ -671,7 +664,6 @@
<root url="file://$USER_HOME$/.pub-cache/hosted/pub.dartlang.org/source_map_stack_trace-1.1.5/lib" />
<root url="file://$USER_HOME$/.pub-cache/hosted/pub.dartlang.org/source_maps-0.10.8/lib" />
<root url="file://$USER_HOME$/.pub-cache/hosted/pub.dartlang.org/source_span-1.5.4/lib" />
<root url="file://$USER_HOME$/.pub-cache/hosted/pub.dartlang.org/sqlite2-0.5.1/lib" />
<root url="file://$USER_HOME$/.pub-cache/hosted/pub.dartlang.org/stack_trace-1.9.3/lib" />
<root url="file://$USER_HOME$/.pub-cache/hosted/pub.dartlang.org/stream_channel-1.6.8/lib" />
<root url="file://$USER_HOME$/.pub-cache/hosted/pub.dartlang.org/stream_transform-0.0.14+1/lib" />

View File

@ -2,7 +2,6 @@
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/end_to_end_testing/end_to_end_testing.iml" filepath="$PROJECT_DIR$/end_to_end_testing/end_to_end_testing.iml" />
<module fileurl="file://$PROJECT_DIR$/sally/sally.iml" filepath="$PROJECT_DIR$/sally/sally.iml" />
<module fileurl="file://$PROJECT_DIR$/sally_flutter/sally_flutter.iml" filepath="$PROJECT_DIR$/sally_flutter/sally_flutter.iml" />
<module fileurl="file://$PROJECT_DIR$/sally_generator/sally_generator.iml" filepath="$PROJECT_DIR$/sally_generator/sally_generator.iml" />

View File

@ -1 +0,0 @@
sqflite=/home/simon/Android/flutter/.pub-cache/hosted/pub.dartlang.org/sqflite-1.1.0/

View File

@ -11,14 +11,16 @@ import 'package:sally/src/runtime/statements/update.dart';
abstract class GeneratedDatabase {
final SqlTypeSystem typeSystem;
final QueryExecutor executor;
final StreamQueryStore streamQueries = StreamQueryStore();
@visibleForTesting
StreamQueryStore streamQueries;
int get schemaVersion;
MigrationStrategy get migration;
List<TableInfo> get allTables;
GeneratedDatabase(this.typeSystem, this.executor);
GeneratedDatabase(this.typeSystem, this.executor,
{this.streamQueries = const StreamQueryStore()});
/// Creates a migrator with the provided query executor. We sometimes can't
/// use the regular [GeneratedDatabase.executor] because migration happens
@ -29,6 +31,9 @@ abstract class GeneratedDatabase {
streamQueries.handleTableUpdates(tableName);
}
Stream<List<T>> createStream<T>(SelectStatement<dynamic, T> stmt) =>
streamQueries.registerStream(stmt);
Future<void> handleDatabaseCreation({@required SqlExecutor executor}) {
final migrator = _createMigrator(executor);
return migration.onCreate(migrator);

View File

@ -3,7 +3,9 @@ import 'dart:async';
import 'package:sally/sally.dart';
class StreamQueryStore {
final List<_QueryStream> _activeStreams = [];
final List<_QueryStream> _activeStreams = const [];
const StreamQueryStore();
Stream<List<T>> registerStream<T>(SelectStatement<dynamic, T> statement) {
final stream = _QueryStream(statement, this);

View File

@ -24,6 +24,6 @@ class SelectStatement<T, D> extends Query<T, D> {
/// Creates an auto-updating stream that emits new items whenever this table
/// changes.
Stream<List<D>> watch() {
return database.streamQueries.registerStream(this);
return database.createStream(this);
}
}

View File

@ -11,6 +11,5 @@
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Dart SDK" level="project" />
<orderEntry type="library" name="Dart Packages" level="project" />
<orderEntry type="library" name="Flutter Plugins" level="project" />
</component>
</module>

View File

@ -0,0 +1,54 @@
import 'package:sally/sally.dart';
import 'package:test_api/test_api.dart';
import 'tables/todos.dart';
import 'utils/mocks.dart';
void main() {
TodoDb db;
MockExecutor executor;
MockStreamQueries streamQueries;
setUp(() {
executor = MockExecutor();
streamQueries = MockStreamQueries();
db = TodoDb(executor)..streamQueries = streamQueries;
});
group('Generates DELETE statements', () {
test('without any constraints', () async {
await db.delete(db.users).go();
verify(executor.runDelete('DELETE FROM users;', argThat(isEmpty)));
});
test('for complex components', () async {
await (db.delete(db.users)
..where((u) => or(not(u.isAwesome), u.id.isSmallerThan(100)))
..limit(10, offset: 100))
.go();
verify(executor.runDelete(
'DELETE FROM users WHERE (NOT (is_awesome = 1)) OR (id < ?) LIMIT 10, 100;',
[100]));
});
});
group('Table updates for delete statements', () {
test('are issued when data was changed', () async {
when(executor.runDelete(any, any)).thenAnswer((_) => Future.value(3));
await db.delete(db.users).go();
verify(streamQueries.handleTableUpdates('users'));
});
test('are not issues when no data was changed', () async {
when(executor.runDelete(any, any)).thenAnswer((_) => Future.value(0));
await db.delete(db.users).go();
verifyNever(streamQueries.handleTableUpdates(any));
});
});
}

View File

@ -1,69 +0,0 @@
import 'package:sally/sally.dart';
import 'package:sally/src/runtime/migration.dart';
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text().withLength(min: 6, max: 32)();
BoolColumn get isAwesome => boolean()();
}
// Example tables and data classes, these would be generated by sally_generator
// in a real project
class UserDataObject {
final int id;
final String name;
UserDataObject(this.id, this.name);
}
class GeneratedUsersTable extends Users with TableInfo<Users, UserDataObject> {
final GeneratedDatabase db;
GeneratedUsersTable(this.db);
@override
Set<GeneratedColumn> get $primaryKey => Set()..add(id);
@override
GeneratedIntColumn id = GeneratedIntColumn('id', false);
@override
GeneratedTextColumn name = GeneratedTextColumn('name', false);
@override
GeneratedBoolColumn isAwesome = GeneratedBoolColumn('is_awesome', true);
@override
List<GeneratedColumn<dynamic, SqlType>> get $columns => [id, name, isAwesome];
@override
String get $tableName => 'users';
@override
Users get asDslTable => this;
@override
UserDataObject map(Map<String, dynamic> data) {
return null;
}
@override
Map<String, Variable> entityToSql(UserDataObject d) {
final map = <String, Variable>{};
if (d.id != null) {
map['id'] = Variable<int, IntType>(d.id);
}
if (d.name != null) {
map['name'] = Variable<String, StringType>(d.name);
}
return map;
}
}
class TestDatabase extends GeneratedDatabase {
TestDatabase(QueryExecutor executor)
: super(const SqlTypeSystem.withDefaults(), executor);
GeneratedUsersTable get users => GeneratedUsersTable(this);
@override
MigrationStrategy get migration => MigrationStrategy();
@override
int get schemaVersion => 1;
@override
List<TableInfo> get allTables => [users];
}

View File

@ -1,44 +0,0 @@
import 'package:mockito/mockito.dart';
import 'package:sally/sally.dart';
import 'package:test_api/test_api.dart';
import 'generated_tables.dart';
// used so that we can mock the SqlExecutor typedef
abstract class SqlExecutorAsClass {
Future<void> call(String sql);
}
class MockQueryExecutor extends Mock implements SqlExecutorAsClass {}
void main() {
Migrator migrator;
TestDatabase db;
MockQueryExecutor executor;
setUp(() {
executor = MockQueryExecutor();
db = TestDatabase(null);
migrator = Migrator(db, executor);
});
test('generates CREATE TABLE statements', () {
migrator.createAllTables();
verify(executor.call(
'CREATE TABLE IF NOT EXISTS users (id INTEGER NOT NULL , name VARCHAR NOT NULL , is_awesome BOOLEAN NULL CHECK (is_awesome in (0, 1)))'));
});
test('generates DROP TABLE statements', () {
migrator.deleteTable('users');
verify(executor.call('DROP TABLE IF EXISTS users'));
});
test('generates ALTER TABLE statements to add columns', () {
migrator.addColumn(db.users, db.users.isAwesome);
verify(executor.call(
'ALTER TABLE users ADD COLUMN is_awesome BOOLEAN NULL CHECK (is_awesome in (0, 1))'));
});
}

View File

@ -1,109 +0,0 @@
import 'package:sally/sally.dart';
import 'package:test_api/test_api.dart';
import 'package:mockito/mockito.dart';
import 'generated_tables.dart';
class MockExecutor extends Mock implements QueryExecutor {}
void main() {
TestDatabase db;
MockExecutor executor;
setUp(() {
executor = MockExecutor();
db = TestDatabase(executor);
when(executor.runSelect(any, any)).thenAnswer((_) => Future.value([]));
when(executor.runUpdate(any, any)).thenAnswer((_) => Future.value(0));
when(executor.runDelete(any, any)).thenAnswer((_) => Future.value(0));
when(executor.runInsert(any, any)).thenAnswer((_) => Future.value(0));
});
group('Generates SELECT statements', () {
test('generates simple statements', () {
db.select(db.users).get();
verify(executor.runSelect('SELECT * FROM users;', argThat(isEmpty)));
});
test('generates limit statements', () {
(db.select(db.users)..limit(10)).get();
verify(executor.runSelect(
'SELECT * FROM users LIMIT 10;', argThat(isEmpty)));
});
test('generates like expressions', () {
(db.select(db.users)..where((u) => u.name.like('Dash%'))).get();
verify(executor
.runSelect('SELECT * FROM users WHERE name LIKE ?;', ['Dash%']));
});
test('generates complex predicates', () {
(db.select(db.users)
..where((u) =>
and(not(u.name.equals('Dash')), (u.id.isBiggerThan(12)))))
.get();
verify(executor.runSelect(
'SELECT * FROM users WHERE (NOT name = ?) AND (id > ?);',
['Dash', 12]));
});
test('generates expressions from boolean columns', () {
(db.select(db.users)..where((u) => u.isAwesome)).get();
verify(executor.runSelect(
'SELECT * FROM users WHERE (is_awesome = 1);', argThat(isEmpty)));
});
});
group('Streams for queries', () {
test('update correctly', () {
final stream = db.select(db.users).watch();
stream.listen((_) => null);
db.markTableUpdated('users');
verify(executor.runSelect('SELECT * FROM users;', argThat(isEmpty))).called(2);
});
});
group('Generates DELETE statements', () {
test('without any constraints', () {
db.delete(db.users).go();
verify(executor.runDelete('DELETE FROM users;', argThat(isEmpty)));
});
test('for complex components', () {
(db.delete(db.users)
..where((u) => or(not(u.isAwesome), u.id.isSmallerThan(100)))
..limit(10, offset: 100))
.go();
verify(executor.runDelete(
'DELETE FROM users WHERE (NOT (is_awesome = 1)) OR (id < ?) LIMIT 10, 100;',
[100]));
});
});
group('Generates INSERT statements', () {
test('with full data', () {
db.into(db.users).insert(UserDataObject(10, 'User'));
verify(executor.runInsert(
'INSERT INTO users (id, name) VALUES (?, ?)', [10, 'User']));
});
// todo verify auto-increment and default values
});
group('Generates UPDATE statements', () {
test('without constraints', () {
db.update(db.users).write(UserDataObject(3, 'User'));
verify(
executor.runUpdate('UPDATE users SET id = ? name = ?;', [3, 'User']));
});
});
}

View File

@ -0,0 +1,98 @@
import 'package:sally/sally.dart';
import 'package:test_api/test_api.dart';
import 'tables/todos.dart';
import 'utils/mocks.dart';
void main() {
TodoDb db;
MockExecutor executor;
setUp(() {
executor = MockExecutor();
db = TodoDb(executor);
});
group('SELECT statements are generated', () {
test('for simple statements', () {
db.select(db.users).get();
verify(executor.runSelect('SELECT * FROM users;', argThat(isEmpty)));
});
test('with limit statements', () {
(db.select(db.users)..limit(10)).get();
verify(executor.runSelect(
'SELECT * FROM users LIMIT 10;', argThat(isEmpty)));
});
test('with like expressions', () {
(db.select(db.users)..where((u) => u.name.like('Dash%'))).get();
verify(executor
.runSelect('SELECT * FROM users WHERE name LIKE ?;', ['Dash%']));
});
test('with complex predicates', () {
(db.select(db.users)
..where((u) =>
and(not(u.name.equals('Dash')), (u.id.isBiggerThan(12)))))
.get();
verify(executor.runSelect(
'SELECT * FROM users WHERE (NOT name = ?) AND (id > ?);',
['Dash', 12]));
});
test('with expressions from boolean columns', () {
(db.select(db.users)..where((u) => u.isAwesome)).get();
verify(executor.runSelect(
'SELECT * FROM users WHERE (is_awesome = 1);', argThat(isEmpty)));
});
});
group('SELECT results are parsed', () {
test('when all fields are non-null', () {
final data = [
{
'id': 10,
'title': 'A todo title',
'content': 'Content',
'category': 3
}
];
final resolved = TodoEntry(
id: 10,
title: 'A todo title',
content: 'Content',
category: 3,
);
when(executor.runSelect('SELECT * FROM todos;', any))
.thenAnswer((_) => Future.value(data));
expect(db.select(db.todosTable).get(), completion([resolved]));
});
test('when some fields are null', () {
final data = [
{
'id': 10,
'title': null,
'content': 'Content',
'category': null,
}
];
final resolved = TodoEntry(
id: 10,
title: null,
content: 'Content',
category: null,
);
when(executor.runSelect('SELECT * FROM todos;', any))
.thenAnswer((_) => Future.value(data));
expect(db.select(db.todosTable).get(), completion([resolved]));
});
});
}

View File

@ -1,10 +1,43 @@
import 'package:sally/sally.dart';
part 'todos.g.dart';
@DataClassName('TodoEntry')
class TodosTable extends Table {
@override
String get tableName => 'todos';
IntColumn get id => integer().autoIncrement()();
TextColumn get title => text().withLength(min: 4, max: 6)();
TextColumn get title => text().withLength(min: 4, max: 16).nullable()();
TextColumn get content => text()();
IntColumn get category => integer().nullable()();
}
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text().withLength(min: 6, max: 32)();
BoolColumn get isAwesome => boolean()();
}
@DataClassName('Category')
class Categories extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get description => text().named('desc')();
}
@UseSally(tables: [TodosTable, Categories, Users])
class TodoDb extends _$TodoDb {
TodoDb(QueryExecutor e) : super(e);
@override
MigrationStrategy get migration => MigrationStrategy();
@override
int get schemaVersion => 1;
}

View File

@ -0,0 +1,236 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'todos.dart';
// **************************************************************************
// SallyGenerator
// **************************************************************************
class TodoEntry {
final int id;
final String title;
final String content;
final int category;
TodoEntry({this.id, this.title, this.content, this.category});
@override
int get hashCode =>
(((id.hashCode) * 31 + title.hashCode) * 31 + content.hashCode) * 31 +
category.hashCode;
@override
bool operator ==(other) =>
identical(this, other) ||
(other is TodoEntry &&
other.id == id &&
other.title == title &&
other.content == content &&
other.category == category);
}
class _$TodosTableTable extends TodosTable
implements TableInfo<TodosTable, TodoEntry> {
final GeneratedDatabase _db;
_$TodosTableTable(this._db);
@override
GeneratedIntColumn get id =>
GeneratedIntColumn('id', false, hasAutoIncrement: true);
@override
GeneratedTextColumn get title => GeneratedTextColumn(
'title',
true,
);
@override
GeneratedTextColumn get content => GeneratedTextColumn(
'content',
false,
);
@override
GeneratedIntColumn get category => GeneratedIntColumn(
'category',
true,
);
@override
List<GeneratedColumn> get $columns => [id, title, content, category];
@override
TodosTable get asDslTable => this;
@override
String get $tableName => 'todos';
@override
void validateIntegrity(TodoEntry instance, bool isInserting) =>
id.isAcceptableValue(instance.id, isInserting) &&
title.isAcceptableValue(instance.title, isInserting) &&
content.isAcceptableValue(instance.content, isInserting) &&
category.isAcceptableValue(instance.category, isInserting);
@override
Set<GeneratedColumn> get $primaryKey => Set();
@override
TodoEntry map(Map<String, dynamic> data) {
final intType = _db.typeSystem.forDartType<int>();
final stringType = _db.typeSystem.forDartType<String>();
return TodoEntry(
id: intType.mapFromDatabaseResponse(data['id']),
title: stringType.mapFromDatabaseResponse(data['title']),
content: stringType.mapFromDatabaseResponse(data['content']),
category: intType.mapFromDatabaseResponse(data['category']),
);
}
@override
Map<String, Variable> entityToSql(TodoEntry d) {
final map = <String, Variable>{};
if (d.id != null) {
map['id'] = Variable<int, IntType>(d.id);
}
if (d.title != null) {
map['title'] = Variable<String, StringType>(d.title);
}
if (d.content != null) {
map['content'] = Variable<String, StringType>(d.content);
}
if (d.category != null) {
map['category'] = Variable<int, IntType>(d.category);
}
return map;
}
}
class Category {
final int id;
final String description;
Category({this.id, this.description});
@override
int get hashCode => (id.hashCode) * 31 + description.hashCode;
@override
bool operator ==(other) =>
identical(this, other) ||
(other is Category && other.id == id && other.description == description);
}
class _$CategoriesTable extends Categories
implements TableInfo<Categories, Category> {
final GeneratedDatabase _db;
_$CategoriesTable(this._db);
@override
GeneratedIntColumn get id =>
GeneratedIntColumn('id', false, hasAutoIncrement: true);
@override
GeneratedTextColumn get description => GeneratedTextColumn(
'`desc`',
false,
);
@override
List<GeneratedColumn> get $columns => [id, description];
@override
Categories get asDslTable => this;
@override
String get $tableName => 'categories';
@override
void validateIntegrity(Category instance, bool isInserting) =>
id.isAcceptableValue(instance.id, isInserting) &&
description.isAcceptableValue(instance.description, isInserting);
@override
Set<GeneratedColumn> get $primaryKey => Set();
@override
Category map(Map<String, dynamic> data) {
final intType = _db.typeSystem.forDartType<int>();
final stringType = _db.typeSystem.forDartType<String>();
return Category(
id: intType.mapFromDatabaseResponse(data['id']),
description: stringType.mapFromDatabaseResponse(data['`desc`']),
);
}
@override
Map<String, Variable> entityToSql(Category d) {
final map = <String, Variable>{};
if (d.id != null) {
map['id'] = Variable<int, IntType>(d.id);
}
if (d.description != null) {
map['`desc`'] = Variable<String, StringType>(d.description);
}
return map;
}
}
class User {
final int id;
final String name;
final bool isAwesome;
User({this.id, this.name, this.isAwesome});
@override
int get hashCode =>
((id.hashCode) * 31 + name.hashCode) * 31 + isAwesome.hashCode;
@override
bool operator ==(other) =>
identical(this, other) ||
(other is User &&
other.id == id &&
other.name == name &&
other.isAwesome == isAwesome);
}
class _$UsersTable extends Users implements TableInfo<Users, User> {
final GeneratedDatabase _db;
_$UsersTable(this._db);
@override
GeneratedIntColumn get id =>
GeneratedIntColumn('id', false, hasAutoIncrement: true);
@override
GeneratedTextColumn get name => GeneratedTextColumn(
'name',
false,
);
@override
GeneratedBoolColumn get isAwesome => GeneratedBoolColumn(
'is_awesome',
false,
);
@override
List<GeneratedColumn> get $columns => [id, name, isAwesome];
@override
Users get asDslTable => this;
@override
String get $tableName => 'users';
@override
void validateIntegrity(User instance, bool isInserting) =>
id.isAcceptableValue(instance.id, isInserting) &&
name.isAcceptableValue(instance.name, isInserting) &&
isAwesome.isAcceptableValue(instance.isAwesome, isInserting);
@override
Set<GeneratedColumn> get $primaryKey => Set();
@override
User map(Map<String, dynamic> data) {
final intType = _db.typeSystem.forDartType<int>();
final stringType = _db.typeSystem.forDartType<String>();
final boolType = _db.typeSystem.forDartType<bool>();
return User(
id: intType.mapFromDatabaseResponse(data['id']),
name: stringType.mapFromDatabaseResponse(data['name']),
isAwesome: boolType.mapFromDatabaseResponse(data['is_awesome']),
);
}
@override
Map<String, Variable> entityToSql(User d) {
final map = <String, Variable>{};
if (d.id != null) {
map['id'] = Variable<int, IntType>(d.id);
}
if (d.name != null) {
map['name'] = Variable<String, StringType>(d.name);
}
if (d.isAwesome != null) {
map['is_awesome'] = Variable<bool, BoolType>(d.isAwesome);
}
return map;
}
}
abstract class _$TodoDb extends GeneratedDatabase {
_$TodoDb(QueryExecutor e) : super(const SqlTypeSystem.withDefaults(), e);
_$TodosTableTable get todosTable => _$TodosTableTable(this);
_$CategoriesTable get categories => _$CategoriesTable(this);
_$UsersTable get users => _$UsersTable(this);
@override
List<TableInfo> get allTables => [todosTable, categories, users];
}

View File

@ -0,0 +1,25 @@
import 'package:mockito/mockito.dart';
import 'package:sally/sally.dart';
import 'package:sally/src/runtime/executor/stream_queries.dart';
export 'package:mockito/mockito.dart';
class MockExecutor extends Mock implements QueryExecutor {
MockExecutor() {
when(runSelect(any, any)).thenAnswer((_) => Future.value([]));
when(runUpdate(any, any)).thenAnswer((_) => Future.value(0));
when(runDelete(any, any)).thenAnswer((_) => Future.value(0));
when(runInsert(any, any)).thenAnswer((_) => Future.value(0));
}
}
class MockStreamQueries extends Mock implements StreamQueryStore {}
// used so that we can mock the SqlExecutor typedef
abstract class SqlExecutorAsClass {
Future<void> call(String sql);
}
class MockQueryExecutor extends Mock implements SqlExecutorAsClass {}

View File

@ -36,6 +36,8 @@ class TableParser extends ParserBase {
}
String _parseTableName(ClassElement element) {
// todo allow override via a field (final String tableName = '') as well
final tableNameGetter = element.getGetter('tableName');
if (tableNameGetter == null) {
// class does not override tableName. So just use the dart class name