From 020d63a9736a9c61b63bca8282ec5134c92b1609 Mon Sep 17 00:00:00 2001 From: Prasanna Venkatesh T S <17687018+vipranarayan14@users.noreply.github.com> Date: Tue, 18 Jul 2023 11:57:42 -0500 Subject: [PATCH 01/17] Fix typo in docs --- docs/pages/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/index.html b/docs/pages/index.html index 5ee2dc6f..78c66c17 100644 --- a/docs/pages/index.html +++ b/docs/pages/index.html @@ -80,7 +80,7 @@ Other database libraries can easily be integrated into drift as well. {% block "blocks/feature.html" icon="fas fa-star" title="And much more!" %} {% block "blocks/markdown.html" %} Drift provides auto-updating streams for all your queries, makes dealing with transactions and migrations easy -and lets your write modular database code with DAOs. We even have a [sql IDE]({{ 'docs/Using SQL/sql_ide.md' | pageUrl }}) builtin to the project +and lets your write modular database code with DAOs. We even have a [sql IDE]({{ 'docs/Using SQL/sql_ide.md' | pageUrl }}) builtin to the project. When using drift, working with databases in Dart is fun! {% endblock %} {% endblock %} From e98da9cecb9c4f3fcf177e8e9a32971d9de88f93 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 19 Jul 2023 23:36:00 +0200 Subject: [PATCH 02/17] Add tests for empty isIn behavior --- .../expressions_integration_test.dart | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/drift/test/database/expressions/expressions_integration_test.dart b/drift/test/database/expressions/expressions_integration_test.dart index d4ea60c5..56a34dee 100644 --- a/drift/test/database/expressions/expressions_integration_test.dart +++ b/drift/test/database/expressions/expressions_integration_test.dart @@ -201,6 +201,27 @@ void main() { .bitwiseAnd(Variable(BigInt.from(10)))), completion(BigInt.two)); }); + + group('isIn and isNotIn', () { + test('non-empty', () async { + expect(await eval(Variable.withInt(3).isIn([2, 4])), isFalse); + expect(await eval(Variable.withInt(3).isIn([3, 5])), isTrue); + + expect(await eval(Variable.withInt(3).isNotIn([2, 4])), isTrue); + expect(await eval(Variable.withInt(3).isNotIn([3, 5])), isFalse); + + expect(await eval(const Constant(null).isIn([2, 4])), isNull); + expect(await eval(const Constant(null).isNotIn([2, 4])), isNull); + }); + + test('empty', () async { + expect(await eval(Variable.withInt(3).isIn([])), isFalse); + expect(await eval(Variable.withInt(3).isNotIn([])), isTrue); + + expect(await eval(const Constant(null).isIn([])), isFalse); + expect(await eval(const Constant(null).isNotIn([])), isTrue); + }); + }); }); } From 03d3c837be1137231f318b171b9901f96c018a9c Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 20 Jul 2023 22:30:20 +0200 Subject: [PATCH 03/17] Add support for subqueries. Closes #612. --- docs/lib/snippets/queries.dart | 36 +++++ .../docs/Advanced Features/expressions.md | 5 + docs/pages/docs/Advanced Features/joins.md | 14 ++ drift/CHANGELOG.md | 4 + .../query_builder/components/join.dart | 3 +- .../query_builder/components/subquery.dart | 135 ++++++++++++++++++ .../query_builder/expressions/expression.dart | 2 +- .../src/runtime/query_builder/helpers.dart | 19 +++ .../runtime/query_builder/query_builder.dart | 3 + .../statements/select/select.dart | 22 ++- .../statements/select/select_with_join.dart | 82 ++++++----- drift/pubspec.yaml | 2 +- drift/test/database/statements/join_test.dart | 80 ++++++++++- .../test/database/statements/select_test.dart | 26 ++++ .../select_integration_test.dart | 48 +++++++ sqlparser/lib/src/engine/sql_engine.dart | 2 +- 16 files changed, 433 insertions(+), 50 deletions(-) create mode 100644 drift/lib/src/runtime/query_builder/components/subquery.dart create mode 100644 drift/lib/src/runtime/query_builder/helpers.dart diff --git a/docs/lib/snippets/queries.dart b/docs/lib/snippets/queries.dart index 79b60877..c763c30b 100644 --- a/docs/lib/snippets/queries.dart +++ b/docs/lib/snippets/queries.dart @@ -107,4 +107,40 @@ extension GroupByQueries on MyDatabase { }); } // #enddocregion createCategoryForUnassignedTodoEntries + + // #docregion subquery + Future> amountOfLengthyTodoItemsPerCategory() async { + final longestTodos = Subquery( + select(todos) + ..orderBy([(row) => OrderingTerm.desc(row.title.length)]) + ..limit(10), + 's', + ); + + // In the main query, we want to count how many entries in longestTodos were + // found for each category. But we can't access todos.title directly since + // we're not selecting from `todos`. Instead, we'll use Subquery.ref to read + // from a column in a subquery. + final itemCount = longestTodos.ref(todos.title).count(); + final query = select(categories).join( + [ + innerJoin( + longestTodos, + // Again using .ref() here to access the category in the outer select + // statement. + longestTodos.ref(todos.category).equalsExp(categories.id), + useColumns: false, + ) + ], + ) + ..addColumns([itemCount]) + ..groupBy([categories.id]); + + final rows = await query.get(); + + return [ + for (final row in rows) (row.readTable(categories), row.read(itemCount)!), + ]; + } + // #enddocregion subquery } diff --git a/docs/pages/docs/Advanced Features/expressions.md b/docs/pages/docs/Advanced Features/expressions.md index 48c7aa15..6e4e36d6 100644 --- a/docs/pages/docs/Advanced Features/expressions.md +++ b/docs/pages/docs/Advanced Features/expressions.md @@ -243,6 +243,11 @@ any rows. For instance, we could use this to find empty categories: {% include "blocks/snippet" snippets = snippets name = 'emptyCategories' %} +### Full subqueries + +Drift also supports subqueries that appear in `JOIN`s, which are described in the +[documentation for joins]({{ 'joins.md#subqueries' | pageUrl }}). + ## Custom expressions If you want to inline custom sql into Dart queries, you can use a `CustomExpression` class. It takes a `sql` parameter that lets you write custom expressions: diff --git a/docs/pages/docs/Advanced Features/joins.md b/docs/pages/docs/Advanced Features/joins.md index 5bc24b8d..e958a501 100644 --- a/docs/pages/docs/Advanced Features/joins.md +++ b/docs/pages/docs/Advanced Features/joins.md @@ -203,3 +203,17 @@ select statement. In the example, the `newDescription` expression as added as a column to the query. Then, the map entry `categories.description: newDescription` is used so that the `description` column for new category rows gets set to that expression. + +## Subqueries + +Starting from drift 2.11, you can use `Subquery` to use an existing select statement as part of more +complex join. + +This snippet uses `Subquery` to count how many of the top-10 todo items (by length of their title) are +in each category. +It does this by first creating a select statement for the top-10 items (but not executing it), and then +joining this select statement onto a larger one grouping by category: + +{% include "blocks/snippet" snippets = snippets name = 'subquery' %} + +Any statement can be used as a subquery. But be aware that, unlike [subquery expressions]({{ 'expressions.md#scalar-subqueries' | pageUrl }}), full subqueries can't use tables from the outer select statement. diff --git a/drift/CHANGELOG.md b/drift/CHANGELOG.md index f3cade29..da2bdae6 100644 --- a/drift/CHANGELOG.md +++ b/drift/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.11.0 + +- Add support for subqueries in the Dart query builder. + ## 2.10.0 - Adds the `schema steps` command to `drift_dev`. It generates an API making it diff --git a/drift/lib/src/runtime/query_builder/components/join.dart b/drift/lib/src/runtime/query_builder/components/join.dart index 5402797d..17ae9b82 100644 --- a/drift/lib/src/runtime/query_builder/components/join.dart +++ b/drift/lib/src/runtime/query_builder/components/join.dart @@ -58,8 +58,7 @@ class Join extends Component { context.buffer.write(' JOIN '); final resultSet = table as ResultSetImplementation; - context.buffer.write(resultSet.tableWithAlias); - context.watchedTables.add(resultSet); + context.writeResultSet(resultSet); if (_type != _JoinType.cross) { context.buffer.write(' ON '); diff --git a/drift/lib/src/runtime/query_builder/components/subquery.dart b/drift/lib/src/runtime/query_builder/components/subquery.dart new file mode 100644 index 00000000..56dcdfe9 --- /dev/null +++ b/drift/lib/src/runtime/query_builder/components/subquery.dart @@ -0,0 +1,135 @@ +part of '../query_builder.dart'; + +/// A subquery allows reading from another complex query in a join. +/// +/// An existing query can be constructed via [DatabaseConnectionUser.select] or +/// [DatabaseConnectionUser.selectOnly] and then wrapped in [Subquery] to be +/// used in another query. +/// +/// For instance, assuming database storing todo items with optional categories +/// (through a reference from todo items to categories), this query uses a +/// subquery to count how many of the top-10 todo items (by length) are in each +/// category: +/// +/// ```dart +/// final longestTodos = Subquery( +/// select(todosTable) +/// ..orderBy([(row) => OrderingTerm.desc(row.title.length)]) +/// ..limit(10), +/// 's', +/// ); +/// +/// final itemCount = subquery.ref(todosTable.id).count(); +/// final query = select(categories).join([ +/// innerJoin( +/// longestTodos, +/// subquery.ref(todosTable.category).equalsExp(categories.id), +/// useColumns: false, +/// )]) +/// ..groupBy([categories.id]) +/// ..addColumns([itemCount]); +/// ``` +/// +/// Note that the column from the subquery (here, the id of a todo entry) is not +/// directly available in the outer query, it needs to be accessed through +/// [Subquery.ref]. +/// Columns added to the top-level query (via [ref]) can be accessed directly +/// through [TypedResult.read]. When columns from a subquery are added to the +/// top-level select as well, [TypedResult.readTable] can be used to read an +/// entire row from the subquery. It returns a nested [TypedResult] for the +/// subquery. +/// +/// See also: [subqueryExpression], for subqueries which only return one row and +/// one column. +class Subquery extends ResultSetImplementation + implements HasResultSet { + /// The inner [select] statement of this subquery. + final BaseSelectStatement select; + @override + final String entityName; + + /// Creates a subqery from the inner [select] statement forming the base of + /// the subquery and a unique name of this subquery in the statement being + /// executed. + Subquery(this.select, this.entityName); + + /// Makes a column from the subquery available to the outer select statement. + /// + /// For instance, consider a complex column like `subqueryContentLength` being + /// added into a subquery: + /// + /// ```dart + /// final subqueryContentLength = todoEntries.content.length.sum(); + /// final subquery = Subquery( + /// db.selectOnly(todoEntries) + /// ..addColumns([todoEntries.category, subqueryContentLength]) + /// ..groupBy([todoEntries.category]), + /// 's'); + /// ``` + /// + /// When the `subqueryContentLength` column gets written, drift will write + /// the actual `SUM()` expression which is only valid in the subquery itself. + /// When an outer query joining the subqery wants to read the column, it needs + /// to refer to that expression by name. This is what [ref] is doing: + /// + /// ```dart + /// final readableLength = subquery.ref(subqueryContentLength); + /// final query = selectOnly(categories) + /// ..addColumns([categories.id, readableLength]) + /// ..join([ + /// innerJoin(subquery, + /// subquery.ref(db.todosTable.category).equalsExp(db.categories.id)) + /// ]); + /// ``` + /// + /// Here, [ref] is called two times: Once to obtain a column selected by the + /// outer query and once as a join condition. + /// + /// [ref] needs to be used every time a column from a subquery is used in an + /// outer query, regardless of the context. + Expression ref(Expression inner) { + final name = select._nameForColumn(inner); + if (name == null) { + throw ArgumentError( + 'The source select statement does not contain that column'); + } + + return columnsByName[name]!.dartCast(); + } + + @override + late final List> $columns = [ + for (final (expr, name) in select._expandedColumns) + GeneratedColumn( + name, + entityName, + true, + type: expr.driftSqlType, + ), + ]; + + @override + late final Map> columnsByName = { + for (final column in $columns) column.name: column, + }; + + @override + Subquery get asDslTable => this; + + @override + DatabaseConnectionUser get attachedDatabase => (select as Query).database; + + @override + FutureOr map(Map data, {String? tablePrefix}) { + if (tablePrefix == null) { + return select._mapRow(data); + } else { + final withoutPrefix = { + for (final MapEntry(:key, :value) in columnsByName.entries) + key: data['$tablePrefix.$value'] + }; + + return select._mapRow(withoutPrefix); + } + } +} diff --git a/drift/lib/src/runtime/query_builder/expressions/expression.dart b/drift/lib/src/runtime/query_builder/expressions/expression.dart index fb3680af..a062c8ba 100644 --- a/drift/lib/src/runtime/query_builder/expressions/expression.dart +++ b/drift/lib/src/runtime/query_builder/expressions/expression.dart @@ -503,7 +503,7 @@ class FunctionCallExpression extends Expression { } void _checkSubquery(BaseSelectStatement statement) { - final columns = statement._returnedColumnCount; + final columns = statement._expandedColumns.length; if (columns != 1) { throw ArgumentError.value(statement, 'statement', 'Must return exactly one column (actually returns $columns)'); diff --git a/drift/lib/src/runtime/query_builder/helpers.dart b/drift/lib/src/runtime/query_builder/helpers.dart new file mode 100644 index 00000000..c88eb9d4 --- /dev/null +++ b/drift/lib/src/runtime/query_builder/helpers.dart @@ -0,0 +1,19 @@ +import 'package:drift/drift.dart'; + +/// Utilities for writing the definition of a result set into a query. +extension WriteDefinition on GenerationContext { + /// Writes the result set to this context, suitable to implement `FROM` + /// clauses and joins. + void writeResultSet(ResultSetImplementation resultSet) { + if (resultSet is Subquery) { + buffer.write('('); + resultSet.select.writeInto(this); + buffer + ..write(') ') + ..write(resultSet.aliasedName); + } else { + buffer.write(resultSet.tableWithAlias); + watchedTables.add(resultSet); + } + } +} diff --git a/drift/lib/src/runtime/query_builder/query_builder.dart b/drift/lib/src/runtime/query_builder/query_builder.dart index 69c8f77e..25ce7e1d 100644 --- a/drift/lib/src/runtime/query_builder/query_builder.dart +++ b/drift/lib/src/runtime/query_builder/query_builder.dart @@ -23,8 +23,10 @@ import 'package:meta/meta.dart'; import '../../utils/async.dart'; // New files should not be part of this mega library, which we're trying to // split up. + import 'expressions/case_when.dart'; import 'expressions/internal.dart'; +import 'helpers.dart'; export 'expressions/bitwise.dart'; export 'expressions/case_when.dart'; @@ -34,6 +36,7 @@ part 'components/group_by.dart'; part 'components/join.dart'; part 'components/limit.dart'; part 'components/order_by.dart'; +part 'components/subquery.dart'; part 'components/where.dart'; part 'expressions/aggregate.dart'; part 'expressions/algebra.dart'; diff --git a/drift/lib/src/runtime/query_builder/statements/select/select.dart b/drift/lib/src/runtime/query_builder/statements/select/select.dart index 7c179024..9b9140f6 100644 --- a/drift/lib/src/runtime/query_builder/statements/select/select.dart +++ b/drift/lib/src/runtime/query_builder/statements/select/select.dart @@ -8,12 +8,14 @@ typedef OrderClauseGenerator = OrderingTerm Function(T tbl); /// /// Users are not allowed to extend, implement or mix-in this class. @sealed -abstract class BaseSelectStatement extends Component { - int get _returnedColumnCount; +abstract class BaseSelectStatement extends Component { + Iterable<(Expression, String)> get _expandedColumns; /// The name for the given [expression] in the result set, or `null` if /// [expression] was not added as a column to this select statement. String? _nameForColumn(Expression expression); + + FutureOr _mapRow(Map fromDatabase); } /// A select statement that doesn't use joins. @@ -21,7 +23,7 @@ abstract class BaseSelectStatement extends Component { /// For more information, see [DatabaseConnectionUser.select]. class SimpleSelectStatement extends Query with SingleTableQueryMixin, LimitContainerMixin, Selectable - implements BaseSelectStatement { + implements BaseSelectStatement { /// Whether duplicate rows should be eliminated from the result (this is a /// `SELECT DISTINCT` statement in sql). Defaults to false. final bool distinct; @@ -39,7 +41,8 @@ class SimpleSelectStatement extends Query Set get watchedTables => {table}; @override - int get _returnedColumnCount => table.$columns.length; + Iterable<(Expression, String)> get _expandedColumns => + table.$columns.map((e) => (e, e.name)); @override String? _nameForColumn(Expression expression) { @@ -54,8 +57,8 @@ class SimpleSelectStatement extends Query void writeStartPart(GenerationContext ctx) { ctx.buffer ..write(_beginOfSelect(distinct)) - ..write(' * FROM ${table.tableWithAlias}'); - ctx.watchedTables.add(table); + ..write(' * FROM '); + ctx.writeResultSet(table); } @override @@ -82,8 +85,13 @@ class SimpleSelectStatement extends Query }); } + @override + FutureOr _mapRow(Map row) { + return table.map(row); + } + Future> _mapResponse(List> rows) { - return rows.mapAsyncAndAwait(table.map); + return rows.mapAsyncAndAwait(_mapRow); } /// Creates a select statement that operates on more than one table by diff --git a/drift/lib/src/runtime/query_builder/statements/select/select_with_join.dart b/drift/lib/src/runtime/query_builder/statements/select/select_with_join.dart index 036ec079..7e0497a2 100644 --- a/drift/lib/src/runtime/query_builder/statements/select/select_with_join.dart +++ b/drift/lib/src/runtime/query_builder/statements/select/select_with_join.dart @@ -31,11 +31,11 @@ class JoinedSelectStatement /// /// Each table column can be uniquely identified by its (potentially aliased) /// table and its name. So a column named `id` in a table called `users` would - /// be written as `users.id AS "users.id"`. These columns will NOT be written - /// into this map. + /// be written as `users.id AS "users.id"`. These columns are also included in + /// the map when added through [addColumns], but they have a predicatable name. /// - /// Other expressions used as columns will be included here. There just named - /// in increasing order, so something like `AS c3`. + /// More interestingly, other expressions used as columns will be included + /// here. They're just named in increasing order, so something like `AS c3`. final Map _columnAliases = {}; /// The tables this select statement reads from @@ -44,13 +44,16 @@ class JoinedSelectStatement Set get watchedTables => _queriedTables().toSet(); @override - int get _returnedColumnCount { - return _joins.fold(_selectedColumns.length, (prev, join) { - if (join.includeInResult ?? _includeJoinedTablesInResult) { - return prev + (join.table as ResultSetImplementation).$columns.length; + Iterable<(Expression, String)> get _expandedColumns sync* { + for (final column in _selectedColumns) { + yield (column, _columnAliases[column]!); + } + + for (final table in _queriedTables(true)) { + for (final column in table.$columns) { + yield (column, _nameForTableColumn(column)); } - return prev; - }); + } } @override @@ -122,9 +125,8 @@ class JoinedSelectStatement chosenAlias = _nameForTableColumn(column, generatingForView: ctx.generatingForView); } else { - chosenAlias = 'c$i'; + chosenAlias = _columnAliases[column]!; } - _columnAliases[column] = chosenAlias; column.writeInto(ctx); ctx.buffer @@ -133,8 +135,8 @@ class JoinedSelectStatement ..write('"'); } - ctx.buffer.write(' FROM ${table.tableWithAlias}'); - ctx.watchedTables.add(table); + ctx.buffer.write(' FROM '); + ctx.writeResultSet(table); if (_joins.isNotEmpty) { ctx.writeWhitespace(); @@ -195,7 +197,21 @@ class JoinedSelectStatement /// - The docs on expressions: https://drift.simonbinder.eu/docs/getting-started/expressions/ /// {@endtemplate} void addColumns(Iterable expressions) { - _selectedColumns.addAll(expressions); + for (final expression in expressions) { + // Otherwise, we generate an alias. + _columnAliases.putIfAbsent(expression, () { + // Only add the column if it hasn't been added yet - it's fine if the + // same column is added multiple times through the Dart API, they will + // read from the same SQL column internally. + _selectedColumns.add(expression); + + if (expression is GeneratedColumn) { + return _nameForTableColumn(expression); + } else { + return 'c${_columnAliases.length}'; + } + }); + } } /// Adds more joined tables to this [JoinedSelectStatement]. @@ -233,14 +249,14 @@ class JoinedSelectStatement return database .createStream(fetcher) - .asyncMapPerSubscription((rows) => _mapResponse(ctx, rows)); + .asyncMapPerSubscription((rows) => _mapResponse(rows)); } @override Future> get() async { final ctx = constructQuery(); final raw = await _getRaw(ctx); - return _mapResponse(ctx, raw); + return _mapResponse(raw); } Future>> _getRaw(GenerationContext ctx) { @@ -260,24 +276,26 @@ class JoinedSelectStatement }); } - Future> _mapResponse( - GenerationContext ctx, List> rows) { - return Future.wait(rows.map((row) async { - final readTables = {}; + @override + Future _mapRow(Map row) async { + final readTables = {}; - for (final table in _queriedTables(true)) { - final prefix = '${table.aliasedName}.'; - // if all columns of this table are null, skip the table - if (table.$columns.any((c) => row[prefix + c.$name] != null)) { - readTables[table] = - await table.map(row, tablePrefix: table.aliasedName); - } + for (final table in _queriedTables(true)) { + final prefix = '${table.aliasedName}.'; + // if all columns of this table are null, skip the table + if (table.$columns.any((c) => row[prefix + c.$name] != null)) { + readTables[table] = + await table.map(row, tablePrefix: table.aliasedName); } + } - final driftRow = QueryRow(row, database); - return TypedResult( - readTables, driftRow, _LazyExpressionMap(_columnAliases, driftRow)); - })); + final driftRow = QueryRow(row, database); + return TypedResult( + readTables, driftRow, _LazyExpressionMap(_columnAliases, driftRow)); + } + + Future> _mapResponse(List> rows) { + return Future.wait(rows.map(_mapRow)); } Never _warnAboutDuplicate( diff --git a/drift/pubspec.yaml b/drift/pubspec.yaml index 69f93ea2..e954f337 100644 --- a/drift/pubspec.yaml +++ b/drift/pubspec.yaml @@ -1,6 +1,6 @@ name: drift description: Drift is a reactive library to store relational data in Dart and Flutter applications. -version: 2.10.0 +version: 2.11.0-dev repository: https://github.com/simolus3/drift homepage: https://drift.simonbinder.eu/ issue_tracker: https://github.com/simolus3/drift/issues diff --git a/drift/test/database/statements/join_test.dart b/drift/test/database/statements/join_test.dart index aa270977..5dceff45 100644 --- a/drift/test/database/statements/join_test.dart +++ b/drift/test/database/statements/join_test.dart @@ -224,7 +224,7 @@ void main() { 'c.desc': 'Description', 'c.description_in_upper_case': 'DESCRIPTION', 'c.priority': 1, - 'c4': 11 + 'c0': 11 } ]; }); @@ -234,7 +234,7 @@ void main() { verify(executor.runSelect( 'SELECT "c"."id" AS "c.id", "c"."desc" AS "c.desc", ' '"c"."priority" AS "c.priority", "c"."description_in_upper_case" AS ' - '"c.description_in_upper_case", LENGTH("c"."desc") AS "c4" ' + '"c.description_in_upper_case", LENGTH("c"."desc") AS "c0" ' 'FROM "categories" "c";', [], )); @@ -273,7 +273,7 @@ void main() { 'c.desc': 'Description', 'c.description_in_upper_case': 'DESCRIPTION', 'c.priority': 1, - 'c4': 11, + 'c0': 11, }, ]; }); @@ -283,7 +283,7 @@ void main() { verify(executor.runSelect( 'SELECT "c"."id" AS "c.id", "c"."desc" AS "c.desc", "c"."priority" AS "c.priority"' ', "c"."description_in_upper_case" AS "c.description_in_upper_case", ' - 'LENGTH("c"."desc") AS "c4" ' + 'LENGTH("c"."desc") AS "c0" ' 'FROM "categories" "c" ' 'INNER JOIN "todos" "t" ON "c"."id" = "t"."category";', [], @@ -328,7 +328,7 @@ void main() { 'c.id': 3, 'c.desc': 'desc', 'c.priority': 0, - 'c4': 10, + 'c0': 10, 'c.description_in_upper_case': 'DESC', } ]; @@ -340,7 +340,7 @@ void main() { 'SELECT "c"."id" AS "c.id", "c"."desc" AS "c.desc", ' '"c"."priority" AS "c.priority", ' '"c"."description_in_upper_case" AS "c.description_in_upper_case", ' - 'COUNT("t"."id") AS "c4" ' + 'COUNT("t"."id") AS "c0" ' 'FROM "categories" "c" INNER JOIN "todos" "t" ON "t"."category" = "c"."id" ' 'GROUP BY "c"."id" HAVING COUNT("t"."id") >= ?;', [10])); @@ -474,4 +474,72 @@ void main() { throwsA(isNot(isA())), ); }); + + group('subquery', () { + test('can be joined', () async { + final subquery = Subquery( + db.select(db.todosTable) + ..orderBy([(row) => OrderingTerm.desc(row.title.length)]) + ..limit(10), + 's', + ); + + final query = db.selectOnly(db.categories) + ..addColumns([db.categories.id]) + ..join([ + innerJoin(subquery, + subquery.ref(db.todosTable.category).equalsExp(db.categories.id)) + ]); + await query.get(); + + verify( + executor.runSelect( + 'SELECT "categories"."id" AS "categories.id" FROM "categories" ' + 'INNER JOIN (SELECT * FROM "todos" ' + 'ORDER BY LENGTH("todos"."title") DESC LIMIT 10) s ' + 'ON "s"."category" = "categories"."id";', + argThat(isEmpty), + ), + ); + }); + + test('use column from subquery', () async { + when(executor.runSelect(any, any)).thenAnswer((_) { + return Future.value([ + {'c0': 42} + ]); + }); + + final sumOfTitleLength = db.todosTable.title.length.sum(); + final subquery = Subquery( + db.selectOnly(db.todosTable) + ..addColumns([db.todosTable.category, sumOfTitleLength]) + ..groupBy([db.todosTable.category]), + 's'); + + final readableLength = subquery.ref(sumOfTitleLength); + final query = db.selectOnly(db.categories) + ..addColumns([readableLength]) + ..join([ + innerJoin(subquery, + subquery.ref(db.todosTable.category).equalsExp(db.categories.id)) + ]); + + final row = await query.getSingle(); + + verify( + executor.runSelect( + 'SELECT "s"."c1" AS "c0" FROM "categories" ' + 'INNER JOIN (' + 'SELECT "todos"."category" AS "todos.category", ' + 'SUM(LENGTH("todos"."title")) AS "c1" FROM "todos" ' + 'GROUP BY "todos"."category") s ' + 'ON "s"."todos.category" = "categories"."id";', + argThat(isEmpty), + ), + ); + + expect(row.read(readableLength), 42); + }); + }); } diff --git a/drift/test/database/statements/select_test.dart b/drift/test/database/statements/select_test.dart index 1020dcb0..b92237f9 100644 --- a/drift/test/database/statements/select_test.dart +++ b/drift/test/database/statements/select_test.dart @@ -214,4 +214,30 @@ void main() { await pumpEventQueue(); db.markTablesUpdated([db.categories]); }); + + test('select from subquery', () async { + final data = [ + { + 'id': 10, + 'title': null, + 'content': 'Content', + 'category': null, + } + ]; + when(executor.runSelect(any, any)).thenAnswer((_) => Future.value(data)); + + final subquery = Subquery(db.todosTable.select(), 's'); + final rows = await db.select(subquery).get(); + + expect(rows, [ + TodoEntry( + id: 10, + title: null, + content: 'Content', + category: null, + ) + ]); + + verify(executor.runSelect('SELECT * FROM (SELECT * FROM "todos") s;', [])); + }); } diff --git a/drift/test/integration_tests/select_integration_test.dart b/drift/test/integration_tests/select_integration_test.dart index 6be129f0..09b85241 100644 --- a/drift/test/integration_tests/select_integration_test.dart +++ b/drift/test/integration_tests/select_integration_test.dart @@ -50,4 +50,52 @@ void main() { expect(await db.users.all().get(), [user]); }); + + test('subqueries', () async { + await db.batch((batch) { + batch.insertAll(db.categories, [ + CategoriesCompanion.insert(description: 'a'), + CategoriesCompanion.insert(description: 'b'), + ]); + + batch.insertAll( + db.todosTable, + [ + TodosTableCompanion.insert(content: 'aaaaa', category: Value(1)), + TodosTableCompanion.insert(content: 'aa', category: Value(1)), + TodosTableCompanion.insert(content: 'bbbbbb', category: Value(2)), + ], + ); + }); + + // Now write a query returning the amount of content chars in each + // category (written using subqueries). + final subqueryContentLength = db.todosTable.content.length.sum(); + final subquery = Subquery( + db.selectOnly(db.todosTable) + ..addColumns([db.todosTable.category, subqueryContentLength]) + ..groupBy([db.todosTable.category]), + 's'); + + final readableLength = subquery.ref(subqueryContentLength); + final query = db.selectOnly(db.categories) + ..addColumns([db.categories.id, readableLength]) + ..join([ + innerJoin(subquery, + subquery.ref(db.todosTable.category).equalsExp(db.categories.id)) + ]) + ..orderBy([OrderingTerm.asc(db.categories.id)]); + + final rows = await query.get(); + expect(rows, hasLength(2)); + + final first = rows[0]; + final second = rows[1]; + + expect(first.read(db.categories.id), 1); + expect(first.read(readableLength), 7); + + expect(second.read(db.categories.id), 2); + expect(second.read(readableLength), 6); + }); } diff --git a/sqlparser/lib/src/engine/sql_engine.dart b/sqlparser/lib/src/engine/sql_engine.dart index 6a96d229..be66a0f4 100644 --- a/sqlparser/lib/src/engine/sql_engine.dart +++ b/sqlparser/lib/src/engine/sql_engine.dart @@ -145,7 +145,7 @@ class SqlEngine { /// Parses multiple [sql] statements, separated by a semicolon. /// - /// You can use the [AstNode.children] of the returned [ParseResult.rootNode] + /// You can use the [AstNode.childNodes] of the returned [ParseResult.rootNode] /// to inspect the returned statements. ParseResult parseMultiple(String sql) { final tokens = tokenize(sql); From ed06182dbfa4dfb9c50a98dfa77d02281a840583 Mon Sep 17 00:00:00 2001 From: fabiancrx <37002358+fabiancrx@users.noreply.github.com> Date: Fri, 21 Jul 2023 11:59:46 -0400 Subject: [PATCH 04/17] fix typo --- docs/pages/docs/Examples/existing_databases.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/docs/Examples/existing_databases.md b/docs/pages/docs/Examples/existing_databases.md index b176ad1d..4667d8d8 100644 --- a/docs/pages/docs/Examples/existing_databases.md +++ b/docs/pages/docs/Examples/existing_databases.md @@ -75,7 +75,7 @@ class MyDatabase extends _$MyDatabase { // ... ``` -## Exporting a databasee +## Exporting a database To export a sqlite3 database into a file, you can use the `VACUUM INTO` statement. Inside your database class, this could look like the following: From cc83a58d8987118514edbebd78f7e66eb6bc1a09 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 21 Jul 2023 21:04:03 +0200 Subject: [PATCH 05/17] Report invalid statements in parseMultiple --- sqlparser/lib/src/reader/parser.dart | 5 +++ .../test/parser/multiple_statements_test.dart | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/sqlparser/lib/src/reader/parser.dart b/sqlparser/lib/src/reader/parser.dart index a272b58a..01b60f86 100644 --- a/sqlparser/lib/src/reader/parser.dart +++ b/sqlparser/lib/src/reader/parser.dart @@ -189,9 +189,14 @@ class Parser { final first = _peek; final statements = []; while (!_isAtEnd) { + final firstForStatement = _peek; final statement = _parseAsStatement(_statementWithoutSemicolon); + if (statement != null) { statements.add(statement); + } else { + statements + .add(InvalidStatement()..setSpan(firstForStatement, _previous)); } } diff --git a/sqlparser/test/parser/multiple_statements_test.dart b/sqlparser/test/parser/multiple_statements_test.dart index 1f999404..c43aedaa 100644 --- a/sqlparser/test/parser/multiple_statements_test.dart +++ b/sqlparser/test/parser/multiple_statements_test.dart @@ -143,4 +143,35 @@ void main() { ), ); }); + + test('parseMultiple reports spans for invalid statements', () { + const sql = ''' +UPDATE users SET foo = bar; +ALTER TABLE this syntax is not yet supported; +SELECT * FROM users; +'''; + + final engine = SqlEngine(); + final ast = engine.parseMultiple(sql).rootNode; + enforceHasSpan(ast); + + final statements = ast.childNodes.toList(); + expect(statements, hasLength(3)); + + expect( + statements[0], + isA() + .having((e) => e.span?.text, 'span', 'UPDATE users SET foo = bar;'), + ); + expect( + statements[1], + isA().having((e) => e.span?.text, 'span', + 'ALTER TABLE this syntax is not yet supported;'), + ); + expect( + statements[2], + isA() + .having((e) => e.span?.text, 'span', 'SELECT * FROM users;'), + ); + }); } From cb7de63a3fd071d4fb9f26d1983f72a38eb95ac3 Mon Sep 17 00:00:00 2001 From: Amir Panahandeh Date: Sat, 22 Jul 2023 09:26:26 +0330 Subject: [PATCH 06/17] Fix Assertion failure during setting up router --- examples/app/lib/main.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/app/lib/main.dart b/examples/app/lib/main.dart index aca8176b..05f77b34 100644 --- a/examples/app/lib/main.dart +++ b/examples/app/lib/main.dart @@ -36,8 +36,7 @@ class MyApp extends StatelessWidget { primarySwatch: Colors.amber, typography: Typography.material2018(), ), - routeInformationParser: _router.routeInformationParser, - routerDelegate: _router.routerDelegate, + routerConfig: _router, ), ); } From addb68fb454519eee92c86be1a74ca4648cdd86b Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Sat, 22 Jul 2023 15:14:38 +0200 Subject: [PATCH 07/17] Upgrade go_router in example app --- examples/app/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/app/pubspec.yaml b/examples/app/pubspec.yaml index 358b875a..1323ef87 100644 --- a/examples/app/pubspec.yaml +++ b/examples/app/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: file_picker: ^5.2.5 flutter_colorpicker: ^1.0.3 flutter_riverpod: ^2.3.0 - go_router: ^9.0.0 + go_router: ^10.0.0 intl: ^0.18.0 sqlite3_flutter_libs: ^0.5.5 sqlite3: ^2.0.0 From b3ea00b8af17622407308bd1b8beb3bb118e3948 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Sun, 23 Jul 2023 17:31:08 +0200 Subject: [PATCH 08/17] Add `isIn` methods based on expressions --- drift/CHANGELOG.md | 2 ++ .../query_builder/expressions/expression.dart | 20 +++++++++++++++++-- .../runtime/query_builder/expressions/in.dart | 12 +++++------ .../expressions/in_expression_test.dart | 20 +++++++++++++++++++ 4 files changed, 45 insertions(+), 9 deletions(-) diff --git a/drift/CHANGELOG.md b/drift/CHANGELOG.md index da2bdae6..1393415c 100644 --- a/drift/CHANGELOG.md +++ b/drift/CHANGELOG.md @@ -1,6 +1,8 @@ ## 2.11.0 - Add support for subqueries in the Dart query builder. +- Add `isInExp` and `isNotInExp` to construct `IS IN` expressions with arbitrary + expressions. ## 2.10.0 diff --git a/drift/lib/src/runtime/query_builder/expressions/expression.dart b/drift/lib/src/runtime/query_builder/expressions/expression.dart index a062c8ba..b72ea493 100644 --- a/drift/lib/src/runtime/query_builder/expressions/expression.dart +++ b/drift/lib/src/runtime/query_builder/expressions/expression.dart @@ -142,13 +142,29 @@ abstract class Expression implements FunctionParameter { /// An expression that is true if `this` resolves to any of the values in /// [values]. Expression isIn(Iterable values) { - return _InExpression(this, values.toList(), false); + return isInExp([for (final value in values) Variable(value)]); } /// An expression that is true if `this` does not resolve to any of the values /// in [values]. Expression isNotIn(Iterable values) { - return _InExpression(this, values.toList(), true); + return isNotInExp([for (final value in values) Variable(value)]); + } + + /// An expression that evaluates to `true` if this expression resolves to a + /// value that one of the [expressions] resolve to as well. + /// + /// For an "is in" comparison with values, use [isIn]. + Expression isInExp(List> expressions) { + return _InExpression(this, expressions, false); + } + + /// An expression that evaluates to `true` if this expression does not resolve + /// to any value that the [expressions] resolve to. + /// + /// For an "is not in" comparison with values, use [isNotIn]. + Expression isNotInExp(List> expressions) { + return _InExpression(this, expressions, true); } /// An expression checking whether `this` is included in any row of the diff --git a/drift/lib/src/runtime/query_builder/expressions/in.dart b/drift/lib/src/runtime/query_builder/expressions/in.dart index 073f3ba4..e56ddfc3 100644 --- a/drift/lib/src/runtime/query_builder/expressions/in.dart +++ b/drift/lib/src/runtime/query_builder/expressions/in.dart @@ -1,6 +1,6 @@ part of '../query_builder.dart'; -abstract class _BaseInExpression extends Expression { +sealed class _BaseInExpression extends Expression { final Expression _expression; final bool _not; @@ -25,8 +25,8 @@ abstract class _BaseInExpression extends Expression { void _writeValues(GenerationContext context); } -class _InExpression extends _BaseInExpression { - final List _values; +final class _InExpression extends _BaseInExpression { + final List> _values; _InExpression(Expression expression, this._values, bool not) : super(expression, not); @@ -35,15 +35,13 @@ class _InExpression extends _BaseInExpression { void _writeValues(GenerationContext context) { var first = true; for (final value in _values) { - final variable = Variable(value); - if (first) { first = false; } else { context.buffer.write(', '); } - variable.writeInto(context); + value.writeInto(context); } } @@ -59,7 +57,7 @@ class _InExpression extends _BaseInExpression { } } -class _InSelectExpression extends _BaseInExpression { +final class _InSelectExpression extends _BaseInExpression { final BaseSelectStatement _select; _InSelectExpression(this._select, Expression expression, bool not) diff --git a/drift/test/database/expressions/in_expression_test.dart b/drift/test/database/expressions/in_expression_test.dart index f83ce34d..a6b6c2e7 100644 --- a/drift/test/database/expressions/in_expression_test.dart +++ b/drift/test/database/expressions/in_expression_test.dart @@ -23,6 +23,26 @@ void main() { }); }); + group('expressions', () { + test('in', () { + final isInExpression = innerExpression.isInExp([ + CustomExpression('a'), + CustomExpression('b'), + ]); + + expect(isInExpression, generates('name IN (a, b)')); + }); + + test('not in', () { + final isNotInExpression = innerExpression.isNotInExp([ + CustomExpression('a'), + CustomExpression('b'), + ]); + + expect(isNotInExpression, generates('name NOT IN (a, b)')); + }); + }); + group('subquery', () { test('in expressions are generated', () { final isInExpression = innerExpression From 3f74d65e6900959f33f0f3b64b7a47f250905c4e Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Sun, 23 Jul 2023 23:57:00 +0200 Subject: [PATCH 09/17] Support nested columns for any result set --- .../query_builder/schema/table_info.dart | 2 +- .../resolver/queries/existing_row_class.dart | 69 +++--- .../resolver/queries/query_analyzer.dart | 35 ++- drift_dev/lib/src/analysis/results/dart.dart | 10 +- drift_dev/lib/src/analysis/results/query.dart | 144 +++++++++-- .../lib/src/writer/queries/query_writer.dart | 225 +++++++++--------- .../src/writer/queries/result_set_writer.dart | 29 ++- .../lib/src/writer/queries/sql_writer.dart | 4 +- .../queries/existing_row_classes_test.dart | 68 ++---- .../resolver/queries/query_analyzer_test.dart | 22 +- .../test/analysis/resolver/queries/utils.dart | 46 ++++ .../src/analysis/types/resolving_visitor.dart | 7 + 12 files changed, 418 insertions(+), 243 deletions(-) diff --git a/drift/lib/src/runtime/query_builder/schema/table_info.dart b/drift/lib/src/runtime/query_builder/schema/table_info.dart index 85ec0aaa..89ca0573 100644 --- a/drift/lib/src/runtime/query_builder/schema/table_info.dart +++ b/drift/lib/src/runtime/query_builder/schema/table_info.dart @@ -151,7 +151,7 @@ extension TableInfoUtils on ResultSetImplementation { /// Drift would generate code to call this method with `'c1': 'foo'` and /// `'c2': 'bar'` in [alias]. Future mapFromRowWithAlias(QueryRow row, Map alias) async { - return map({ + return await map({ for (final entry in row.data.entries) alias[entry.key]!: entry.value, }); } diff --git a/drift_dev/lib/src/analysis/resolver/queries/existing_row_class.dart b/drift_dev/lib/src/analysis/resolver/queries/existing_row_class.dart index 443d488b..f8cc1404 100644 --- a/drift_dev/lib/src/analysis/resolver/queries/existing_row_class.dart +++ b/drift_dev/lib/src/analysis/resolver/queries/existing_row_class.dart @@ -35,7 +35,7 @@ class MatchExistingTypeForQuery { } } - ExistingQueryRowType? _findRowType( + QueryRowType? _findRowType( InferredResultSet resultSet, dynamic /*DartType|RequestedQueryResultType*/ requestedType, _ErrorReporter reportError, @@ -52,8 +52,8 @@ class MatchExistingTypeForQuery { 'Must be a DartType of a RequestedQueryResultType'); } - final positionalColumns = []; - final namedColumns = {}; + final positionalColumns = []; + final namedColumns = {}; final unmatchedColumnsByName = { for (final column in resultSet.columns) @@ -94,10 +94,9 @@ class MatchExistingTypeForQuery { addEntry(name, () => transformedTypeBuilder.addDartType(type)); } - void addCheckedType( - ArgumentForExistingQueryRowType type, DartType originalType, + void addCheckedType(ArgumentForQueryRowType type, DartType originalType, {String? name}) { - if (type is ExistingQueryRowType) { + if (type is QueryRowType) { addEntry(name, () => transformedTypeBuilder.addCode(type.rowType)); } else if (type is MappedNestedListQuery) { addEntry(name, () { @@ -171,7 +170,7 @@ class MatchExistingTypeForQuery { final verified = _verifyArgument(resultSet.scalarColumns.single, desiredType, 'Single column', (ignore) {}); if (verified != null) { - return ExistingQueryRowType( + return QueryRowType( rowType: AnnotatedDartCode.type(desiredType), singleValue: verified, positionalArguments: const [], @@ -184,7 +183,7 @@ class MatchExistingTypeForQuery { final verified = _verifyMatchingDriftTable(resultSet.matchingTable!, desiredType); if (verified != null) { - return ExistingQueryRowType( + return QueryRowType( rowType: AnnotatedDartCode.build((builder) => builder.addElementRowType(resultSet.matchingTable!.table)), singleValue: verified, @@ -237,7 +236,7 @@ class MatchExistingTypeForQuery { } } - return ExistingQueryRowType( + return QueryRowType( rowType: annotatedTypeCode, constructorName: constructorName ?? '', isRecord: desiredType is RecordType, @@ -249,13 +248,13 @@ class MatchExistingTypeForQuery { /// Returns the default record type chosen by drift when a user declares the /// generic `Record` type as a desired result type. - ExistingQueryRowType _defaultRecord(InferredResultSet resultSet) { + QueryRowType _defaultRecord(InferredResultSet resultSet) { // If there's only a single scalar column, or if we're mapping this result // set to an existing table, then there's only a single value in the end. // Singleton records are forbidden, so we just return the inner type // directly. if (resultSet.singleColumn) { - return ExistingQueryRowType( + return QueryRowType( rowType: AnnotatedDartCode.build( (builder) => builder.addDriftType(resultSet.scalarColumns.single)), singleValue: resultSet.scalarColumns.single, @@ -264,7 +263,7 @@ class MatchExistingTypeForQuery { ); } else if (resultSet.matchingTable != null) { final table = resultSet.matchingTable!; - return ExistingQueryRowType( + return QueryRowType( rowType: AnnotatedDartCode.build( (builder) => builder.addElementRowType(table.table)), singleValue: table, @@ -273,7 +272,7 @@ class MatchExistingTypeForQuery { ); } - final namedArguments = {}; + final namedArguments = {}; final type = AnnotatedDartCode.build((builder) { builder.addText('({'); @@ -288,8 +287,10 @@ class MatchExistingTypeForQuery { builder.addDriftType(column); namedArguments[fieldName] = column; } else if (column is NestedResultTable) { - builder.addElementRowType(column.table); - namedArguments[fieldName] = column; + final innerRecord = _defaultRecord(column.innerResultSet); + builder.addCode(innerRecord.rowType); + namedArguments[fieldName] = + StructuredFromNestedColumn(column, innerRecord); } else if (column is NestedResultQuery) { final nestedResultSet = column.query.resultSet; @@ -310,7 +311,7 @@ class MatchExistingTypeForQuery { builder.addText('})'); }); - return ExistingQueryRowType( + return QueryRowType( rowType: type, singleValue: null, positionalArguments: const [], @@ -319,7 +320,14 @@ class MatchExistingTypeForQuery { ); } - ArgumentForExistingQueryRowType? _verifyArgument( + /// Finds a way to map the [column] into the desired [existingTypeForColumn], + /// which is represented as a [ArgumentForExistingQueryRowType]. + /// + /// If this doesn't succeed (mainly due to incompatible types), reports a + /// error through [reportError] and returns `null`. + /// [name] is used in error messages to inform the user about the field name + /// in their existing Dart class that is causing the problem. + ArgumentForQueryRowType? _verifyArgument( ResultColumn column, DartType existingTypeForColumn, String name, @@ -339,25 +347,15 @@ class MatchExistingTypeForQuery { if (matches) return column; } else if (column is NestedResultTable) { - final table = column.table; + final foundInnerType = _findRowType( + column.innerResultSet, + existingTypeForColumn, + (msg) => reportError('For $name: $msg'), + ); - // Usually, the table is about to be generated by drift - so we can't - // verify the existing type. If there's an existing row class though, we - // can compare against that. - if (table.hasExistingRowClass) { - final existingType = table.existingRowClass!.targetType; - if (column.isNullable) { - existingTypeForColumn = - typeSystem.promoteToNonNull(existingTypeForColumn); - } - - if (!typeSystem.isAssignableTo(existingType, existingTypeForColumn)) { - reportError('$name must accept ' - '${existingType.getDisplayString(withNullability: true)}'); - } + if (foundInnerType != null) { + return StructuredFromNestedColumn(column, foundInnerType); } - - return column; } else if (column is NestedResultQuery) { // A nested query has its own type, which we can recursively try to // structure in the existing type. @@ -378,13 +376,14 @@ class MatchExistingTypeForQuery { return MappedNestedListQuery(column, innerExistingType); } } + return null; } /// Allows using a matching drift table from a result set as an argument if /// the the [existingTypeForColumn] matches the table's type (either the /// existing result type or `dynamic` if it's drift-generated). - ArgumentForExistingQueryRowType? _verifyMatchingDriftTable( + ArgumentForQueryRowType? _verifyMatchingDriftTable( MatchingDriftTable match, DartType existingTypeForColumn) { final table = match.table; if (table.hasExistingRowClass) { diff --git a/drift_dev/lib/src/analysis/resolver/queries/query_analyzer.dart b/drift_dev/lib/src/analysis/resolver/queries/query_analyzer.dart index 29fcf3ec..996c43ff 100644 --- a/drift_dev/lib/src/analysis/resolver/queries/query_analyzer.dart +++ b/drift_dev/lib/src/analysis/resolver/queries/query_analyzer.dart @@ -330,6 +330,7 @@ class QueryAnalyzer { if (column is NestedStarResultColumn) { final resolved = _resolveNestedResultTable(queryContext, column); + if (resolved != null) { // The single table optimization doesn't make sense when nested result // sets are present. @@ -439,19 +440,37 @@ class QueryAnalyzer { _QueryHandlerContext queryContext, NestedStarResultColumn column) { final originalResult = column.resultSet; final result = originalResult?.unalias(); - if (result is! Table && result is! View) { - return null; - } + final rawColumns = result?.resolvedColumns; + + if (result == null || rawColumns == null) return null; + + final driftResultSet = _inferResultSet( + _QueryHandlerContext( + foundElements: queryContext.foundElements, + root: queryContext.root, + queryName: queryContext.queryName, + nestedScope: queryContext.nestedScope, + sourceForFixedName: queryContext.sourceForFixedName, + // Remove desired result class, if any. It will be resolved by the + // parent _inferResultSet call. + ), + rawColumns, + null, + ); - final driftTable = _lookupReference( - (result as NamedResultSet).name); final analysis = JoinModel.of(column); final isNullable = analysis == null || analysis.isNullableTable(originalResult!); + + final queryIndex = nestedQueryCounter++; + final resultClassName = + '${ReCase(queryContext.queryName).pascalCase}NestedColumn$queryIndex'; + return NestedResultTable( - column, - column.as ?? column.tableName, - driftTable, + from: column, + name: column.as ?? column.tableName, + innerResultSet: driftResultSet, + nameForGeneratedRowClass: resultClassName, isNullable: isNullable, ); } diff --git a/drift_dev/lib/src/analysis/results/dart.dart b/drift_dev/lib/src/analysis/results/dart.dart index 95745177..6f90ca87 100644 --- a/drift_dev/lib/src/analysis/results/dart.dart +++ b/drift_dev/lib/src/analysis/results/dart.dart @@ -171,6 +171,11 @@ class AnnotatedDartCodeBuilder { 'This query (${query.name}) does not have a result set'); } + addResultSetRowType(resultSet, resultSet.resultClassName!); + } + + void addResultSetRowType( + InferredResultSet resultSet, String resultClassName) { if (resultSet.existingRowType != null) { return addCode(resultSet.existingRowType!.rowType); } @@ -183,12 +188,13 @@ class AnnotatedDartCodeBuilder { return addDriftType(resultSet.scalarColumns.single); } - return addText(query.resultClassName); + return addText(resultClassName); } void addTypeOfNestedResult(NestedResult nested) { if (nested is NestedResultTable) { - return addElementRowType(nested.table); + return addResultSetRowType( + nested.innerResultSet, nested.nameForGeneratedRowClass); } else if (nested is NestedResultQuery) { addSymbol('List', AnnotatedDartCode.dartCore); addText('<'); diff --git a/drift_dev/lib/src/analysis/results/query.dart b/drift_dev/lib/src/analysis/results/query.dart index 75d36ac9..c4002627 100644 --- a/drift_dev/lib/src/analysis/results/query.dart +++ b/drift_dev/lib/src/analysis/results/query.dart @@ -1,7 +1,6 @@ import 'package:analyzer/dart/element/type.dart'; import 'package:collection/collection.dart'; import 'package:drift/drift.dart' show DriftSqlType, UpdateKind; -import 'package:meta/meta.dart'; import 'package:recase/recase.dart'; import 'package:sqlparser/sqlparser.dart'; @@ -25,7 +24,7 @@ abstract class DriftQueryDeclaration { /// We deliberately only store very basic information here: The actual query /// model is very complex and hard to serialize. Further, lots of generation /// logic requires actual references to the AST which will be difficult to -/// translate across serialization run. +/// translate across serialization runs. /// Since SQL queries only need to be fully analyzed before generation, and /// since they are local elements which can't be referenced by others, there's /// no clear advantage wrt. incremental compilation if queries are fully @@ -209,6 +208,16 @@ abstract class SqlQuery { return elements; } + + QueryRowType queryRowType(DriftOptions options) { + final resultSet = this.resultSet; + if (resultSet == null) { + throw StateError('This query ($name) does not have a result set'); + } + + return resultSet.mappingToRowClass( + resultSet.needsOwnClass ? resultClassName : null, options); + } } class SqlSelectQuery extends SqlQuery { @@ -416,7 +425,7 @@ class InferredResultSet { /// If specified, an existing user-defined Dart type to use instead of /// generating another class for the result of this query. - final ExistingQueryRowType? existingRowType; + final QueryRowType? existingRowType; /// Explicitly controls that no result class should be generated for this /// result set. @@ -494,21 +503,83 @@ class InferredResultSet { const columnsEquality = UnorderedIterableEquality(_ResultColumnEquality()); return columnsEquality.equals(columns, other.columns); } + + /// Returns [existingRowType], or constructs an equivalent mapping to the + /// default row class generated by drift_dev. + /// + /// The code to map raw result sets into structured data, be it into a class + /// written by a user or something generated by drift_dev, is really similar. + /// To share that logic in the query writer, we represent both mappings with + /// the same [QueryRowType] class. + QueryRowType mappingToRowClass( + String? resultClassName, DriftOptions options) { + final existingType = existingRowType; + final matchingTable = this.matchingTable; + + if (existingType != null) { + return existingType; + } else if (singleColumn) { + final column = scalarColumns.single; + + return QueryRowType( + rowType: AnnotatedDartCode.build((b) => b.addDriftType(column)), + singleValue: _columnAsArgument(column, options), + positionalArguments: const [], + namedArguments: const {}, + ); + } else if (matchingTable != null) { + return QueryRowType( + rowType: AnnotatedDartCode.build( + (b) => b.addElementRowType(matchingTable.table)), + singleValue: matchingTable, + positionalArguments: const [], + namedArguments: const {}, + ); + } else { + return QueryRowType( + rowType: AnnotatedDartCode.build((b) => b.addText(resultClassName!)), + singleValue: null, + positionalArguments: const [], + namedArguments: { + if (options.rawResultSetData) 'raw': RawQueryRow(), + for (final column in columns) + dartNameFor(column): _columnAsArgument(column, options), + }, + ); + } + } + + ArgumentForQueryRowType _columnAsArgument( + ResultColumn column, + DriftOptions options, + ) { + return switch (column) { + ScalarResultColumn() => column, + NestedResultTable() => column.innerResultSet + .mappingToRowClass(column.nameForGeneratedRowClass, options), + NestedResultQuery() => MappedNestedListQuery( + column, + column.query.queryRowType(options), + ), + }; + } } -class ExistingQueryRowType implements ArgumentForExistingQueryRowType { +/// Describes a data type for a query, and how to map raw data into that +/// structured type. +class QueryRowType implements ArgumentForQueryRowType { final AnnotatedDartCode rowType; final String constructorName; final bool isRecord; /// When set, instead of constructing the [rowType] from the arguments, the /// argument specified here can just be cast into the desired [rowType]. - ArgumentForExistingQueryRowType? singleValue; + ArgumentForQueryRowType? singleValue; - final List positionalArguments; - final Map namedArguments; + final List positionalArguments; + final Map namedArguments; - ExistingQueryRowType({ + QueryRowType({ required this.rowType, required this.singleValue, required this.positionalArguments, @@ -524,12 +595,26 @@ class ExistingQueryRowType implements ArgumentForExistingQueryRowType { } } -@sealed -abstract class ArgumentForExistingQueryRowType {} +sealed class ArgumentForQueryRowType {} -class MappedNestedListQuery extends ArgumentForExistingQueryRowType { +/// An argument that just maps the raw query row. +/// +/// This is used for generated query classes which can optionally hold a +/// reference to the raw result set. +class RawQueryRow extends ArgumentForQueryRowType {} + +class StructuredFromNestedColumn extends ArgumentForQueryRowType { + final NestedResultTable table; + final QueryRowType nestedType; + + bool get nullable => table.isNullable; + + StructuredFromNestedColumn(this.table, this.nestedType); +} + +class MappedNestedListQuery extends ArgumentForQueryRowType { final NestedResultQuery column; - final ExistingQueryRowType nestedType; + final QueryRowType nestedType; MappedNestedListQuery(this.column, this.nestedType); } @@ -538,7 +623,7 @@ class MappedNestedListQuery extends ArgumentForExistingQueryRowType { /// selects all columns from that table, and nothing more. /// /// We still need to handle column aliases. -class MatchingDriftTable implements ArgumentForExistingQueryRowType { +class MatchingDriftTable implements ArgumentForQueryRowType { final DriftElementWithResultSet table; final Map aliasToColumn; @@ -554,8 +639,7 @@ class MatchingDriftTable implements ArgumentForExistingQueryRowType { } } -@sealed -abstract class ResultColumn { +sealed class ResultColumn { /// A unique name for this column in Dart. String dartGetterName(Iterable existingNames); @@ -567,8 +651,8 @@ abstract class ResultColumn { bool isCompatibleTo(ResultColumn other); } -class ScalarResultColumn extends ResultColumn - implements HasType, ArgumentForExistingQueryRowType { +final class ScalarResultColumn extends ResultColumn + implements HasType, ArgumentForQueryRowType { final String name; @override final DriftSqlType sqlType; @@ -610,7 +694,7 @@ class ScalarResultColumn extends ResultColumn /// A nested result, could either be a [NestedResultTable] or a /// [NestedResultQuery]. -abstract class NestedResult extends ResultColumn {} +sealed class NestedResult extends ResultColumn {} /// A nested table extracted from a `**` column. /// @@ -638,14 +722,24 @@ abstract class NestedResult extends ResultColumn {} /// /// Knowing that `User` should be extracted into a field is represented with a /// [NestedResultTable] information as part of the result set. -class NestedResultTable extends NestedResult - implements ArgumentForExistingQueryRowType { +final class NestedResultTable extends NestedResult { final bool isNullable; final NestedStarResultColumn from; final String name; - final DriftElementWithResultSet table; - NestedResultTable(this.from, this.name, this.table, {this.isNullable = true}); + /// The inner result set, e.g. the table or subquery/table-valued function + /// that the [from] column resolves to. + final InferredResultSet innerResultSet; + + final String nameForGeneratedRowClass; + + NestedResultTable({ + required this.from, + required this.name, + required this.innerResultSet, + required this.nameForGeneratedRowClass, + this.isNullable = true, + }); @override String dartGetterName(Iterable existingNames) { @@ -655,7 +749,7 @@ class NestedResultTable extends NestedResult /// [hashCode] that matches [isCompatibleTo] instead of `==`. @override int get compatibilityHashCode { - return Object.hash(name, table); + return Object.hash(name, innerResultSet.compatibilityHashCode); } /// Checks whether this is compatible to the [other] nested result, which is @@ -665,12 +759,12 @@ class NestedResultTable extends NestedResult if (other is! NestedResultTable) return false; return other.name == name && - other.table == table && + other.innerResultSet.isCompatibleTo(other.innerResultSet) && other.isNullable == isNullable; } } -class NestedResultQuery extends NestedResult { +final class NestedResultQuery extends NestedResult { final NestedQueryColumn from; final SqlSelectQuery query; diff --git a/drift_dev/lib/src/writer/queries/query_writer.dart b/drift_dev/lib/src/writer/queries/query_writer.dart index 5ec99603..495ca633 100644 --- a/drift_dev/lib/src/writer/queries/query_writer.dart +++ b/drift_dev/lib/src/writer/queries/query_writer.dart @@ -13,6 +13,16 @@ import 'utils.dart'; const highestAssignedIndexVar = '\$arrayStartIndex'; +typedef _ArgumentContext = ({ + // Indicates that the argument is available under a prefix in SQL, probably + // because it comes from a [NestedResultTable]. + String? sqlPrefix, + // Indicates that, even if the argument appears to be non-nullable by itself, + // it comes from a [NestedResultTable] part of an outer join that could make + // the entire structure nullable. + bool isNullable, +}); + /// Writes the handling code for a query. The code emitted will be a method that /// should be included in a generated database or dao class. class QueryWriter { @@ -76,130 +86,108 @@ class QueryWriter { /// custom return type of a query. void _writeMappingLambda(SqlQuery query) { final resultSet = query.resultSet!; + final rowClass = query.queryRowType(options); + final queryRow = _emitter.drift('QueryRow'); - final existingRowType = resultSet.existingRowType; final asyncModifier = query.needsAsyncMapping ? 'async' : ''; - if (existingRowType != null) { - _emitter.write('($queryRow row) $asyncModifier => '); - _writeArgumentExpression(existingRowType, resultSet); - } else if (resultSet.singleColumn) { - final column = resultSet.scalarColumns.single; - _emitter.write('($queryRow row) => '); - _readScalar(column); - } else if (resultSet.matchingTable != null) { - final match = resultSet.matchingTable!; - - if (match.effectivelyNoAlias) { - // Tear-off mapFromRow method on table - _emitter.write('${match.table.dbGetterName}.mapFromRow'); - } else { - _emitter.write('($queryRow row) => '); - _writeArgumentExpression(match, resultSet); - } + // We can write every available mapping as a Dart expression via + // _writeArgumentExpression. This can be turned into a lambda by appending + // it with `(QueryRow row) => $expression`. That's also what we're doing, + // but if we'll just call mapFromRow in there, we can just tear that method + // off instead. This is just an optimization. + final matchingTable = resultSet.matchingTable; + if (matchingTable != null && matchingTable.effectivelyNoAlias) { + // Tear-off mapFromRow method on table + _emitter.write('${matchingTable.table.dbGetterName}.mapFromRow'); } else { - _buffer - ..writeln('($queryRow row) $asyncModifier {') - ..write('return ${query.resultClassName}('); - - if (options.rawResultSetData) { - _buffer.write('row: row,\n'); - } - - for (final column in resultSet.columns) { - final fieldName = resultSet.dartNameFor(column); - - if (column is ScalarResultColumn) { - _buffer.write('$fieldName: '); - _readScalar(column); - _buffer.write(', '); - } else if (column is NestedResultTable) { - final prefix = resultSet.nestedPrefixFor(column); - if (prefix == null) continue; - - _buffer.write('$fieldName: '); - _readNestedTable(column, prefix); - _buffer.write(','); - } else if (column is NestedResultQuery) { - _buffer.write('$fieldName: await '); - _writeCustomSelectStatement(column.query); - _buffer.write('.get(),'); - } - } - - _buffer.write(');\n}'); + // In all other cases, we're off to write the expression. + _emitter.write('($queryRow row) $asyncModifier => '); + _writeArgumentExpression( + rowClass, resultSet, (sqlPrefix: null, isNullable: false)); } } /// Writes code that will read the [argument] for an existing row type from /// the raw `QueryRow`. void _writeArgumentExpression( - ArgumentForExistingQueryRowType argument, InferredResultSet resultSet) { - if (argument is MappedNestedListQuery) { - final queryRow = _emitter.drift('QueryRow'); + ArgumentForQueryRowType argument, + InferredResultSet resultSet, + _ArgumentContext context, + ) { + switch (argument) { + case RawQueryRow(): + _buffer.write('row'); + case ScalarResultColumn(): + _readScalar(argument, context); + case MatchingDriftTable(): + _readMatchingTable(argument, context); + case StructuredFromNestedColumn(): + final prefix = resultSet.nestedPrefixFor(argument.table); + _writeArgumentExpression( + argument, + resultSet, + (sqlPrefix: prefix, isNullable: argument.nullable), + ); + case MappedNestedListQuery(): + final queryRow = _emitter.drift('QueryRow'); - _buffer.write('await '); - _writeCustomSelectStatement(argument.column.query, - includeMappingToDart: false); - _buffer.write('.map('); - _buffer.write('($queryRow row) => '); - _writeArgumentExpression(argument.nestedType, resultSet); - _buffer.write(').get()'); - } else if (argument is ExistingQueryRowType) { - final singleValue = argument.singleValue; - if (singleValue != null) { - return _writeArgumentExpression(singleValue, resultSet); - } - - if (!argument.isRecord) { - // We're writing a constructor, so let's start with the class name. - _emitter.writeDart(argument.rowType); - - final constructorName = argument.constructorName; - if (constructorName.isNotEmpty) { - _emitter - ..write('.') - ..write(constructorName); + _buffer.write('await '); + _writeCustomSelectStatement(argument.column.query, + includeMappingToDart: false); + _buffer.write('.map('); + _buffer.write('($queryRow row) => '); + _writeArgumentExpression(argument.nestedType, resultSet, context); + _buffer.write(').get()'); + case QueryRowType(): + final singleValue = argument.singleValue; + if (singleValue != null) { + return _writeArgumentExpression(singleValue, resultSet, context); } - } - _buffer.write('('); - for (final positional in argument.positionalArguments) { - _writeArgumentExpression(positional, resultSet); - _buffer.write(', '); - } - argument.namedArguments.forEach((name, parameter) { - _buffer.write('$name: '); - _writeArgumentExpression(parameter, resultSet); - _buffer.write(', '); - }); + if (!argument.isRecord) { + // We're writing a constructor, so let's start with the class name. + _emitter.writeDart(argument.rowType); - _buffer.write(')'); - } else if (argument is NestedResultTable) { - final prefix = resultSet.nestedPrefixFor(argument); - _readNestedTable(argument, prefix!); - } else if (argument is ScalarResultColumn) { - return _readScalar(argument); - } else if (argument is MatchingDriftTable) { - _readMatchingTable(argument); + final constructorName = argument.constructorName; + if (constructorName.isNotEmpty) { + _emitter + ..write('.') + ..write(constructorName); + } + } + + _buffer.write('('); + for (final positional in argument.positionalArguments) { + _writeArgumentExpression(positional, resultSet, context); + _buffer.write(', '); + } + argument.namedArguments.forEach((name, parameter) { + _buffer.write('$name: '); + _writeArgumentExpression(parameter, resultSet, context); + _buffer.write(', '); + }); + + _buffer.write(')'); } } /// Writes Dart code that, given a variable of type `QueryRow` named `row` /// in the same scope, reads the [column] from that row and brings it into a /// suitable type. - void _readScalar(ScalarResultColumn column) { + void _readScalar(ScalarResultColumn column, _ArgumentContext context) { final specialName = _transformer.newNameFor(column.sqlParserColumn!); + final isNullable = context.isNullable || column.nullable; final dartLiteral = asDartLiteral(specialName ?? column.name); - final method = column.nullable ? 'readNullable' : 'read'; + final method = isNullable ? 'readNullable' : 'read'; final rawDartType = _emitter.dartCode(AnnotatedDartCode([dartTypeNames[column.sqlType]!])); var code = 'row.$method<$rawDartType>($dartLiteral)'; final converter = column.typeConverter; if (converter != null) { - if (converter.canBeSkippedForNulls && column.nullable) { + if (converter.canBeSkippedForNulls && isNullable) { // The type converter maps non-nullable types, but the column may be // nullable in SQL => just map null to null and only invoke the type // converter for non-null values. @@ -214,36 +202,61 @@ class QueryWriter { _emitter.write(code); } - void _readMatchingTable(MatchingDriftTable match) { + void _readMatchingTable(MatchingDriftTable match, _ArgumentContext context) { // note that, even if the result set has a matching table, we can't just // use the mapFromRow() function of that table - the column names might // be different! final table = match.table; if (match.effectivelyNoAlias) { - _emitter.write('${table.dbGetterName}.mapFromRow(row)'); + final mappingMethod = + context.isNullable ? 'mapFromRowOrNull' : 'mapFromRow'; + final sqlPrefix = context.sqlPrefix; + + _emitter.write('${table.dbGetterName}.$mappingMethod(row'); + if (sqlPrefix != null) { + _emitter.write(', tablePrefix: ${asDartLiteral(sqlPrefix)}'); + } + + _emitter.write(')'); } else { + final sqlPrefix = context.sqlPrefix; + + // If the entire table can be nullable, we can check whether a non-nullable + // column from the table is null. If it is, the entire table is null. This + // can happen when the table comes from an outer join. + if (context.isNullable) { + for (final MapEntry(:key, :value) in match.aliasToColumn.entries) { + if (!value.nullable) { + final mapKey = sqlPrefix == null ? key : '$sqlPrefix.$key'; + + _emitter + .write('row.data[${asDartLiteral(mapKey)}] == null ? null : '); + } + } + } + _emitter.write('${table.dbGetterName}.mapFromRowWithAlias(row, const {'); for (final alias in match.aliasToColumn.entries) { + var sqlKey = alias.key; + if (sqlPrefix != null) { + sqlKey = '$sqlPrefix.'; + } + _emitter - ..write(asDartLiteral(alias.key)) + ..write(asDartLiteral(sqlKey)) ..write(': ') ..write(asDartLiteral(alias.value.nameInSql)) ..write(', '); } + + _emitter.write('}'); + _emitter.write('})'); } } - void _readNestedTable(NestedResultTable table, String prefix) { - final tableGetter = table.table.dbGetterName; - final mappingMethod = table.isNullable ? 'mapFromRowOrNull' : 'mapFromRow'; - - _emitter.write('await $tableGetter.$mappingMethod(row, ' - 'tablePrefix: ${asDartLiteral(prefix)})'); - } - /// Writes a method returning a `Selectable`, where `T` is the return type /// of the custom query. void _writeSelectStatementCreator(SqlSelectQuery select) { diff --git a/drift_dev/lib/src/writer/queries/result_set_writer.dart b/drift_dev/lib/src/writer/queries/result_set_writer.dart index 12da4752..48a739db 100644 --- a/drift_dev/lib/src/writer/queries/result_set_writer.dart +++ b/drift_dev/lib/src/writer/queries/result_set_writer.dart @@ -5,20 +5,23 @@ import '../writer.dart'; /// Writes a class holding the result of an sql query into Dart. class ResultSetWriter { - final SqlQuery query; + final InferredResultSet resultSet; + final String resultClassName; final Scope scope; - ResultSetWriter(this.query, this.scope); + ResultSetWriter(SqlQuery query, this.scope) + : resultSet = query.resultSet!, + resultClassName = query.resultClassName; + + ResultSetWriter.fromResultSetAndClassName( + this.resultSet, this.resultClassName, this.scope); void write() { - final className = query.resultClassName; final fields = []; final nonNullableFields = {}; final into = scope.leaf(); - final resultSet = query.resultSet!; - - into.write('class $className '); + into.write('class $resultClassName '); if (scope.options.rawResultSetData) { into.write('extends CustomResultSet {\n'); } else { @@ -39,6 +42,12 @@ class ResultSetWriter { fields.add(EqualityField(fieldName, isList: column.isUint8ListInDart)); if (!column.nullable) nonNullableFields.add(fieldName); } else if (column is NestedResultTable) { + if (column.innerResultSet.needsOwnClass) { + ResultSetWriter.fromResultSetAndClassName( + column.innerResultSet, column.nameForGeneratedRowClass, scope) + .write(); + } + into ..write('$modifier ') ..writeDart( @@ -66,9 +75,9 @@ class ResultSetWriter { // write the constructor if (scope.options.rawResultSetData) { - into.write('$className({required QueryRow row,'); + into.write('$resultClassName({required QueryRow row,'); } else { - into.write('$className({'); + into.write('$resultClassName({'); } for (final column in fields) { @@ -90,9 +99,9 @@ class ResultSetWriter { writeHashCode(fields, into); into.write(';\n'); - overrideEquals(fields, className, into); + overrideEquals(fields, resultClassName, into); overrideToString( - className, fields.map((f) => f.lexeme).toList(), into.buffer); + resultClassName, fields.map((f) => f.lexeme).toList(), into.buffer); } into.write('}\n'); diff --git a/drift_dev/lib/src/writer/queries/sql_writer.dart b/drift_dev/lib/src/writer/queries/sql_writer.dart index 43e4c7d8..4ebdfc3c 100644 --- a/drift_dev/lib/src/writer/queries/sql_writer.dart +++ b/drift_dev/lib/src/writer/queries/sql_writer.dart @@ -194,14 +194,14 @@ class SqlWriter extends NodeSqlBuilder { // Convert foo.** to "foo.a" AS "nested_0.a", ... for all columns in foo var isFirst = true; - for (final column in result.table.columns) { + for (final column in result.innerResultSet.scalarColumns) { if (isFirst) { isFirst = false; } else { _out.write(', '); } - final columnName = column.nameInSql; + final columnName = column.name; _out.write('"$table"."$columnName" AS "$prefix.$columnName"'); } } else if (e is DartPlaceholder) { diff --git a/drift_dev/test/analysis/resolver/queries/existing_row_classes_test.dart b/drift_dev/test/analysis/resolver/queries/existing_row_classes_test.dart index dcd5381a..62e94e99 100644 --- a/drift_dev/test/analysis/resolver/queries/existing_row_classes_test.dart +++ b/drift_dev/test/analysis/resolver/queries/existing_row_classes_test.dart @@ -2,6 +2,7 @@ import 'package:drift_dev/src/analysis/results/results.dart'; import 'package:test/test.dart'; import '../../test_utils.dart'; +import 'utils.dart'; void main() { test('recognizes existing row classes', () async { @@ -287,7 +288,12 @@ class MyRow { type: 'MyRow', positional: [ scalarColumn('a'), - nestedTableColumm('b'), + structedFromNested( + isExistingRowType( + type: 'TblData', + singleValue: isA(), + ), + ), ], ), ); @@ -323,14 +329,16 @@ class MyRow { type: 'MyRow', positional: [ scalarColumn('a'), - isExistingRowType( - type: '(String, int)', - positional: [scalarColumn('foo'), scalarColumn('bar')], + structedFromNested( + isExistingRowType( + type: '(String, int)', + positional: [scalarColumn('foo'), scalarColumn('bar')], + ), ), ], ), ); - }, skip: 'Blocked by https://github.com/simolus3/drift/issues/2233'); + }); test('nested - custom result set with class', () async { final state = TestBackend.inTest({ @@ -536,7 +544,12 @@ class MyRow { isExistingRowType(type: 'MyRow', positional: [ scalarColumn('name'), ], named: { - 'otherUser': nestedTableColumm('otherUser'), + 'otherUser': structedFromNested( + isExistingRowType( + type: 'MyUser', + singleValue: isA(), + ), + ), 'nested': nestedListQuery( 'nested', isExistingRowType( @@ -723,46 +736,3 @@ class MyRow { ); }); } - -TypeMatcher scalarColumn(String name) => - isA().having((e) => e.name, 'name', name); - -TypeMatcher nestedTableColumm(String name) => - isA().having((e) => e.name, 'name', name); - -TypeMatcher nestedListQuery( - String columnName, TypeMatcher nestedType) { - return isA() - .having((e) => e.column.filedName(), 'column', columnName) - .having((e) => e.nestedType, 'nestedType', nestedType); -} - -TypeMatcher isExistingRowType({ - String? type, - String? constructorName, - Object? singleValue, - Object? positional, - Object? named, -}) { - var matcher = isA(); - - if (type != null) { - matcher = matcher.having((e) => e.rowType.toString(), 'rowType', type); - } - if (constructorName != null) { - matcher = matcher.having( - (e) => e.constructorName, 'constructorName', constructorName); - } - if (singleValue != null) { - matcher = matcher.having((e) => e.singleValue, 'singleValue', singleValue); - } - if (positional != null) { - matcher = matcher.having( - (e) => e.positionalArguments, 'positionalArguments', positional); - } - if (named != null) { - matcher = matcher.having((e) => e.namedArguments, 'namedArguments', named); - } - - return matcher; -} diff --git a/drift_dev/test/analysis/resolver/queries/query_analyzer_test.dart b/drift_dev/test/analysis/resolver/queries/query_analyzer_test.dart index 6c8d1a87..ee2705c0 100644 --- a/drift_dev/test/analysis/resolver/queries/query_analyzer_test.dart +++ b/drift_dev/test/analysis/resolver/queries/query_analyzer_test.dart @@ -5,7 +5,7 @@ import 'package:sqlparser/sqlparser.dart' hide ResultColumn; import 'package:test/test.dart'; import '../../test_utils.dart'; -import 'existing_row_classes_test.dart'; +import 'utils.dart'; void main() { test('respects explicit type arguments', () async { @@ -90,10 +90,22 @@ query: SELECT foo.**, bar.** FROM my_view foo, my_view bar; final query = file.fileAnalysis!.resolvedQueries.values.single; expect(query.resultSet!.nestedResults, hasLength(2)); + + final isFromView = isExistingRowType( + type: 'MyViewData', + singleValue: isA() + .having((e) => e.table.schemaName, 'table.schemaName', 'my_view'), + ); + expect( - query.resultSet!.nestedResults, - everyElement(isA() - .having((e) => e.table.schemaName, 'table.schemName', 'my_view'))); + query.resultSet!.mappingToRowClass('', const DriftOptions.defaults()), + isExistingRowType( + named: { + 'foo': isFromView, + 'bar': isFromView, + }, + ), + ); }); for (final dateTimeAsText in [false, true]) { @@ -178,7 +190,7 @@ FROM routes expect( resultSet.nestedResults .cast() - .map((e) => e.table.schemaName), + .map((e) => e.innerResultSet.matchingTable!.table.schemaName), ['points', 'points'], ); }); diff --git a/drift_dev/test/analysis/resolver/queries/utils.dart b/drift_dev/test/analysis/resolver/queries/utils.dart index ae192de3..6e2a486f 100644 --- a/drift_dev/test/analysis/resolver/queries/utils.dart +++ b/drift_dev/test/analysis/resolver/queries/utils.dart @@ -1,4 +1,5 @@ import 'package:drift_dev/src/analysis/results/results.dart'; +import 'package:test/expect.dart'; import '../../test_utils.dart'; @@ -10,3 +11,48 @@ Future analyzeSingleQueryInDriftFile(String driftFile) async { Future analyzeQuery(String sql) async { return analyzeSingleQueryInDriftFile('a: $sql'); } + +TypeMatcher scalarColumn(String name) => + isA().having((e) => e.name, 'name', name); + +TypeMatcher structedFromNested( + TypeMatcher nestedType) => + isA() + .having((e) => e.nestedType, 'nestedType', nestedType); + +TypeMatcher nestedListQuery( + String columnName, TypeMatcher nestedType) { + return isA() + .having((e) => e.column.filedName(), 'column', columnName) + .having((e) => e.nestedType, 'nestedType', nestedType); +} + +TypeMatcher isExistingRowType({ + String? type, + String? constructorName, + Object? singleValue, + Object? positional, + Object? named, +}) { + var matcher = isA(); + + if (type != null) { + matcher = matcher.having((e) => e.rowType.toString(), 'rowType', type); + } + if (constructorName != null) { + matcher = matcher.having( + (e) => e.constructorName, 'constructorName', constructorName); + } + if (singleValue != null) { + matcher = matcher.having((e) => e.singleValue, 'singleValue', singleValue); + } + if (positional != null) { + matcher = matcher.having( + (e) => e.positionalArguments, 'positionalArguments', positional); + } + if (named != null) { + matcher = matcher.having((e) => e.namedArguments, 'namedArguments', named); + } + + return matcher; +} diff --git a/sqlparser/lib/src/analysis/types/resolving_visitor.dart b/sqlparser/lib/src/analysis/types/resolving_visitor.dart index d26a1e5c..6b1f8c23 100644 --- a/sqlparser/lib/src/analysis/types/resolving_visitor.dart +++ b/sqlparser/lib/src/analysis/types/resolving_visitor.dart @@ -50,6 +50,13 @@ class TypeResolver extends RecursiveVisitor { currentColumnIndex += child.scope.expansionOfStarColumn?.length ?? 1; } else if (child is NestedQueryColumn) { visit(child.select, arg); + } else if (child is NestedStarResultColumn) { + final columns = child.resultSet?.resolvedColumns; + if (columns != null) { + for (final column in columns) { + _handleColumn(column, child); + } + } } } else { visit(child, arg); From 5b7e0110212a90c6d9a445fcaa0be290e3002000 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 24 Jul 2023 00:14:40 +0200 Subject: [PATCH 10/17] Fix generation in drift package --- drift/test/generated/custom_tables.g.dart | 123 ++++++++---------- drift/test/generated/todos.g.dart | 28 ++-- .../drift_files_integration_test.dart | 4 +- .../list_subquery_integration_test.dart | 2 +- drift_dev/lib/src/analysis/results/dart.dart | 8 +- drift_dev/lib/src/analysis/results/query.dart | 10 +- .../lib/src/writer/queries/query_writer.dart | 4 +- 7 files changed, 85 insertions(+), 94 deletions(-) diff --git a/drift/test/generated/custom_tables.g.dart b/drift/test/generated/custom_tables.g.dart index 46498de7..012cda42 100644 --- a/drift/test/generated/custom_tables.g.dart +++ b/drift/test/generated/custom_tables.g.dart @@ -1678,12 +1678,13 @@ abstract class _$CustomTablesDb extends GeneratedDatabase { ], readsFrom: { config, - }).asyncMap((QueryRow row) => config.mapFromRowWithAlias(row, const { - 'ck': 'config_key', - 'cf': 'config_value', - 'cs1': 'sync_state', - 'cs2': 'sync_state_implicit', - })); + }).asyncMap( + (QueryRow row) async => config.mapFromRowWithAlias(row, const { + 'ck': 'config_key', + 'cf': 'config_value', + 'cs1': 'sync_state', + 'cs2': 'sync_state_implicit', + })); } Selectable readMultiple(List var1, @@ -1754,26 +1755,20 @@ abstract class _$CustomTablesDb extends GeneratedDatabase { variables: [], readsFrom: { config, - }).map((QueryRow row) { - return JsonResult( - row: row, - key: row.read('key'), - value: row.readNullable('value'), - ); - }); + }).map((QueryRow row) => JsonResult( + raw: row, + key: row.read('key'), + value: row.readNullable('value'), + )); } Selectable another() { - return customSelect( - 'SELECT \'one\' AS "key", NULLIF(\'two\', \'another\') AS value', - variables: [], - readsFrom: {}).map((QueryRow row) { - return JsonResult( - row: row, - key: row.read('key'), - value: row.readNullable('value'), - ); - }); + return customSelect('SELECT \'one\' AS "key", NULLIF(\'two\', \'another\') AS value', variables: [], readsFrom: {}) + .map((QueryRow row) => JsonResult( + raw: row, + key: row.read('key'), + value: row.readNullable('value'), + )); } Selectable multiple({required Multiple$predicate predicate}) { @@ -1793,14 +1788,12 @@ abstract class _$CustomTablesDb extends GeneratedDatabase { withDefaults, withConstraints, ...generatedpredicate.watchedTables, - }).asyncMap((QueryRow row) async { - return MultipleResult( - row: row, - a: row.readNullable('a'), - b: row.readNullable('b'), - c: await withConstraints.mapFromRowOrNull(row, tablePrefix: 'nested_0'), - ); - }); + }).asyncMap((QueryRow row) async => MultipleResult( + raw: row, + a: row.readNullable('a'), + b: row.readNullable('b'), + c: withConstraints.mapFromRow(row), + )); } Selectable searchEmails({required String? term}) { @@ -1827,20 +1820,18 @@ abstract class _$CustomTablesDb extends GeneratedDatabase { readsFrom: { config, ...generatedexpr.watchedTables, - }).map((QueryRow row) { - return ReadRowIdResult( - row: row, - rowid: row.read('rowid'), - configKey: row.read('config_key'), - configValue: row.readNullable('config_value'), - syncState: NullAwareTypeConverter.wrapFromSql( - ConfigTable.$convertersyncState, - row.readNullable('sync_state')), - syncStateImplicit: NullAwareTypeConverter.wrapFromSql( - ConfigTable.$convertersyncStateImplicit, - row.readNullable('sync_state_implicit')), - ); - }); + }).map((QueryRow row) => ReadRowIdResult( + raw: row, + rowid: row.read('rowid'), + configKey: row.read('config_key'), + configValue: row.readNullable('config_value'), + syncState: NullAwareTypeConverter.wrapFromSql( + ConfigTable.$convertersyncState, + row.readNullable('sync_state')), + syncStateImplicit: NullAwareTypeConverter.wrapFromSql( + ConfigTable.$convertersyncStateImplicit, + row.readNullable('sync_state_implicit')), + )); } Selectable readView({ReadView$where? where}) { @@ -1895,21 +1886,19 @@ abstract class _$CustomTablesDb extends GeneratedDatabase { readsFrom: { withConstraints, withDefaults, - }).asyncMap((QueryRow row) async { - return NestedResult( - row: row, - defaults: await withDefaults.mapFromRow(row, tablePrefix: 'nested_0'), - nestedQuery0: await customSelect( - 'SELECT * FROM with_constraints AS c WHERE c.b = ?1', - variables: [ - Variable(row.read('\$n_0')) - ], - readsFrom: { - withConstraints, - withDefaults, - }).asyncMap(withConstraints.mapFromRow).get(), - ); - }); + }).asyncMap((QueryRow row) async => NestedResult( + raw: row, + defaults: withDefaults.mapFromRow(row), + nestedQuery1: await customSelect( + 'SELECT * FROM with_constraints AS c WHERE c.b = ?1', + variables: [ + Variable(row.read('\$n_0')) + ], + readsFrom: { + withConstraints, + withDefaults, + }).map((QueryRow row) => withConstraints.mapFromRow(row)).get(), + )); } Selectable customResult() { @@ -1925,8 +1914,8 @@ abstract class _$CustomTablesDb extends GeneratedDatabase { syncState: NullAwareTypeConverter.wrapFromSql( ConfigTable.$convertersyncState, row.readNullable('sync_state')), - config: await config.mapFromRow(row, tablePrefix: 'nested_0'), - noIds: await noIds.mapFromRow(row, tablePrefix: 'nested_1'), + config: config.mapFromRow(row, tablePrefix: 'nested_0'), + noIds: noIds.mapFromRow(row, tablePrefix: 'nested_1'), nested: await customSelect('SELECT * FROM no_ids', variables: [], readsFrom: { @@ -2081,25 +2070,25 @@ typedef ReadView$where = Expression Function(MyView my_view); class NestedResult extends CustomResultSet { final WithDefault defaults; - final List nestedQuery0; + final List nestedQuery1; NestedResult({ required QueryRow row, required this.defaults, - required this.nestedQuery0, + required this.nestedQuery1, }) : super(row); @override - int get hashCode => Object.hash(defaults, nestedQuery0); + int get hashCode => Object.hash(defaults, nestedQuery1); @override bool operator ==(Object other) => identical(this, other) || (other is NestedResult && other.defaults == this.defaults && - other.nestedQuery0 == this.nestedQuery0); + other.nestedQuery1 == this.nestedQuery1); @override String toString() { return (StringBuffer('NestedResult(') ..write('defaults: $defaults, ') - ..write('nestedQuery0: $nestedQuery0') + ..write('nestedQuery1: $nestedQuery1') ..write(')')) .toString(); } diff --git a/drift/test/generated/todos.g.dart b/drift/test/generated/todos.g.dart index 096ca4b9..24e03fbd 100644 --- a/drift/test/generated/todos.g.dart +++ b/drift/test/generated/todos.g.dart @@ -1714,21 +1714,19 @@ abstract class _$TodoDb extends GeneratedDatabase { readsFrom: { categories, todosTable, - }).map((QueryRow row) { - return AllTodosWithCategoryResult( - row: row, - id: row.read('id'), - title: row.readNullable('title'), - content: row.read('content'), - targetDate: row.readNullable('target_date'), - category: row.readNullable('category'), - status: NullAwareTypeConverter.wrapFromSql( - $TodosTableTable.$converterstatus, - row.readNullable('status')), - catId: row.read('catId'), - catDesc: row.read('catDesc'), - ); - }); + }).map((QueryRow row) => AllTodosWithCategoryResult( + raw: row, + id: row.read('id'), + title: row.readNullable('title'), + content: row.read('content'), + targetDate: row.readNullable('target_date'), + category: row.readNullable('category'), + status: NullAwareTypeConverter.wrapFromSql( + $TodosTableTable.$converterstatus, + row.readNullable('status')), + catId: row.read('catId'), + catDesc: row.read('catDesc'), + )); } Future deleteTodoById(int var1) { diff --git a/drift/test/integration_tests/drift_files_integration_test.dart b/drift/test/integration_tests/drift_files_integration_test.dart index 6e8157d5..cad798b4 100644 --- a/drift/test/integration_tests/drift_files_integration_test.dart +++ b/drift/test/integration_tests/drift_files_integration_test.dart @@ -136,7 +136,7 @@ void main() { contains( isA() .having((e) => e.defaults, 'defaults', first) - .having((e) => e.nestedQuery0, 'nested', hasLength(2)), + .having((e) => e.nestedQuery1, 'nested', hasLength(2)), ), ); @@ -145,7 +145,7 @@ void main() { contains( isA() .having((e) => e.defaults, 'defaults', second) - .having((e) => e.nestedQuery0, 'nested', hasLength(1)), + .having((e) => e.nestedQuery1, 'nested', hasLength(1)), ), ); }); diff --git a/drift/test/integration_tests/list_subquery_integration_test.dart b/drift/test/integration_tests/list_subquery_integration_test.dart index a70bfb9a..f84dbf44 100644 --- a/drift/test/integration_tests/list_subquery_integration_test.dart +++ b/drift/test/integration_tests/list_subquery_integration_test.dart @@ -30,6 +30,6 @@ void main() { final result = results.single; expect(result.defaults, defaults); - expect(result.nestedQuery0, [constraints]); + expect(result.nestedQuery1, [constraints]); }); } diff --git a/drift_dev/lib/src/analysis/results/dart.dart b/drift_dev/lib/src/analysis/results/dart.dart index 6f90ca87..3acc2103 100644 --- a/drift_dev/lib/src/analysis/results/dart.dart +++ b/drift_dev/lib/src/analysis/results/dart.dart @@ -171,11 +171,11 @@ class AnnotatedDartCodeBuilder { 'This query (${query.name}) does not have a result set'); } - addResultSetRowType(resultSet, resultSet.resultClassName!); + addResultSetRowType(resultSet, () => query.resultClassName); } void addResultSetRowType( - InferredResultSet resultSet, String resultClassName) { + InferredResultSet resultSet, String Function() resultClassName) { if (resultSet.existingRowType != null) { return addCode(resultSet.existingRowType!.rowType); } @@ -188,13 +188,13 @@ class AnnotatedDartCodeBuilder { return addDriftType(resultSet.scalarColumns.single); } - return addText(resultClassName); + return addText(resultClassName()); } void addTypeOfNestedResult(NestedResult nested) { if (nested is NestedResultTable) { return addResultSetRowType( - nested.innerResultSet, nested.nameForGeneratedRowClass); + nested.innerResultSet, () => nested.nameForGeneratedRowClass); } else if (nested is NestedResultQuery) { addSymbol('List', AnnotatedDartCode.dartCore); addText('<'); diff --git a/drift_dev/lib/src/analysis/results/query.dart b/drift_dev/lib/src/analysis/results/query.dart index c4002627..e153eda0 100644 --- a/drift_dev/lib/src/analysis/results/query.dart +++ b/drift_dev/lib/src/analysis/results/query.dart @@ -172,13 +172,19 @@ abstract class SqlQuery { return false; } + bool get _useResultClassName { + final resultSet = this.resultSet!; + + return resultSet.matchingTable == null && !resultSet.singleColumn; + } + String get resultClassName { final resultSet = this.resultSet; if (resultSet == null) { throw StateError('This query ($name) does not have a result set'); } - if (resultSet.matchingTable != null || resultSet.singleColumn) { + if (!_useResultClassName) { throw UnsupportedError('This result set does not introduce a class, ' 'either because it has a matching table or because it only returns ' 'one column.'); @@ -216,7 +222,7 @@ abstract class SqlQuery { } return resultSet.mappingToRowClass( - resultSet.needsOwnClass ? resultClassName : null, options); + _useResultClassName ? resultClassName : null, options); } } diff --git a/drift_dev/lib/src/writer/queries/query_writer.dart b/drift_dev/lib/src/writer/queries/query_writer.dart index 495ca633..0b275242 100644 --- a/drift_dev/lib/src/writer/queries/query_writer.dart +++ b/drift_dev/lib/src/writer/queries/query_writer.dart @@ -125,7 +125,7 @@ class QueryWriter { case StructuredFromNestedColumn(): final prefix = resultSet.nestedPrefixFor(argument.table); _writeArgumentExpression( - argument, + argument.nestedType, resultSet, (sqlPrefix: prefix, isNullable: argument.nullable), ); @@ -251,8 +251,6 @@ class QueryWriter { ..write(', '); } - _emitter.write('}'); - _emitter.write('})'); } } From 941409381b3bee431029f45c3ec0ca224d94b891 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 24 Jul 2023 20:08:49 +0200 Subject: [PATCH 11/17] Tests for generalized nested columns --- .../resolver/drift/sqlparser/drift_lints.dart | 12 - drift_dev/lib/src/analysis/results/query.dart | 7 +- .../lib/src/writer/queries/query_writer.dart | 84 ++-- .../queries/existing_row_classes_test.dart | 383 ++++++++++++------ .../resolver/queries/linter_test.dart | 16 - .../resolver/queries/query_analyzer_test.dart | 30 ++ .../test/analysis/resolver/queries/utils.dart | 4 + .../writer/queries/query_writer_test.dart | 131 ++++-- 8 files changed, 450 insertions(+), 217 deletions(-) diff --git a/drift_dev/lib/src/analysis/resolver/drift/sqlparser/drift_lints.dart b/drift_dev/lib/src/analysis/resolver/drift/sqlparser/drift_lints.dart index f91ecfd2..0e7c9ffd 100644 --- a/drift_dev/lib/src/analysis/resolver/drift/sqlparser/drift_lints.dart +++ b/drift_dev/lib/src/analysis/resolver/drift/sqlparser/drift_lints.dart @@ -198,18 +198,6 @@ class _LintingVisitor extends RecursiveVisitor { relevantNode: e, )); } - - // check that it actually refers to a table - final result = e.resultSet?.unalias(); - if (result is! Table && result is! View) { - linter.sqlParserErrors.add(AnalysisError( - type: AnalysisErrorType.other, - message: 'Nested star columns must refer to a table directly. They ' - "can't refer to a table-valued function or another select " - 'statement.', - relevantNode: e, - )); - } } if (e is NestedQueryColumn) { diff --git a/drift_dev/lib/src/analysis/results/query.dart b/drift_dev/lib/src/analysis/results/query.dart index e153eda0..c7e1ec43 100644 --- a/drift_dev/lib/src/analysis/results/query.dart +++ b/drift_dev/lib/src/analysis/results/query.dart @@ -561,8 +561,11 @@ class InferredResultSet { ) { return switch (column) { ScalarResultColumn() => column, - NestedResultTable() => column.innerResultSet - .mappingToRowClass(column.nameForGeneratedRowClass, options), + NestedResultTable() => StructuredFromNestedColumn( + column, + column.innerResultSet + .mappingToRowClass(column.nameForGeneratedRowClass, options), + ), NestedResultQuery() => MappedNestedListQuery( column, column.query.queryRowType(options), diff --git a/drift_dev/lib/src/writer/queries/query_writer.dart b/drift_dev/lib/src/writer/queries/query_writer.dart index 0b275242..ab38c768 100644 --- a/drift_dev/lib/src/writer/queries/query_writer.dart +++ b/drift_dev/lib/src/writer/queries/query_writer.dart @@ -130,21 +130,40 @@ class QueryWriter { (sqlPrefix: prefix, isNullable: argument.nullable), ); case MappedNestedListQuery(): - final queryRow = _emitter.drift('QueryRow'); - _buffer.write('await '); - _writeCustomSelectStatement(argument.column.query, - includeMappingToDart: false); - _buffer.write('.map('); - _buffer.write('($queryRow row) => '); - _writeArgumentExpression(argument.nestedType, resultSet, context); - _buffer.write(').get()'); + final query = argument.column.query; + _writeCustomSelectStatement(query); + _buffer.write('.get()'); case QueryRowType(): final singleValue = argument.singleValue; if (singleValue != null) { return _writeArgumentExpression(singleValue, resultSet, context); } + if (context.isNullable) { + // If this structed type is nullable, it's coming from an OUTER join + // which means that, even if the individual components making up the + // structure are non-nullable, they might all be null in SQL. We + // detect this case by looking for a non-nullable column and, if it's + // null, return null directly instead of creating the structured type. + for (final arg in argument.positionalArguments + .followedBy(argument.namedArguments.values) + .whereType()) { + if (!arg.nullable) { + final keyInMap = context.applyPrefix(arg.name); + _buffer.write( + 'row.data[${asDartLiteral(keyInMap)}] == null ? null : '); + } + } + } + + final _ArgumentContext childContext = ( + sqlPrefix: context.sqlPrefix, + // Individual fields making up this query row type aren't covered by + // the outer nullability. + isNullable: false, + ); + if (!argument.isRecord) { // We're writing a constructor, so let's start with the class name. _emitter.writeDart(argument.rowType); @@ -159,12 +178,12 @@ class QueryWriter { _buffer.write('('); for (final positional in argument.positionalArguments) { - _writeArgumentExpression(positional, resultSet, context); + _writeArgumentExpression(positional, resultSet, childContext); _buffer.write(', '); } argument.namedArguments.forEach((name, parameter) { _buffer.write('$name: '); - _writeArgumentExpression(parameter, resultSet, context); + _writeArgumentExpression(parameter, resultSet, childContext); _buffer.write(', '); }); @@ -179,7 +198,12 @@ class QueryWriter { final specialName = _transformer.newNameFor(column.sqlParserColumn!); final isNullable = context.isNullable || column.nullable; - final dartLiteral = asDartLiteral(specialName ?? column.name); + var name = specialName ?? column.name; + if (context.sqlPrefix != null) { + name = '${context.sqlPrefix}.$name'; + } + + final dartLiteral = asDartLiteral(name); final method = isNullable ? 'readNullable' : 'read'; final rawDartType = _emitter.dartCode(AnnotatedDartCode([dartTypeNames[column.sqlType]!])); @@ -213,22 +237,20 @@ class QueryWriter { context.isNullable ? 'mapFromRowOrNull' : 'mapFromRow'; final sqlPrefix = context.sqlPrefix; - _emitter.write('${table.dbGetterName}.$mappingMethod(row'); + _emitter.write('await ${table.dbGetterName}.$mappingMethod(row'); if (sqlPrefix != null) { _emitter.write(', tablePrefix: ${asDartLiteral(sqlPrefix)}'); } _emitter.write(')'); } else { - final sqlPrefix = context.sqlPrefix; - // If the entire table can be nullable, we can check whether a non-nullable // column from the table is null. If it is, the entire table is null. This // can happen when the table comes from an outer join. if (context.isNullable) { for (final MapEntry(:key, :value) in match.aliasToColumn.entries) { if (!value.nullable) { - final mapKey = sqlPrefix == null ? key : '$sqlPrefix.$key'; + final mapKey = context.applyPrefix(key); _emitter .write('row.data[${asDartLiteral(mapKey)}] == null ? null : '); @@ -239,13 +261,8 @@ class QueryWriter { _emitter.write('${table.dbGetterName}.mapFromRowWithAlias(row, const {'); for (final alias in match.aliasToColumn.entries) { - var sqlKey = alias.key; - if (sqlPrefix != null) { - sqlKey = '$sqlPrefix.'; - } - _emitter - ..write(asDartLiteral(sqlKey)) + ..write(asDartLiteral(context.applyPrefix(alias.key))) ..write(': ') ..write(asDartLiteral(alias.value.nameInSql)) ..write(', '); @@ -280,23 +297,19 @@ class QueryWriter { _buffer.write(';\n}\n'); } - void _writeCustomSelectStatement(SqlSelectQuery select, - {bool includeMappingToDart = true}) { + void _writeCustomSelectStatement(SqlSelectQuery select) { _buffer.write(' customSelect(${_queryCode(select)}, '); _writeVariables(select); _buffer.write(', '); _writeReadsFrom(select); - if (includeMappingToDart) { - if (select.needsAsyncMapping) { - _buffer.write(').asyncMap('); - } else { - _buffer.write(').map('); - } - - _writeMappingLambda(select); + if (select.needsAsyncMapping) { + _buffer.write(').asyncMap('); + } else { + _buffer.write(').map('); } + _writeMappingLambda(select); _buffer.write(')'); } @@ -811,3 +824,12 @@ String? _defaultForDartPlaceholder( return null; } } + +extension on _ArgumentContext { + String applyPrefix(String originalName) { + return switch (sqlPrefix) { + null => originalName, + var s => '$s.$originalName', + }; + } +} diff --git a/drift_dev/test/analysis/resolver/queries/existing_row_classes_test.dart b/drift_dev/test/analysis/resolver/queries/existing_row_classes_test.dart index 62e94e99..e6a84c9a 100644 --- a/drift_dev/test/analysis/resolver/queries/existing_row_classes_test.dart +++ b/drift_dev/test/analysis/resolver/queries/existing_row_classes_test.dart @@ -1,4 +1,6 @@ +import 'package:drift_dev/src/analysis/options.dart'; import 'package:drift_dev/src/analysis/results/results.dart'; +import 'package:sqlparser/sqlparser.dart'; import 'package:test/test.dart'; import '../../test_utils.dart'; @@ -225,83 +227,124 @@ class MyQueryRow { ); }); - test('nested - single column type', () async { - final state = TestBackend.inTest({ - 'a|lib/a.drift': ''' + group('nested column', () { + test('single column into field', () async { + final state = TestBackend.inTest({ + 'a|lib/a.drift': ''' import 'a.dart'; -foo WITH MyQueryRow: SELECT 1 a, LIST(SELECT 2 AS b) c; +foo WITH MyQueryRow: SELECT 1 AS a, b.** FROM (SELECT 2 AS b) b; ''', - 'a|lib/a.dart': ''' + 'a|lib/a.dart': ''' class MyQueryRow { - MyQueryRow(int a, List c); + MyQueryRow(int a, int b); } ''', + }); + + final file = await state.analyze('package:a/a.drift'); + state.expectNoErrors(); + + final query = file.fileAnalysis!.resolvedQueries.values.single; + expect( + query.resultSet?.existingRowType, + isExistingRowType( + type: 'MyQueryRow', + positional: [ + scalarColumn('a'), + structedFromNested( + isExistingRowType( + singleValue: scalarColumn('b'), + ), + ), + ], + ), + ); }); - final file = await state.analyze('package:a/a.drift'); - state.expectNoErrors(); - - final query = file.fileAnalysis!.resolvedQueries.values.single; - expect( - query.resultSet?.existingRowType, - isExistingRowType( - type: 'MyQueryRow', - positional: [ - scalarColumn('a'), - nestedListQuery( - 'c', - isExistingRowType( - type: 'int', - singleValue: scalarColumn('b'), - ), - ), - ], - ), - ); - }); - - test('nested - table', () async { - final state = TestBackend.inTest({ - 'a|lib/a.drift': ''' + test('single column into single-element record', () async { + final state = TestBackend.inTest({ + 'a|lib/a.drift': ''' import 'a.dart'; -CREATE TABLE tbl (foo TEXT, bar INT); - -foo WITH MyRow: SELECT 1 AS a, b.** FROM tbl - INNER JOIN tbl b ON TRUE; +foo WITH MyQueryRow: SELECT 1 AS a, b.** FROM (SELECT 2 AS b) b; ''', - 'a|lib/a.dart': ''' -class MyRow { - MyRow(int a, TblData b); + 'a|lib/a.dart': ''' +class MyQueryRow { + MyQueryRow(int a, (int) b); } ''', + }); + + final file = await state.analyze('package:a/a.drift'); + state.expectNoErrors(); + + final query = file.fileAnalysis!.resolvedQueries.values.single; + expect( + query.resultSet?.existingRowType, + isExistingRowType( + type: 'MyQueryRow', + positional: [ + scalarColumn('a'), + structedFromNested( + isExistingRowType( + positional: [scalarColumn('b')], + isRecord: isTrue, + ), + ), + ], + ), + ); }); - final file = await state.analyze('package:a/a.drift'); - state.expectNoErrors(); + test('custom result set', () async { + final state = TestBackend.inTest( + { + 'a|lib/a.drift': ''' +import 'a.dart'; - final query = file.fileAnalysis!.resolvedQueries.values.single; - expect( - query.resultSet?.existingRowType, - isExistingRowType( - type: 'MyRow', - positional: [ - scalarColumn('a'), - structedFromNested( - isExistingRowType( - type: 'TblData', - singleValue: isA(), - ), +foo WITH MyQueryRow: SELECT 1 AS id, j.** FROM json_each('') AS j; +''', + 'a|lib/a.dart': ''' +class MyQueryRow { + MyQueryRow(int id, JsonStructure j); +} + +class JsonStructure { + JsonStructure(DriftAny key, DriftAny value, String type); +} +''', + }, + options: const DriftOptions.defaults( + sqliteAnalysisOptions: SqliteAnalysisOptions( + // Make sure json_each is supported + version: SqliteVersion.v3(38), ), - ], - ), - ); - }); + ), + ); - test('nested - table as alternative to row class', () async { - final state = TestBackend.inTest( - { + final file = await state.analyze('package:a/a.drift'); + state.expectNoErrors(); + + final query = file.fileAnalysis!.resolvedQueries.values.single; + expect( + query.resultSet?.existingRowType, + isExistingRowType( + type: 'MyQueryRow', + positional: [ + scalarColumn('id'), + structedFromNested( + isExistingRowType( + type: 'JsonStructure', + ), + ), + ], + ), + ); + }); + + test('table', () async { + final state = TestBackend.inTest({ 'a|lib/a.drift': ''' import 'a.dart'; @@ -311,45 +354,122 @@ foo WITH MyRow: SELECT 1 AS a, b.** FROM tbl INNER JOIN tbl b ON TRUE; ''', 'a|lib/a.dart': ''' +class MyRow { + MyRow(int a, TblData b); +} +''', + }); + + final file = await state.analyze('package:a/a.drift'); + state.expectNoErrors(); + + final query = file.fileAnalysis!.resolvedQueries.values.single; + expect( + query.resultSet?.existingRowType, + isExistingRowType( + type: 'MyRow', + positional: [ + scalarColumn('a'), + structedFromNested( + isExistingRowType( + type: 'TblData', + singleValue: isA(), + ), + ), + ], + ), + ); + }); + + test('table as alternative to row class', () async { + final state = TestBackend.inTest( + { + 'a|lib/a.drift': ''' +import 'a.dart'; + +CREATE TABLE tbl (foo TEXT, bar INT); + +foo WITH MyRow: SELECT 1 AS a, b.** FROM tbl + INNER JOIN tbl b ON TRUE; +''', + 'a|lib/a.dart': ''' class MyRow { MyRow(int a, (String, int) b); } ''', - }, - analyzerExperiments: ['records'], - ); + }, + analyzerExperiments: ['records'], + ); - final file = await state.analyze('package:a/a.drift'); - state.expectNoErrors(); + final file = await state.analyze('package:a/a.drift'); + state.expectNoErrors(); - final query = file.fileAnalysis!.resolvedQueries.values.single; - expect( - query.resultSet?.existingRowType, - isExistingRowType( - type: 'MyRow', - positional: [ - scalarColumn('a'), - structedFromNested( - isExistingRowType( - type: '(String, int)', - positional: [scalarColumn('foo'), scalarColumn('bar')], + final query = file.fileAnalysis!.resolvedQueries.values.single; + expect( + query.resultSet?.existingRowType, + isExistingRowType( + type: 'MyRow', + positional: [ + scalarColumn('a'), + structedFromNested( + isExistingRowType( + type: '(String, int)', + positional: [scalarColumn('foo'), scalarColumn('bar')], + ), ), - ), - ], - ), - ); + ], + ), + ); + }); }); - test('nested - custom result set with class', () async { - final state = TestBackend.inTest({ - 'a|lib/a.drift': ''' + group('nested LIST query', () { + test('single column type', () async { + final state = TestBackend.inTest({ + 'a|lib/a.drift': ''' +import 'a.dart'; + +foo WITH MyQueryRow: SELECT 1 a, LIST(SELECT 2 AS b) c; +''', + 'a|lib/a.dart': ''' +class MyQueryRow { + MyQueryRow(int a, List c); +} +''', + }); + + final file = await state.analyze('package:a/a.drift'); + state.expectNoErrors(); + + final query = file.fileAnalysis!.resolvedQueries.values.single; + expect( + query.resultSet?.existingRowType, + isExistingRowType( + type: 'MyQueryRow', + positional: [ + scalarColumn('a'), + nestedListQuery( + 'c', + isExistingRowType( + type: 'int', + singleValue: scalarColumn('b'), + ), + ), + ], + ), + ); + }); + + test('custom result set with class', () async { + final state = TestBackend.inTest({ + 'a|lib/a.drift': ''' import 'a.dart'; CREATE TABLE tbl (foo TEXT, bar INT); foo WITH MyRow: SELECT 1 AS a, LIST(SELECT * FROM tbl) AS b FROM tbl; ''', - 'a|lib/a.dart': ''' + 'a|lib/a.dart': ''' class MyRow { MyRow(int a, List b); } @@ -358,69 +478,70 @@ class MyNestedTable { MyNestedTable(String foo, int bar) } ''', + }); + + final file = await state.analyze('package:a/a.drift'); + state.expectNoErrors(); + + final query = file.fileAnalysis!.resolvedQueries.values.single; + expect( + query.resultSet?.existingRowType, + isExistingRowType( + type: 'MyRow', + positional: [ + scalarColumn('a'), + nestedListQuery( + 'b', + isExistingRowType( + type: 'MyNestedTable', + positional: [scalarColumn('foo'), scalarColumn('bar')], + ), + ), + ], + ), + ); }); - final file = await state.analyze('package:a/a.drift'); - state.expectNoErrors(); - - final query = file.fileAnalysis!.resolvedQueries.values.single; - expect( - query.resultSet?.existingRowType, - isExistingRowType( - type: 'MyRow', - positional: [ - scalarColumn('a'), - nestedListQuery( - 'b', - isExistingRowType( - type: 'MyNestedTable', - positional: [scalarColumn('foo'), scalarColumn('bar')], - ), - ), - ], - ), - ); - }); - - test('nested - custom result set with record', () async { - final state = TestBackend.inTest( - { - 'a|lib/a.drift': ''' + test('custom result set with record', () async { + final state = TestBackend.inTest( + { + 'a|lib/a.drift': ''' import 'a.dart'; CREATE TABLE tbl (foo TEXT, bar INT); foo WITH MyRow: SELECT 1 AS a, LIST(SELECT * FROM tbl) AS b FROM tbl; ''', - 'a|lib/a.dart': ''' + 'a|lib/a.dart': ''' class MyRow { MyRow(int a, List<(String, int)> b); } ''', - }, - analyzerExperiments: ['records'], - ); + }, + analyzerExperiments: ['records'], + ); - final file = await state.analyze('package:a/a.drift'); - state.expectNoErrors(); + final file = await state.analyze('package:a/a.drift'); + state.expectNoErrors(); - final query = file.fileAnalysis!.resolvedQueries.values.single; - expect( - query.resultSet?.existingRowType, - isExistingRowType( - type: 'MyRow', - positional: [ - scalarColumn('a'), - nestedListQuery( - 'b', - isExistingRowType( - type: '(String, int)', - positional: [scalarColumn('foo'), scalarColumn('bar')], + final query = file.fileAnalysis!.resolvedQueries.values.single; + expect( + query.resultSet?.existingRowType, + isExistingRowType( + type: 'MyRow', + positional: [ + scalarColumn('a'), + nestedListQuery( + 'b', + isExistingRowType( + type: '(String, int)', + positional: [scalarColumn('foo'), scalarColumn('bar')], + ), ), - ), - ], - ), - ); + ], + ), + ); + }); }); test('into record', () async { diff --git a/drift_dev/test/analysis/resolver/queries/linter_test.dart b/drift_dev/test/analysis/resolver/queries/linter_test.dart index d66112cf..a4997ef6 100644 --- a/drift_dev/test/analysis/resolver/queries/linter_test.dart +++ b/drift_dev/test/analysis/resolver/queries/linter_test.dart @@ -75,22 +75,6 @@ q: SELECT * FROM t WHERE i IN ?1; expect(result.allErrors, isEmpty); }); - test('warns when nested results refer to table-valued functions', () async { - final result = await TestBackend.analyzeSingle( - "a: SELECT json_each.** FROM json_each('');", - options: DriftOptions.defaults(modules: [SqlModule.json1]), - ); - - expect( - result.allErrors, - [ - isDriftError( - contains('Nested star columns must refer to a table directly.')) - .withSpan('json_each.**') - ], - ); - }); - test('warns about default values outside of expressions', () async { final state = TestBackend.inTest({ 'foo|lib/a.drift': r''' diff --git a/drift_dev/test/analysis/resolver/queries/query_analyzer_test.dart b/drift_dev/test/analysis/resolver/queries/query_analyzer_test.dart index ee2705c0..0401ac09 100644 --- a/drift_dev/test/analysis/resolver/queries/query_analyzer_test.dart +++ b/drift_dev/test/analysis/resolver/queries/query_analyzer_test.dart @@ -108,6 +108,36 @@ query: SELECT foo.**, bar.** FROM my_view foo, my_view bar; ); }); + test('infers nested result sets for custom result sets', () async { + final state = TestBackend.inTest({ + 'foo|lib/main.drift': r''' +query: SELECT 1 AS a, b.** FROM (SELECT 2 AS b, 3 AS c) AS b; + ''', + }); + + final file = await state.analyze('package:foo/main.drift'); + state.expectNoErrors(); + + final query = file.fileAnalysis!.resolvedQueries.values.single; + + expect( + query.resultSet!.mappingToRowClass('Row', const DriftOptions.defaults()), + isExistingRowType( + type: 'Row', + named: { + 'a': scalarColumn('a'), + 'b': isExistingRowType( + type: 'QueryNestedColumn0', + named: { + 'b': scalarColumn('b'), + 'c': scalarColumn('c'), + }, + ) + }, + ), + ); + }); + for (final dateTimeAsText in [false, true]) { test('analyzing date times (stored as text: $dateTimeAsText)', () async { final state = TestBackend.inTest( diff --git a/drift_dev/test/analysis/resolver/queries/utils.dart b/drift_dev/test/analysis/resolver/queries/utils.dart index 6e2a486f..7f22ebd7 100644 --- a/drift_dev/test/analysis/resolver/queries/utils.dart +++ b/drift_dev/test/analysis/resolver/queries/utils.dart @@ -33,6 +33,7 @@ TypeMatcher isExistingRowType({ Object? singleValue, Object? positional, Object? named, + Object? isRecord, }) { var matcher = isA(); @@ -53,6 +54,9 @@ TypeMatcher isExistingRowType({ if (named != null) { matcher = matcher.having((e) => e.namedArguments, 'namedArguments', named); } + if (isRecord != null) { + matcher = matcher.having((e) => e.isRecord, 'isRecord', isRecord); + } return matcher; } diff --git a/drift_dev/test/writer/queries/query_writer_test.dart b/drift_dev/test/writer/queries/query_writer_test.dart index 69ece4b4..ed630cd9 100644 --- a/drift_dev/test/writer/queries/query_writer_test.dart +++ b/drift_dev/test/writer/queries/query_writer_test.dart @@ -3,6 +3,7 @@ import 'package:drift_dev/src/analysis/options.dart'; import 'package:drift_dev/src/writer/import_manager.dart'; import 'package:drift_dev/src/writer/queries/query_writer.dart'; import 'package:drift_dev/src/writer/writer.dart'; +import 'package:sqlparser/sqlparser.dart'; import 'package:test/test.dart'; import '../../analysis/test_utils.dart'; @@ -14,6 +15,7 @@ void main() { final state = TestBackend.inTest({'a|lib/main.drift': driftFile}, options: options); final file = await state.analyze('package:a/main.drift'); + state.expectNoErrors(); final writer = Writer( const DriftOptions.defaults(generateNamedParameters: true), @@ -55,32 +57,112 @@ void main() { ); }); - test('generates correct name for renamed nested star columns', () async { - final generated = await generateForQueryInDriftFile(''' + group('nested star column', () { + test('get renamed in SQL', () async { + final generated = await generateForQueryInDriftFile(''' CREATE TABLE tbl ( id INTEGER NULL ); query: SELECT t.** AS tableName FROM tbl AS t; '''); - expect( - generated, - allOf( - contains('SELECT"t"."id" AS "nested_0.id"'), - contains('final TblData tableName;'), - ), - ); + expect( + generated, + allOf( + contains('SELECT"t"."id" AS "nested_0.id"'), + contains('final TblData tableName;'), + ), + ); + }); + + test('makes single columns nullable if from outer join', () async { + final generated = await generateForQueryInDriftFile(''' + query: SELECT 1 AS r, joined.** FROM (SELECT 1) + LEFT OUTER JOIN (SELECT 2 AS b) joined; + '''); + + expect( + generated, + allOf( + contains("joined: row.readNullable('nested_0.b')"), + contains('final int? joined;'), + ), + ); + }); + + test('checks for nullable column in nested table', () async { + final generated = await generateForQueryInDriftFile(''' + CREATE TABLE tbl ( + id INTEGER NULL + ); + + query: SELECT 1 AS a, tbl.** FROM (SELECT 1) LEFT OUTER JOIN tbl; + '''); + + expect( + generated, + allOf( + contains( + "tbl: await tbl.mapFromRowOrNull(row, tablePrefix: 'nested_0')"), + contains('final TblData? tbl;'), + ), + ); + }); + + test('checks for nullable column in nested table with alias', () async { + final generated = await generateForQueryInDriftFile(''' + CREATE TABLE tbl ( + id INTEGER NULL, + col TEXT NOT NULL + ); + + query: SELECT 1 AS a, tbl.** FROM (SELECT 1) LEFT OUTER JOIN (SELECT id AS a, col AS b from tbl) tbl; + '''); + + expect( + generated, + allOf( + contains("tbl: row.data['nested_0.b'] == null ? null : " + 'tbl.mapFromRowWithAlias(row'), + contains('final TblData? tbl;'), + ), + ); + }); + + test('checks for nullable column in nested result set', () async { + final generated = await generateForQueryInDriftFile(''' + query: SELECT 1 AS r, joined.** FROM (SELECT 1) + LEFT OUTER JOIN (SELECT NULL AS b, 3 AS c) joined; + '''); + + expect( + generated, + allOf( + contains("joined: row.data['nested_0.c'] == null ? null : " + "QueryNestedColumn0(b: row.readNullable('nested_0.b'), " + "c: row.read('nested_0.c'), )"), + contains('final QueryNestedColumn0? joined;'), + ), + ); + }); }); test('generates correct returning mapping', () async { - final generated = await generateForQueryInDriftFile(''' + final generated = await generateForQueryInDriftFile( + ''' CREATE TABLE tbl ( id INTEGER, text TEXT ); query: INSERT INTO tbl (id, text) VALUES(10, 'test') RETURNING id; - '''); + ''', + options: const DriftOptions.defaults( + sqliteAnalysisOptions: + // Assuming 3.35 because dso that returning works. + SqliteAnalysisOptions(version: SqliteVersion.v3(35)), + ), + ); expect(generated, contains('.toList()')); }); @@ -346,20 +428,19 @@ failQuery: ], readsFrom: { t, - }).asyncMap((i0.QueryRow row) async { - return FailQueryResult( - a: row.readNullable('a'), - b: row.readNullable('b'), - nestedQuery0: await customSelect( - 'SELECT * FROM t AS x WHERE x.b = b OR x.b = ?1', - variables: [ - i0.Variable(inB) - ], - readsFrom: { - t, - }).asyncMap(t.mapFromRow).get(), - ); - }); + }).asyncMap((i0.QueryRow row) async => FailQueryResult( + a: row.readNullable('a'), + b: row.readNullable('b'), + nestedQuery0: await customSelect( + 'SELECT * FROM t AS x WHERE x.b = b OR x.b = ?1', + variables: [ + i0.Variable(inB) + ], + readsFrom: { + t, + }).asyncMap(t.mapFromRow).get(), + )); + } ''')) }, outputs.dartOutputs, outputs); }); From 6a36957a85922cade52347ae540011b24ef2ce0c Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 24 Jul 2023 20:50:43 +0200 Subject: [PATCH 12/17] Don't generate async mapping if not needed --- drift/test/generated/custom_tables.g.dart | 21 +++---- drift/test/generated/todos.g.dart | 2 +- drift_dev/lib/src/analysis/results/query.dart | 56 +++++++++++++------ .../lib/src/writer/queries/query_writer.dart | 35 +++++++----- 4 files changed, 71 insertions(+), 43 deletions(-) diff --git a/drift/test/generated/custom_tables.g.dart b/drift/test/generated/custom_tables.g.dart index 012cda42..097a47e8 100644 --- a/drift/test/generated/custom_tables.g.dart +++ b/drift/test/generated/custom_tables.g.dart @@ -1756,7 +1756,7 @@ abstract class _$CustomTablesDb extends GeneratedDatabase { readsFrom: { config, }).map((QueryRow row) => JsonResult( - raw: row, + row: row, key: row.read('key'), value: row.readNullable('value'), )); @@ -1765,7 +1765,7 @@ abstract class _$CustomTablesDb extends GeneratedDatabase { Selectable another() { return customSelect('SELECT \'one\' AS "key", NULLIF(\'two\', \'another\') AS value', variables: [], readsFrom: {}) .map((QueryRow row) => JsonResult( - raw: row, + row: row, key: row.read('key'), value: row.readNullable('value'), )); @@ -1789,10 +1789,11 @@ abstract class _$CustomTablesDb extends GeneratedDatabase { withConstraints, ...generatedpredicate.watchedTables, }).asyncMap((QueryRow row) async => MultipleResult( - raw: row, + row: row, a: row.readNullable('a'), b: row.readNullable('b'), - c: withConstraints.mapFromRow(row), + c: await withConstraints.mapFromRowOrNull(row, + tablePrefix: 'nested_0'), )); } @@ -1821,7 +1822,7 @@ abstract class _$CustomTablesDb extends GeneratedDatabase { config, ...generatedexpr.watchedTables, }).map((QueryRow row) => ReadRowIdResult( - raw: row, + row: row, rowid: row.read('rowid'), configKey: row.read('config_key'), configValue: row.readNullable('config_value'), @@ -1887,8 +1888,8 @@ abstract class _$CustomTablesDb extends GeneratedDatabase { withConstraints, withDefaults, }).asyncMap((QueryRow row) async => NestedResult( - raw: row, - defaults: withDefaults.mapFromRow(row), + row: row, + defaults: await withDefaults.mapFromRow(row, tablePrefix: 'nested_0'), nestedQuery1: await customSelect( 'SELECT * FROM with_constraints AS c WHERE c.b = ?1', variables: [ @@ -1897,7 +1898,7 @@ abstract class _$CustomTablesDb extends GeneratedDatabase { readsFrom: { withConstraints, withDefaults, - }).map((QueryRow row) => withConstraints.mapFromRow(row)).get(), + }).asyncMap(withConstraints.mapFromRow).get(), )); } @@ -1914,8 +1915,8 @@ abstract class _$CustomTablesDb extends GeneratedDatabase { syncState: NullAwareTypeConverter.wrapFromSql( ConfigTable.$convertersyncState, row.readNullable('sync_state')), - config: config.mapFromRow(row, tablePrefix: 'nested_0'), - noIds: noIds.mapFromRow(row, tablePrefix: 'nested_1'), + config: await config.mapFromRow(row, tablePrefix: 'nested_0'), + noIds: await noIds.mapFromRow(row, tablePrefix: 'nested_1'), nested: await customSelect('SELECT * FROM no_ids', variables: [], readsFrom: { diff --git a/drift/test/generated/todos.g.dart b/drift/test/generated/todos.g.dart index 24e03fbd..e2bbd0cf 100644 --- a/drift/test/generated/todos.g.dart +++ b/drift/test/generated/todos.g.dart @@ -1715,7 +1715,7 @@ abstract class _$TodoDb extends GeneratedDatabase { categories, todosTable, }).map((QueryRow row) => AllTodosWithCategoryResult( - raw: row, + row: row, id: row.read('id'), title: row.readNullable('title'), content: row.read('content'), diff --git a/drift_dev/lib/src/analysis/results/query.dart b/drift_dev/lib/src/analysis/results/query.dart index c7e1ec43..1b85ddd1 100644 --- a/drift_dev/lib/src/analysis/results/query.dart +++ b/drift_dev/lib/src/analysis/results/query.dart @@ -161,17 +161,6 @@ abstract class SqlQuery { placeholders = elements.whereType().toList(); } - bool get needsAsyncMapping { - final result = resultSet; - if (result != null) { - // Mapping to tables is asynchronous - if (result.matchingTable != null) return true; - if (result.nestedResults.any((e) => e is NestedResultTable)) return true; - } - - return false; - } - bool get _useResultClassName { final resultSet = this.resultSet!; @@ -245,9 +234,6 @@ class SqlSelectQuery extends SqlQuery { bool get hasNestedQuery => resultSet.nestedResults.any((e) => e is NestedResultQuery); - @override - bool get needsAsyncMapping => hasNestedQuery || super.needsAsyncMapping; - SqlSelectQuery( String name, this.fromContext, @@ -547,7 +533,7 @@ class InferredResultSet { singleValue: null, positionalArguments: const [], namedArguments: { - if (options.rawResultSetData) 'raw': RawQueryRow(), + if (options.rawResultSetData) 'row': RawQueryRow(), for (final column in columns) dartNameFor(column): _columnAsArgument(column, options), }, @@ -597,6 +583,19 @@ class QueryRowType implements ArgumentForQueryRowType { this.isRecord = false, }); + Iterable get allArguments sync* { + if (singleValue != null) { + yield singleValue!; + } else { + yield* positionalArguments; + yield* namedArguments.values; + } + } + + @override + bool get requiresAsynchronousContext => + allArguments.any((arg) => arg.requiresAsynchronousContext); + @override String toString() { return 'ExistingQueryRowType(type: $rowType, singleValue: $singleValue, ' @@ -604,13 +603,20 @@ class QueryRowType implements ArgumentForQueryRowType { } } -sealed class ArgumentForQueryRowType {} +sealed class ArgumentForQueryRowType { + /// Whether the code constructing this argument may need to be in an async + /// context. + bool get requiresAsynchronousContext; +} /// An argument that just maps the raw query row. /// /// This is used for generated query classes which can optionally hold a /// reference to the raw result set. -class RawQueryRow extends ArgumentForQueryRowType {} +class RawQueryRow extends ArgumentForQueryRowType { + @override + bool get requiresAsynchronousContext => false; +} class StructuredFromNestedColumn extends ArgumentForQueryRowType { final NestedResultTable table; @@ -619,6 +625,10 @@ class StructuredFromNestedColumn extends ArgumentForQueryRowType { bool get nullable => table.isNullable; StructuredFromNestedColumn(this.table, this.nestedType); + + @override + bool get requiresAsynchronousContext => + nestedType.requiresAsynchronousContext; } class MappedNestedListQuery extends ArgumentForQueryRowType { @@ -626,6 +636,10 @@ class MappedNestedListQuery extends ArgumentForQueryRowType { final QueryRowType nestedType; MappedNestedListQuery(this.column, this.nestedType); + + // List queries run another statement and always need an asynchronous mapping. + @override + bool get requiresAsynchronousContext => true; } /// Information about a matching table. A table matches a query if a query @@ -638,6 +652,11 @@ class MatchingDriftTable implements ArgumentForQueryRowType { MatchingDriftTable(this.table, this.aliasToColumn); + @override + // Mapping from tables is currently asynchronous because the existing data + // class could be an asynchronous factory. + bool get requiresAsynchronousContext => true; + /// Whether the column alias can be ignored. /// /// This is the case if each result column name maps to a drift column with @@ -680,6 +699,9 @@ final class ScalarResultColumn extends ResultColumn @override bool get isArray => false; + @override + bool get requiresAsynchronousContext => false; + @override String dartGetterName(Iterable existingNames) { return dartNameForSqlColumn(name, existingNames: existingNames); diff --git a/drift_dev/lib/src/writer/queries/query_writer.dart b/drift_dev/lib/src/writer/queries/query_writer.dart index ab38c768..46cb5d56 100644 --- a/drift_dev/lib/src/writer/queries/query_writer.dart +++ b/drift_dev/lib/src/writer/queries/query_writer.dart @@ -84,22 +84,19 @@ class QueryWriter { /// Writes the function literal that turns a "QueryRow" into the desired /// custom return type of a query. - void _writeMappingLambda(SqlQuery query) { - final resultSet = query.resultSet!; - final rowClass = query.queryRowType(options); - + void _writeMappingLambda(InferredResultSet resultSet, QueryRowType rowClass) { final queryRow = _emitter.drift('QueryRow'); - final asyncModifier = query.needsAsyncMapping ? 'async' : ''; + final asyncModifier = rowClass.requiresAsynchronousContext ? 'async' : ''; // We can write every available mapping as a Dart expression via // _writeArgumentExpression. This can be turned into a lambda by appending // it with `(QueryRow row) => $expression`. That's also what we're doing, // but if we'll just call mapFromRow in there, we can just tear that method // off instead. This is just an optimization. - final matchingTable = resultSet.matchingTable; - if (matchingTable != null && matchingTable.effectivelyNoAlias) { + final singleValue = rowClass.singleValue; + if (singleValue is MatchingDriftTable && singleValue.effectivelyNoAlias) { // Tear-off mapFromRow method on table - _emitter.write('${matchingTable.table.dbGetterName}.mapFromRow'); + _emitter.write('${singleValue.table.dbGetterName}.mapFromRow'); } else { // In all other cases, we're off to write the expression. _emitter.write('($queryRow row) $asyncModifier => '); @@ -132,7 +129,7 @@ class QueryWriter { case MappedNestedListQuery(): _buffer.write('await '); final query = argument.column.query; - _writeCustomSelectStatement(query); + _writeCustomSelectStatement(query, argument.nestedType); _buffer.write('.get()'); case QueryRowType(): final singleValue = argument.singleValue; @@ -297,19 +294,23 @@ class QueryWriter { _buffer.write(';\n}\n'); } - void _writeCustomSelectStatement(SqlSelectQuery select) { + void _writeCustomSelectStatement(SqlSelectQuery select, + [QueryRowType? resultType]) { _buffer.write(' customSelect(${_queryCode(select)}, '); _writeVariables(select); _buffer.write(', '); _writeReadsFrom(select); - if (select.needsAsyncMapping) { + final resultSet = select.resultSet; + resultType ??= select.queryRowType(options); + + if (resultType.requiresAsynchronousContext) { _buffer.write(').asyncMap('); } else { _buffer.write(').map('); } - _writeMappingLambda(select); + _writeMappingLambda(resultSet, resultType); _buffer.write(')'); } @@ -335,13 +336,17 @@ class QueryWriter { _writeCommonUpdateParameters(update); _buffer.write(').then((rows) => '); - if (update.needsAsyncMapping) { + + final resultSet = update.resultSet!; + final rowType = update.queryRowType(options); + + if (rowType.requiresAsynchronousContext) { _buffer.write('Future.wait(rows.map('); - _writeMappingLambda(update); + _writeMappingLambda(resultSet, rowType); _buffer.write('))'); } else { _buffer.write('rows.map('); - _writeMappingLambda(update); + _writeMappingLambda(resultSet, rowType); _buffer.write(').toList()'); } _buffer.write(');\n}'); From 5ed6115b0da78adc161ede652bad9aad6cdf1891 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 24 Jul 2023 20:54:32 +0200 Subject: [PATCH 13/17] Fix outdated tests in drift_dev --- .../analysis/resolver/queries/query_analyzer_test.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/drift_dev/test/analysis/resolver/queries/query_analyzer_test.dart b/drift_dev/test/analysis/resolver/queries/query_analyzer_test.dart index 0401ac09..dc073249 100644 --- a/drift_dev/test/analysis/resolver/queries/query_analyzer_test.dart +++ b/drift_dev/test/analysis/resolver/queries/query_analyzer_test.dart @@ -101,8 +101,8 @@ query: SELECT foo.**, bar.** FROM my_view foo, my_view bar; query.resultSet!.mappingToRowClass('', const DriftOptions.defaults()), isExistingRowType( named: { - 'foo': isFromView, - 'bar': isFromView, + 'foo': structedFromNested(isFromView), + 'bar': structedFromNested(isFromView), }, ), ); @@ -126,13 +126,13 @@ query: SELECT 1 AS a, b.** FROM (SELECT 2 AS b, 3 AS c) AS b; type: 'Row', named: { 'a': scalarColumn('a'), - 'b': isExistingRowType( + 'b': structedFromNested(isExistingRowType( type: 'QueryNestedColumn0', named: { 'b': scalarColumn('b'), 'c': scalarColumn('c'), }, - ) + )), }, ), ); From e433cff932f82c97ecc365a4ec5e456b0b68f236 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 24 Jul 2023 21:10:04 +0200 Subject: [PATCH 14/17] Update docs for nested columns --- .../docs/Advanced Features/custom_row_classes.md | 11 +++++++---- docs/pages/docs/Using SQL/drift_files.md | 10 ++++------ drift_dev/CHANGELOG.md | 6 ++++++ 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/pages/docs/Advanced Features/custom_row_classes.md b/docs/pages/docs/Advanced Features/custom_row_classes.md index 02f07b99..51e989ad 100644 --- a/docs/pages/docs/Advanced Features/custom_row_classes.md +++ b/docs/pages/docs/Advanced Features/custom_row_classes.md @@ -181,8 +181,9 @@ fields in existing types as well. Depending on what kind of result set your query has, you can use different fields for the existing Dart class: -1. For a nested table selected with `**`, your field needs to store an instance of the table's row class. - This is true for both drift-generated row classes and tables with existing, user-defined row classes. +1. For a nested table selected with `**`, your field needs to store a structure compatible with the result set + the nested column points to. For `my_table.**`, that field could either be the generated row class for `MyTable` + or a custom class as described by rule 3. 2. For nested list results, you have to use a `List`. The `T` has to be compatible with the inner result set of the `LIST()` as described by these rules. 3. For a single-table result, you can use the table class, regardless of whether the table uses an existing table @@ -221,8 +222,8 @@ class EmployeeWithStaff { } ``` -As `self` is a `**` column, rule 1 applies. Therefore, `T1` must be `Employee`, the row class for the -`employees` table. +As `self` is a `**` column, rule 1 applies. `self` references a table, `employees`. +By rule 3, this means that `T1` can be a `Employee`, the row class for the `employees` table. On the other hand, `staff` is a `LIST()` column and rule 2 applies here. This means that `T3` must be a `List`. The inner result set of the `LIST` references all columns of `employees` and nothing more, so rule @@ -235,6 +236,8 @@ class IdAndName { final int id; final String name; + // This class can be used since id and name column are available from the list query. + // We could have also used the `Employee` class or a record like `(int, String)`. IdAndName(this.id, this.name); } diff --git a/docs/pages/docs/Using SQL/drift_files.md b/docs/pages/docs/Using SQL/drift_files.md index d9a44b0f..b6d67d7b 100644 --- a/docs/pages/docs/Using SQL/drift_files.md +++ b/docs/pages/docs/Using SQL/drift_files.md @@ -228,12 +228,10 @@ class RoutesWithNestedPointsResult { Great! This class matches our intent much better than the flat result class from before. -At the moment, there are some limitations with this approach: - -- `**` is not yet supported in compound select statements -- you can only use `table.**` if table is an actual table or a reference to it. - In particular, it doesn't work for result sets from `WITH` clauses or table- - valued functions. +These nested result columns (`**`) can appear in top-level select statements +only, they're not supported in compound select statements or subqueries yet. +However, they can refer to any result set in SQL that has been joined to the +select statement - including subqueries table-valued functions. You might be wondering how `**` works under the hood, since it's not valid sql. At build time, drift's generator will transform `**` into a list of all columns diff --git a/drift_dev/CHANGELOG.md b/drift_dev/CHANGELOG.md index 642d253b..26bbd10c 100644 --- a/drift_dev/CHANGELOG.md +++ b/drift_dev/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.11.0 + +- [Nested result columns](https://drift.simonbinder.eu/docs/using-sql/drift_files/#nested-results) + in drift files can now refer to any result set (e.g. a table-valued function or a subquery). + They were restricted to direct table references before. + ## 2.10.0 - Add the `schema steps` command to generate help in writing step-by-step schema migrations. From 94c4c1a8e0e5acb7af5756e684820aa0423c7936 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Tue, 25 Jul 2023 23:24:08 +0200 Subject: [PATCH 15/17] Document #2537 better --- docs/pages/docs/Getting started/index.md | 30 +------------- .../docs/Getting started/starting_with_sql.md | 29 +------------- docs/templates/partials/changed_to_ffi.html | 15 ------- docs/templates/partials/dependencies.html | 39 +++++++++++++++++++ drift_dev/build.yaml | 8 ++-- 5 files changed, 46 insertions(+), 75 deletions(-) delete mode 100644 docs/templates/partials/changed_to_ffi.html create mode 100644 docs/templates/partials/dependencies.html diff --git a/docs/pages/docs/Getting started/index.md b/docs/pages/docs/Getting started/index.md index 2c6bea96..fd4d4554 100644 --- a/docs/pages/docs/Getting started/index.md +++ b/docs/pages/docs/Getting started/index.md @@ -15,37 +15,11 @@ how to get started. You can watch it [here](https://youtu.be/zpWsedYMczM). A complete cross-platform Flutter app using drift is also available [here](https://github.com/simolus3/drift/tree/develop/examples/app). ## Adding the dependency -First, lets add drift to your project's `pubspec.yaml`. -At the moment, the current version of `drift` is [![Drift version](https://img.shields.io/pub/v/drift.svg)](https://pub.dev/packages/drift) -and the latest version of `drift_dev` is [![Generator version](https://img.shields.io/pub/v/drift_dev.svg)](https://pub.dev/packages/drift_dev). -{% assign versions = 'package:drift_docs/versions.json' | readString | json_decode %} +{% include "partials/dependencies" %} + {% assign snippets = 'package:drift_docs/snippets/tables/filename.dart.excerpt.json' | readString | json_decode %} -```yaml -dependencies: - drift: ^{{ versions.drift }} - sqlite3_flutter_libs: ^0.5.0 - path_provider: ^2.0.0 - path: ^{{ versions.path }} - -dev_dependencies: - drift_dev: ^{{ versions.drift_dev }} - build_runner: ^{{ versions.build_runner }} -``` - -If you're wondering why so many packages are necessary, here's a quick overview over what each package does: - -- `drift`: This is the core package defining most apis -- `sqlite3_flutter_libs`: Ships the latest `sqlite3` version with your Android or iOS app. This is not required when you're _not_ using Flutter, - but then you need to take care of including `sqlite3` yourself. - For an overview on other platforms, see [platforms]({{ '../platforms.md' | pageUrl }}). -- `path_provider` and `path`: Used to find a suitable location to store the database. Maintained by the Flutter and Dart team -- `drift_dev`: This development-only dependency generates query code based on your tables. It will not be included in your final app. -- `build_runner`: Common tool for code-generation, maintained by the Dart team - -{% include "partials/changed_to_ffi" %} - ### Declaring tables Using drift, you can model the structure of your tables with simple dart code. diff --git a/docs/pages/docs/Getting started/starting_with_sql.md b/docs/pages/docs/Getting started/starting_with_sql.md index 70cc3315..933ed494 100644 --- a/docs/pages/docs/Getting started/starting_with_sql.md +++ b/docs/pages/docs/Getting started/starting_with_sql.md @@ -12,35 +12,8 @@ declaring both tables and queries in Dart. This version will focus on how to use A complete cross-platform Flutter app using drift is also available [here](https://github.com/simolus3/drift/tree/develop/examples/app). ## Adding the dependency -First, lets add drift to your project's `pubspec.yaml`. -At the moment, the current version of `drift` is [![Drift version](https://img.shields.io/pub/v/drift.svg)](https://pub.dev/packages/drift) -and the latest version of `drift_dev` is [![Generator version](https://img.shields.io/pub/v/drift_dev.svg)](https://pub.dev/packages/drift_dev). -{% assign versions = 'package:drift_docs/versions.json' | readString | json_decode %} - -```yaml -dependencies: - drift: ^{{ versions.drift }} - sqlite3_flutter_libs: ^0.5.0 - path_provider: ^2.0.0 - path: ^{{ versions.path }} - -dev_dependencies: - drift_dev: ^{{ versions.drift_dev }} - build_runner: ^{{ versions.build_runner }} -``` - -If you're wondering why so many packages are necessary, here's a quick overview over what each package does: - -- `drift`: This is the core package defining most apis -- `sqlite3_flutter_libs`: Ships the latest `sqlite3` version with your Android or iOS app. This is not required when you're _not_ using Flutter, - but then you need to take care of including `sqlite3` yourself. - For an overview on other platforms, see [platforms]({{ '../platforms.md' | pageUrl }}). -- `path_provider` and `path`: Used to find a suitable location to store the database. Maintained by the Flutter and Dart team -- `drift_dev`: This development-only dependency generates query code based on your tables. It will not be included in your final app. -- `build_runner`: Common tool for code-generation, maintained by the Dart team - -{% include "partials/changed_to_ffi" %} +{% include "partials/dependencies" %} ## Declaring tables and queries diff --git a/docs/templates/partials/changed_to_ffi.html b/docs/templates/partials/changed_to_ffi.html deleted file mode 100644 index 9129ae86..00000000 --- a/docs/templates/partials/changed_to_ffi.html +++ /dev/null @@ -1,15 +0,0 @@ - - -Some versions of the Flutter tool create a broken `settings.gradle` on Android, which can cause problems with `drift/native.dart`. -If you get a "Failed to load dynamic library" exception, see [this comment](https://github.com/flutter/flutter/issues/55827#issuecomment-623779910). \ No newline at end of file diff --git a/docs/templates/partials/dependencies.html b/docs/templates/partials/dependencies.html new file mode 100644 index 00000000..534f8bd0 --- /dev/null +++ b/docs/templates/partials/dependencies.html @@ -0,0 +1,39 @@ +{% block "blocks/markdown" %} + +First, lets add drift to your project's `pubspec.yaml`. +At the moment, the current version of `drift` is [![Drift version](https://img.shields.io/pub/v/drift.svg)](https://pub.dev/packages/drift) +and the latest version of `drift_dev` is [![Generator version](https://img.shields.io/pub/v/drift_dev.svg)](https://pub.dev/packages/drift_dev). + +{% assign versions = 'package:drift_docs/versions.json' | readString | json_decode %} + +```yaml +dependencies: + drift: ^{{ versions.drift }} + sqlite3_flutter_libs: ^0.5.0 + path_provider: ^2.0.0 + path: ^{{ versions.path }} + +dev_dependencies: + drift_dev: ^{{ versions.drift_dev }} + build_runner: ^{{ versions.build_runner }} +``` + +If you're wondering why so many packages are necessary, here's a quick overview over what each package does: + +- `drift`: This is the core package defining the APIs you use to access drift databases. +- `sqlite3_flutter_libs`: Ships the latest `sqlite3` version with your Android or iOS app. This is not required when you're _not_ using Flutter, + but then you need to take care of including `sqlite3` yourself. + For an overview on other platforms, see [platforms]({{ '../platforms.md' | pageUrl }}). + Note that the `sqlite3_flutter_libs` package will include the native sqlite3 library for the following + architectures: `armv8`, `armv7`, `x86` and `x86_64`. + Most Flutter apps don't run on 32-bit x86 devices without further setup, so you should + [add a snippet](https://github.com/simolus3/sqlite3.dart/tree/main/sqlite3_flutter_libs#included-platforms) + to your `build.gradle` if you don't need `x86` builds. + Otherwise, the Play Store might allow users on `x86` devices to install your app even though it is not + supported. + In Flutter's current native build system, drift unfortunately can't do that for you. +- `path_provider` and `path`: Used to find a suitable location to store the database. Maintained by the Flutter and Dart team. +- `drift_dev`: This development-only dependency generates query code based on your tables. It will not be included in your final app. +- `build_runner`: Common tool for code-generation, maintained by the Dart team. + +{% endblock %} \ No newline at end of file diff --git a/drift_dev/build.yaml b/drift_dev/build.yaml index 5faeee00..3b806dbb 100644 --- a/drift_dev/build.yaml +++ b/drift_dev/build.yaml @@ -1,14 +1,14 @@ # Short description for each builder -# - preparing_builder: Infers the type of inline Dart expressions in moor files. +# - preparing_builder: Infers the type of inline Dart expressions in drift files. # We create a `input.temp.dart` file containing the expressions so that they # can be resolved. -# - moor_generator: The regular SharedPartBuilder for @UseMoor and @UseDao +# - drift_dev: The regular SharedPartBuilder for @DriftDatabase and @DriftAccessor # annotations -# - moor_generator_not_shared: Like moor_generator, but as a PartBuilder instead of +# - not_shared: Like drift_dev, but as a PartBuilder instead of # a SharedPartBuilder. This builder is disabled by default, but users may choose # to use it so that generated classes can be used by other builders. -# - moor_cleanup: Deletes the `.temp.dart` files generated by the `preparing_builder`. +# - cleanup: Deletes the `.temp.dart` files generated by the `preparing_builder`. builders: preparing_builder: From 8d3f490604ff5f17fcb1f966c0d88e55cb137ef2 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Wed, 26 Jul 2023 23:59:06 +0200 Subject: [PATCH 16/17] Add multi-dialect generation option --- .../docs/Advanced Features/builder_options.md | 30 +++++++++ drift/example/main.g.dart | 4 +- drift/lib/internal/versioned_schema.dart | 4 ++ .../query_builder/expressions/custom.dart | 20 +++++- .../src/runtime/query_builder/helpers.dart | 24 ++++++- .../src/runtime/query_builder/migration.dart | 17 +++-- .../query_builder/schema/entities.dart | 46 ++++++++++--- .../query_builder/schema/view_info.dart | 9 ++- .../test/database/statements/schema_test.dart | 26 ++++++++ drift/test/generated/custom_tables.g.dart | 6 +- drift/test/generated/todos.g.dart | 21 +++--- drift_dev/lib/src/analysis/options.dart | 20 ++++-- .../lib/src/analysis/resolver/drift/view.dart | 4 +- .../lib/src/generated/analysis/options.g.dart | 13 +++- drift_dev/lib/src/writer/database_writer.dart | 24 +++++-- .../lib/src/writer/queries/query_writer.dart | 52 +++++++++++++-- .../lib/src/writer/queries/sql_writer.dart | 17 +++-- .../lib/src/writer/tables/table_writer.dart | 6 +- .../lib/src/writer/tables/view_writer.dart | 19 ++++-- drift_dev/lib/src/writer/writer.dart | 65 ++++++++++++++++--- .../resolver/drift/virtual_table_test.dart | 3 +- .../build/build_integration_test.dart | 2 +- .../writer/queries/query_writer_test.dart | 29 ++++++++- .../test/writer/queries/sql_writer_test.dart | 13 ++-- examples/modular/lib/src/users.drift.dart | 6 +- .../drift_testcases/build.yaml | 4 +- .../lib/database/database.g.dart | 51 +++++++++++---- .../drift_testcases/pubspec.yaml | 2 +- 28 files changed, 436 insertions(+), 101 deletions(-) diff --git a/docs/pages/docs/Advanced Features/builder_options.md b/docs/pages/docs/Advanced Features/builder_options.md index 98398a07..262ca913 100644 --- a/docs/pages/docs/Advanced Features/builder_options.md +++ b/docs/pages/docs/Advanced Features/builder_options.md @@ -110,6 +110,36 @@ in 3.34, so an error would be reported. Currently, the generator can't provide compatibility checks for versions below 3.34, which is the minimum version needed in options. +### Multi-dialect code generation + +Thanks to community contributions, drift has in-progress support for Postgres and MariaDB. +You can change the `dialect` option to `postgres` or `mariadb` to generate code for those +database management systems. + +In some cases, your generated code might have to support more than one DBMS. For instance, +you might want to share database code between your backend and a Flutter app. Or maybe +you're writing a server that should be able to talk to both MariaDB and Postgres, depending +on what the operator prefers. +Drift can generate code for multiple dialects - in that case, the right SQL will be chosen +at runtime when it makes a difference. + +To enable this feature, remove the `dialect` option in the `sql` block and replace it with +a list of `dialects`: + +```yaml +targets: + $default: + builders: + drift_dev: + options: + sql: + dialect: + - sqlite + - postgres + options: + version: "3.34" +``` + ### Available extensions __Note__: This enables extensions in the analyzer for custom queries only. For instance, when the `json1` extension is diff --git a/drift/example/main.g.dart b/drift/example/main.g.dart index 52c57318..63d7509f 100644 --- a/drift/example/main.g.dart +++ b/drift/example/main.g.dart @@ -534,7 +534,7 @@ class $TodoCategoryItemCountView @override String get entityName => 'todo_category_item_count'; @override - String? get createViewStmt => null; + Map? get createViewStatements => null; @override $TodoCategoryItemCountView get asDslTable => this; @override @@ -639,7 +639,7 @@ class $TodoItemWithCategoryNameViewView extends ViewInfo< @override String get entityName => 'customViewName'; @override - String? get createViewStmt => null; + Map? get createViewStatements => null; @override $TodoItemWithCategoryNameViewView get asDslTable => this; @override diff --git a/drift/lib/internal/versioned_schema.dart b/drift/lib/internal/versioned_schema.dart index 70685c5e..41e33de7 100644 --- a/drift/lib/internal/versioned_schema.dart +++ b/drift/lib/internal/versioned_schema.dart @@ -157,6 +157,10 @@ class VersionedView implements ViewInfo, HasResultSet { @override final String createViewStmt; + @override + Map? get createViewStatements => + {SqlDialect.sqlite: createViewStmt}; + @override final List $columns; diff --git a/drift/lib/src/runtime/query_builder/expressions/custom.dart b/drift/lib/src/runtime/query_builder/expressions/custom.dart index 378e23ab..763e7285 100644 --- a/drift/lib/src/runtime/query_builder/expressions/custom.dart +++ b/drift/lib/src/runtime/query_builder/expressions/custom.dart @@ -11,6 +11,8 @@ class CustomExpression extends Expression { /// The SQL of this expression final String content; + final Map? _dialectSpecificContent; + /// Additional tables that this expression is watching. /// /// When this expression is used in a stream query, the stream will update @@ -24,11 +26,25 @@ class CustomExpression extends Expression { /// Constructs a custom expression by providing the raw sql [content]. const CustomExpression(this.content, - {this.watchedTables = const [], this.precedence = Precedence.unknown}); + {this.watchedTables = const [], this.precedence = Precedence.unknown}) + : _dialectSpecificContent = null; + + /// Constructs a custom expression providing the raw SQL in [content] depending + /// on the SQL dialect when this expression is built. + const CustomExpression.dialectSpecific(Map content, + {this.watchedTables = const [], this.precedence = Precedence.unknown}) + : _dialectSpecificContent = content, + content = ''; @override void writeInto(GenerationContext context) { - context.buffer.write(content); + final dialectSpecific = _dialectSpecificContent; + + if (dialectSpecific != null) { + } else { + context.buffer.write(content); + } + context.watchedTables.addAll(watchedTables); } diff --git a/drift/lib/src/runtime/query_builder/helpers.dart b/drift/lib/src/runtime/query_builder/helpers.dart index c88eb9d4..ccd78f56 100644 --- a/drift/lib/src/runtime/query_builder/helpers.dart +++ b/drift/lib/src/runtime/query_builder/helpers.dart @@ -1,6 +1,11 @@ +@internal +library; + import 'package:drift/drift.dart'; -/// Utilities for writing the definition of a result set into a query. +import 'package:meta/meta.dart'; + +/// Internal utilities for building queries that aren't exported. extension WriteDefinition on GenerationContext { /// Writes the result set to this context, suitable to implement `FROM` /// clauses and joins. @@ -16,4 +21,21 @@ extension WriteDefinition on GenerationContext { watchedTables.add(resultSet); } } + + /// Returns a suitable SQL string in [sql] based on the current dialect. + String pickForDialect(Map sql) { + assert( + sql.containsKey(dialect), + 'Tried running SQL optimized for the following dialects: ${sql.keys.join}. ' + 'However, the database is running $dialect. Has that dialect been added ' + 'to the `dialects` drift builder option?', + ); + + final found = sql[dialect]; + if (found != null) { + return found; + } + + return sql.values.first; // Fallback + } } diff --git a/drift/lib/src/runtime/query_builder/migration.dart b/drift/lib/src/runtime/query_builder/migration.dart index 46513dd9..87cf119c 100644 --- a/drift/lib/src/runtime/query_builder/migration.dart +++ b/drift/lib/src/runtime/query_builder/migration.dart @@ -87,7 +87,7 @@ class Migrator { } else if (entity is Index) { await createIndex(entity); } else if (entity is OnCreateQuery) { - await _issueCustomQuery(entity.sql, const []); + await _issueQueryByDialect(entity.sqlByDialect); } else if (entity is ViewInfo) { await createView(entity); } else { @@ -363,19 +363,19 @@ class Migrator { /// Executes the `CREATE TRIGGER` statement that created the [trigger]. Future createTrigger(Trigger trigger) { - return _issueCustomQuery(trigger.createTriggerStmt, const []); + return _issueQueryByDialect(trigger.createStatementsByDialect); } /// Executes a `CREATE INDEX` statement to create the [index]. Future createIndex(Index index) { - return _issueCustomQuery(index.createIndexStmt, const []); + return _issueQueryByDialect(index.createStatementsByDialect); } /// Executes a `CREATE VIEW` statement to create the [view]. Future createView(ViewInfo view) async { - final stmt = view.createViewStmt; - if (stmt != null) { - await _issueCustomQuery(stmt, const []); + final stmts = view.createViewStatements; + if (stmts != null) { + await _issueQueryByDialect(stmts); } else if (view.query != null) { final context = GenerationContext.fromDb(_db, supportsVariables: false); final columnNames = view.$columns.map((e) => e.escapedName).join(', '); @@ -478,6 +478,11 @@ class Migrator { return _issueCustomQuery(sql, args); } + Future _issueQueryByDialect(Map sql) { + final context = _createContext(); + return _issueCustomQuery(context.pickForDialect(sql), const []); + } + Future _issueCustomQuery(String sql, [List? args]) { return _db.customStatement(sql, args); } diff --git a/drift/lib/src/runtime/query_builder/schema/entities.dart b/drift/lib/src/runtime/query_builder/schema/entities.dart index 11542420..8bd76c03 100644 --- a/drift/lib/src/runtime/query_builder/schema/entities.dart +++ b/drift/lib/src/runtime/query_builder/schema/entities.dart @@ -17,15 +17,26 @@ abstract class DatabaseSchemaEntity { /// [sqlite-docs]: https://sqlite.org/lang_createtrigger.html /// [sql-tut]: https://www.sqlitetutorial.net/sqlite-trigger/ class Trigger extends DatabaseSchemaEntity { - /// The `CREATE TRIGGER` sql statement that can be used to create this - /// trigger. - final String createTriggerStmt; @override final String entityName; + /// The `CREATE TRIGGER` sql statement that can be used to create this + /// trigger. + @Deprecated('Use createStatementsByDialect instead') + String get createTriggerStmt => createStatementsByDialect.values.first; + + /// The `CREATE TRIGGER` SQL statements used to create this trigger, accessible + /// for each dialect enabled when generating code. + final Map createStatementsByDialect; + /// Creates a trigger representation by the [createTriggerStmt] and its /// [entityName]. Mainly used by generated code. - Trigger(this.createTriggerStmt, this.entityName); + Trigger(String createTriggerStmt, String entityName) + : this.byDialect(entityName, {SqlDialect.sqlite: createTriggerStmt}); + + /// Creates the trigger model from its [entityName] in the schema and all + /// [createStatementsByDialect] for the supported dialects. + Trigger.byDialect(this.entityName, this.createStatementsByDialect); } /// A sqlite index on columns or expressions. @@ -40,11 +51,21 @@ class Index extends DatabaseSchemaEntity { final String entityName; /// The `CREATE INDEX` sql statement that can be used to create this index. - final String createIndexStmt; + @Deprecated('Use createStatementsByDialect instead') + String get createIndexStmt => createStatementsByDialect.values.first; + + /// The `CREATE INDEX` SQL statements used to create this index, accessible + /// for each dialect enabled when generating code. + final Map createStatementsByDialect; /// Creates an index model by the [createIndexStmt] and its [entityName]. /// Mainly used by generated code. - Index(this.entityName, this.createIndexStmt); + Index(this.entityName, String createIndexStmt) + : createStatementsByDialect = {SqlDialect.sqlite: createIndexStmt}; + + /// Creates an index model by its [entityName] used in the schema and the + /// `CREATE INDEX` statements for each supported dialect. + Index.byDialect(this.entityName, this.createStatementsByDialect); } /// An internal schema entity to run an sql statement when the database is @@ -61,10 +82,19 @@ class Index extends DatabaseSchemaEntity { /// drift file. class OnCreateQuery extends DatabaseSchemaEntity { /// The sql statement that should be run in the default `onCreate` clause. - final String sql; + @Deprecated('Use sqlByDialect instead') + String get sql => sqlByDialect.values.first; + + /// The SQL statement to run, indexed by the dialect used in the database. + final Map sqlByDialect; /// Create a query that will be run in the default `onCreate` migration. - OnCreateQuery(this.sql); + OnCreateQuery(String sql) : this.byDialect({SqlDialect.sqlite: sql}); + + /// Creates the entity of a query to run in the default `onCreate` migration. + /// + /// The migrator will lookup a suitable query from the [sqlByDialect] map. + OnCreateQuery.byDialect(this.sqlByDialect); @override String get entityName => r'$internal$'; diff --git a/drift/lib/src/runtime/query_builder/schema/view_info.dart b/drift/lib/src/runtime/query_builder/schema/view_info.dart index 0b6482f5..8455f9d9 100644 --- a/drift/lib/src/runtime/query_builder/schema/view_info.dart +++ b/drift/lib/src/runtime/query_builder/schema/view_info.dart @@ -17,7 +17,14 @@ abstract class ViewInfo /// The `CREATE VIEW` sql statement that can be used to create this view. /// /// This will be null if the view was defined in Dart. - String? get createViewStmt; + @Deprecated('Use createViewStatements instead') + String? get createViewStmt => createViewStatements?.values.first; + + /// The `CREATE VIEW` sql statement that can be used to create this view, + /// depending on the dialect used by the current database. + /// + /// This will be null if the view was defined in Dart. + Map? get createViewStatements; /// Predefined query from `View.as()` /// diff --git a/drift/test/database/statements/schema_test.dart b/drift/test/database/statements/schema_test.dart index 683330a9..da7397fe 100644 --- a/drift/test/database/statements/schema_test.dart +++ b/drift/test/database/statements/schema_test.dart @@ -259,6 +259,32 @@ void main() { )); }); }); + + group('dialect-specific', () { + Map statements(String base) { + return { + for (final dialect in SqlDialect.values) dialect: '$base $dialect', + }; + } + + for (final dialect in [SqlDialect.sqlite, SqlDialect.postgres]) { + test('with dialect $dialect', () async { + final executor = MockExecutor(); + when(executor.dialect).thenReturn(dialect); + + final db = TodoDb(executor); + final migrator = db.createMigrator(); + + await migrator.create(Trigger.byDialect('a', statements('trigger'))); + await migrator.create(Index.byDialect('a', statements('index'))); + await migrator.create(OnCreateQuery.byDialect(statements('@'))); + + verify(executor.runCustom('trigger $dialect', [])); + verify(executor.runCustom('index $dialect', [])); + verify(executor.runCustom('@ $dialect', [])); + }); + } + }); } final class _FakeSchemaVersion extends VersionedSchema { diff --git a/drift/test/generated/custom_tables.g.dart b/drift/test/generated/custom_tables.g.dart index 097a47e8..b9b041d7 100644 --- a/drift/test/generated/custom_tables.g.dart +++ b/drift/test/generated/custom_tables.g.dart @@ -1600,8 +1600,10 @@ class MyView extends ViewInfo implements HasResultSet { @override String get entityName => 'my_view'; @override - String get createViewStmt => - 'CREATE VIEW my_view AS SELECT * FROM config WHERE sync_state = 2'; + Map get createViewStatements => { + SqlDialect.sqlite: + 'CREATE VIEW my_view AS SELECT * FROM config WHERE sync_state = 2', + }; @override MyView get asDslTable => this; @override diff --git a/drift/test/generated/todos.g.dart b/drift/test/generated/todos.g.dart index e2bbd0cf..01d537cb 100644 --- a/drift/test/generated/todos.g.dart +++ b/drift/test/generated/todos.g.dart @@ -641,16 +641,13 @@ class $UsersTable extends Users with TableInfo<$UsersTable, User> { static const VerificationMeta _isAwesomeMeta = const VerificationMeta('isAwesome'); @override - late final GeneratedColumn isAwesome = - GeneratedColumn('is_awesome', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintsDependsOnDialect({ - SqlDialect.sqlite: 'CHECK ("is_awesome" IN (0, 1))', - SqlDialect.mysql: '', - SqlDialect.postgres: '', - }), - defaultValue: const Constant(true)); + late final GeneratedColumn isAwesome = GeneratedColumn( + 'is_awesome', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_awesome" IN (0, 1))'), + defaultValue: const Constant(true)); static const VerificationMeta _profilePictureMeta = const VerificationMeta('profilePicture'); @override @@ -1549,7 +1546,7 @@ class $CategoryTodoCountViewView @override String get entityName => 'category_todo_count_view'; @override - String? get createViewStmt => null; + Map? get createViewStatements => null; @override $CategoryTodoCountViewView get asDslTable => this; @override @@ -1660,7 +1657,7 @@ class $TodoWithCategoryViewView @override String get entityName => 'todo_with_category_view'; @override - String? get createViewStmt => null; + Map? get createViewStatements => null; @override $TodoWithCategoryViewView get asDslTable => this; @override diff --git a/drift_dev/lib/src/analysis/options.dart b/drift_dev/lib/src/analysis/options.dart index a71fd119..73ea0ad0 100644 --- a/drift_dev/lib/src/analysis/options.dart +++ b/drift_dev/lib/src/analysis/options.dart @@ -124,7 +124,7 @@ class DriftOptions { this.modules = const [], this.sqliteAnalysisOptions, this.storeDateTimeValuesAsText = false, - this.dialect = const DialectOptions(SqlDialect.sqlite, null), + this.dialect = const DialectOptions(null, [SqlDialect.sqlite], null), this.caseFromDartToSql = CaseFromDartToSql.snake, this.writeToColumnsMixins = false, this.fatalWarnings = false, @@ -189,7 +189,18 @@ class DriftOptions { /// Whether the [module] has been enabled in this configuration. bool hasModule(SqlModule module) => effectiveModules.contains(module); - SqlDialect get effectiveDialect => dialect?.dialect ?? SqlDialect.sqlite; + List get supportedDialects { + final dialects = dialect?.dialects; + final singleDialect = dialect?.dialect; + + if (dialects != null) { + return dialects; + } else if (singleDialect != null) { + return [singleDialect]; + } else { + return const [SqlDialect.sqlite]; + } + } /// The assumed sqlite version used when analyzing queries. SqliteVersion get sqliteVersion { @@ -201,10 +212,11 @@ class DriftOptions { @JsonSerializable() class DialectOptions { - final SqlDialect dialect; + final SqlDialect? dialect; + final List? dialects; final SqliteAnalysisOptions? options; - const DialectOptions(this.dialect, this.options); + const DialectOptions(this.dialect, this.dialects, this.options); factory DialectOptions.fromJson(Map json) => _$DialectOptionsFromJson(json); diff --git a/drift_dev/lib/src/analysis/resolver/drift/view.dart b/drift_dev/lib/src/analysis/resolver/drift/view.dart index f4f5a076..f824ab7f 100644 --- a/drift_dev/lib/src/analysis/resolver/drift/view.dart +++ b/drift_dev/lib/src/analysis/resolver/drift/view.dart @@ -1,3 +1,4 @@ +import 'package:drift/drift.dart' show SqlDialect; import 'package:recase/recase.dart'; import 'package:sqlparser/sqlparser.dart'; import 'package:sqlparser/sqlparser.dart' as sql; @@ -110,7 +111,8 @@ class DriftViewResolver extends DriftElementResolver { query: stmt.query, // Remove drift-specific syntax driftTableName: null, - ).toSqlWithoutDriftSpecificSyntax(resolver.driver.options); + ).toSqlWithoutDriftSpecificSyntax( + resolver.driver.options, SqlDialect.sqlite); return DriftView( discovered.ownId, diff --git a/drift_dev/lib/src/generated/analysis/options.g.dart b/drift_dev/lib/src/generated/analysis/options.g.dart index af43ec35..6c2e86f6 100644 --- a/drift_dev/lib/src/generated/analysis/options.g.dart +++ b/drift_dev/lib/src/generated/analysis/options.g.dart @@ -179,11 +179,16 @@ DialectOptions _$DialectOptionsFromJson(Map json) => $checkedCreate( ($checkedConvert) { $checkKeys( json, - allowedKeys: const ['dialect', 'options'], + allowedKeys: const ['dialect', 'dialects', 'options'], ); final val = DialectOptions( $checkedConvert( - 'dialect', (v) => $enumDecode(_$SqlDialectEnumMap, v)), + 'dialect', (v) => $enumDecodeNullable(_$SqlDialectEnumMap, v)), + $checkedConvert( + 'dialects', + (v) => (v as List?) + ?.map((e) => $enumDecode(_$SqlDialectEnumMap, e)) + .toList()), $checkedConvert( 'options', (v) => @@ -195,7 +200,9 @@ DialectOptions _$DialectOptionsFromJson(Map json) => $checkedCreate( Map _$DialectOptionsToJson(DialectOptions instance) => { - 'dialect': _$SqlDialectEnumMap[instance.dialect]!, + 'dialect': _$SqlDialectEnumMap[instance.dialect], + 'dialects': + instance.dialects?.map((e) => _$SqlDialectEnumMap[e]!).toList(), 'options': instance.options?.toJson(), }; diff --git a/drift_dev/lib/src/writer/database_writer.dart b/drift_dev/lib/src/writer/database_writer.dart index 96eba75a..ef550a47 100644 --- a/drift_dev/lib/src/writer/database_writer.dart +++ b/drift_dev/lib/src/writer/database_writer.dart @@ -220,25 +220,37 @@ class DatabaseWriter { } static String createTrigger(Scope scope, DriftTrigger entity) { - final sql = scope.sqlCode(entity.parsedStatement!); + final (sql, dialectSpecific) = scope.sqlByDialect(entity.parsedStatement!); final trigger = scope.drift('Trigger'); - return '$trigger(${asDartLiteral(sql)}, ${asDartLiteral(entity.schemaName)})'; + if (dialectSpecific) { + return '$trigger.byDialect(${asDartLiteral(entity.schemaName)}, $sql)'; + } else { + return '$trigger($sql, ${asDartLiteral(entity.schemaName)})'; + } } static String createIndex(Scope scope, DriftIndex entity) { - final sql = scope.sqlCode(entity.parsedStatement!); + final (sql, dialectSpecific) = scope.sqlByDialect(entity.parsedStatement!); final index = scope.drift('Index'); - return '$index(${asDartLiteral(entity.schemaName)}, ${asDartLiteral(sql)})'; + if (dialectSpecific) { + return '$index.byDialect(${asDartLiteral(entity.schemaName)}, $sql)'; + } else { + return '$index(${asDartLiteral(entity.schemaName)}, $sql)'; + } } static String createOnCreate( Scope scope, DefinedSqlQuery query, SqlQuery resolved) { - final sql = scope.sqlCode(resolved.root!); + final (sql, dialectSpecific) = scope.sqlByDialect(resolved.root!); final onCreate = scope.drift('OnCreateQuery'); - return '$onCreate(${asDartLiteral(sql)})'; + if (dialectSpecific) { + return '$onCreate.byDialect($sql)'; + } else { + return '$onCreate($sql)'; + } } } diff --git a/drift_dev/lib/src/writer/queries/query_writer.dart b/drift_dev/lib/src/writer/queries/query_writer.dart index 46cb5d56..b781c8e3 100644 --- a/drift_dev/lib/src/writer/queries/query_writer.dart +++ b/drift_dev/lib/src/writer/queries/query_writer.dart @@ -1,3 +1,4 @@ +import 'package:drift/drift.dart'; import 'package:recase/recase.dart'; import 'package:sqlparser/sqlparser.dart' hide ResultColumn; @@ -495,7 +496,45 @@ class QueryWriter { /// been expanded. For instance, 'SELECT * FROM t WHERE x IN ?' will be turned /// into 'SELECT * FROM t WHERE x IN ($expandedVar1)'. String _queryCode(SqlQuery query) { - return SqlWriter(scope.options, query: query).write(); + final dialectForCode = >{}; + + for (final dialect in scope.options.supportedDialects) { + final code = + SqlWriter(scope.options, dialect: dialect, query: query).write(); + + dialectForCode.putIfAbsent(code, () => []).add(dialect); + } + + if (dialectForCode.length == 1) { + // All supported dialects use the same SQL syntax, so we can just use that + return dialectForCode.keys.single; + } else { + // Create a switch expression matching over the dialect of the database + // we're connected to. + final buffer = StringBuffer('switch (executor.dialect) {'); + final dialectEnum = scope.drift('SqlDialect'); + + var index = 0; + for (final MapEntry(key: code, value: dialects) + in dialectForCode.entries) { + index++; + + buffer + .write(dialects.map((e) => '$dialectEnum.${e.name}').join(' || ')); + if (index == dialectForCode.length) { + // In the last branch, match all dialects as a fallback + buffer.write(' || _ '); + } + + buffer + ..write(' => ') + ..write(code) + ..write(', '); + } + + buffer.writeln('}'); + return buffer.toString(); + } } void _writeReadsFrom(SqlSelectQuery select) { @@ -818,9 +857,14 @@ String? _defaultForDartPlaceholder( if (kind is ExpressionDartPlaceholderType && kind.defaultValue != null) { // Wrap the default expression in parentheses to avoid issues with // the surrounding precedence in SQL. - final sql = SqlWriter(scope.options) - .writeNodeIntoStringLiteral(Parentheses(kind.defaultValue!)); - return 'const ${scope.drift('CustomExpression')}($sql)'; + final (sql, dialectSpecific) = + scope.sqlByDialect(Parentheses(kind.defaultValue!)); + + if (dialectSpecific) { + return 'const ${scope.drift('CustomExpression')}.dialectSpecific($sql)'; + } else { + return 'const ${scope.drift('CustomExpression')}($sql)'; + } } else if (kind is SimpleDartPlaceholderType && kind.kind == SimpleDartPlaceholderKind.orderBy) { return 'const ${scope.drift('OrderBy')}.nothing()'; diff --git a/drift_dev/lib/src/writer/queries/sql_writer.dart b/drift_dev/lib/src/writer/queries/sql_writer.dart index 4ebdfc3c..4eefbe8a 100644 --- a/drift_dev/lib/src/writer/queries/sql_writer.dart +++ b/drift_dev/lib/src/writer/queries/sql_writer.dart @@ -26,8 +26,9 @@ String placeholderContextName(FoundDartPlaceholder placeholder) { } extension ToSqlText on AstNode { - String toSqlWithoutDriftSpecificSyntax(DriftOptions options) { - final writer = SqlWriter(options, escapeForDart: false); + String toSqlWithoutDriftSpecificSyntax( + DriftOptions options, SqlDialect dialect) { + final writer = SqlWriter(options, dialect: dialect, escapeForDart: false); return writer.writeSql(this); } } @@ -36,17 +37,19 @@ class SqlWriter extends NodeSqlBuilder { final StringBuffer _out; final SqlQuery? query; final DriftOptions options; + final SqlDialect dialect; final Map _starColumnToResolved; - bool get _isPostgres => options.effectiveDialect == SqlDialect.postgres; + bool get _isPostgres => dialect == SqlDialect.postgres; - SqlWriter._(this.query, this.options, this._starColumnToResolved, - StringBuffer out, bool escapeForDart) + SqlWriter._(this.query, this.options, this.dialect, + this._starColumnToResolved, StringBuffer out, bool escapeForDart) : _out = out, super(escapeForDart ? _DartEscapingSink(out) : out); factory SqlWriter( DriftOptions options, { + required SqlDialect dialect, SqlQuery? query, bool escapeForDart = true, StringBuffer? buffer, @@ -61,7 +64,7 @@ class SqlWriter extends NodeSqlBuilder { if (nestedResult is NestedResultTable) nestedResult.from: nestedResult }; } - return SqlWriter._(query, options, doubleStarColumnToResolvedTable, + return SqlWriter._(query, options, dialect, doubleStarColumnToResolvedTable, buffer ?? StringBuffer(), escapeForDart); } @@ -84,7 +87,7 @@ class SqlWriter extends NodeSqlBuilder { @override bool isKeyword(String lexeme) { - switch (options.effectiveDialect) { + switch (dialect) { case SqlDialect.postgres: return isKeywordLexeme(lexeme) || isPostgresKeywordLexeme(lexeme); default: diff --git a/drift_dev/lib/src/writer/tables/table_writer.dart b/drift_dev/lib/src/writer/tables/table_writer.dart index 8f05a103..388b58b8 100644 --- a/drift_dev/lib/src/writer/tables/table_writer.dart +++ b/drift_dev/lib/src/writer/tables/table_writer.dart @@ -162,7 +162,7 @@ abstract class TableOrViewWriter { } /// Returns the Dart type and the Dart expression creating a `GeneratedColumn` - /// instance in drift for the givne [column]. + /// instance in drift for the given [column]. static (String, String) instantiateColumn( DriftColumn column, TextEmitter emitter, { @@ -173,6 +173,10 @@ abstract class TableOrViewWriter { final expressionBuffer = StringBuffer(); final constraints = defaultConstraints(column); + // Remove dialect-specific constraints for dialects we don't care about. + constraints.removeWhere( + (key, _) => !emitter.writer.options.supportedDialects.contains(key)); + for (final constraint in column.constraints) { if (constraint is LimitingTextLength) { final buffer = diff --git a/drift_dev/lib/src/writer/tables/view_writer.dart b/drift_dev/lib/src/writer/tables/view_writer.dart index 659b8843..9d43f6a0 100644 --- a/drift_dev/lib/src/writer/tables/view_writer.dart +++ b/drift_dev/lib/src/writer/tables/view_writer.dart @@ -75,18 +75,29 @@ class ViewWriter extends TableOrViewWriter { ..write('@override\n String get entityName=>' ' ${asDartLiteral(view.schemaName)};\n'); + emitter + ..writeln('@override') + ..write('Map<${emitter.drift('SqlDialect')}, String>') + ..write(source is! SqlViewSource ? '?' : '') + ..write('get createViewStatements => '); if (source is SqlViewSource) { final astNode = source.parsedStatement; - emitter.write('@override\nString get createViewStmt =>'); if (astNode != null) { - emitter.writeSqlAsDartLiteral(astNode); + emitter.writeSqlByDialectMap(astNode); } else { - emitter.write(asDartLiteral(source.sqlCreateViewStmt)); + final firstDialect = scope.options.supportedDialects.first; + + emitter + ..write('{') + ..writeDriftRef('SqlDialect') + ..write('.${firstDialect.name}: ') + ..write(asDartLiteral(source.sqlCreateViewStmt)) + ..write('}'); } buffer.writeln(';'); } else { - buffer.write('@override\n String? get createViewStmt => null;\n'); + buffer.writeln('null;'); } writeAsDslTable(); diff --git a/drift_dev/lib/src/writer/writer.dart b/drift_dev/lib/src/writer/writer.dart index 430160a9..98a3e0b3 100644 --- a/drift_dev/lib/src/writer/writer.dart +++ b/drift_dev/lib/src/writer/writer.dart @@ -1,3 +1,4 @@ +import 'package:drift/drift.dart'; import 'package:recase/recase.dart'; import 'package:sqlparser/sqlparser.dart' as sql; import 'package:path/path.dart' show url; @@ -228,8 +229,50 @@ abstract class _NodeOrWriter { return buffer.toString(); } - String sqlCode(sql.AstNode node) { - return SqlWriter(writer.options, escapeForDart: false).writeSql(node); + String sqlCode(sql.AstNode node, SqlDialect dialect) { + return SqlWriter(writer.options, dialect: dialect, escapeForDart: false) + .writeSql(node); + } + + /// Builds a Dart expression writing the [node] into a Dart string. + /// + /// If the code for [node] depends on the dialect, the code returned evaluates + /// to a `Map`. Otherwise, the code is a direct string + /// literal. + /// + /// The boolean component in the record describes whether the code will be + /// dialect specific. + (String, bool) sqlByDialect(sql.AstNode node) { + final dialects = writer.options.supportedDialects; + + if (dialects.length == 1) { + return ( + SqlWriter(writer.options, dialect: dialects.single) + .writeNodeIntoStringLiteral(node), + false + ); + } + + final buffer = StringBuffer(); + _writeSqlByDialectMap(node, buffer); + return (buffer.toString(), true); + } + + void _writeSqlByDialectMap(sql.AstNode node, StringBuffer buffer) { + buffer.write('{'); + + for (final dialect in writer.options.supportedDialects) { + buffer + ..write(drift('SqlDialect')) + ..write(".${dialect.name}: '"); + + SqlWriter(writer.options, dialect: dialect, buffer: buffer) + .writeSql(node); + + buffer.writeln("',"); + } + + buffer.write('}'); } } @@ -302,16 +345,18 @@ class TextEmitter extends _Node { void writeDart(AnnotatedDartCode code) => write(dartCode(code)); - void writeSql(sql.AstNode node, {bool escapeForDartString = true}) { - SqlWriter(writer.options, - escapeForDart: escapeForDartString, buffer: buffer) - .writeSql(node); + void writeSql(sql.AstNode node, + {required SqlDialect dialect, bool escapeForDartString = true}) { + SqlWriter( + writer.options, + dialect: dialect, + escapeForDart: escapeForDartString, + buffer: buffer, + ).writeSql(node); } - void writeSqlAsDartLiteral(sql.AstNode node) { - buffer.write("'"); - writeSql(node); - buffer.write("'"); + void writeSqlByDialectMap(sql.AstNode node) { + _writeSqlByDialectMap(node, buffer); } } diff --git a/drift_dev/test/analysis/resolver/drift/virtual_table_test.dart b/drift_dev/test/analysis/resolver/drift/virtual_table_test.dart index 2fbabea8..3322b976 100644 --- a/drift_dev/test/analysis/resolver/drift/virtual_table_test.dart +++ b/drift_dev/test/analysis/resolver/drift/virtual_table_test.dart @@ -76,7 +76,8 @@ SELECT rowid, highlight(example_table_search, 0, '[match]', '[match]') name, {'a|lib/a.drift': 'CREATE VIRTUAL TABLE demo USING spellfix1;'}, options: DriftOptions.defaults( dialect: DialectOptions( - SqlDialect.sqlite, + null, + [SqlDialect.sqlite], SqliteAnalysisOptions( modules: [SqlModule.spellfix1], ), diff --git a/drift_dev/test/backends/build/build_integration_test.dart b/drift_dev/test/backends/build/build_integration_test.dart index a818a31e..094d5dcb 100644 --- a/drift_dev/test/backends/build/build_integration_test.dart +++ b/drift_dev/test/backends/build/build_integration_test.dart @@ -307,7 +307,7 @@ TypeConverter myConverter() => throw UnimplementedError(); 'a|lib/a.drift.dart': decodedMatches( allOf( contains( - ''''CREATE VIEW my_view AS SELECT CAST(1 AS INT) AS c1, CAST(\\'bar\\' AS TEXT) AS c2, 1 AS c3, NULLIF(1, 2) AS c4';'''), + ''''CREATE VIEW my_view AS SELECT CAST(1 AS INT) AS c1, CAST(\\'bar\\' AS TEXT) AS c2, 1 AS c3, NULLIF(1, 2) AS c4','''), contains(r'$converterc1 ='), contains(r'$converterc2 ='), contains(r'$converterc3 ='), diff --git a/drift_dev/test/writer/queries/query_writer_test.dart b/drift_dev/test/writer/queries/query_writer_test.dart index ed630cd9..6e2ecc2e 100644 --- a/drift_dev/test/writer/queries/query_writer_test.dart +++ b/drift_dev/test/writer/queries/query_writer_test.dart @@ -1,4 +1,5 @@ import 'package:build_test/build_test.dart'; +import 'package:drift/drift.dart'; import 'package:drift_dev/src/analysis/options.dart'; import 'package:drift_dev/src/writer/import_manager.dart'; import 'package:drift_dev/src/writer/queries/query_writer.dart'; @@ -11,14 +12,16 @@ import '../../utils.dart'; void main() { Future generateForQueryInDriftFile(String driftFile, - {DriftOptions options = const DriftOptions.defaults()}) async { + {DriftOptions options = const DriftOptions.defaults( + generateNamedParameters: true, + )}) async { final state = TestBackend.inTest({'a|lib/main.drift': driftFile}, options: options); final file = await state.analyze('package:a/main.drift'); state.expectNoErrors(); final writer = Writer( - const DriftOptions.defaults(generateNamedParameters: true), + options, generationOptions: GenerationOptions( imports: ImportManagerForPartFiles(), ), @@ -528,4 +531,26 @@ class ADrift extends i1.ModularAccessor { }''')) }, outputs.dartOutputs, outputs); }); + + test('creates dialect-specific query code', () async { + final result = await generateForQueryInDriftFile( + r''' +query (:foo AS TEXT): SELECT :foo; +''', + options: const DriftOptions.defaults( + dialect: DialectOptions( + null, [SqlDialect.sqlite, SqlDialect.postgres], null), + ), + ); + + expect( + result, + contains( + 'switch (executor.dialect) {' + "SqlDialect.sqlite => 'SELECT ?1 AS _c0', " + "SqlDialect.postgres || _ => 'SELECT \\\$1 AS _c0', " + '}', + ), + ); + }); } diff --git a/drift_dev/test/writer/queries/sql_writer_test.dart b/drift_dev/test/writer/queries/sql_writer_test.dart index 0b603981..836e931f 100644 --- a/drift_dev/test/writer/queries/sql_writer_test.dart +++ b/drift_dev/test/writer/queries/sql_writer_test.dart @@ -6,14 +6,18 @@ import 'package:sqlparser/sqlparser.dart'; import 'package:test/test.dart'; void main() { - void check(String sql, String expectedDart, - {DriftOptions options = const DriftOptions.defaults()}) { + void check( + String sql, + String expectedDart, { + DriftOptions options = const DriftOptions.defaults(), + SqlDialect dialect = SqlDialect.sqlite, + }) { final engine = SqlEngine(); final context = engine.analyze(sql); final query = SqlSelectQuery('name', context, context.root, [], [], InferredResultSet(null, []), null, null); - final result = SqlWriter(options, query: query).write(); + final result = SqlWriter(options, dialect: dialect, query: query).write(); expect(result, expectedDart); } @@ -33,7 +37,6 @@ void main() { test('escapes postgres keywords', () { check('SELECT * FROM user', "'SELECT * FROM user'"); check('SELECT * FROM user', "'SELECT * FROM \"user\"'", - options: DriftOptions.defaults( - dialect: DialectOptions(SqlDialect.postgres, null))); + dialect: SqlDialect.postgres); }); } diff --git a/examples/modular/lib/src/users.drift.dart b/examples/modular/lib/src/users.drift.dart index 1be5e6d4..5f745523 100644 --- a/examples/modular/lib/src/users.drift.dart +++ b/examples/modular/lib/src/users.drift.dart @@ -602,8 +602,10 @@ class PopularUsers extends i0.ViewInfo @override String get entityName => 'popular_users'; @override - String get createViewStmt => - 'CREATE VIEW popular_users AS SELECT * FROM users ORDER BY (SELECT count(*) FROM follows WHERE followed = users.id)'; + Map get createViewStatements => { + i0.SqlDialect.sqlite: + 'CREATE VIEW popular_users AS SELECT * FROM users ORDER BY (SELECT count(*) FROM follows WHERE followed = users.id)', + }; @override PopularUsers get asDslTable => this; @override diff --git a/extras/integration_tests/drift_testcases/build.yaml b/extras/integration_tests/drift_testcases/build.yaml index 7603954b..95992969 100644 --- a/extras/integration_tests/drift_testcases/build.yaml +++ b/extras/integration_tests/drift_testcases/build.yaml @@ -9,9 +9,7 @@ targets: raw_result_set_data: false named_parameters: false sql: - # As sqlite3 is compatible with the postgres dialect (but not vice-versa), we're - # using this dialect so that we can run the tests on postgres as well. - dialect: postgres + dialects: [sqlite, postgres] options: version: "3.37" modules: diff --git a/extras/integration_tests/drift_testcases/lib/database/database.g.dart b/extras/integration_tests/drift_testcases/lib/database/database.g.dart index af723224..d50ced56 100644 --- a/extras/integration_tests/drift_testcases/lib/database/database.g.dart +++ b/extras/integration_tests/drift_testcases/lib/database/database.g.dart @@ -336,7 +336,6 @@ class $FriendshipsTable extends Friendships requiredDuringInsert: false, defaultConstraints: GeneratedColumn.constraintsDependsOnDialect({ SqlDialect.sqlite: 'CHECK ("really_good_friends" IN (0, 1))', - SqlDialect.mysql: '', SqlDialect.postgres: '', }), defaultValue: const Constant(false)); @@ -554,7 +553,13 @@ abstract class _$Database extends GeneratedDatabase { late final $FriendshipsTable friendships = $FriendshipsTable(this); Selectable mostPopularUsers(int amount) { return customSelect( - 'SELECT * FROM users AS u ORDER BY (SELECT COUNT(*) FROM friendships WHERE first_user = u.id OR second_user = u.id) DESC LIMIT \$1', + switch (executor.dialect) { + SqlDialect.sqlite => + 'SELECT * FROM users AS u ORDER BY (SELECT COUNT(*) FROM friendships WHERE first_user = u.id OR second_user = u.id) DESC LIMIT ?1', + SqlDialect.postgres || + _ => + 'SELECT * FROM users AS u ORDER BY (SELECT COUNT(*) FROM friendships WHERE first_user = u.id OR second_user = u.id) DESC LIMIT \$1', + }, variables: [ Variable(amount) ], @@ -566,7 +571,13 @@ abstract class _$Database extends GeneratedDatabase { Selectable amountOfGoodFriends(int user) { return customSelect( - 'SELECT COUNT(*) AS _c0 FROM friendships AS f WHERE f.really_good_friends = TRUE AND(f.first_user = \$1 OR f.second_user = \$1)', + switch (executor.dialect) { + SqlDialect.sqlite => + 'SELECT COUNT(*) AS _c0 FROM friendships AS f WHERE f.really_good_friends = TRUE AND(f.first_user = ?1 OR f.second_user = ?1)', + SqlDialect.postgres || + _ => + 'SELECT COUNT(*) AS _c0 FROM friendships AS f WHERE f.really_good_friends = TRUE AND(f.first_user = \$1 OR f.second_user = \$1)', + }, variables: [ Variable(user) ], @@ -577,19 +588,23 @@ abstract class _$Database extends GeneratedDatabase { Selectable friendshipsOf(int user) { return customSelect( - 'SELECT f.really_good_friends,"user"."id" AS "nested_0.id", "user"."name" AS "nested_0.name", "user"."birth_date" AS "nested_0.birth_date", "user"."profile_picture" AS "nested_0.profile_picture", "user"."preferences" AS "nested_0.preferences" FROM friendships AS f INNER JOIN users AS "user" ON "user".id IN (f.first_user, f.second_user) AND "user".id != \$1 WHERE(f.first_user = \$1 OR f.second_user = \$1)', + switch (executor.dialect) { + SqlDialect.sqlite => + 'SELECT f.really_good_friends,"user"."id" AS "nested_0.id", "user"."name" AS "nested_0.name", "user"."birth_date" AS "nested_0.birth_date", "user"."profile_picture" AS "nested_0.profile_picture", "user"."preferences" AS "nested_0.preferences" FROM friendships AS f INNER JOIN users AS user ON user.id IN (f.first_user, f.second_user) AND user.id != ?1 WHERE(f.first_user = ?1 OR f.second_user = ?1)', + SqlDialect.postgres || + _ => + 'SELECT f.really_good_friends,"user"."id" AS "nested_0.id", "user"."name" AS "nested_0.name", "user"."birth_date" AS "nested_0.birth_date", "user"."profile_picture" AS "nested_0.profile_picture", "user"."preferences" AS "nested_0.preferences" FROM friendships AS f INNER JOIN users AS "user" ON "user".id IN (f.first_user, f.second_user) AND "user".id != \$1 WHERE(f.first_user = \$1 OR f.second_user = \$1)', + }, variables: [ Variable(user) ], readsFrom: { friendships, users, - }).asyncMap((QueryRow row) async { - return FriendshipsOfResult( - reallyGoodFriends: row.read('really_good_friends'), - user: await users.mapFromRow(row, tablePrefix: 'nested_0'), - ); - }); + }).asyncMap((QueryRow row) async => FriendshipsOfResult( + reallyGoodFriends: row.read('really_good_friends'), + user: await users.mapFromRow(row, tablePrefix: 'nested_0'), + )); } Selectable userCount() { @@ -601,7 +616,13 @@ abstract class _$Database extends GeneratedDatabase { } Selectable settingsFor(int user) { - return customSelect('SELECT preferences FROM users WHERE id = \$1', + return customSelect( + switch (executor.dialect) { + SqlDialect.sqlite => 'SELECT preferences FROM users WHERE id = ?1', + SqlDialect.postgres || + _ => + 'SELECT preferences FROM users WHERE id = \$1', + }, variables: [ Variable(user) ], @@ -626,7 +647,13 @@ abstract class _$Database extends GeneratedDatabase { Future> returning(int var1, int var2, bool var3) { return customWriteReturning( - 'INSERT INTO friendships VALUES (\$1, \$2, \$3) RETURNING *', + switch (executor.dialect) { + SqlDialect.sqlite => + 'INSERT INTO friendships VALUES (?1, ?2, ?3) RETURNING *', + SqlDialect.postgres || + _ => + 'INSERT INTO friendships VALUES (\$1, \$2, \$3) RETURNING *', + }, variables: [ Variable(var1), Variable(var2), diff --git a/extras/integration_tests/drift_testcases/pubspec.yaml b/extras/integration_tests/drift_testcases/pubspec.yaml index d040d86d..9bec7f09 100644 --- a/extras/integration_tests/drift_testcases/pubspec.yaml +++ b/extras/integration_tests/drift_testcases/pubspec.yaml @@ -4,7 +4,7 @@ version: 1.0.0 # homepage: https://www.example.com environment: - sdk: '>=2.17.0 <3.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: drift: ^2.0.0-0 From 5e32e549ea01e54c120008cb06d8e2bbe8d9b0c0 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Thu, 27 Jul 2023 10:22:37 +0200 Subject: [PATCH 17/17] Add changelog entry for`dialects` option --- drift_dev/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/drift_dev/CHANGELOG.md b/drift_dev/CHANGELOG.md index 26bbd10c..bd823b56 100644 --- a/drift_dev/CHANGELOG.md +++ b/drift_dev/CHANGELOG.md @@ -3,6 +3,7 @@ - [Nested result columns](https://drift.simonbinder.eu/docs/using-sql/drift_files/#nested-results) in drift files can now refer to any result set (e.g. a table-valued function or a subquery). They were restricted to direct table references before. +- Add the `dialects` builder option to generate code supporting multiple SQL dialects. ## 2.10.0