Merge branch 'develop' into refactor-type-system

This commit is contained in:
Simon Binder 2022-07-20 22:35:58 +02:00
commit 3744fa0601
No known key found for this signature in database
GPG Key ID: 7891917E4147B8C0
34 changed files with 374 additions and 145 deletions

2
.gitignore vendored
View File

@ -16,3 +16,5 @@ flutter_export_environment.sh
.DS_Store .DS_Store
docs/**/*.g.dart docs/**/*.g.dart
*/build/

View File

@ -5,14 +5,14 @@ _Note: Moor has been renamed to drift_
[![Build Status](https://api.cirrus-ci.com/github/simolus3/moor.svg)](https://github.com/simolus3/drift/actions/workflows/main.yml/badge.svg) [![Build Status](https://api.cirrus-ci.com/github/simolus3/moor.svg)](https://github.com/simolus3/drift/actions/workflows/main.yml/badge.svg)
[![Chat on Gitter](https://img.shields.io/gitter/room/moor-dart/community)](https://gitter.im/moor-dart/community) [![Chat on Gitter](https://img.shields.io/gitter/room/moor-dart/community)](https://gitter.im/moor-dart/community)
## Proudly Sponsored by [Stream 💙](https://getstream.io/chat/flutter/tutorial/?utm_source=Github&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Github_Jan2022_FlutterChat&utm_term=moor) ## Proudly Sponsored by [Stream 💙](https://getstream.io/chat/sdk/android/?utm_source=Moor&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Moor_July2022_AndroidChatSDK_klmh22)
<p align="center"> <p align="center">
<table> <table>
<tbody> <tbody>
<tr> <tr>
<td align="center"> <td align="center">
<a href="https://getstream.io/chat/flutter/tutorial/?utm_source=Github&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Github_Jan2022_FlutterChat&utm_term=moor" target="_blank"><img width="250px" src="https://stream-blog.s3.amazonaws.com/blog/wp-content/uploads/fc148f0fc75d02841d017bb36e14e388/Stream-logo-with-background-.png"/></a><br/><span><a href="https://getstream.io/chat/flutter/tutorial/?utm_source=Github&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Github_Jan2022_FlutterChat&utm_term=moor" target="_blank">Try the Flutter Chat Tutorial &nbsp💬</a></span> <a href="https://getstream.io/chat/sdk/android/?utm_source=Moor&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Moor_July2022_AndroidChatSDK_klmh22" target="_blank"><img width="250px" src="https://stream-blog.s3.amazonaws.com/blog/wp-content/uploads/fc148f0fc75d02841d017bb36e14e388/Stream-logo-with-background-.png"/></a><br/><span><a href="https://getstream.io/chat/sdk/android/?utm_source=Moor&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Moor_July2022_AndroidChatSDK_klmh22" target="_blank">Try the Flutter Chat Tutorial &nbsp💬</a></span>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@ -0,0 +1,74 @@
import 'package:drift/drift.dart';
import '../tables/filename.dart';
part 'custom_queries.g.dart';
// #docregion manual
class CategoryWithCount {
final Category category;
final int count; // amount of entries in this category
CategoryWithCount({required this.category, required this.count});
}
// #enddocregion manual
// #docregion setup
@DriftDatabase(
tables: [Todos, Categories],
queries: {
'categoriesWithCount': 'SELECT *, '
'(SELECT COUNT(*) FROM todos WHERE category = c.id) AS "amount" '
'FROM categories c;'
},
)
class MyDatabase extends _$MyDatabase {
// rest of class stays the same
// #enddocregion setup
@override
int get schemaVersion => 1;
MyDatabase(QueryExecutor e) : super(e);
// #docregion run
Future<void> useGeneratedQuery() async {
// The generated query can be run once as a future:
await categoriesWithCount().get();
// Or multiple times as a stream
await for (final snapshot in categoriesWithCount().watch()) {
print('Found ${snapshot.length} category results');
}
}
// #enddocregion run
// #docregion manual
// then, in the database class:
Stream<List<CategoryWithCount>> allCategoriesWithCount() {
// select all categories and load how many associated entries there are for
// each category
return customSelect(
'SELECT *, (SELECT COUNT(*) FROM todos WHERE category = c.id) AS "amount"'
' FROM categories c;',
// used for the stream: the stream will update when either table changes
readsFrom: {todos, categories},
).watch().map((rows) {
// we get list of rows here. We just have to turn the raw data from the
// row into a CategoryWithCount instnace. As we defined the Category table
// earlier, drift knows how to parse a category. The only thing left to do
// manually is extracting the amount.
return rows
.map((row) => CategoryWithCount(
category: categories.map(row.data),
count: row.read<int>('amount'),
))
.toList();
});
}
// #enddocregion manual
// #docregion setup
}
// #enddocregion setup

View File

@ -2,17 +2,25 @@
data: data:
title: "Custom queries" title: "Custom queries"
weight: 10 weight: 10
description: Let drift generate Dart from your SQL statements description: Write SQL for advanced queries that drift can't express in Dart yet.
aliases: aliases:
- /queries/custom - /queries/custom
template: layouts/docs/single template: layouts/docs/single
--- ---
{% assign snippets = "package:drift_docs/snippets/drift_files/custom_queries.dart.excerpt.json" | readString | json_decode %}
Although drift includes a fluent api that can be used to model most statements, advanced Although drift includes a fluent api that can be used to model most statements, advanced
features like `WITH` clauses or subqueries aren't supported yet. You can features like `WITH` clauses or some subqueries aren't supported yet.
use these features with custom statements. You don't have to miss out on other benefits However, you can use methods like `customSelect` and `customStatement` to run advanced
drift brings, though: Drift helps you parse the result rows and custom queries also statements on the database by writing the SQL manually.
support auto-updating streams.
For most custom queries, drift can analyze their SQL at compile time, make sure they're valid
and generate a type-safe API for them.
This approach can be much safer than writing custom SQL at runtime.
This page describes both approaches: The first section introduces methods generated by drift,
the second section gives an example for a custom query defined at runtime.
## Statements with a generated api ## Statements with a generated api
@ -21,26 +29,29 @@ API for your select, update and delete statements. Of course, you can still writ
sql manually. See the sections below for details. sql manually. See the sections below for details.
To use this feature, all you need to is define your queries in your `DriftDatabase` annotation: To use this feature, all you need to is define your queries in your `DriftDatabase` annotation:
```dart
@DriftDatabase(
tables: [Todos, Categories],
queries: {
'categoriesWithCount':
'SELECT *, (SELECT COUNT(*) FROM todos WHERE category = c.id) AS "amount" FROM categories c;'
},
)
class MyDatabase extends _$MyDatabase {
// rest of class stays the same
}
```
After running the build step again, drift will have written the `CategoriesWithCountResult` class for you -
it will hold the result of your query. Also, the `_$MyDatabase` class from which you inherit will have the
methods `categoriesWithCount` (which runs the query once) and `watchCategoriesWithCount` (which returns
an auto-updating stream).
Queries can have parameters in them by using the `?` or `:name` syntax. When your queries contains parameters, {% include "blocks/snippet" snippets = snippets name = "setup" %}
drift will figure out an appropriate type for them and include them in the generated methods. For instance,
After running the build step again, drift will have written the `CategoriesWithCountResult` class for you -
it will hold the result of your query. Also, the `_$MyDatabase` class from which you inherit will have a
`Selectable<CategoriesWithCountResult> categoriesWithCount()` method which can be used to run the query.
Like all `Selectable`s in drift, you can use `get()` to run the query once or `watch()` to get an auto-updating
stream of results:
{% block "blocks/alert" title="Better support for custom queries in drift files" %}
Defining SQL in the `@DriftDatabase` annotation is a great way to define a few custom queries. For apps that
use lots of custom queries, extracting them into separate files may be more manageable.
[Drift files]({{ "drift_files.md" | pageUrl }}), which can be included into the database, are a really great fit for this, and may be easier
to use.
{% endblock %}
{% include "blocks/snippet" snippets = snippets name = "run" %}
Queries can have parameters in them by using the `?` or `:name` syntax. For parameters in queries,
drift will figure out an appropriate type and include them in the generated methods. For instance,
`'categoryById': 'SELECT * FROM categories WHERE id = :id'` will generate the method `categoryById(int id)`. `'categoryById': 'SELECT * FROM categories WHERE id = :id'` will generate the method `categoryById(int id)`.
Drift also supports additional convenience features in custom queries, like embededding Dart expressions in
SQL. For more details, see the documentation on [drift files]({{ 'drift_files.md' | pageUrl }}).
{% block "blocks/alert" title="On table names" color="info" %} {% block "blocks/alert" title="On table names" color="info" %}
To use this feature, it's helpful to know how Dart tables are named in sql. For tables that don't To use this feature, it's helpful to know how Dart tables are named in sql. For tables that don't
@ -63,31 +74,9 @@ still send custom queries by calling `customSelect` for a one-time query or
the underlying data changes. Using the todo example introduced in the the underlying data changes. Using the todo example introduced in the
[getting started guide]({{ "../Getting started/index.md" | pageUrl }}), we can [getting started guide]({{ "../Getting started/index.md" | pageUrl }}), we can
write this query which will load the amount of todo entries in each category: write this query which will load the amount of todo entries in each category:
```dart
class CategoryWithCount {
final Category category;
final int count; // amount of entries in this category
CategoryWithCount(this.category, this.count); {% include "blocks/snippet" snippets = snippets name = "manual" %}
}
// then, in the database class:
Stream<List<CategoryWithCount>> categoriesWithCount() {
// select all categories and load how many associated entries there are for
// each category
return customSelect(
'SELECT *, (SELECT COUNT(*) FROM todos WHERE category = c.id) AS "amount" FROM categories c;',
readsFrom: {todos, categories}, // used for the stream: the stream will update when either table changes
).watch().map((rows) {
// we get list of rows here. We just have to turn the raw data from the row into a
// CategoryWithCount. As we defined the Category table earlier, drift knows how to parse
// a category. The only thing left to do manually is extracting the amount
return rows
.map((row) => CategoryWithCount(Category.fromData(row.data, this), row.read<int>('amount')))
.toList();
});
}
```
For custom selects, you should use the `readsFrom` parameter to specify from which tables the query is For custom selects, you should use the `readsFrom` parameter to specify from which tables the query is
reading. When using a `Stream`, drift will be able to know after which updates the stream should emit reading. When using a `Stream`, drift will be able to know after which updates the stream should emit
items. items.

View File

@ -9,6 +9,14 @@
- To describe the type a column has, use the `DriftSqlType` enum - To describe the type a column has, use the `DriftSqlType` enum
- To map a value from Dart to SQL and vice-versa, use an instance of `SqlTypes`, - To map a value from Dart to SQL and vice-versa, use an instance of `SqlTypes`,
reachable via `database.options.types`. reachable via `database.options.types`.
- __Breaking__: `Expression`s (including `Column`s) always have a non-nullable type
parameter now. They are implicitly nullable, so `TypedResult.read` now returns a
nullable value.
- __Breaking__: `QueryRow.read` can only read non-nullable values now. To read nullable
values, use `readNullable`.
- __Breaking__: Remove the `includeJoinedTableColumns` parameter on `selectOnly()`.
The method now behaves as if that parameter was turned off. To use columns from a
joined table, add them with `addColumns`.
- Consistently handle transaction errors like a failing `BEGIN` or `COMMIT` - Consistently handle transaction errors like a failing `BEGIN` or `COMMIT`
across database implementations. across database implementations.
- Support nested transactions. - Support nested transactions.

View File

@ -1,13 +1,13 @@
# Drift # Drift
## Proudly Sponsored by [Stream 💙](https://getstream.io/chat/flutter/tutorial/?utm_source=Github&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Github_Jan2022_FlutterChat&utm_term=moor) ## Proudly Sponsored by [Stream 💙](https://getstream.io/chat/sdk/android/?utm_source=Moor&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Moor_July2022_AndroidChatSDK_klmh22)
<p align="center"> <p align="center">
<table> <table>
<tbody> <tbody>
<tr> <tr>
<td align="center"> <td align="center">
<a href="https://getstream.io/chat/flutter/tutorial/?utm_source=Github&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Github_Jan2022_FlutterChat&utm_term=moor" target="_blank"><img width="250px" src="https://stream-blog.s3.amazonaws.com/blog/wp-content/uploads/fc148f0fc75d02841d017bb36e14e388/Stream-logo-with-background-.png"/></a><br/><span><a href="https://getstream.io/chat/flutter/tutorial/?utm_source=Github&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Github_Jan2022_FlutterChat&utm_term=moor" target="_blank">Try the Flutter Chat Tutorial &nbsp💬</a></span> <a href="https://getstream.io/chat/sdk/android/?utm_source=Moor&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Moor_July2022_AndroidChatSDK_klmh22" target="_blank"><img width="250px" src="https://stream-blog.s3.amazonaws.com/blog/wp-content/uploads/fc148f0fc75d02841d017bb36e14e388/Stream-logo-with-background-.png"/></a><br/><span><a href="https://getstream.io/chat/sdk/android/?utm_source=Moor&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Moor_July2022_AndroidChatSDK_klmh22" target="_blank">Try the Flutter Chat Tutorial &nbsp💬</a></span>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@ -10,7 +10,7 @@ part of 'main.dart';
class TodoCategory extends DataClass implements Insertable<TodoCategory> { class TodoCategory extends DataClass implements Insertable<TodoCategory> {
final int id; final int id;
final String name; final String name;
TodoCategory({required this.id, required this.name}); const TodoCategory({required this.id, required this.name});
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{}; final map = <String, Expression>{};
@ -185,7 +185,7 @@ class TodoItem extends DataClass implements Insertable<TodoItem> {
final String? content; final String? content;
final int categoryId; final int categoryId;
final String? generatedText; final String? generatedText;
TodoItem( const TodoItem(
{required this.id, {required this.id,
required this.title, required this.title,
this.content, this.content,
@ -466,7 +466,8 @@ class $TodoItemsTable extends TodoItems
class TodoCategoryItemCountData extends DataClass { class TodoCategoryItemCountData extends DataClass {
final String name; final String name;
final int itemCount; final int itemCount;
TodoCategoryItemCountData({required this.name, required this.itemCount}); const TodoCategoryItemCountData(
{required this.name, required this.itemCount});
factory TodoCategoryItemCountData.fromJson(Map<String, dynamic> json, factory TodoCategoryItemCountData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) { {ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer; serializer ??= driftRuntimeOptions.defaultSerializer;
@ -558,10 +559,8 @@ class $TodoCategoryItemCountView
} }
@override @override
Query? get query => (attachedDatabase.selectOnly(todoCategories, Query? get query =>
includeJoinedTableColumns: false) (attachedDatabase.selectOnly(todoCategories)..addColumns($columns)).join([
..addColumns($columns))
.join([
innerJoin(todoItems, todoItems.categoryId.equalsExp(todoCategories.id)) innerJoin(todoItems, todoItems.categoryId.equalsExp(todoCategories.id))
]); ]);
@override @override
@ -571,7 +570,8 @@ class $TodoCategoryItemCountView
class TodoItemWithCategoryNameViewData extends DataClass { class TodoItemWithCategoryNameViewData extends DataClass {
final int id; final int id;
final String title; final String title;
TodoItemWithCategoryNameViewData({required this.id, required this.title}); const TodoItemWithCategoryNameViewData(
{required this.id, required this.title});
factory TodoItemWithCategoryNameViewData.fromJson(Map<String, dynamic> json, factory TodoItemWithCategoryNameViewData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) { {ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer; serializer ??= driftRuntimeOptions.defaultSerializer;
@ -668,9 +668,7 @@ class $TodoItemWithCategoryNameViewView extends ViewInfo<
@override @override
Query? get query => Query? get query =>
(attachedDatabase.selectOnly(todoItems, includeJoinedTableColumns: false) (attachedDatabase.selectOnly(todoItems)..addColumns($columns)).join([
..addColumns($columns))
.join([
innerJoin( innerJoin(
todoCategories, todoCategories.id.equalsExp(todoItems.categoryId)) todoCategories, todoCategories.id.equalsExp(todoItems.categoryId))
]); ]);

View File

@ -242,13 +242,6 @@ abstract class DatabaseConnectionUser {
/// The [distinct] parameter (defaults to false) can be used to remove /// The [distinct] parameter (defaults to false) can be used to remove
/// duplicate rows from the result set. /// duplicate rows from the result set.
/// ///
/// The [includeJoinedTableColumns] parameter (defaults to true) can be used
/// to determinate join statement's `useColumns` parameter default value. Set
/// it to false if you don't want to include joined table columns by default.
/// If you leave it on true and don't set `useColumns` parameter to false in
/// join declarations, all columns of joined table will be included in query
/// by default.
///
/// For simple queries, use [select]. /// For simple queries, use [select].
/// ///
/// See also: /// See also:
@ -256,10 +249,9 @@ abstract class DatabaseConnectionUser {
/// - the documentation on [group by](https://drift.simonbinder.eu/docs/advanced-features/joins/#group-by) /// - the documentation on [group by](https://drift.simonbinder.eu/docs/advanced-features/joins/#group-by)
JoinedSelectStatement<T, R> selectOnly<T extends HasResultSet, R>( JoinedSelectStatement<T, R> selectOnly<T extends HasResultSet, R>(
ResultSetImplementation<T, R> table, ResultSetImplementation<T, R> table,
{bool distinct = false, {bool distinct = false}) {
bool includeJoinedTableColumns = true}) {
return JoinedSelectStatement<T, R>( return JoinedSelectStatement<T, R>(
resolvedEngine, table, [], distinct, false, includeJoinedTableColumns); resolvedEngine, table, [], distinct, false, false);
} }
/// Starts a [DeleteStatement] that can be used to delete rows from a table. /// Starts a [DeleteStatement] that can be used to delete rows from a table.
@ -625,11 +617,8 @@ class TableOrViewOperations<Tbl extends HasResultSet, Row> {
/// Composes a `SELECT` statement only selecting a subset of columns. /// Composes a `SELECT` statement only selecting a subset of columns.
/// ///
/// This is equivalent to calling [DatabaseConnectionUser.selectOnly]. /// This is equivalent to calling [DatabaseConnectionUser.selectOnly].
JoinedSelectStatement<Tbl, Row> selectOnly( JoinedSelectStatement<Tbl, Row> selectOnly({bool distinct = false}) {
{bool distinct = false, bool includeJoinedTableColumns = true}) { return _user.selectOnly(_sourceSet, distinct: distinct);
return _user.selectOnly(_sourceSet,
distinct: distinct,
includeJoinedTableColumns: includeJoinedTableColumns);
} }
} }

View File

@ -102,8 +102,8 @@ class Migrator {
} }
} }
GenerationContext _createContext() { GenerationContext _createContext({bool supportsVariables = false}) {
return GenerationContext.fromDb(_db); return GenerationContext.fromDb(_db, supportsVariables: supportsVariables);
} }
/// Creates the given table if it doesn't exist /// Creates the given table if it doesn't exist
@ -197,7 +197,7 @@ class Migrator {
await createTable(temporaryTable); await createTable(temporaryTable);
// Step 5: Transfer old content into the new table // Step 5: Transfer old content into the new table
final context = _createContext(); final context = _createContext(supportsVariables: true);
final expressionsForSelect = <Expression>[]; final expressionsForSelect = <Expression>[];
context.buffer.write('INSERT INTO $temporaryName ('); context.buffer.write('INSERT INTO $temporaryName (');

View File

@ -14,11 +14,8 @@ extension TableOrViewStatements<Tbl extends HasResultSet, Row>
/// Composes a `SELECT` statement only selecting a subset of columns. /// Composes a `SELECT` statement only selecting a subset of columns.
/// ///
/// This is equivalent to calling [DatabaseConnectionUser.selectOnly]. /// This is equivalent to calling [DatabaseConnectionUser.selectOnly].
JoinedSelectStatement<Tbl, Row> selectOnly( JoinedSelectStatement<Tbl, Row> selectOnly({bool distinct = false}) {
{bool distinct = false, bool includeJoinedTableColumns = true}) { return attachedDatabase.selectOnly(this, distinct: distinct);
return attachedDatabase.selectOnly(this,
distinct: distinct,
includeJoinedTableColumns: includeJoinedTableColumns);
} }
} }

View File

@ -189,7 +189,7 @@ void main() {
test('updates stream queries', () async { test('updates stream queries', () async {
await db.batch((b) { await db.batch((b) {
b.insert(db.todosTable, TodoEntry(id: 3, content: 'content')); b.insert(db.todosTable, const TodoEntry(id: 3, content: 'content'));
b.update(db.users, const UsersCompanion(name: Value('new user name'))); b.update(db.users, const UsersCompanion(name: Value('new user name')));
b.replace( b.replace(

View File

@ -77,7 +77,7 @@ void main() {
}); });
test('generated data classes can be converted to companions', () { test('generated data classes can be converted to companions', () {
final entry = Category( const entry = Category(
id: 3, id: 3,
description: 'description', description: 'description',
priority: CategoryPriority.low, priority: CategoryPriority.low,
@ -97,7 +97,7 @@ void main() {
}); });
test('data classes can be converted to companions with null to absent', () { test('data classes can be converted to companions with null to absent', () {
final entry = PureDefault(txt: null); const entry = PureDefault(txt: null);
expect(entry.toCompanion(false), expect(entry.toCompanion(false),
const PureDefaultsCompanion(txt: Value(null))); const PureDefaultsCompanion(txt: Value(null)));

View File

@ -33,14 +33,18 @@ void main() {
.go(); .go();
verify(executor.runDelete( verify(executor.runDelete(
'DELETE FROM users WHERE NOT is_awesome OR id < ?;', [100])); 'DELETE FROM users WHERE NOT is_awesome OR id < ?;', const [100]));
}); });
test('to delete an entity via a dataclasss', () async { test('to delete an entity via a dataclasss', () async {
await db.delete(db.sharedTodos).delete(SharedTodo(todo: 3, user: 2)); await db
.delete(db.sharedTodos)
.delete(const SharedTodo(todo: 3, user: 2));
verify(executor.runDelete( verify(executor.runDelete(
'DELETE FROM shared_todos WHERE todo = ? AND user = ?;', [3, 2])); 'DELETE FROM shared_todos WHERE todo = ? AND user = ?;',
const [3, 2],
));
}); });
}); });
@ -75,19 +79,19 @@ void main() {
test('delete()', () async { test('delete()', () async {
await db.users.delete().go(); await db.users.delete().go();
verify(executor.runDelete('DELETE FROM users;', [])); verify(executor.runDelete('DELETE FROM users;', const []));
}); });
test('deleteOne()', () async { test('deleteOne()', () async {
await db.users.deleteOne(const UsersCompanion(id: Value(3))); await db.users.deleteOne(const UsersCompanion(id: Value(3)));
verify(executor.runDelete('DELETE FROM users WHERE id = ?;', [3])); verify(executor.runDelete('DELETE FROM users WHERE id = ?;', const [3]));
}); });
test('deleteWhere', () async { test('deleteWhere', () async {
await db.users.deleteWhere((tbl) => tbl.id.isSmallerThanValue(3)); await db.users.deleteWhere((tbl) => tbl.id.isSmallerThanValue(3));
verify(executor.runDelete('DELETE FROM users WHERE id < ?;', [3])); verify(executor.runDelete('DELETE FROM users WHERE id < ?;', const [3]));
}); });
}); });
} }

View File

@ -55,7 +55,7 @@ void main() {
test('generates insert or replace statements', () async { test('generates insert or replace statements', () async {
await db.into(db.todosTable).insert( await db.into(db.todosTable).insert(
TodoEntry( const TodoEntry(
id: 113, id: 113,
content: 'Done', content: 'Done',
), ),
@ -452,7 +452,7 @@ void main() {
CategoriesCompanion.insert(description: 'description')); CategoriesCompanion.insert(description: 'description'));
expect( expect(
row, row,
Category( const Category(
id: 1, id: 1,
description: 'description', description: 'description',
descriptionInUpperCase: 'DESCRIPTION', descriptionInUpperCase: 'DESCRIPTION',
@ -503,7 +503,7 @@ void main() {
CategoriesCompanion.insert(description: 'description')); CategoriesCompanion.insert(description: 'description'));
expect( expect(
row, row,
Category( const Category(
id: 1, id: 1,
description: 'description', description: 'description',
descriptionInUpperCase: 'DESCRIPTION', descriptionInUpperCase: 'DESCRIPTION',

View File

@ -72,7 +72,7 @@ void main() {
expect( expect(
row.readTable(categories), row.readTable(categories),
Category( const Category(
id: 3, id: 3,
description: 'description', description: 'description',
priority: CategoryPriority.high, priority: CategoryPriority.high,
@ -107,7 +107,7 @@ void main() {
expect(() => row.readTable(db.categories), throwsArgumentError); expect(() => row.readTable(db.categories), throwsArgumentError);
expect( expect(
row.readTable(db.todosTable), row.readTable(db.todosTable),
TodoEntry( const TodoEntry(
id: 5, id: 5,
title: 'title', title: 'title',
content: 'content', content: 'content',
@ -224,7 +224,7 @@ void main() {
expect( expect(
result.readTable(categories), result.readTable(categories),
equals( equals(
Category( const Category(
id: 3, id: 3,
description: 'Description', description: 'Description',
descriptionInUpperCase: 'DESCRIPTION', descriptionInUpperCase: 'DESCRIPTION',
@ -274,7 +274,7 @@ void main() {
expect( expect(
result.readTable(categories), result.readTable(categories),
equals( equals(
Category( const Category(
id: 3, id: 3,
description: 'Description', description: 'Description',
descriptionInUpperCase: 'DESCRIPTION', descriptionInUpperCase: 'DESCRIPTION',
@ -330,7 +330,7 @@ void main() {
expect(result.readTableOrNull(todos), isNull); expect(result.readTableOrNull(todos), isNull);
expect( expect(
result.readTable(categories), result.readTable(categories),
Category( const Category(
id: 3, id: 3,
description: 'desc', description: 'desc',
descriptionInUpperCase: 'DESC', descriptionInUpperCase: 'DESC',
@ -406,8 +406,7 @@ void main() {
final categories = db.categories; final categories = db.categories;
final todos = db.todosTable; final todos = db.todosTable;
final query = final query = db.selectOnly(categories).join([
db.selectOnly(categories, includeJoinedTableColumns: false).join([
innerJoin( innerJoin(
todos, todos,
todos.category.equalsExp(categories.id), todos.category.equalsExp(categories.id),

View File

@ -187,6 +187,28 @@ void main() {
verify(executor.transactions.runCustom(any, any)); verify(executor.transactions.runCustom(any, any));
verifyNever(executor.runCustom(any, any)); verifyNever(executor.runCustom(any, any));
}); });
test('removes variables in `CREATE TABLE` statements', () async {
final executor = MockExecutor();
final db = _DefaultDb(executor);
late GeneratedColumn<int> column;
column = GeneratedColumn<int>(
'foo',
'foo',
true,
type: DriftSqlType.int,
check: () => column.isSmallerThan(const Variable(3)),
);
final table = CustomTable('foo', db, [column]);
await db.createMigrator().createTable(table);
await db.close();
// This should not attempt to generate a parameter (`?`)
// https://github.com/simolus3/drift/discussions/1936
verify(executor.runCustom(argThat(contains('CHECK(foo < 3)')), []));
});
} }
class _DefaultDb extends GeneratedDatabase { class _DefaultDb extends GeneratedDatabase {

View File

@ -14,7 +14,7 @@ final _dataOfTodoEntry = {
'category': 3 'category': 3
}; };
final _todoEntry = TodoEntry( const _todoEntry = TodoEntry(
id: 10, id: 10,
title: 'A todo title', title: 'A todo title',
content: 'Content', content: 'Content',
@ -123,7 +123,7 @@ void main() {
'category': null, 'category': null,
} }
]; ];
final resolved = TodoEntry( const resolved = TodoEntry(
id: 10, id: 10,
title: null, title: null,
content: 'Content', content: 'Content',
@ -193,7 +193,7 @@ void main() {
expect( expect(
category, category,
Category( const Category(
id: 1, id: 1,
description: 'description', description: 'description',
descriptionInUpperCase: 'DESCRIPTION', descriptionInUpperCase: 'DESCRIPTION',

View File

@ -52,7 +52,7 @@ void main() {
group('generates replace statements', () { group('generates replace statements', () {
test('regular', () async { test('regular', () async {
await db.update(db.todosTable).replace(TodoEntry( await db.update(db.todosTable).replace(const TodoEntry(
id: 3, id: 3,
title: 'Title', title: 'Title',
content: 'Updated content', content: 'Updated content',

View File

@ -43,7 +43,7 @@ void main() {
final user = db.sharedTodos.mapFromCompanion(companion, db); final user = db.sharedTodos.mapFromCompanion(companion, db);
expect( expect(
user, user,
SharedTodo(todo: 3, user: 4), const SharedTodo(todo: 3, user: 4),
); );
}); });
@ -58,7 +58,7 @@ void main() {
final todo = db.todosTable.mapFromRowOrNull(QueryRow(rowData, db)); final todo = db.todosTable.mapFromRowOrNull(QueryRow(rowData, db));
expect( expect(
todo, todo,
TodoEntry( const TodoEntry(
id: 1, id: 1,
title: 'some title', title: 'some title',
content: 'do this', content: 'do this',

View File

@ -12,7 +12,7 @@ class Config extends DataClass implements Insertable<Config> {
final String? configValue; final String? configValue;
final SyncType? syncState; final SyncType? syncState;
final SyncType? syncStateImplicit; final SyncType? syncStateImplicit;
Config( const Config(
{required this.configKey, {required this.configKey,
this.configValue, this.configValue,
this.syncState, this.syncState,
@ -292,7 +292,7 @@ class ConfigTable extends Table with TableInfo<ConfigTable, Config> {
class WithDefault extends DataClass implements Insertable<WithDefault> { class WithDefault extends DataClass implements Insertable<WithDefault> {
final String? a; final String? a;
final int? b; final int? b;
WithDefault({this.a, this.b}); const WithDefault({this.a, this.b});
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{}; final map = <String, Expression>{};
@ -565,7 +565,7 @@ class WithConstraint extends DataClass implements Insertable<WithConstraint> {
final String? a; final String? a;
final int b; final int b;
final double? c; final double? c;
WithConstraint({this.a, required this.b, this.c}); const WithConstraint({this.a, required this.b, this.c});
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{}; final map = <String, Expression>{};
@ -783,7 +783,7 @@ class MytableData extends DataClass implements Insertable<MytableData> {
final String? sometext; final String? sometext;
final bool? isInserting; final bool? isInserting;
final DateTime? somedate; final DateTime? somedate;
MytableData( const MytableData(
{required this.someid, this.sometext, this.isInserting, this.somedate}); {required this.someid, this.sometext, this.isInserting, this.somedate});
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
@ -1046,7 +1046,7 @@ class EMail extends DataClass implements Insertable<EMail> {
final String sender; final String sender;
final String title; final String title;
final String body; final String body;
EMail({required this.sender, required this.title, required this.body}); const EMail({required this.sender, required this.title, required this.body});
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{}; final map = <String, Expression>{};
@ -1261,7 +1261,7 @@ class Email extends Table
class WeirdData extends DataClass implements Insertable<WeirdData> { class WeirdData extends DataClass implements Insertable<WeirdData> {
final int sqlClass; final int sqlClass;
final String textColumn; final String textColumn;
WeirdData({required this.sqlClass, required this.textColumn}); const WeirdData({required this.sqlClass, required this.textColumn});
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{}; final map = <String, Expression>{};
@ -1444,7 +1444,7 @@ class MyViewData extends DataClass {
final String? configValue; final String? configValue;
final SyncType? syncState; final SyncType? syncState;
final SyncType? syncStateImplicit; final SyncType? syncStateImplicit;
MyViewData( const MyViewData(
{required this.configKey, {required this.configKey,
this.configValue, this.configValue,
this.syncState, this.syncState,

View File

@ -12,7 +12,7 @@ class Category extends DataClass implements Insertable<Category> {
final String description; final String description;
final CategoryPriority priority; final CategoryPriority priority;
final String descriptionInUpperCase; final String descriptionInUpperCase;
Category( const Category(
{required this.id, {required this.id,
required this.description, required this.description,
required this.priority, required this.priority,
@ -267,7 +267,7 @@ class TodoEntry extends DataClass implements Insertable<TodoEntry> {
final String content; final String content;
final DateTime? targetDate; final DateTime? targetDate;
final int? category; final int? category;
TodoEntry( const TodoEntry(
{required this.id, {required this.id,
this.title, this.title,
required this.content, required this.content,
@ -570,7 +570,7 @@ class User extends DataClass implements Insertable<User> {
final bool isAwesome; final bool isAwesome;
final Uint8List profilePicture; final Uint8List profilePicture;
final DateTime creationTime; final DateTime creationTime;
User( const User(
{required this.id, {required this.id,
required this.name, required this.name,
required this.isAwesome, required this.isAwesome,
@ -863,7 +863,7 @@ class $UsersTable extends Users with TableInfo<$UsersTable, User> {
class SharedTodo extends DataClass implements Insertable<SharedTodo> { class SharedTodo extends DataClass implements Insertable<SharedTodo> {
final int todo; final int todo;
final int user; final int user;
SharedTodo({required this.todo, required this.user}); const SharedTodo({required this.todo, required this.user});
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{}; final map = <String, Expression>{};
@ -1229,7 +1229,7 @@ class $TableWithoutPKTable extends TableWithoutPK
class PureDefault extends DataClass implements Insertable<PureDefault> { class PureDefault extends DataClass implements Insertable<PureDefault> {
final MyCustomObject? txt; final MyCustomObject? txt;
PureDefault({this.txt}); const PureDefault({this.txt});
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{}; final map = <String, Expression>{};
@ -1381,7 +1381,7 @@ class $PureDefaultsTable extends PureDefaults
class CategoryTodoCountViewData extends DataClass { class CategoryTodoCountViewData extends DataClass {
final String description; final String description;
final int itemCount; final int itemCount;
CategoryTodoCountViewData( const CategoryTodoCountViewData(
{required this.description, required this.itemCount}); {required this.description, required this.itemCount});
factory CategoryTodoCountViewData.fromJson(Map<String, dynamic> json, factory CategoryTodoCountViewData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) { {ValueSerializer? serializer}) {
@ -1477,8 +1477,7 @@ class $CategoryTodoCountViewView
@override @override
Query? get query => Query? get query =>
(attachedDatabase.selectOnly(categories, includeJoinedTableColumns: false) (attachedDatabase.selectOnly(categories)..addColumns($columns))
..addColumns($columns))
.join([innerJoin(todos, todos.category.equalsExp(categories.id))]) .join([innerJoin(todos, todos.category.equalsExp(categories.id))])
..groupBy([categories.id]); ..groupBy([categories.id]);
@override @override
@ -1488,7 +1487,7 @@ class $CategoryTodoCountViewView
class TodoWithCategoryViewData extends DataClass { class TodoWithCategoryViewData extends DataClass {
final String? title; final String? title;
final String description; final String description;
TodoWithCategoryViewData({this.title, required this.description}); const TodoWithCategoryViewData({this.title, required this.description});
factory TodoWithCategoryViewData.fromJson(Map<String, dynamic> json, factory TodoWithCategoryViewData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) { {ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer; serializer ??= driftRuntimeOptions.defaultSerializer;
@ -1580,9 +1579,7 @@ class $TodoWithCategoryViewView
} }
@override @override
Query? get query => (attachedDatabase.selectOnly(todos, Query? get query => (attachedDatabase.selectOnly(todos)..addColumns($columns))
includeJoinedTableColumns: false)
..addColumns($columns))
.join([innerJoin(categories, categories.id.equalsExp(todos.category))]); .join([innerJoin(categories, categories.id.equalsExp(todos.category))]);
@override @override
Set<String> get readTables => const {'todos', 'categories'}; Set<String> get readTables => const {'todos', 'categories'};

View File

@ -69,7 +69,7 @@ void main() {
test('can be used in a query stream', () async { test('can be used in a query stream', () async {
final stream = db.readView().watch(); final stream = db.readView().watch();
final entry = Config( const entry = Config(
configKey: 'another_key', configKey: 'another_key',
configValue: 'value', configValue: 'value',
syncState: SyncType.synchronized, syncState: SyncType.synchronized,
@ -141,7 +141,7 @@ void main() {
expect(result, hasLength(1)); expect(result, hasLength(1));
expect( expect(
result.single, result.single,
Config( const Config(
configKey: 'key2', configKey: 'key2',
configValue: 'val', configValue: 'val',
syncState: SyncType.locallyCreated, syncState: SyncType.locallyCreated,

View File

@ -124,7 +124,7 @@ void main() {
verify( verify(
mock.runSelect('SELECT * FROM config WHERE config_key = ?1', ['key'])); mock.runSelect('SELECT * FROM config WHERE config_key = ?1', ['key']));
expect(parsed, Config(configKey: 'key', configValue: 'value')); expect(parsed, const Config(configKey: 'key', configValue: 'value'));
}); });
test('applies default parameter expressions when not set', () async { test('applies default parameter expressions when not set', () async {
@ -167,7 +167,7 @@ void main() {
row: QueryRow(row, db), row: QueryRow(row, db),
a: 'text for a', a: 'text for a',
b: 42, b: 42,
c: WithConstraint(a: 'text', b: 1337, c: 18.7), c: const WithConstraint(a: 'text', b: 1337, c: 18.7),
), ),
); );
}); });
@ -216,7 +216,7 @@ void main() {
final entry = await db.readConfig('key').getSingle(); final entry = await db.readConfig('key').getSingle();
expect( expect(
entry, entry,
Config( const Config(
configKey: 'key', configKey: 'key',
configValue: 'value', configValue: 'value',
syncState: SyncType.locallyUpdated, syncState: SyncType.locallyUpdated,

View File

@ -104,7 +104,7 @@ void main() {
expect( expect(
entry, entry,
Category( const Category(
id: 1, id: 1,
description: 'Description', description: 'Description',
priority: CategoryPriority.low, priority: CategoryPriority.low,

View File

@ -14,7 +14,12 @@ void main() {
db.select(db.myView).watch(), db.select(db.myView).watch(),
emitsInOrder([ emitsInOrder([
isEmpty, isEmpty,
[MyViewData(configKey: 'another', syncState: SyncType.synchronized)] [
const MyViewData(
configKey: 'another',
syncState: SyncType.synchronized,
),
]
]), ]),
); );

View File

@ -40,7 +40,7 @@ void main() {
final result = await db.todoWithCategoryView.select().getSingle(); final result = await db.todoWithCategoryView.select().getSingle();
expect( expect(
result, result,
TodoWithCategoryViewData( const TodoWithCategoryViewData(
description: 'category description', title: 'title')); description: 'category description', title: 'title'));
}); });
} }

View File

@ -171,7 +171,10 @@ void _runTests(FutureOr<DriftIsolate> Function() spawner, bool terminateIsolate,
await expectLater(stream, emits(null)); await expectLater(stream, emits(null));
await database.into(database.todosTable).insert(initialCompanion); await database.into(database.todosTable).insert(initialCompanion);
await expectLater(stream, emits(TodoEntry(id: 1, content: 'my content'))); await expectLater(
stream,
emits(const TodoEntry(id: 1, content: 'my content')),
);
}); });
test('can start transactions', () async { test('can start transactions', () async {

View File

@ -29,3 +29,29 @@ class _NullExecutor extends Fake implements QueryExecutor {
@override @override
SqlDialect get dialect => SqlDialect.sqlite; SqlDialect get dialect => SqlDialect.sqlite;
} }
class CustomTable extends Table with TableInfo<CustomTable, Null> {
@override
final String actualTableName;
@override
final DatabaseConnectionUser attachedDatabase;
final List<GeneratedColumn<Object>> columns;
final String? _alias;
CustomTable(this.actualTableName, this.attachedDatabase, this.columns,
[this._alias]);
@override
List<GeneratedColumn<Object>> get $columns => columns;
@override
String get aliasedName => _alias ?? actualTableName;
@override
CustomTable createAlias(String alias) {
return CustomTable(actualTableName, attachedDatabase, columns, alias);
}
@override
Null map(Map<String, dynamic> data, {String? tablePrefix}) => null;
}

View File

@ -53,6 +53,10 @@ class DataClassWriter {
} }
// write constructor with named optional fields // write constructor with named optional fields
if (!scope.options.generateMutableClasses) {
_buffer.write('const ');
}
_buffer _buffer
..write(table.dartTypeName) ..write(table.dartTypeName)
..write('({') ..write('({')

View File

@ -132,8 +132,8 @@ class ViewWriter extends TableOrViewWriter {
buffer.write('@override\nQuery? get query => '); buffer.write('@override\nQuery? get query => ');
final query = view.viewQuery; final query = view.viewQuery;
if (query != null) { if (query != null) {
buffer.write('(attachedDatabase.selectOnly(${query.from}, ' buffer.write('(attachedDatabase.selectOnly(${query.from})'
'includeJoinedTableColumns: false)..addColumns(\$columns))' '..addColumns(\$columns))'
'${query.query};'); '${query.query};');
} else { } else {
buffer.write('null;\n'); buffer.write('null;\n');

View File

@ -2,7 +2,7 @@ import 'package:drift_dev/src/model/model.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
void main() { void main() {
test('removes leaading numbers', () { test('removes leading numbers', () {
expect(dartNameForSqlColumn('foo'), 'foo'); expect(dartNameForSqlColumn('foo'), 'foo');
expect(dartNameForSqlColumn('123a'), 'a'); expect(dartNameForSqlColumn('123a'), 'a');
}); });

View File

@ -0,0 +1,111 @@
import 'package:analyzer/dart/analysis/features.dart';
import 'package:analyzer/dart/analysis/utilities.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/file_system/memory_file_system.dart';
import 'package:build/build.dart';
import 'package:build_test/build_test.dart';
import 'package:drift_dev/src/backends/build/drift_builder.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:test/test.dart';
const _testInput = r'''
import 'package:drift/drift.dart';
part 'main.moor.dart';
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
}
@DriftDatabase(
tables: [Users],
)
class Database extends _$Database {}
''';
void main() {
test(
'generates const constructor for data classes can companion classes',
() async {
await testBuilder(
DriftPartBuilder(const BuilderOptions({})),
const {'a|lib/main.dart': _testInput},
reader: await PackageAssetReader.currentIsolate(),
outputs: const {
'a|lib/main.moor.dart': _GeneratesConstDataClasses(
{'User', 'UsersCompanion'},
),
},
);
},
tags: 'analyzer',
);
}
class _GeneratesConstDataClasses extends Matcher {
final Set<String> expectedWithConstConstructor;
const _GeneratesConstDataClasses(this.expectedWithConstConstructor);
@override
Description describe(Description description) {
return description.add('generates classes $expectedWithConstConstructor '
'const constructor.');
}
@override
bool matches(dynamic desc, Map matchState) {
// Parse the file, assure we don't have final fields in data classes.
final resourceProvider = MemoryResourceProvider();
if (desc is List<int>) {
resourceProvider.newFileWithBytes('/foo.dart', desc);
} else if (desc is String) {
resourceProvider.newFile('/foo.dart', desc);
} else {
desc['desc'] = 'Neither a List<int> or String - cannot be parsed';
return false;
}
final parsed = parseFile(
path: '/foo.dart',
featureSet: FeatureSet.fromEnableFlags2(
sdkLanguageVersion: Version(2, 12, 0),
flags: const [],
),
resourceProvider: resourceProvider,
throwIfDiagnostics: true,
).unit;
final remaining = expectedWithConstConstructor.toSet();
final definedClasses = parsed.declarations.whereType<ClassDeclaration>();
for (final definedClass in definedClasses) {
if (expectedWithConstConstructor.contains(definedClass.name.name)) {
final constructor = definedClass.getConstructor(null);
if (constructor?.constKeyword == null) {
matchState['desc'] = 'Constructor ${definedClass.name.name} is not '
'const.';
return false;
}
remaining.remove(definedClass.name.name);
}
}
// Also ensure that all expected classes were generated.
if (remaining.isNotEmpty) {
matchState['desc'] = 'Did not generate $remaining classes';
return false;
}
return true;
}
@override
Description describeMismatch(dynamic item, Description mismatchDescription,
Map matchState, bool verbose) {
return mismatchDescription
.add((matchState['desc'] as String?) ?? 'Had syntax errors');
}
}

View File

@ -76,8 +76,9 @@ class AppDatabase extends _$AppDatabase {
/// Returns an auto-updating stream of all todo entries in a given category /// Returns an auto-updating stream of all todo entries in a given category
/// id. /// id.
Stream<List<TodoEntryWithCategory>> entriesInCategory(int? categoryId) { Stream<List<TodoEntryWithCategory>> entriesInCategory(int? categoryId) {
final query = select(todoEntries).join( final query = select(todoEntries).join([
[leftOuterJoin(categories, categories.id.equalsExp(todoEntries.id))]); leftOuterJoin(categories, categories.id.equalsExp(todoEntries.category))
]);
if (categoryId != null) { if (categoryId != null) {
query.where(categories.id.equals(categoryId)); query.where(categories.id.equals(categoryId));

View File

@ -7,14 +7,14 @@ Thank you for understanding!
__To start using Drift, read our detailed [docs](https://drift.simonbinder.eu/docs/getting-started/).__ __To start using Drift, read our detailed [docs](https://drift.simonbinder.eu/docs/getting-started/).__
## Proudly Sponsored by [Stream 💙](https://getstream.io/chat/flutter/tutorial/?utm_source=Github&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Github_Jan2022_FlutterChat&utm_term=moor) ## Proudly Sponsored by [Stream 💙](https://getstream.io/chat/sdk/android/?utm_source=Moor&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Moor_July2022_AndroidChatSDK_klmh22)
<p align="center"> <p align="center">
<table> <table>
<tbody> <tbody>
<tr> <tr>
<td align="center"> <td align="center">
<a href="https://getstream.io/chat/flutter/tutorial/?utm_source=Github&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Github_Jan2022_FlutterChat&utm_term=moor" target="_blank"><img width="250px" src="https://stream-blog.s3.amazonaws.com/blog/wp-content/uploads/fc148f0fc75d02841d017bb36e14e388/Stream-logo-with-background-.png"/></a><br/><span><a href="https://getstream.io/chat/flutter/tutorial/?utm_source=Github&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Github_Jan2022_FlutterChat&utm_term=moor" target="_blank">Try the Flutter Chat Tutorial &nbsp💬</a></span> <a href="https://getstream.io/chat/sdk/android/?utm_source=Moor&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Moor_July2022_AndroidChatSDK_klmh22" target="_blank"><img width="250px" src="https://stream-blog.s3.amazonaws.com/blog/wp-content/uploads/fc148f0fc75d02841d017bb36e14e388/Stream-logo-with-background-.png"/></a><br/><span><a href="https://getstream.io/chat/sdk/android/?utm_source=Moor&utm_medium=Github_Repo_Content_Ad&utm_content=Developer&utm_campaign=Moor_July2022_AndroidChatSDK_klmh22" target="_blank">Try the Flutter Chat Tutorial &nbsp💬</a></span>
</td> </td>
</tr> </tr>
</tbody> </tbody>