diff --git a/moor/CHANGELOG.md b/moor/CHANGELOG.md index e5a23f39..8a8083d9 100644 --- a/moor/CHANGELOG.md +++ b/moor/CHANGELOG.md @@ -3,6 +3,7 @@ - Date time columns are now comparable - Make transactions easier to use: Thanks to some Dart async magic, methods called on your database object in a transaction callback will automatically be called on the transaction object. +- Support list parameters in compiled custom queries (`SELECT * FROM entries WHERE id IN ?`) ## 1.5.1 - Fixed an issue where transformed streams would not always update diff --git a/moor/test/custom_queries_test.dart b/moor/test/custom_queries_test.dart new file mode 100644 index 00000000..c9dd9769 --- /dev/null +++ b/moor/test/custom_queries_test.dart @@ -0,0 +1,28 @@ +import 'package:test_api/test_api.dart'; + +import 'data/tables/todos.dart'; +import 'data/utils/mocks.dart'; + +void main() { + TodoDb db; + MockExecutor executor; + + setUp(() { + executor = MockExecutor(); + db = TodoDb(executor); + }); + + group('compiled custom queries', () { + // defined query: SELECT * FROM todos WHERE title = ?2 OR id IN ? OR title = ?1 + test('work with arrays', () async { + await db.withIn('one', 'two', [1, 2, 3]); + + verify( + executor.runSelect( + 'SELECT * FROM todos WHERE title = ?2 OR id IN (?,?,?) OR title = ?1', + ['one', 'two', 1, 2, 3], + ), + ); + }); + }); +} diff --git a/moor/test/data/tables/todos.dart b/moor/test/data/tables/todos.dart index 50181aa1..78ba320a 100644 --- a/moor/test/data/tables/todos.dart +++ b/moor/test/data/tables/todos.dart @@ -71,6 +71,7 @@ class PureDefaults extends Table { 'allTodosWithCategory': 'SELECT t.*, c.id as catId, c."desc" as catDesc ' 'FROM todos t INNER JOIN categories c ON c.id = t.category', 'deleteTodoById': 'DELETE FROM todos WHERE id = ?', + 'withIn': 'SELECT * FROM todos WHERE title = ?2 OR id IN ? OR title = ?1' }, ) class TodoDb extends _$TodoDb { diff --git a/moor/test/data/tables/todos.g.dart b/moor/test/data/tables/todos.g.dart index a182cb47..b404d7c6 100644 --- a/moor/test/data/tables/todos.g.dart +++ b/moor/test/data/tables/todos.g.dart @@ -1216,12 +1216,50 @@ abstract class _$TodoDb extends GeneratedDatabase { } Future deleteTodoById(int var1, {QueryEngine operateOn}) { - return (operateOn ?? this) - .customUpdate('DELETE FROM todos WHERE id = ?', variables: [ - Variable.withInt(var1), - ], updates: { - todosTable - }); + return (operateOn ?? this).customUpdate( + 'DELETE FROM todos WHERE id = ?', + variables: [ + Variable.withInt(var1), + ], + updates: {todosTable}, + ); + } + + TodoEntry _rowToTodoEntry(QueryRow row) { + return TodoEntry( + id: row.readInt('id'), + title: row.readString('title'), + content: row.readString('content'), + targetDate: row.readDateTime('target_date'), + category: row.readInt('category'), + ); + } + + Future> withIn(String var1, String var2, List var3, + {QueryEngine operateOn}) { + final expandedvar3 = List.filled(var3.length, '?').join(','); + return (operateOn ?? this).customSelect( + 'SELECT * FROM todos WHERE title = ?2 OR id IN ($expandedvar3) OR title = ?1', + variables: [ + Variable.withString(var1), + Variable.withString(var2), + for (var $ in var3) Variable.withInt($), + ]).then((rows) => rows.map(_rowToTodoEntry).toList()); + } + + Stream> watchWithIn( + String var1, String var2, List var3) { + final expandedvar3 = List.filled(var3.length, '?').join(','); + return customSelectStream( + 'SELECT * FROM todos WHERE title = ?2 OR id IN ($expandedvar3) OR title = ?1', + variables: [ + Variable.withString(var1), + Variable.withString(var2), + for (var $ in var3) Variable.withInt($), + ], + readsFrom: { + todosTable + }).map((rows) => rows.map(_rowToTodoEntry).toList()); } @override diff --git a/moor_generator/lib/src/model/sql_query.dart b/moor_generator/lib/src/model/sql_query.dart index 73eceb8e..15f63402 100644 --- a/moor_generator/lib/src/model/sql_query.dart +++ b/moor_generator/lib/src/model/sql_query.dart @@ -1,16 +1,18 @@ import 'package:moor_generator/src/model/specified_column.dart'; import 'package:moor_generator/src/model/specified_table.dart'; import 'package:recase/recase.dart'; +import 'package:sqlparser/sqlparser.dart'; final _illegalChars = RegExp(r'[^0-9a-zA-Z_]'); final _leadingDigits = RegExp(r'^\d*'); abstract class SqlQuery { final String name; - final String sql; + final AnalysisContext fromContext; + String get sql => fromContext.sql; final List variables; - SqlQuery(this.name, this.sql, this.variables); + SqlQuery(this.name, this.fromContext, this.variables); } class SqlSelectQuery extends SqlQuery { @@ -24,17 +26,17 @@ class SqlSelectQuery extends SqlQuery { return '${ReCase(name).pascalCase}Result'; } - SqlSelectQuery(String name, String sql, List variables, - this.readsFrom, this.resultSet) - : super(name, sql, variables); + SqlSelectQuery(String name, AnalysisContext fromContext, + List variables, this.readsFrom, this.resultSet) + : super(name, fromContext, variables); } class UpdatingQuery extends SqlQuery { final List updates; - UpdatingQuery( - String name, String sql, List variables, this.updates) - : super(name, sql, variables); + UpdatingQuery(String name, AnalysisContext fromContext, + List variables, this.updates) + : super(name, fromContext, variables); } class InferredResultSet { @@ -95,9 +97,21 @@ class FoundVariable { int index; String name; final ColumnType type; + final Variable variable; - FoundVariable(this.index, this.name, this.type); + /// Whether this variable is an array, which will be expanded into multiple + /// variables at runtime. We only accept queries where no explicitly numbered + /// vars appear after an array. This means that we can expand array variables + /// without having to look at other variables. + final bool isArray; - String get dartParameterName => - name?.replaceAll(_illegalChars, '') ?? 'var$index'; + FoundVariable(this.index, this.name, this.type, this.variable, this.isArray); + + String get dartParameterName { + if (name != null) { + return name.replaceAll(_illegalChars, ''); + } else { + return 'var${variable.resolvedIndex}'; + } + } } diff --git a/moor_generator/lib/src/parser/sql/query_handler.dart b/moor_generator/lib/src/parser/sql/query_handler.dart index 1edf3a2c..57cf929e 100644 --- a/moor_generator/lib/src/parser/sql/query_handler.dart +++ b/moor_generator/lib/src/parser/sql/query_handler.dart @@ -35,7 +35,7 @@ class QueryHandler { context.root.accept(updatedFinder); _foundTables = updatedFinder.foundTables; - return UpdatingQuery(name, context.sql, _foundVariables, + return UpdatingQuery(name, context, _foundVariables, _foundTables.map(mapper.tableToMoor).toList()); } @@ -46,7 +46,7 @@ class QueryHandler { final moorTables = _foundTables.map(mapper.tableToMoor).toList(); return SqlSelectQuery( - name, context.sql, _foundVariables, moorTables, _inferResultSet()); + name, context, _foundVariables, moorTables, _inferResultSet()); } InferredResultSet _inferResultSet() { diff --git a/moor_generator/lib/src/parser/sql/sql_parser.dart b/moor_generator/lib/src/parser/sql/sql_parser.dart index 779dd222..5a474ade 100644 --- a/moor_generator/lib/src/parser/sql/sql_parser.dart +++ b/moor_generator/lib/src/parser/sql/sql_parser.dart @@ -47,7 +47,11 @@ class SqlParser { )); } - foundQueries.add(QueryHandler(name, context, _mapper).handle()); + try { + foundQueries.add(QueryHandler(name, context, _mapper).handle()); + } catch (e) { + print('Error while generating APIs for ${context.sql}: $e'); + } }); } } diff --git a/moor_generator/lib/src/parser/sql/type_mapping.dart b/moor_generator/lib/src/parser/sql/type_mapping.dart index c802948b..9ff3bf8d 100644 --- a/moor_generator/lib/src/parser/sql/type_mapping.dart +++ b/moor_generator/lib/src/parser/sql/type_mapping.dart @@ -74,6 +74,11 @@ class TypeMapper { ..sort((a, b) => a.resolvedIndex.compareTo(b.resolvedIndex)); final foundVariables = []; + // we don't allow variables with an explicit index after an array. For + // instance: SELECT * FROM t WHERE id IN ? OR id = ?2. The reason this is + // not allowed is that we expand the first arg into multiple vars at runtime + // which would break the index. + var maxIndex = 999; var currentIndex = 0; for (var used in usedVars) { @@ -83,9 +88,30 @@ class TypeMapper { currentIndex++; final name = (used is ColonNamedVariable) ? used.name : null; - final type = resolvedToMoor(ctx.typeOf(used).type); + final explicitIndex = + (used is NumberedVariable) ? used.explicitIndex : null; + final internalType = ctx.typeOf(used); + final type = resolvedToMoor(internalType.type); + final isArray = internalType.type?.isArray ?? false; - foundVariables.add(FoundVariable(currentIndex, name, type)); + if (explicitIndex != null && currentIndex >= maxIndex) { + throw ArgumentError( + 'Cannot have a variable with an index lower than that of an array ' + 'appearing after an array!'); + } + + foundVariables + .add(FoundVariable(currentIndex, name, type, used, isArray)); + + // arrays cannot be indexed explicitly because they're expanded into + // multiple variables when executor + if (isArray && explicitIndex != null) { + throw ArgumentError( + 'Cannot use an array variable with an explicit index'); + } + if (isArray) { + maxIndex = used.resolvedIndex; + } } return foundVariables; diff --git a/moor_generator/lib/src/utils/string_escaper.dart b/moor_generator/lib/src/utils/string_escaper.dart index 7d5aef02..17df4baf 100644 --- a/moor_generator/lib/src/utils/string_escaper.dart +++ b/moor_generator/lib/src/utils/string_escaper.dart @@ -1,4 +1,11 @@ String asDartLiteral(String value) { - final escaped = value.replaceAll("'", "\\'").replaceAll('\n', '\\n'); + final escaped = escapeForDart(value); return "'$escaped'"; } + +String escapeForDart(String value) { + return value + .replaceAll("'", "\\'") + .replaceAll('\$', '\\\$') + .replaceAll('\n', '\\n'); +} diff --git a/moor_generator/lib/src/writer/query_writer.dart b/moor_generator/lib/src/writer/query_writer.dart index 9ea90d19..412c8caa 100644 --- a/moor_generator/lib/src/writer/query_writer.dart +++ b/moor_generator/lib/src/writer/query_writer.dart @@ -2,6 +2,7 @@ import 'package:moor_generator/src/model/specified_column.dart'; import 'package:moor_generator/src/model/sql_query.dart'; import 'package:moor_generator/src/utils/string_escaper.dart'; import 'package:recase/recase.dart'; +import 'package:sqlparser/sqlparser.dart'; /// 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. @@ -12,6 +13,18 @@ class QueryWriter { QueryWriter(this.query); + /// The expanded sql that we insert into queries whenever an array variable + /// appears. For the query "SELECT * FROM t WHERE x IN ?", we generate + /// ```dart + /// test(List var1) { + /// final expandedvar1 = List.filled(var1.length, '?').join(','); + /// customSelect('SELECT * FROM t WHERE x IN ($expandedvar1)', ...); + /// } + /// ``` + String _expandedName(FoundVariable v) { + return 'expanded${v.dartParameterName}'; + } + void writeInto(StringBuffer buffer) { if (query is SqlSelectQuery) { _writeSelect(buffer); @@ -50,10 +63,11 @@ class QueryWriter { void _writeOneTimeReader(StringBuffer buffer) { buffer.write('Future> ${query.name}('); _writeParameters(buffer); + buffer.write(') {\n'); + _writeExpandedDeclarations(buffer); buffer - ..write(') {\n') ..write('return (operateOn ?? this).') // use custom engine, if set - ..write('customSelect(${asDartLiteral(query.sql)},'); + ..write('customSelect(${_queryCode()},'); _writeVariables(buffer); buffer ..write(')') @@ -70,9 +84,10 @@ class QueryWriter { // be used in transaction or similar context, only on the main database // engine. _writeParameters(buffer, dontOverrideEngine: true); - buffer - ..write(') {\n') - ..write('return customSelectStream(${asDartLiteral(query.sql)},'); + buffer.write(') {\n'); + + _writeExpandedDeclarations(buffer); + buffer..write('return customSelectStream(${_queryCode()},'); _writeVariables(buffer); buffer.write(','); @@ -92,23 +107,29 @@ class QueryWriter { */ buffer.write('Future ${query.name}('); _writeParameters(buffer); + buffer.write(') {\n'); + + _writeExpandedDeclarations(buffer); buffer - ..write(') {\n') ..write('return (operateOn ?? this).') - ..write('customUpdate(${asDartLiteral(query.sql)},'); + ..write('customUpdate(${_queryCode()},'); _writeVariables(buffer); buffer.write(','); _writeUpdates(buffer); - buffer..write(');\n}\n'); + buffer..write(',);\n}\n'); } void _writeParameters(StringBuffer buffer, {bool dontOverrideEngine = false}) { - final paramList = query.variables - .map((v) => '${dartTypeNames[v.type]} ${v.dartParameterName}') - .join(', '); + final paramList = query.variables.map((v) { + var dartType = dartTypeNames[v.type]; + if (v.isArray) { + dartType = 'List<$dartType>'; + } + return '$dartType ${v.dartParameterName}'; + }).join(', '); buffer.write(paramList); @@ -120,18 +141,75 @@ class QueryWriter { } } + void _writeExpandedDeclarations(StringBuffer buffer) { + for (var variable in query.variables) { + if (variable.isArray) { + // final expandedvar1 = List.filled(var1.length, '?').join(','); + buffer + ..write('final ') + ..write(_expandedName(variable)) + ..write(' = ') + ..write('List.filled(') + ..write(variable.dartParameterName) + ..write(".length, '?').join(',');"); + } + } + } + void _writeVariables(StringBuffer buffer) { buffer..write('variables: ['); for (var variable in query.variables) { - buffer - ..write(createVariable[variable.type]) - ..write('(${variable.dartParameterName}),'); + // for a regular variable: Variable.withInt(x), + // for a list of vars: for (var $ in vars) Variable.withInt($), + final constructor = createVariable[variable.type]; + final name = variable.dartParameterName; + + if (variable.isArray) { + buffer.write('for (var \$ in $name) $constructor(\$)'); + } else { + buffer.write('$constructor($name)'); + } + + buffer.write(','); } buffer..write(']'); } + /// Returns a Dart string literal representing the query after variables have + /// been expanded. For instance, 'SELECT * FROM t WHERE x IN ?' will be turned + /// into 'SELECT * FROM t WHERE x IN ($expandedVar1)'. + String _queryCode() { + // sort variables by the order in which they appear + final vars = query.fromContext.root.allDescendants + .whereType() + .toList() + ..sort((a, b) => a.firstPosition.compareTo(b.firstPosition)); + + final buffer = StringBuffer("'"); + var lastIndex = 0; + + for (var sqlVar in vars) { + final moorVar = query.variables.singleWhere((f) => f.variable == sqlVar); + if (!moorVar.isArray) continue; + + // write everything that comes before this var into the buffer + final currentIndex = sqlVar.firstPosition; + final queryPart = query.sql.substring(lastIndex, currentIndex); + buffer.write(escapeForDart(queryPart)); + lastIndex = sqlVar.lastPosition; + + // write the ($expandedVar) par + buffer.write('(\$${_expandedName(moorVar)})'); + } + + // write the final part after the last variable, plus the ending ' + buffer..write(escapeForDart(query.sql.substring(lastIndex)))..write("'"); + + return buffer.toString(); + } + void _writeReadsFrom(StringBuffer buffer) { final from = _select.readsFrom.map((t) => t.tableFieldName).join(', '); buffer..write('readsFrom: {')..write(from)..write('}');