diff --git a/docs/lib/snippets/dart_api/select.dart b/docs/lib/snippets/dart_api/select.dart index 937f9a09..7752f78b 100644 --- a/docs/lib/snippets/dart_api/select.dart +++ b/docs/lib/snippets/dart_api/select.dart @@ -9,6 +9,8 @@ import '../_shared/todo_tables.drift.dart'; class EntryWithCategory { EntryWithCategory(this.entry, this.category); + // The classes are generated by drift for each of the tables involved in the + // join. final TodoItem entry; final Category? category; } @@ -69,8 +71,6 @@ extension SelectExamples on CanUseCommonTables { leftOuterJoin(categories, categories.id.equalsExp(todoItems.category)), ]); - // see next section on how to parse the result - // #enddocregion joinIntro // #docregion results return query.watch().map((rows) { return rows.map((row) { @@ -81,7 +81,6 @@ extension SelectExamples on CanUseCommonTables { }).toList(); }); // #enddocregion results - // #docregion joinIntro } // #enddocregion joinIntro diff --git a/docs/lib/snippets/setup/database.dart b/docs/lib/snippets/setup/database.dart index 00d175a5..3779df69 100644 --- a/docs/lib/snippets/setup/database.dart +++ b/docs/lib/snippets/setup/database.dart @@ -1,21 +1,19 @@ +// #docregion after_generation // #docregion before_generation import 'package:drift/drift.dart'; // #enddocregion before_generation +// #enddocregion after_generation -// #docregion open -// These imports are necessary to open the sqlite3 database +// #docregion after_generation +// These additional imports are necessary to open the sqlite3 database import 'dart:io'; - import 'package:drift/native.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; -// ... the TodoItems table definition stays the same -// #enddocregion open - // #docregion before_generation part 'database.g.dart'; @@ -27,25 +25,22 @@ class TodoItems extends Table { IntColumn get category => integer().nullable()(); } // #enddocregion table -// #docregion open @DriftDatabase(tables: [TodoItems]) class AppDatabase extends _$AppDatabase { -// #enddocregion open +// #enddocregion before_generation +// #enddocregion after_generation // After generating code, this class needs to define a `schemaVersion` getter // and a constructor telling drift where the database should be stored. // These are described in the getting started guide: https://drift.simonbinder.eu/getting-started/#open -// #enddocregion before_generation -// #docregion open +// #docregion after_generation AppDatabase() : super(_openConnection()); @override int get schemaVersion => 1; // #docregion before_generation } -// #enddocregion before_generation, open - -// #docregion open +// #enddocregion before_generation LazyDatabase _openConnection() { // the LazyDatabase util lets us find the right location for the file async. @@ -70,7 +65,7 @@ LazyDatabase _openConnection() { return NativeDatabase.createInBackground(file); }); } -// #enddocregion open +// #enddocregion after_generation class WidgetsFlutterBinding { static void ensureInitialized() {} diff --git a/docs/pages/docs/Dart API/select.md b/docs/pages/docs/Dart API/select.md index b26f3bfe..88e308a7 100644 --- a/docs/pages/docs/Dart API/select.md +++ b/docs/pages/docs/Dart API/select.md @@ -114,14 +114,14 @@ Of course, you can also join multiple tables: {% include "blocks/snippet" snippets = snippets name = 'otherTodosInSameCategory' %} -## Parsing results +### Parsing results Calling `get()` or `watch` on a select statement with join returns a `Future` or `Stream` of `List`, respectively. Each `TypedResult` represents a row from which data can be read. It contains a `rawData` getter to obtain the raw columns. But more importantly, the `readTable` method can be used to read a data class from a table. -In the example query above, we can read the todo entry and the category from each row like this: +In the example query above, we've read the todo entry and the category from each row like this: {% include "blocks/snippet" snippets = snippets name = 'results' %} diff --git a/docs/pages/docs/Dart API/transactions.md b/docs/pages/docs/Dart API/transactions.md index d6b6dd44..4e7ab788 100644 --- a/docs/pages/docs/Dart API/transactions.md +++ b/docs/pages/docs/Dart API/transactions.md @@ -30,6 +30,9 @@ There are a couple of things that should be kept in mind when working with trans on the transaction after it has been closed! This can cause data loss or runtime crashes. Drift contains some runtime checks against this misuse and will throw an exception when a transaction is used after being closed. + A transaction is active during all asynchronous calls made in a `transaction` block, so transactions + also can't schedule timers or other operations using the database (as those would try to use the + transaction after the main `transaction` block has completed). 2. __Different behavior of stream queries__: Inside a `transaction` callback, stream queries behave differently. If you're creating streams inside a transaction, check the next section to learn how they behave. diff --git a/docs/pages/docs/SQL API/drift_files.md b/docs/pages/docs/SQL API/drift_files.md index a2ff0a69..572c59ee 100644 --- a/docs/pages/docs/SQL API/drift_files.md +++ b/docs/pages/docs/SQL API/drift_files.md @@ -116,8 +116,10 @@ to determine the column type based on the declared type name. Additionally, columns that have the type name `BOOLEAN` or `DATETIME` will have `bool` or `DateTime` as their Dart counterpart. Booleans are stored as `INTEGER` (either `0` or `1`). Datetimes are stored as -unix timestamps (`INTEGER`) or ISO-8601 (`TEXT`) depending on a configurable -build option. +unix timestamps (`INTEGER`) or ISO-8601 (`TEXT`) [depending on a configurable build option]({{ '../Dart API/tables.md#datetime-options' | pageUrl }}). +For integers that should be represented as a `BigInt` in Dart (i.e. to have better compatibility with large numbers when compiling to JS), +define the column with the `INT64` type. + Dart enums can automatically be stored by their index by using an `ENUM()` type referencing the Dart enum class: diff --git a/docs/pages/docs/setup.md b/docs/pages/docs/setup.md index a1379099..d1660c91 100644 --- a/docs/pages/docs/setup.md +++ b/docs/pages/docs/setup.md @@ -81,7 +81,7 @@ to store todo items for a todo list app. Everything there is to know about defining tables in Dart is described on the [Dart tables]({{'Dart API/tables.md' | pageUrl}}) page. If you prefer using SQL to define your tables, drift supports that too! You can read all about the [SQL API]({{ 'SQL API/index.md' | pageUrl }}) here. -For now, the contents of `database.dart` are: +For now, populate the contents of `database.dart` with: {% include "blocks/snippet" snippets = snippets name = 'before_generation' %} @@ -97,10 +97,11 @@ After running either command, the `database.g.dart` file containing the generate class will have been generated. You will now see errors related to missing overrides and a missing constructor. The constructor is responsible for telling drift how to open the database. The `schemaVersion` getter is relevant -for migrations after changing the database, we can leave it at `1` for now. The database class -now looks like this: - -{% include "blocks/snippet" snippets = snippets name = 'open' %} +for migrations after changing the database, we can leave it at `1` for now. Update `database.dart` +so it now looks like this: + + +{% include "blocks/snippet" snippets = snippets name = 'after_generation' %} The Android-specific workarounds are necessary because sqlite3 attempts to use `/tmp` to store private data on unix-like systems, which is forbidden on Android. We also use this opportunity diff --git a/drift/CHANGELOG.md b/drift/CHANGELOG.md index 90151da5..f4a87ebb 100644 --- a/drift/CHANGELOG.md +++ b/drift/CHANGELOG.md @@ -1,4 +1,11 @@ -## 2.17.0-dev +## 2.18.0-dev + +- Add `AggregateFunctionExpression` to write custom [aggregate function](https://www.sqlite.org/lang_aggfunc.html) + invocations in the Dart query builder. +- The `json_group_array` and `jsonb_group_array` functions now contain an `orderBy` + and `filter` parameter. + +## 2.17.0 - Adds `companion` entry to `DataClassName` to override the name of the generated companion class. diff --git a/drift/lib/extensions/json1.dart b/drift/lib/extensions/json1.dart index 1e600dc8..cd2fc8f9 100644 --- a/drift/lib/extensions/json1.dart +++ b/drift/lib/extensions/json1.dart @@ -144,8 +144,13 @@ extension JsonExtensions on Expression { /// all emails in that folder. /// This string could be turned back into a list with /// `(json.decode(row.read(subjects)!) as List).cast()`. -Expression jsonGroupArray(Expression value) { - return FunctionCallExpression('json_group_array', [value]); +Expression jsonGroupArray( + Expression value, { + OrderBy? orderBy, + Expression? filter, +}) { + return AggregateFunctionExpression('json_group_array', [value], + orderBy: orderBy, filter: filter); } /// Returns a binary representation of a JSON array containing the result of @@ -153,8 +158,13 @@ Expression jsonGroupArray(Expression value) { /// /// See [jsonGroupArray], the variant of this function returning a textual /// description, for more details and an example. -Expression jsonbGroupArray(Expression value) { - return FunctionCallExpression('jsonb_group_array', [value]); +Expression jsonbGroupArray( + Expression value, { + OrderBy? orderBy, + Expression? filter, +}) { + return AggregateFunctionExpression('jsonb_group_array', [value], + orderBy: orderBy, filter: filter); } List _groupObjectArgs(Map, Expression> values) { diff --git a/drift/lib/src/runtime/executor/helpers/engines.dart b/drift/lib/src/runtime/executor/helpers/engines.dart index bb488ea7..47365c4e 100644 --- a/drift/lib/src/runtime/executor/helpers/engines.dart +++ b/drift/lib/src/runtime/executor/helpers/engines.dart @@ -140,7 +140,7 @@ abstract class _TransactionExecutor extends _BaseExecutor if (_closed) { throw StateError( - "A tranaction was used after being closed. Please check that you're " + "A transaction was used after being closed. Please check that you're " 'awaiting all database operations inside a `transaction` block.'); } } diff --git a/drift/lib/src/runtime/query_builder/expressions/aggregate.dart b/drift/lib/src/runtime/query_builder/expressions/aggregate.dart index b2f99b52..451ff018 100644 --- a/drift/lib/src/runtime/query_builder/expressions/aggregate.dart +++ b/drift/lib/src/runtime/query_builder/expressions/aggregate.dart @@ -12,7 +12,7 @@ part of '../query_builder.dart'; /// This is equivalent to the `COUNT(*) FILTER (WHERE filter)` sql function. The /// filter will be omitted if null. Expression countAll({Expression? filter}) { - return _AggregateExpression('COUNT', const [_StarFunctionParameter()], + return AggregateFunctionExpression('COUNT', const [_StarFunctionParameter()], filter: filter); } @@ -26,7 +26,7 @@ extension BaseAggregate
on Expression
{ /// counted twice. /// {@macro drift_aggregate_filter} Expression count({bool distinct = false, Expression? filter}) { - return _AggregateExpression('COUNT', [this], + return AggregateFunctionExpression('COUNT', [this], filter: filter, distinct: distinct); } @@ -35,14 +35,14 @@ extension BaseAggregate
on Expression
{ /// If there are no non-null values in the group, returns null. /// {@macro drift_aggregate_filter} Expression
max({Expression? filter}) => - _AggregateExpression('MAX', [this], filter: filter); + AggregateFunctionExpression('MAX', [this], filter: filter); /// Return the minimum of all non-null values in this group. /// /// If there are no non-null values in the group, returns null. /// {@macro drift_aggregate_filter} Expression
min({Expression? filter}) => - _AggregateExpression('MIN', [this], filter: filter); + AggregateFunctionExpression('MIN', [this], filter: filter); /// Returns the concatenation of all non-null values in the current group, /// joined by the [separator]. @@ -71,7 +71,7 @@ extension BaseAggregate
on Expression
{ 'Cannot use groupConcat with distinct: true and a custom separator'); } - return _AggregateExpression( + return AggregateFunctionExpression( 'GROUP_CONCAT', [ this, @@ -89,21 +89,21 @@ extension ArithmeticAggregates
on Expression
{ /// /// {@macro drift_aggregate_filter} Expression avg({Expression? filter}) => - _AggregateExpression('AVG', [this], filter: filter); + AggregateFunctionExpression('AVG', [this], filter: filter); /// Return the maximum of all non-null values in this group. /// /// If there are no non-null values in the group, returns null. /// {@macro drift_aggregate_filter} Expression
max({Expression? filter}) => - _AggregateExpression('MAX', [this], filter: filter); + AggregateFunctionExpression('MAX', [this], filter: filter); /// Return the minimum of all non-null values in this group. /// /// If there are no non-null values in the group, returns null. /// {@macro drift_aggregate_filter} Expression
min({Expression? filter}) => - _AggregateExpression('MIN', [this], filter: filter); + AggregateFunctionExpression('MIN', [this], filter: filter); /// Calculate the sum of all non-null values in the group. /// @@ -115,7 +115,7 @@ extension ArithmeticAggregates
on Expression
{ /// value and doesn't throw an overflow exception. /// {@macro drift_aggregate_filter} Expression
sum({Expression? filter}) => - _AggregateExpression('SUM', [this], filter: filter); + AggregateFunctionExpression('SUM', [this], filter: filter); /// Calculate the sum of all non-null values in the group. /// @@ -123,7 +123,7 @@ extension ArithmeticAggregates
on Expression
{ /// uses floating-point values internally. /// {@macro drift_aggregate_filter} Expression total({Expression? filter}) => - _AggregateExpression('TOTAL', [this], filter: filter); + AggregateFunctionExpression('TOTAL', [this], filter: filter); } /// Provides aggregate functions that are available for BigInt expressions. @@ -197,16 +197,41 @@ extension DateTimeAggregate on Expression { } } -class _AggregateExpression extends Expression { +/// An expression invoking an [aggregate function](https://www.sqlite.org/lang_aggfunc.html). +/// +/// Aggregate functions, like `count()` or `sum()` collapse the entire data set +/// (or a partition of it, if `GROUP BY` is used) into a single value. +/// +/// Drift exposes direct bindings to most aggregate functions (e.g. via +/// [BaseAggregate.count]). This class is useful when writing custom aggregate +/// function invocations. +final class AggregateFunctionExpression + extends Expression { + /// The name of the aggregate function to invoke. final String functionName; - final bool distinct; - final List parameter; + /// Whether only distinct rows should be passed to the function. + final bool distinct; + + /// The arguments to pass to the function. + final List arguments; + + /// The order in which rows of the current group should be passed to the + /// aggregate function. + final OrderBy? orderBy; + + /// An optional filter clause only passing rows matching this condition into + /// the function. final Where? filter; - _AggregateExpression(this.functionName, this.parameter, - {Expression? filter, this.distinct = false}) - : filter = filter != null ? Where(filter) : null; + /// Creates an aggregate function expression from the syntactic components. + AggregateFunctionExpression( + this.functionName, + this.arguments, { + Expression? filter, + this.distinct = false, + this.orderBy, + }) : filter = filter != null ? Where(filter) : null; @override final Precedence precedence = Precedence.primary; @@ -220,7 +245,11 @@ class _AggregateExpression extends Expression { if (distinct) { context.buffer.write('DISTINCT '); } - _writeCommaSeparated(context, parameter); + _writeCommaSeparated(context, arguments); + if (orderBy case final orderBy?) { + context.writeWhitespace(); + orderBy.writeInto(context); + } context.buffer.write(')'); if (filter != null) { @@ -233,20 +262,20 @@ class _AggregateExpression extends Expression { @override int get hashCode { return Object.hash(functionName, distinct, - const ListEquality().hash(parameter), filter); + const ListEquality().hash(arguments), orderBy, filter); } @override bool operator ==(Object other) { - if (!identical(this, other) && other.runtimeType != runtimeType) { + if (!identical(this, other) && other is! AggregateFunctionExpression) { return false; } - // ignore: test_types_in_equals - final typedOther = other as _AggregateExpression; + final typedOther = other as AggregateFunctionExpression; return typedOther.functionName == functionName && typedOther.distinct == distinct && - const ListEquality().equals(typedOther.parameter, parameter) && + const ListEquality().equals(typedOther.arguments, arguments) && + typedOther.orderBy == orderBy && typedOther.filter == filter; } } diff --git a/drift/pubspec.yaml b/drift/pubspec.yaml index ba3bf91a..fcbd149e 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.16.0 +version: 2.17.0 repository: https://github.com/simolus3/drift homepage: https://drift.simonbinder.eu/ issue_tracker: https://github.com/simolus3/drift/issues @@ -30,7 +30,7 @@ dev_dependencies: drift_dev: any drift_testcases: path: ../extras/integration_tests/drift_testcases - http: ^0.13.4 + http: ^1.2.1 lints: ^3.0.0 uuid: ^4.0.0 build_runner: ^2.0.0 @@ -39,4 +39,4 @@ dev_dependencies: rxdart: ^0.27.0 shelf: ^1.3.0 test_descriptor: ^2.0.1 - vm_service: ^13.0.0 + vm_service: ^14.0.0 diff --git a/drift/test/extensions/json1_integration_test.dart b/drift/test/extensions/json1_integration_test.dart index 2a268218..f62a1f22 100644 --- a/drift/test/extensions/json1_integration_test.dart +++ b/drift/test/extensions/json1_integration_test.dart @@ -101,15 +101,17 @@ void main() { db.todosTable, db.todosTable.category.equalsExp(db.categories.id)) ]); - final stringArray = jsonGroupArray(db.todosTable.id); - final binaryArray = jsonbGroupArray(db.todosTable.id).json(); + final stringArray = jsonGroupArray(db.todosTable.id, + orderBy: OrderBy([OrderingTerm.desc(db.todosTable.id)])); + final binaryArray = jsonbGroupArray(db.todosTable.id, + orderBy: OrderBy([OrderingTerm.asc(db.todosTable.id)])).json(); query ..groupBy([db.categories.id]) ..addColumns([stringArray, binaryArray]); final row = await query.getSingle(); - expect(json.decode(row.read(stringArray)!), unorderedEquals([1, 3])); - expect(json.decode(row.read(binaryArray)!), unorderedEquals([1, 3])); + expect(json.decode(row.read(stringArray)!), [3, 1]); + expect(json.decode(row.read(binaryArray)!), [1, 3]); }); test('json_group_object', () async { diff --git a/drift/test/extensions/json1_test.dart b/drift/test/extensions/json1_test.dart index f6eca7d0..bee8f927 100644 --- a/drift/test/extensions/json1_test.dart +++ b/drift/test/extensions/json1_test.dart @@ -42,6 +42,17 @@ void main() { test('aggregates', () { expect(jsonGroupArray(column), generates('json_group_array(col)')); + expect( + jsonGroupArray( + column, + orderBy: OrderBy([OrderingTerm.desc(column)]), + filter: column.length.isBiggerOrEqualValue(10), + ), + generates( + 'json_group_array(col ORDER BY col DESC) FILTER (WHERE LENGTH(col) >= ?)', + [10], + ), + ); expect( jsonGroupObject({ Variable('foo'): column, @@ -84,6 +95,17 @@ void main() { test('aggregates', () { expect(jsonbGroupArray(column), generates('jsonb_group_array(col)')); + expect( + jsonbGroupArray( + column, + orderBy: OrderBy([OrderingTerm.desc(column)]), + filter: column.length.isBiggerOrEqualValue(10), + ), + generates( + 'jsonb_group_array(col ORDER BY col DESC) FILTER (WHERE LENGTH(col) >= ?)', + [10], + ), + ); expect( jsonbGroupObject({ Variable('foo'): column, diff --git a/drift/test/test_utils/matchers.dart b/drift/test/test_utils/matchers.dart index 98497716..7b72826e 100644 --- a/drift/test/test_utils/matchers.dart +++ b/drift/test/test_utils/matchers.dart @@ -107,7 +107,7 @@ class _GeneratesSqlMatcher extends Matcher { matches = false; } - final argsMatchState = {}; + final argsMatchState = {}; if (_matchVariables != null && !_matchVariables.matches(ctx.boundVariables, argsMatchState)) { matchState['vars'] = ctx.boundVariables; diff --git a/drift_dev/CHANGELOG.md b/drift_dev/CHANGELOG.md index 8d6b0e3b..596fc483 100644 --- a/drift_dev/CHANGELOG.md +++ b/drift_dev/CHANGELOG.md @@ -1,4 +1,4 @@ -## 2.17.0-dev +## 2.17.0 - Fix drift using the wrong import alias in generated part files. - Add the `use_sql_column_name_as_json_key` builder option. diff --git a/drift_dev/pubspec.yaml b/drift_dev/pubspec.yaml index e4d69927..bf826fe3 100644 --- a/drift_dev/pubspec.yaml +++ b/drift_dev/pubspec.yaml @@ -1,6 +1,6 @@ name: drift_dev description: Dev-dependency for users of drift. Contains the generator and development tools. -version: 2.16.0 +version: 2.17.0 repository: https://github.com/simolus3/drift homepage: https://drift.simonbinder.eu/ issue_tracker: https://github.com/simolus3/drift/issues @@ -30,9 +30,9 @@ dependencies: io: ^1.0.3 # Drift-specific analysis and apis - drift: '>=2.16.0 <2.17.0' + drift: '>=2.17.0 <2.18.0' sqlite3: '>=0.1.6 <3.0.0' - sqlparser: '^0.34.0' + sqlparser: '^0.35.0' # Dart analysis analyzer: '>=5.12.0 <7.0.0' diff --git a/drift_dev/test/analysis/resolver/drift/table_test.dart b/drift_dev/test/analysis/resolver/drift/table_test.dart index f9aaaa9c..6c80cf9f 100644 --- a/drift_dev/test/analysis/resolver/drift/table_test.dart +++ b/drift_dev/test/analysis/resolver/drift/table_test.dart @@ -295,4 +295,21 @@ class MyType implements CustomSqlType {} expect(custom.expression.toString(), 'MyType()'); } }); + + test('recognizes bigint columns', () async { + final state = await TestBackend.inTest({ + 'a|lib/a.drift': ''' +CREATE TABLE foo ( + bar INT64 NOT NULL +); +''', + }); + + final file = await state.analyze('package:a/a.drift'); + state.expectNoErrors(); + + final table = file.analyzedElements.single as DriftTable; + final column = table.columns.single; + expect(column.sqlType.builtin, DriftSqlType.bigInt); + }); } diff --git a/examples/app/ios/Podfile b/examples/app/ios/Podfile index 1e8c3c90..f8dabcef 100644 --- a/examples/app/ios/Podfile +++ b/examples/app/ios/Podfile @@ -1,6 +1,8 @@ # Uncomment this line to define a global platform for your project # platform :ios, '9.0' +inhibit_all_warnings! + # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/examples/app/macos/Podfile b/examples/app/macos/Podfile index c795730d..0e7ccfc6 100644 --- a/examples/app/macos/Podfile +++ b/examples/app/macos/Podfile @@ -1,5 +1,7 @@ platform :osx, '10.14' +inhibit_all_warnings! + # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/examples/app/macos/Podfile.lock b/examples/app/macos/Podfile.lock index 9464e3e5..436f5a54 100644 --- a/examples/app/macos/Podfile.lock +++ b/examples/app/macos/Podfile.lock @@ -40,8 +40,8 @@ SPEC CHECKSUMS: FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 sqlite3: fd89671d969f3e73efe503ce203e28b016b58f68 - sqlite3_flutter_libs: 00a50503d69f7ab0fe85a5ff25b33082f4df4ce9 + sqlite3_flutter_libs: 01f6f1a7e23e5b22dcbeb49fddab75ecfc1de530 -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 54c2ee7490cf98371b4d49ae3114180468e1140c -COCOAPODS: 1.12.1 +COCOAPODS: 1.14.3 diff --git a/examples/app/macos/Runner.xcodeproj/project.pbxproj b/examples/app/macos/Runner.xcodeproj/project.pbxproj index f0ccb5be..eb29dbb0 100644 --- a/examples/app/macos/Runner.xcodeproj/project.pbxproj +++ b/examples/app/macos/Runner.xcodeproj/project.pbxproj @@ -259,7 +259,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 331C80D4294CF70F00263BE5 = { diff --git a/examples/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/examples/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 2b285b44..c8582aec 100644 --- a/examples/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/examples/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ get resultSetAvailableToChildScopes => const Iterable.empty(); @@ -167,8 +167,8 @@ mixin _HasParentScope on ReferenceScope { /// them in a [StatementScope] as well. /// - subqueries appearing in a `FROM` clause _can't_ see outer columns and /// tables. These statements are also wrapped in a [StatementScope], but a -/// [SubqueryInFromScope] is insertted as an intermediatet scope to prevent -/// the inner scope from seeing the outer columns. +/// [SourceScope] is inserted as an intermediate scope to prevent the inner +/// scope from seeing the outer columns. class StatementScope extends ReferenceScope with _HasParentScope { final ReferenceScope parent; diff --git a/sqlparser/lib/src/analysis/steps/column_resolver.dart b/sqlparser/lib/src/analysis/steps/column_resolver.dart index aa49629f..7295d8cf 100644 --- a/sqlparser/lib/src/analysis/steps/column_resolver.dart +++ b/sqlparser/lib/src/analysis/steps/column_resolver.dart @@ -170,6 +170,16 @@ class ColumnResolver extends RecursiveVisitor { visitExcept(e, e.foreignTable, arg); } + @override + void visitInExpression(InExpression e, ColumnResolverContext arg) { + if (e.inside case Queryable query) { + _handle(query, [], arg); + visitExcept(e, e.inside, arg); + } else { + super.visitInExpression(e, arg); + } + } + @override void visitUpdateStatement(UpdateStatement e, ColumnResolverContext arg) { // Resolve CTEs first diff --git a/sqlparser/lib/src/analysis/steps/linting_visitor.dart b/sqlparser/lib/src/analysis/steps/linting_visitor.dart index 8258e1f2..a814140a 100644 --- a/sqlparser/lib/src/analysis/steps/linting_visitor.dart +++ b/sqlparser/lib/src/analysis/steps/linting_visitor.dart @@ -199,6 +199,56 @@ class LintingVisitor extends RecursiveVisitor { visitChildren(e, arg); } + @override + void visitInExpression(InExpression e, void arg) { + final expectedColumns = switch (e.left) { + Tuple(:var expressions) => expressions.length, + _ => 1, + }; + + switch (e.inside) { + case Tuple tuple: + for (final element in tuple.expressions) { + final actualColumns = switch (element) { + Tuple(:var expressions) => expressions.length, + _ => 1, + }; + + if (expectedColumns != actualColumns) { + context.reportError(AnalysisError( + type: AnalysisErrorType.other, + message: 'Expected $expectedColumns columns in this entry, got ' + '$actualColumns', + relevantNode: element, + )); + } + } + case SubQuery subquery: + final columns = subquery.select.resolvedColumns; + if (columns != null && columns.length != expectedColumns) { + context.reportError(AnalysisError( + type: AnalysisErrorType.other, + message: 'The subquery must return $expectedColumns columns, ' + 'it returns ${columns.length}', + relevantNode: subquery, + )); + } + case TableOrSubquery table: + final columns = + table.availableResultSet?.resultSet.resultSet?.resolvedColumns; + if (columns != null && columns.length != expectedColumns) { + context.reportError(AnalysisError( + type: AnalysisErrorType.other, + message: 'To be used in this `IN` expression, this table must ' + 'have $expectedColumns columns (it has ${columns.length}).', + relevantNode: table, + )); + } + } + + visitChildren(e, arg); + } + @override void visitIsExpression(IsExpression e, void arg) { if (e.distinctFromSyntax && options.version < SqliteVersion.v3_39) { @@ -526,9 +576,9 @@ class LintingVisitor extends RecursiveVisitor { isAllowed = !comparisons.any((e) => !isRowValue(e)); } } else if (parent is InExpression) { - // In expressions are tricky. The rhs can always be a row value, but the - // lhs can only be a row value if the rhs is a subquery - isAllowed = e == parent.inside || parent.inside is SubQuery; + // For in expressions we have a more accurate analysis on whether tuples + // are allowed that looks at both the LHS and the RHS. + isAllowed = true; } else if (parent is SetComponent) { isAllowed = true; } diff --git a/sqlparser/lib/src/analysis/steps/prepare_ast.dart b/sqlparser/lib/src/analysis/steps/prepare_ast.dart index b12f9e77..1c984721 100644 --- a/sqlparser/lib/src/analysis/steps/prepare_ast.dart +++ b/sqlparser/lib/src/analysis/steps/prepare_ast.dart @@ -136,6 +136,14 @@ class AstPreparingVisitor extends RecursiveVisitor { visitChildren(e, arg); } + @override + void visitInExpression(InExpression e, void arg) { + // The RHS can use everything from the parent scope, but it can't add new + // table references that would be visible to others. + e.scope = StatementScope(e.scope); + visitChildren(e, arg); + } + @override void visitNumberedVariable(NumberedVariable e, void arg) { _foundVariables.add(e); diff --git a/sqlparser/lib/src/analysis/types/resolving_visitor.dart b/sqlparser/lib/src/analysis/types/resolving_visitor.dart index c3e843bd..9370830c 100644 --- a/sqlparser/lib/src/analysis/types/resolving_visitor.dart +++ b/sqlparser/lib/src/analysis/types/resolving_visitor.dart @@ -427,9 +427,11 @@ class TypeResolver extends RecursiveVisitor { @override void visitInExpression(InExpression e, TypeExpectation arg) { session._checkAndResolve(e, const ResolvedType.bool(), arg); - session._addRelation(NullableIfSomeOtherIs(e, e.childNodes)); - session._addRelation(CopyTypeFrom(e.inside, e.left, array: true)); + if (e.inside case Expression inExpr) { + session._addRelation(NullableIfSomeOtherIs(e, [e.left, inExpr])); + session._addRelation(CopyTypeFrom(inExpr, e.left, array: true)); + } visitChildren(e, const NoTypeExpectation()); } diff --git a/sqlparser/lib/src/ast/common/queryables.dart b/sqlparser/lib/src/ast/common/queryables.dart index 45e9d8b9..9ce807c0 100644 --- a/sqlparser/lib/src/ast/common/queryables.dart +++ b/sqlparser/lib/src/ast/common/queryables.dart @@ -41,7 +41,7 @@ abstract class TableOrSubquery extends Queryable { /// set. class TableReference extends TableOrSubquery with ReferenceOwner - implements Renamable, ResolvesToResultSet { + implements Renamable, ResolvesToResultSet, InExpressionTarget { final String? schemaName; final String tableName; Token? tableNameToken; @@ -213,7 +213,12 @@ class UsingConstraint extends JoinConstraint { } class TableValuedFunction extends Queryable - implements TableOrSubquery, SqlInvocation, Renamable, ResolvesToResultSet { + implements + TableOrSubquery, + SqlInvocation, + Renamable, + ResolvesToResultSet, + InExpressionTarget { @override final String name; diff --git a/sqlparser/lib/src/ast/common/tuple.dart b/sqlparser/lib/src/ast/common/tuple.dart index 22357d5b..6c5ccc62 100644 --- a/sqlparser/lib/src/ast/common/tuple.dart +++ b/sqlparser/lib/src/ast/common/tuple.dart @@ -3,7 +3,7 @@ part of '../ast.dart'; /// A tuple of values, denotes in brackets. `(, ..., )`. /// /// In sqlite, this is also called a "row value". -class Tuple extends Expression { +class Tuple extends Expression implements InExpressionTarget { /// The expressions appearing in this tuple. List expressions; diff --git a/sqlparser/lib/src/ast/expressions/simple.dart b/sqlparser/lib/src/ast/expressions/simple.dart index 1d340dbb..2b5a4028 100644 --- a/sqlparser/lib/src/ast/expressions/simple.dart +++ b/sqlparser/lib/src/ast/expressions/simple.dart @@ -180,10 +180,9 @@ class InExpression extends Expression { /// against. From the sqlite grammar, we support [Tuple] and a [SubQuery]. /// We also support a [Variable] as syntax sugar - it will be expanded into a /// tuple of variables at runtime. - Expression inside; + InExpressionTarget inside; - InExpression({this.not = false, required this.left, required this.inside}) - : assert(inside is Tuple || inside is Variable || inside is SubQuery); + InExpression({this.not = false, required this.left, required this.inside}); @override R accept(AstVisitor visitor, A arg) { @@ -191,7 +190,7 @@ class InExpression extends Expression { } @override - List get childNodes => [left, inside]; + List get childNodes => [left, inside]; @override void transformChildren(Transformer transformer, A arg) { @@ -200,6 +199,19 @@ class InExpression extends Expression { } } +/// Possible values for the right-hand side of an [InExpression]. +/// +/// Valid subclasses are: +/// - [Tuple], to check whether the LHS is equal to any of the elements in the +/// tuple. +/// - [SubQuery], to check whether the LHS is equal to any of the rows returned +/// by the subquery. +/// - [TableReference] and [TableValuedFunction], a short-hand for [SubQuery]s +/// if the table or function only return one column. +/// - [Variable] (only if drift extensions are enabled), drift's generator +/// turns this into a tuple of variables at runtime. +abstract class InExpressionTarget implements AstNode {} + class Parentheses extends Expression { Token? openingLeft; Token? closingRight; diff --git a/sqlparser/lib/src/ast/expressions/subquery.dart b/sqlparser/lib/src/ast/expressions/subquery.dart index 042cdfa1..7b0a18c5 100644 --- a/sqlparser/lib/src/ast/expressions/subquery.dart +++ b/sqlparser/lib/src/ast/expressions/subquery.dart @@ -2,7 +2,7 @@ part of '../ast.dart'; /// A subquery, which is an expression. It is expected that the inner query /// only returns one column and one row. -class SubQuery extends Expression { +class SubQuery extends Expression implements InExpressionTarget { BaseSelectStatement select; SubQuery({required this.select}); diff --git a/sqlparser/lib/src/ast/expressions/variables.dart b/sqlparser/lib/src/ast/expressions/variables.dart index 098924f3..876fff81 100644 --- a/sqlparser/lib/src/ast/expressions/variables.dart +++ b/sqlparser/lib/src/ast/expressions/variables.dart @@ -1,6 +1,6 @@ part of '../ast.dart'; -abstract class Variable extends Expression { +abstract class Variable extends Expression implements InExpressionTarget { int? resolvedIndex; } diff --git a/sqlparser/lib/src/reader/parser.dart b/sqlparser/lib/src/reader/parser.dart index 758166c1..288d09b9 100644 --- a/sqlparser/lib/src/reader/parser.dart +++ b/sqlparser/lib/src/reader/parser.dart @@ -511,7 +511,20 @@ class Parser { final not = _matchOne(TokenType.not); _matchOne(TokenType.$in); - final inside = _variableOrNull() ?? _consumeTuple(orSubQuery: true); + InExpressionTarget inside; + if (_variableOrNull() case var variable?) { + inside = variable; + } else if (_check(TokenType.leftParen)) { + inside = _consumeTuple(orSubQuery: true) as InExpressionTarget; + } else { + final target = _tableOrSubquery(); + // TableOrSubquery is either a table reference, a table-valued function, + // or a Subquery. We don't support subqueries, but they can't be parsed + // here because we would have entered the tuple case above. + assert(target is! SubQuery); + inside = target as InExpressionTarget; + } + return InExpression(left: left, inside: inside, not: not) ..setSpan(left.first!, _previous); } diff --git a/sqlparser/pubspec.yaml b/sqlparser/pubspec.yaml index fba7f470..271a4321 100644 --- a/sqlparser/pubspec.yaml +++ b/sqlparser/pubspec.yaml @@ -1,6 +1,6 @@ name: sqlparser description: Parses sqlite statements and performs static analysis on them -version: 0.34.1 +version: 0.35.0 homepage: https://github.com/simolus3/drift/tree/develop/sqlparser repository: https://github.com/simolus3/drift #homepage: https://drift.simonbinder.eu/ diff --git a/sqlparser/test/analysis/column_resolver_test.dart b/sqlparser/test/analysis/column_resolver_test.dart index 403ab0a2..6108b1b3 100644 --- a/sqlparser/test/analysis/column_resolver_test.dart +++ b/sqlparser/test/analysis/column_resolver_test.dart @@ -363,4 +363,10 @@ SELECT * FROM cars expect(select.resolvedColumns?.map((e) => e.name), ['literal', 'bar']); }); + + test('error for nonexisting table in IN expression', () { + final query = engine.analyze('SELECT 1 IN no_such_table'); + query.expectError('no_such_table', + type: AnalysisErrorType.referencedUnknownTable); + }); } diff --git a/sqlparser/test/analysis/errors/row_value_misuse_test.dart b/sqlparser/test/analysis/errors/row_value_misuse_test.dart index ff8febe0..bc133ea2 100644 --- a/sqlparser/test/analysis/errors/row_value_misuse_test.dart +++ b/sqlparser/test/analysis/errors/row_value_misuse_test.dart @@ -6,7 +6,8 @@ import 'utils.dart'; void main() { late SqlEngine engine; setUp(() { - engine = SqlEngine(); + // enable json1 extension + engine = SqlEngine(EngineOptions(version: SqliteVersion.v3_38)); }); test('when using row value in select', () { @@ -15,12 +16,6 @@ void main() { .expectError('(1, 2, 3)', type: AnalysisErrorType.rowValueMisuse); }); - test('as left hand operator of in', () { - engine - .analyze('SELECT (1, 2, 3) IN (4, 5, 6)') - .expectError('(1, 2, 3)', type: AnalysisErrorType.rowValueMisuse); - }); - test('in BETWEEN expression', () { engine .analyze('SELECT 1 BETWEEN (1, 2, 3) AND 3') @@ -68,4 +63,33 @@ void main() { .expectNoError(); }); }); + + group('in expressions', () { + test('when tuple is expected', () { + engine.analyze('SELECT (1, 2) IN ((4, 5), 6)').expectError('6', + type: AnalysisErrorType.other, + message: contains('Expected 2 columns in this entry, got 1')); + }); + + test('when tuple is not expected', () { + engine.analyze('SELECT 1 IN ((4, 5), 6)').expectError('(4, 5)', + type: AnalysisErrorType.other, + message: contains('Expected 1 columns in this entry, got 2')); + }); + + test('for table reference', () { + engine + .analyze('WITH names AS (VALUES(1, 2, 3)) SELECT (1, 2) IN names') + .expectError('names', + type: AnalysisErrorType.other, + message: contains('must have 2 columns (it has 3)')); + }); + + test('for table-valued function', () { + engine.analyze("SELECT (1, 2) IN json_each('{}')").expectError( + "json_each('{}')", + type: AnalysisErrorType.other, + message: contains('this table must have 2 columns (it has 8)')); + }); + }); } diff --git a/sqlparser/test/analysis/errors/utils.dart b/sqlparser/test/analysis/errors/utils.dart index 3d1ec050..ee1be2a6 100644 --- a/sqlparser/test/analysis/errors/utils.dart +++ b/sqlparser/test/analysis/errors/utils.dart @@ -2,10 +2,10 @@ import 'package:sqlparser/sqlparser.dart'; import 'package:test/test.dart'; extension ExpectErrors on AnalysisContext { - void expectError(String lexeme, {AnalysisErrorType? type}) { + void expectError(String lexeme, {AnalysisErrorType? type, message}) { expect( errors, - [analysisErrorWith(lexeme: lexeme, type: type)], + [analysisErrorWith(lexeme: lexeme, type: type, message: message)], ); } diff --git a/sqlparser/test/analysis/schema/from_create_table_test.dart b/sqlparser/test/analysis/schema/from_create_table_test.dart index 84867922..c4770780 100644 --- a/sqlparser/test/analysis/schema/from_create_table_test.dart +++ b/sqlparser/test/analysis/schema/from_create_table_test.dart @@ -103,7 +103,7 @@ void main() { SqlEngine(EngineOptions(driftOptions: const DriftSqlOptions())); final stmt = engine.parse(''' CREATE TABLE foo ( - a BOOL, b DATETIME, c DATE, d BOOLEAN NOT NULL + a BOOL, b DATETIME, c DATE, d BOOLEAN NOT NULL, e INT64 ) ''').rootNode; @@ -114,6 +114,7 @@ void main() { ResolvedType(type: BasicType.int, hints: [IsDateTime()], nullable: true), ResolvedType(type: BasicType.int, hints: [IsDateTime()], nullable: true), ResolvedType(type: BasicType.int, hints: [IsBoolean()], nullable: false), + ResolvedType(type: BasicType.int, hints: [IsBigInt()], nullable: true), ]); }); diff --git a/sqlparser/test/parser/expression_test.dart b/sqlparser/test/parser/expression_test.dart index 1076058a..66e80aaf 100644 --- a/sqlparser/test/parser/expression_test.dart +++ b/sqlparser/test/parser/expression_test.dart @@ -160,6 +160,22 @@ final Map _testCases = { ], ), ), + 'x IN json_each(bar)': InExpression( + left: Reference(columnName: 'x'), + inside: TableValuedFunction( + 'json_each', + ExprFunctionParameters( + parameters: [ + Reference(columnName: 'bar'), + ], + ), + ), + ), + 'x NOT IN "table"': InExpression( + not: true, + left: Reference(columnName: 'x'), + inside: TableReference('table'), + ), 'CAST(3 + 4 AS TEXT)': CastExpression( BinaryExpression( NumericLiteral(3.0), diff --git a/sqlparser/test/utils/node_to_text_test.dart b/sqlparser/test/utils/node_to_text_test.dart index f4785446..bb516eb1 100644 --- a/sqlparser/test/utils/node_to_text_test.dart +++ b/sqlparser/test/utils/node_to_text_test.dart @@ -485,6 +485,9 @@ CREATE UNIQUE INDEX my_idx ON t1 (c1, c2, c3) WHERE c1 < c3; test('in', () { testFormat('SELECT x IN (SELECT * FROM foo);'); testFormat('SELECT x NOT IN (SELECT * FROM foo);'); + testFormat('SELECT x IN foo'); + testFormat('SELECT x IN json_each(bar)'); + testFormat('SELECT x IN :array'); }); test('boolean literals', () {