diff --git a/sally/lib/src/runtime/executor/executor.dart b/sally/lib/src/runtime/executor/executor.dart index 2367c479..f76469eb 100644 --- a/sally/lib/src/runtime/executor/executor.dart +++ b/sally/lib/src/runtime/executor/executor.dart @@ -57,7 +57,7 @@ abstract class GeneratedDatabase { /// Creates and auto-updating stream from the given select statement. This /// method should not be used directly. - Stream> createStream(SelectStatement stmt) => + Stream> createStream(TableChangeListener> stmt) => streamQueries.registerStream(stmt); /// Handles database creation by delegating the work to the [migration] @@ -112,12 +112,13 @@ abstract class GeneratedDatabase { /// You can use the [updates] parameter so that sally knows which tables are /// affected by your query. All select streams that depend on a table /// specified there will then issue another query. - Future updateCustom(String query, + Future customUpdate(String query, {List variables = const [], Set updates}) async { final ctx = GenerationContext(this); final mappedArgs = variables.map((v) => v.mapToSimpleValue(ctx)).toList(); - final affectedRows = await executor.runUpdate(query, mappedArgs); + final affectedRows = + executor.doWhenOpened((_) => executor.runUpdate(query, mappedArgs)); if (updates != null) { for (var table in updates) { @@ -127,6 +128,25 @@ abstract class GeneratedDatabase { return affectedRows; } + + /// Executes a custom select statement once. To use the variables, mark them + /// with a "?" in your [query]. They will then be changed to the appropriate + /// value. + Future> customSelect(String query, + {List variables = const []}) async { + return CustomSelectStatement(query, variables, Set(), this).read(); + } + + /// Creates a stream from a custom select statement.To use the variables, mark + /// them with a "?" in your [query]. They will then be changed to the + /// appropriate value. The stream will re-emit items when any table in + /// [readsFrom] changes, so be sure to set it to the set of tables your query + /// reads data from. + Stream> customSelectStream(String query, + {List variables = const [], Set readsFrom}) { + final tables = readsFrom ?? Set(); + return createStream(CustomSelectStatement(query, variables, tables, this)); + } } /// A query executor is responsible for executing statements on a database and diff --git a/sally/lib/src/runtime/executor/stream_queries.dart b/sally/lib/src/runtime/executor/stream_queries.dart index 92fef7f6..9404c47b 100644 --- a/sally/lib/src/runtime/executor/stream_queries.dart +++ b/sally/lib/src/runtime/executor/stream_queries.dart @@ -2,6 +2,16 @@ import 'dart:async'; import 'package:sally/sally.dart'; +/// Internal interface to mark classes that respond to table changes +abstract class TableChangeListener { + /// Called to check if this listener should update after the table with the + /// given name has changed. + bool isAffectedBy(String table); + + /// Called to reload data from the table after it has changed. + Future handleDataChanged(); +} + /// Keeps track of active streams created from [SelectStatement]s and updates /// them when needed. class StreamQueryStore { @@ -12,7 +22,7 @@ class StreamQueryStore { StreamQueryStore(); /// Creates a new stream from the select statement. - Stream> registerStream(SelectStatement statement) { + Stream> registerStream(TableChangeListener> statement) { final stream = _QueryStream(statement, this); _activeStreams.add(stream); return stream.stream; @@ -34,13 +44,13 @@ class StreamQueryStore { } } -class _QueryStream { - final SelectStatement query; +class _QueryStream { + final TableChangeListener listener; final StreamQueryStore _store; - StreamController> _controller; + StreamController _controller; - Stream> get stream { + Stream get stream { _controller ??= StreamController.broadcast( onListen: _onListen, onCancel: _onCancel, @@ -49,7 +59,7 @@ class _QueryStream { return _controller.stream; } - _QueryStream(this.query, this._store); + _QueryStream(this.listener, this._store); void _onListen() { // first listener added, fetch query @@ -70,14 +80,12 @@ class _QueryStream { // Fetch data if it's needed, publish that data if it's possible. if (!_controller.hasListener) return; - final data = await query.get(); + final data = await listener.handleDataChanged(); if (!_controller.isClosed) { _controller.add(data); } } - bool isAffectedByTableChange(String table) { - return table == query.table.$tableName; - } + bool isAffectedByTableChange(String table) => listener.isAffectedBy(table); } diff --git a/sally/lib/src/runtime/statements/select.dart b/sally/lib/src/runtime/statements/select.dart index deea10d0..4069f9fa 100644 --- a/sally/lib/src/runtime/statements/select.dart +++ b/sally/lib/src/runtime/statements/select.dart @@ -4,12 +4,14 @@ import 'package:sally/sally.dart'; import 'package:sally/src/runtime/components/component.dart'; import 'package:sally/src/runtime/components/limit.dart'; import 'package:sally/src/runtime/executor/executor.dart'; +import 'package:sally/src/runtime/executor/stream_queries.dart'; import 'package:sally/src/runtime/statements/query.dart'; import 'package:sally/src/runtime/structure/table_info.dart'; typedef OrderingTerm OrderClauseGenerator(T tbl); -class SelectStatement extends Query { +class SelectStatement extends Query + implements TableChangeListener> { SelectStatement(GeneratedDatabase database, TableInfo table) : super(database, table); @@ -47,4 +49,71 @@ class SelectStatement extends Query { Stream> watch() { return database.createStream(this); } + + @override + Future> handleDataChanged() { + return get(); + } + + @override + bool isAffectedBy(String table) { + return table == super.table.$tableName; + } +} + +class CustomSelectStatement implements TableChangeListener> { + /// Tables this select statement reads from + final Set tables; + final String query; + final List variables; + final GeneratedDatabase db; + + CustomSelectStatement(this.query, this.variables, this.tables, this.db); + + Future> read() => handleDataChanged(); + + @override + Future> handleDataChanged() async { + final ctx = GenerationContext(db); + final mappedArgs = variables.map((v) => v.mapToSimpleValue(ctx)).toList(); + + final result = + await db.executor.doWhenOpened((e) => e.runSelect(query, mappedArgs)); + + return result.map((row) => QueryRow(row, db)).toList(); + } + + @override + bool isAffectedBy(String table) { + return tables.any((t) => t.$tableName == table); + } +} + +/// For custom select statement, represents a row in the result set. +class QueryRow { + final Map _data; + final GeneratedDatabase _db; + + QueryRow(this._data, this._db); + + /// Reads an arbitrary value from the row and maps it to a fitting dart type. + /// The dart type [T] must be supported by the type system of the database + /// used (mostly contains booleans, strings, integers and dates). + T read(String key) { + final type = _db.typeSystem.forDartType(); + + return type.mapFromDatabaseResponse(_data[key]); + } + + /// Reads a bool from the column named [key]. + bool readBool(String key) => read(key); + + /// Reads a string from the column named [key]. + String readString(String key) => read(key); + + /// Reads a int from the column named [key]. + int readInt(String key) => read(key); + + /// Reads a [DateTime] from the column named [key]. + DateTime readDateTime(String key) => read(key); } diff --git a/sally/test/expressions/comparable_test.dart b/sally/test/expressions/comparable_test.dart index 461091d1..0c382967 100644 --- a/sally/test/expressions/comparable_test.dart +++ b/sally/test/expressions/comparable_test.dart @@ -2,8 +2,11 @@ import 'package:sally/sally.dart'; import 'package:sally/src/runtime/components/component.dart'; import 'package:test_api/test_api.dart'; +import '../data/tables/todos.dart'; + void main() { final expression = GeneratedIntColumn('col', false); + final db = TodoDb(null); final comparisons = { expression.isSmallerThan: '<', @@ -24,7 +27,7 @@ void main() { comparisons.forEach((fn, value) { test('for operator $value', () { - final ctx = GenerationContext(null); + final ctx = GenerationContext(db); fn(compare).writeInto(ctx); @@ -36,7 +39,7 @@ void main() { group('can compare with values', () { comparisonsVal.forEach((fn, value) { test('for operator $value', () { - final ctx = GenerationContext(null); + final ctx = GenerationContext(db); fn(12).writeInto(ctx); diff --git a/sally/test/update_test.dart b/sally/test/update_test.dart index 2b123985..8be1deef 100644 --- a/sally/test/update_test.dart +++ b/sally/test/update_test.dart @@ -65,13 +65,13 @@ void main() { group('custom updates', () { test('execute the correct sql', () async { - await db.updateCustom('DELETE FROM users'); + await db.customUpdate('DELETE FROM users'); verify(executor.runUpdate('DELETE FROM users', [])); }); test('map the variables correctly', () async { - await db.updateCustom( + await db.customUpdate( 'DELETE FROM users WHERE name = ? AND birthdate < ?', variables: [ Variable.withString('Name'), @@ -87,11 +87,11 @@ void main() { test('returns information from executor', () async { when(executor.runUpdate(any, any)).thenAnswer((_) => Future.value(10)); - expect(await db.updateCustom(''), 10); + expect(await db.customUpdate(''), 10); }); test('informs about updated tables', () async { - await db.updateCustom('', updates: Set.of([db.users, db.todosTable])); + await db.customUpdate('', updates: Set.of([db.users, db.todosTable])); verify(streamQueries.handleTableUpdates('users')); verify(streamQueries.handleTableUpdates('todos')); diff --git a/sally_flutter/README.md b/sally_flutter/README.md index 79743827..d7c066db 100644 --- a/sally_flutter/README.md +++ b/sally_flutter/README.md @@ -189,6 +189,8 @@ If a column is nullable or has a default value (this includes auto-increments), can be omitted. All other fields must be set and non-null. The `insert` method will throw otherwise. +' + ## Migrations Sally 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` @@ -226,6 +228,8 @@ You can also add individual tables or drop them. ## TODO-List and current limitations ### Limitations (at the moment) +Please note that a workaround for most on this list exists with custom statements. + - No joins - No `group by` or window functions - Custom primary key support is very limited diff --git a/sally_flutter/example/lib/database.dart b/sally_flutter/example/lib/database.dart index db22c7e7..1ee4ea48 100644 --- a/sally_flutter/example/lib/database.dart +++ b/sally_flutter/example/lib/database.dart @@ -23,6 +23,13 @@ class Categories extends Table { TextColumn get description => text().named('desc')(); } +class CategoryWithCount { + final Category category; + final int count; // amount of entries in this category + + CategoryWithCount(this.category, this.count); +} + @UseSally(tables: [Todos, Categories]) class Database extends _$Database { Database()