diff --git a/moor_generator/lib/src/analyzer/sql_queries/lints/linter.dart b/moor_generator/lib/src/analyzer/sql_queries/lints/linter.dart index dce406e7..2f00b8fd 100644 --- a/moor_generator/lib/src/analyzer/sql_queries/lints/linter.dart +++ b/moor_generator/lib/src/analyzer/sql_queries/lints/linter.dart @@ -9,12 +9,14 @@ class Linter { final AnalysisContext context; final TypeMapper mapper; final List lints = []; + final bool contextRootIsQuery; - Linter(this.context, this.mapper); + Linter(this.context, this.mapper, {this.contextRootIsQuery = false}); Linter.forHandler(QueryHandler handler) : context = handler.context, - mapper = handler.mapper; + mapper = handler.mapper, + contextRootIsQuery = true; void reportLints() { context.root.acceptWithoutArg(_LintingVisitor(this)); @@ -57,6 +59,31 @@ class _LintingVisitor extends RecursiveVisitor { )); } } + + if (e is NestedStarResultColumn) { + // check that a table.** column only appears in a top-level select + // statement + if (!linter.contextRootIsQuery || e.parent != linter.context.root) { + linter.lints.add(AnalysisError( + type: AnalysisErrorType.other, + message: 'Nested star columns may only appear in a top-level select ' + "query. They're not supported in compound selects or select " + 'expressions', + relevantNode: e, + )); + } + + // check that it actually refers to a table + if (e.resultSet is! Table) { + linter.lints.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, + )); + } + } } @override diff --git a/moor_generator/lib/src/analyzer/sql_queries/query_handler.dart b/moor_generator/lib/src/analyzer/sql_queries/query_handler.dart index 75b418f8..f9572bb5 100644 --- a/moor_generator/lib/src/analyzer/sql_queries/query_handler.dart +++ b/moor_generator/lib/src/analyzer/sql_queries/query_handler.dart @@ -84,6 +84,7 @@ class QueryHandler { final columns = []; final rawColumns = _select.resolvedColumns; + // First, go through regular result columns for (final column in rawColumns) { final type = context.typeOf(column).type; final moorType = mapper.resolvedToMoor(type); @@ -99,6 +100,13 @@ class QueryHandler { candidatesForSingleTable.removeWhere((t) => t != table); } + final nestedResults = _findNestedResultTables(); + if (nestedResults.isNotEmpty) { + // The single table optimization doesn't make sense when nested result + // sets are present. + candidatesForSingleTable.clear(); + } + // if all columns read from the same table, and all columns in that table // are present in the result set, we can use the data class we generate for // that table instead of generating another class just for this result set. @@ -146,7 +154,27 @@ class QueryHandler { } } - return InferredResultSet(null, columns); + return InferredResultSet(null, columns, nestedResults: nestedResults); + } + + List _findNestedResultTables() { + final query = _select; + // We don't currently support nested results for compound statements + if (query is! SelectStatement) return const []; + + final nestedTables = []; + + for (final column in (query as SelectStatement).columns) { + if (column is NestedStarResultColumn) { + final result = column.resultSet; + if (result is! Table) continue; + + final moorTable = mapper.tableToMoor(result as Table); + nestedTables.add(NestedResultTable(column.tableName, moorTable)); + } + } + + return nestedTables; } /// The table a given result column is from, or null if this column doesn't diff --git a/moor_generator/lib/src/model/sql_query.dart b/moor_generator/lib/src/model/sql_query.dart index 6a87a0fb..684d2c94 100644 --- a/moor_generator/lib/src/model/sql_query.dart +++ b/moor_generator/lib/src/model/sql_query.dart @@ -147,11 +147,18 @@ class InferredResultSet { /// If the result columns of a SELECT statement exactly match one table, we /// can just use the data class generated for that table. Otherwise, we'd have /// to create another class. - final MoorTable matchingTable; + final MoorTable /*?*/ matchingTable; + + /// Tables in the result set that should appear as a class. + /// + /// See [NestedResultTable] for further discussion and examples. + final List nestedResults; + final List columns; final Map _dartNames = {}; - InferredResultSet(this.matchingTable, this.columns); + InferredResultSet(this.matchingTable, this.columns, + {this.nestedResults = const []}); /// Whether a new class needs to be written to store the result of this query. /// We don't need to do that for queries which return an existing table model @@ -217,6 +224,39 @@ class ResultColumn { } } +/// A nested table extracted from a `**` column. +/// +/// For instance, consider this query: +/// ```sql +/// CREATE TABLE groups (id INTEGER NOT NULL PRIMARY KEY); +/// CREATE TABLE users (id INTEGER NOT NULL PRIMARY KEY); +/// CREATE TABLE members ( +/// group INT REFERENCES .., +/// user INT REFERENCES ..., +/// is_admin BOOLEAN +/// ); +/// +/// membersOf: SELECT users.**, members.is_admin FROM members +/// INNER JOIN users ON users.id = members.user; +/// ``` +/// +/// The generated result set should now look like this: +/// ```dart +/// class MembersOfResult { +/// final User users; +/// final bool isAdmin; +/// } +/// ``` +/// +/// Knowing that `User` should be extracted into a field is represented with a +/// [NestedResultTable] information as part of the result set. +class NestedResultTable { + final String name; + final MoorTable table; + + NestedResultTable(this.name, this.table); +} + /// Something in the query that needs special attention when generating code, /// such as variables or Dart placeholders. abstract class FoundElement { diff --git a/moor_generator/test/analyzer/sql_queries/query_handler_test.dart b/moor_generator/test/analyzer/sql_queries/query_handler_test.dart index 2a1e31fe..c1328731 100644 --- a/moor_generator/test/analyzer/sql_queries/query_handler_test.dart +++ b/moor_generator/test/analyzer/sql_queries/query_handler_test.dart @@ -1,6 +1,7 @@ import 'package:moor_generator/moor_generator.dart'; import 'package:moor_generator/src/analyzer/moor/create_table_reader.dart'; import 'package:moor_generator/src/analyzer/runner/file_graph.dart'; +import 'package:moor_generator/src/analyzer/runner/results.dart'; import 'package:moor_generator/src/analyzer/runner/steps.dart'; import 'package:moor_generator/src/analyzer/runner/task.dart'; import 'package:moor_generator/src/analyzer/sql_queries/query_handler.dart'; @@ -8,6 +9,8 @@ import 'package:moor_generator/src/analyzer/sql_queries/type_mapping.dart'; import 'package:sqlparser/sqlparser.dart'; import 'package:test/test.dart'; +import '../utils.dart'; + const createFoo = ''' CREATE TABLE foo ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, @@ -67,4 +70,40 @@ Future main() async { expect(() => parse('SELECT ?1 = ?3'), throwsStateError); expect(() => parse('SELECT ?1 = ?3 OR ?2'), returnsNormally); }); + + test('resolves nested result sets', () async { + final state = TestState.withContent({ + 'foo|lib/main.moor': r''' +CREATE TABLE points ( + id INTEGER NOT NULL PRIMARY KEY, + lat REAL NOT NULL, + long REAL NOT NULL +); +CREATE TABLE routes ( + id INTEGER NOT NULL PRIMARY KEY, + "from" INTEGER NOT NULL REFERENCES points (id), + to INTEGER NOT NULL REFERENCES points (id) +); + +allRoutes: SELECT routes.*, "from".**, to.** +FROM routes r + INNER JOIN points "from" ON "from".id = routes.from + INNER JOIN points to ON to.id = routes.to; + ''', + }); + + final file = await state.analyze('package:foo/main.moor'); + final result = file.currentResult as ParsedMoorFile; + + expect(file.errors.errors, isEmpty); + + final query = result.resolvedQueries.single; + final resultSet = (query as SqlSelectQuery).resultSet; + + expect(resultSet.columns.map((e) => e.name), ['id', 'from', 'to']); + expect(resultSet.matchingTable, isNull); + expect(resultSet.nestedResults.map((e) => e.name), ['from', 'to']); + expect(resultSet.nestedResults.map((e) => e.table.sqlName), + ['points', 'points']); + }); } diff --git a/sqlparser/lib/src/analysis/steps/column_resolver.dart b/sqlparser/lib/src/analysis/steps/column_resolver.dart index 4b5971a7..ad2f8441 100644 --- a/sqlparser/lib/src/analysis/steps/column_resolver.dart +++ b/sqlparser/lib/src/analysis/steps/column_resolver.dart @@ -225,6 +225,17 @@ class ColumnResolver extends RecursiveVisitor { availableColumns.add(column); } } + } else if (resultColumn is NestedStarResultColumn) { + final target = scope + .resolve(resultColumn.tableName, orElse: () { + context.reportError(AnalysisError( + type: AnalysisErrorType.referencedUnknownTable, + message: 'Unknown table: ${resultColumn.tableName}', + relevantNode: resultColumn, + )); + }); + + if (target != null) resultColumn.resultSet = target.resultSet; } } diff --git a/sqlparser/lib/src/ast/moor/nested_star_result_column.dart b/sqlparser/lib/src/ast/moor/nested_star_result_column.dart index d73a02a0..19c134ab 100644 --- a/sqlparser/lib/src/ast/moor/nested_star_result_column.dart +++ b/sqlparser/lib/src/ast/moor/nested_star_result_column.dart @@ -5,6 +5,17 @@ part of '../ast.dart'; /// Nested star result columns behave similar to a regular [StarResultColumn] /// when the query is actually run. However, they will affect generated code /// when using moor. -class NestedStarResultColumn extends StarResultColumn { - NestedStarResultColumn(String tableName) : super(tableName); +class NestedStarResultColumn extends ResultColumn { + final String tableName; + ResultSet resultSet; + + NestedStarResultColumn(this.tableName); + + @override + Iterable get childNodes => const []; + + @override + bool contentEquals(NestedStarResultColumn other) { + return other.tableName == tableName; + } } diff --git a/sqlparser/lib/src/reader/parser/crud.dart b/sqlparser/lib/src/reader/parser/crud.dart index 8969d93d..6220bb34 100644 --- a/sqlparser/lib/src/reader/parser/crud.dart +++ b/sqlparser/lib/src/reader/parser/crud.dart @@ -198,13 +198,13 @@ mixin CrudParser on ParserBase { /// Parses a [ResultColumn] or throws if none is found. /// https://www.sqlite.org/syntax/result-column.html ResultColumn _resultColumn() { - if (_match(const [TokenType.star])) { + if (_matchOne(TokenType.star)) { return StarResultColumn(null)..setSpan(_previous, _previous); } final positionBefore = _current; - if (_match(const [TokenType.identifier])) { + if (_matchOne(TokenType.identifier)) { // two options. the identifier could be followed by ".*", in which case // we have a star result column. If it's followed by anything else, it can // still refer to a column in a table as part of a expression diff --git a/sqlparser/lib/src/reader/tokenizer/scanner.dart b/sqlparser/lib/src/reader/tokenizer/scanner.dart index eaabebd3..6f642667 100644 --- a/sqlparser/lib/src/reader/tokenizer/scanner.dart +++ b/sqlparser/lib/src/reader/tokenizer/scanner.dart @@ -80,8 +80,9 @@ class Scanner { case charStar: if (scanMoorTokens && _match(charStar)) { _addToken(TokenType.doubleStar); + } else { + _addToken(TokenType.star); } - _addToken(TokenType.star); break; case charSlash: if (_match(charStar)) { diff --git a/sqlparser/test/analysis/reference_resolver_test.dart b/sqlparser/test/analysis/reference_resolver_test.dart index 811f02f8..14873b3b 100644 --- a/sqlparser/test/analysis/reference_resolver_test.dart +++ b/sqlparser/test/analysis/reference_resolver_test.dart @@ -41,6 +41,16 @@ void main() { expect((where.left as Reference).resolved, id); }); + test("resolved columns don't include moor nested results", () { + final engine = SqlEngine(EngineOptions(useMoorExtensions: true)) + ..registerTable(demoTable); + + final context = engine.analyze('SELECT demo.** FROM demo;'); + + expect(context.errors, isEmpty); + expect((context.root as SelectStatement).resolvedColumns, isEmpty); + }); + test('resolves the column for order by clauses', () { final engine = SqlEngine()..registerTable(demoTable);