diff --git a/drift_dev/lib/src/analysis/driver/driver.dart b/drift_dev/lib/src/analysis/driver/driver.dart index 7dcde19a..947e57e8 100644 --- a/drift_dev/lib/src/analysis/driver/driver.dart +++ b/drift_dev/lib/src/analysis/driver/driver.dart @@ -8,6 +8,7 @@ import '../drift_native_functions.dart'; import '../resolver/dart/helper.dart'; import '../resolver/discover.dart'; import '../resolver/drift/sqlparser/mapping.dart'; +import '../resolver/file_analysis.dart'; import '../resolver/resolver.dart'; import '../results/results.dart'; import '../serializer.dart'; @@ -20,7 +21,7 @@ class DriftAnalysisDriver { final DriftAnalysisCache cache = DriftAnalysisCache(); final DriftOptions options; - late final TypeMapping typeMapping = TypeMapping(options); + late final TypeMapping typeMapping = TypeMapping(this); late final ElementDeserializer deserializer = ElementDeserializer(this); AnalysisResultCacheReader? cacheReader; @@ -157,6 +158,22 @@ class DriftAnalysisDriver { await _analyzePrepared(known); return known; } + + Future fullyAnalyze(Uri uri) async { + // First, make sure that elements in this file and all imports are fully + // resolved. + final state = await resolveElements(uri); + + // Then, run local analysis if needed + if (state.fileAnalysis == null) { + final analyzer = FileAnalyzer(this); + final result = await analyzer.runAnalysisOn(state); + + state.fileAnalysis = result; + } + + return state; + } } abstract class AnalysisResultCacheReader { diff --git a/drift_dev/lib/src/analysis/driver/state.dart b/drift_dev/lib/src/analysis/driver/state.dart index b9d1bc3b..c3c6be7e 100644 --- a/drift_dev/lib/src/analysis/driver/state.dart +++ b/drift_dev/lib/src/analysis/driver/state.dart @@ -3,16 +3,18 @@ import 'package:path/path.dart' show url; import 'package:sqlparser/sqlparser.dart' hide AnalysisError; import '../results/element.dart'; +import '../results/file_results.dart'; import 'error.dart'; class FileState { final Uri ownUri; DiscoveredFileState? discovery; - final Map analysis = {}; - final List errorsDuringDiscovery = []; + final Map analysis = {}; + FileAnalysisResult? fileAnalysis; + FileState(this.ownUri); String get extension => url.extension(ownUri.path); diff --git a/drift_dev/lib/src/analysis/resolver/drift/element_resolver.dart b/drift_dev/lib/src/analysis/resolver/drift/element_resolver.dart index 85241b5c..a7f98211 100644 --- a/drift_dev/lib/src/analysis/resolver/drift/element_resolver.dart +++ b/drift_dev/lib/src/analysis/resolver/drift/element_resolver.dart @@ -20,7 +20,7 @@ abstract class DriftElementResolver context.errors.forEach(reportLint); // Also run drift-specific lints on the query - final linter = DriftSqlLinter(context, this)..collectLints(); + final linter = DriftSqlLinter(context)..collectLints(); linter.sqlParserErrors.forEach(reportLint); } @@ -51,18 +51,7 @@ abstract class DriftElementResolver } SqlEngine newEngineWithTables(Iterable references) { - final driver = resolver.driver; - final engine = driver.newSqlEngine(); - - for (final reference in references) { - if (reference is DriftTable) { - engine.registerTable(driver.typeMapping.asSqlParserTable(reference)); - } else if (reference is DriftView) { - engine.registerView(driver.typeMapping.asSqlParserView(reference)); - } - } - - return engine; + return resolver.driver.typeMapping.newEngineWithTables(references); } Future> resolveSqlReferences(AstNode stmt) async { diff --git a/drift_dev/lib/src/analysis/resolver/drift/query.dart b/drift_dev/lib/src/analysis/resolver/drift/query.dart index 00e402aa..e671c38f 100644 --- a/drift_dev/lib/src/analysis/resolver/drift/query.dart +++ b/drift_dev/lib/src/analysis/resolver/drift/query.dart @@ -12,12 +12,10 @@ class DriftQueryResolver final stmt = discovered.sqlNode.statement; final references = await resolveSqlReferences(stmt); - final engine = newEngineWithTables(references); - final source = (file.discovery as DiscoveredDriftFile).originalSource; - final context = engine.analyzeNode(stmt, source); - reportLints(context); + // Note: We don't analyze the query here, that happens in + // `file_analysis.dart` after elements have been resolved. return DefinedSqlQuery( discovered.ownId, DriftDeclaration.driftFile(stmt, file.ownUri), 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 59fe507e..bf7ab857 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 @@ -1,16 +1,13 @@ import 'package:sqlparser/sqlparser.dart'; -import '../../resolver.dart'; - /// Implements (mostly drift-specific) lints for SQL statements that aren't /// implementeed in `sqlparser`. class DriftSqlLinter { final AnalysisContext _context; - final LocalElementResolver _resolver; final List sqlParserErrors = []; - DriftSqlLinter(this._context, this._resolver); + DriftSqlLinter(this._context); void collectLints() { _context.root.acceptWithoutArg(_LintingVisitor(this)); diff --git a/drift_dev/lib/src/analysis/resolver/drift/sqlparser/mapping.dart b/drift_dev/lib/src/analysis/resolver/drift/sqlparser/mapping.dart index f22b6036..dfe5ae07 100644 --- a/drift_dev/lib/src/analysis/resolver/drift/sqlparser/mapping.dart +++ b/drift_dev/lib/src/analysis/resolver/drift/sqlparser/mapping.dart @@ -1,15 +1,29 @@ import 'package:drift/drift.dart' show DriftSqlType; import 'package:sqlparser/sqlparser.dart'; -import '../../../../analyzer/options.dart'; +import '../../../driver/driver.dart'; import '../../../results/results.dart'; /// Converts tables and types between `drift_dev` internal reprensentation and /// the one used by the `sqlparser` package. class TypeMapping { - final DriftOptions options; + final DriftAnalysisDriver driver; - TypeMapping(this.options); + TypeMapping(this.driver); + + SqlEngine newEngineWithTables(Iterable references) { + final engine = driver.newSqlEngine(); + + for (final reference in references) { + if (reference is DriftTable) { + engine.registerTable(driver.typeMapping.asSqlParserTable(reference)); + } else if (reference is DriftView) { + engine.registerView(driver.typeMapping.asSqlParserView(reference)); + } + } + + return engine; + } Table asSqlParserTable(DriftTable table) { return Table( @@ -61,7 +75,7 @@ class TypeMapping { type: BasicType.int, hint: overrideHint ?? const IsBoolean()); case DriftSqlType.dateTime: return ResolvedType( - type: options.storeDateTimeValuesAsText + type: driver.options.storeDateTimeValuesAsText ? BasicType.text : BasicType.int, hint: overrideHint ?? const IsDateTime(), @@ -85,7 +99,7 @@ class TypeMapping { case BasicType.int: if (type.hint is IsBoolean) { return DriftSqlType.bool; - } else if (!options.storeDateTimeValuesAsText && + } else if (!driver.options.storeDateTimeValuesAsText && type.hint is IsDateTime) { return DriftSqlType.dateTime; } else if (type.hint is IsBigInt) { @@ -95,7 +109,8 @@ class TypeMapping { case BasicType.real: return DriftSqlType.double; case BasicType.text: - if (options.storeDateTimeValuesAsText && type.hint is IsDateTime) { + if (driver.options.storeDateTimeValuesAsText && + type.hint is IsDateTime) { return DriftSqlType.dateTime; } diff --git a/drift_dev/lib/src/analysis/resolver/drift/trigger.dart b/drift_dev/lib/src/analysis/resolver/drift/trigger.dart index ed2281ea..99f28e1e 100644 --- a/drift_dev/lib/src/analysis/resolver/drift/trigger.dart +++ b/drift_dev/lib/src/analysis/resolver/drift/trigger.dart @@ -22,7 +22,7 @@ class DriftTriggerResolver final context = engine.analyzeNode(stmt, source); reportLints(context); - TriggerTableWrite? mapWrite(TableWrite parserWrite) { + WrittenDriftTable? mapWrite(TableWrite parserWrite) { drift.UpdateKind kind; switch (parserWrite.kind) { case UpdateKind.insert: @@ -40,7 +40,7 @@ class DriftTriggerResolver .whereType() .firstWhereOrNull((e) => e.schemaName == parserWrite.table.name); if (table != null) { - return TriggerTableWrite(table, kind); + return WrittenDriftTable(table, kind); } else { return null; } @@ -53,7 +53,7 @@ class DriftTriggerResolver createStmt: source.substring(stmt.firstPosition, stmt.lastPosition), writes: findWrittenTables(stmt) .map(mapWrite) - .whereType() + .whereType() .toList(), ); } diff --git a/drift_dev/lib/src/analysis/resolver/file_analysis.dart b/drift_dev/lib/src/analysis/resolver/file_analysis.dart new file mode 100644 index 00000000..71e60d7e --- /dev/null +++ b/drift_dev/lib/src/analysis/resolver/file_analysis.dart @@ -0,0 +1,133 @@ +import 'package:sqlparser/sqlparser.dart'; + +import '../driver/driver.dart'; +import '../driver/error.dart'; +import '../driver/state.dart'; +import '../results/file_results.dart'; +import '../results/results.dart'; +import 'queries/query_analyzer.dart'; +import 'queries/required_variables.dart'; + +class FileAnalyzer { + final DriftAnalysisDriver driver; + + FileAnalyzer(this.driver); + + Future runAnalysisOn(FileState state) async { + final result = FileAnalysisResult(); + + if (state.extension == '.dart') { + for (final elementAnalysis in state.analysis.values) { + final element = elementAnalysis.result; + + final queries = {}; + + if (element is BaseDriftAccessor) { + for (final query in element.declaredQueries) { + final engine = + driver.typeMapping.newEngineWithTables(element.references); + final context = engine.analyze(query.sql); + + final analyzer = QueryAnalyzer(context, driver, + references: element.references.toList()); + queries[query.name] = analyzer.analyze(query); + + for (final error in analyzer.lints) { + result.analysisErrors.add(DriftAnalysisError( + error.span, 'Error in ${query.name}: ${error.message}')); + } + } + } + } + } else if (state.extension == '.drift' || state.extension == '.moor') { + // We need to map defined query elements to proper analysis results. + final genericEngineForParsing = driver.newSqlEngine(); + final source = await driver.backend.readAsString(state.ownUri); + final parsedFile = + genericEngineForParsing.parse(source).rootNode as DriftFile; + + for (final elementAnalysis in state.analysis.values) { + final element = elementAnalysis.result; + if (element is DefinedSqlQuery) { + final engine = + driver.typeMapping.newEngineWithTables(element.references); + final stmt = parsedFile.statements + .firstWhere((e) => e.firstPosition == element.sqlOffset) + as DeclaredStatement; + final options = _createOptionsAndVars(engine, stmt); + + final analysisResult = + engine.analyzeNode(stmt, source, stmtOptions: options.options); + + final analyzer = QueryAnalyzer(analysisResult, driver, + references: element.references, + requiredVariables: options.variables); + + result.resolvedQueries[element.id] = analyzer.analyze(element) + ..declaredInDriftFile = true; + + for (final error in analyzer.lints) { + result.analysisErrors + .add(DriftAnalysisError(error.span, error.message ?? '')); + } + } + } + } + + return result; + } + + _OptionsAndRequiredVariables _createOptionsAndVars( + SqlEngine engine, DeclaredStatement stmt) { + final reader = engine.schemaReader; + final indexedHints = {}; + final namedHints = {}; + final defaultValues = {}; + final requiredIndex = {}; + final requiredName = {}; + + for (final parameter in stmt.parameters) { + if (parameter is VariableTypeHint) { + final variable = parameter.variable; + + if (parameter.isRequired) { + if (variable is ColonNamedVariable) { + requiredName.add(variable.name); + } else if (variable is NumberedVariable) { + requiredIndex.add(variable.resolvedIndex!); + } + } + + if (parameter.typeName != null) { + final type = reader + .resolveColumnType(parameter.typeName) + .withNullable(parameter.orNull); + + if (variable is ColonNamedVariable) { + namedHints[variable.name] = type; + } else if (variable is NumberedVariable) { + indexedHints[variable.resolvedIndex!] = type; + } + } + } else if (parameter is DartPlaceholderDefaultValue) { + defaultValues[parameter.variableName] = parameter.defaultValue; + } + } + + return _OptionsAndRequiredVariables( + AnalyzeStatementOptions( + indexedVariableTypes: indexedHints, + namedVariableTypes: namedHints, + defaultValuesForPlaceholder: defaultValues, + ), + RequiredVariables(requiredIndex, requiredName), + ); + } +} + +class _OptionsAndRequiredVariables { + final AnalyzeStatementOptions options; + final RequiredVariables variables; + + _OptionsAndRequiredVariables(this.options, this.variables); +} diff --git a/drift_dev/lib/src/analysis/resolver/queries/nested_queries.dart b/drift_dev/lib/src/analysis/resolver/queries/nested_queries.dart new file mode 100644 index 00000000..23343eef --- /dev/null +++ b/drift_dev/lib/src/analysis/resolver/queries/nested_queries.dart @@ -0,0 +1,189 @@ +// ignore_for_file: library_private_types_in_public_api + +import 'package:sqlparser/sqlparser.dart'; + +import '../../results/results.dart'; + +/// Analysis support for nested queries. +/// +/// In drift, nested queries can be added with the `LIST` pseudofunction used +/// as a column. At runtime, the nested query is executed and its result are +/// collected as a list used as result for the main query. +/// As an example, consider the following query which selects all friends for +/// all users in a hypothetical social network: +/// +/// ```sql +/// SELECT u.**, LIST(SELECT friend.* FROM users friend +/// INNER JOIN friendships f ON f.user_a = friend.id OR f.user_b = friend.id +/// INNER JOIN users other +/// ON other.id IN (f.user_a, f.user_b) AND other.id != friend.id +/// WHERE other.id = u.id) friends +/// FROM users u +/// ``` +/// +/// This would generate a class with a `User u` and a `List friends;` +/// fields. +/// +/// As shown in the example, nested queries can refer to columns from outer +/// queries (here, `WHERE other.id = u.id` refers to `u.id` from the outer +/// query). To implement this with separate runtime queries, a transformation +/// is needed. First, we mark all [Reference]s that capture a value from an +/// outer query. The outer query is then modified to include this reference in +/// its result set. In the inner query, the variable is replaced with a +/// variable. In generated code, we first run the outer query and, for each +/// result, then set the variable and run the inner query. +/// In the example, the two transformed queries could look like this: +/// +/// ``` +/// a: SELECT u.**, u.id AS "helper0" FROM users u; +/// b: SELECT friend.* FROM users friend +/// ... +/// WHERE other.id = ?; +/// ``` +/// +/// At runtime, we'd first run `a` and then run `b` with `?` instantiated to +/// `helper0` for each row in `a`. +/// +/// When a nested query appears outside of a [NestedQueriesContainer], an +/// error will be reported. +class NestedQueryAnalyzer extends RecursiveVisitor<_AnalyzerState, void> { + int _capturingVariableCounter = 0; + + final List errors = []; + + NestedQueriesContainer analyzeRoot(SelectStatement node) { + final container = NestedQueriesContainer(node); + + final state = _AnalyzerState(container); + node.accept(this, state); + state._process(); + return container; + } + + @override + void visitDriftSpecificNode(DriftSpecificNode e, _AnalyzerState arg) { + if (e is NestedQueryColumn) { + final expectedParent = arg.container.select; + if (e.parent != expectedParent || !expectedParent.columns.contains(e)) { + // Not in a valid container or placed in an illegal position - report + // error! + errors.add(AnalysisError( + relevantNode: e, + message: 'A `LIST` result cannot be used here!', + type: AnalysisErrorType.other, + )); + } + + final nested = NestedQuery(arg.container, e); + arg.container.nestedQueries[e] = nested; + + final childState = _AnalyzerState(nested); + super.visitDriftSpecificNode(e, childState); + childState._process(); + return; + } + + super.visitDriftSpecificNode(e, arg); + } + + @override + void visitVariable(Variable e, _AnalyzerState arg) { + arg.actualAndAddedVariables.add(e); + + super.visitVariable(e, arg); + } + + @override + void visitReference(Reference e, _AnalyzerState arg) { + final resultEntity = e.resultEntity; + final container = arg.container; + + if (resultEntity != null && container is NestedQuery) { + if (!resultEntity.origin.isChildOf(arg.container.select)) { + // Reference captures a variable outside of this query + final capture = container.capturedVariables[e] = + CapturedVariable(e, _capturingVariableCounter++); + + // Keep track of the position of the variable so that we can later + // assign it the right index. + capture.introducedVariable.setSpan(e.first!, e.last!); + arg.actualAndAddedVariables.add(capture.introducedVariable); + } + } else { + // todo: Reference not resolved properly. An error should have been + // reported already, but we'll definitely not generate correct code for + // this. + } + } +} + +class _AnalyzerState { + final NestedQueriesContainer container; + final List actualAndAddedVariables = []; + + _AnalyzerState(this.container); + + void _process() { + // Add necessary columns to select variables read by inner nested queries. + for (final variable in container.variablesCapturedByChildren) { + container.addedColumns.add( + ExpressionResultColumn( + expression: Reference( + entityName: variable.reference.entityName, + columnName: variable.reference.columnName, + ), + as: variable.helperColumn, + ), + ); + } + + // Re-index variables, this time also considering the synthetic variables + // that we'll insert in [addHelperNodes] later. + AstPreparingVisitor.resolveIndexOfVariables(actualAndAddedVariables); + } +} + +/// Rewrites the query backing the [rootContainer] to +/// +/// - add result columns for outgoing references in nested queries +/// - replace outgoing references with variables +SelectStatement addHelperNodes(NestedQueriesContainer rootContainer) { + return _NestedQueryTransformer() + .transform(rootContainer.select, rootContainer) as SelectStatement; +} + +class _NestedQueryTransformer extends Transformer { + @override + AstNode? visitSelectStatement(SelectStatement e, NestedQueriesContainer arg) { + if (e == arg.select) { + for (final column in arg.addedColumns) { + e.columns.add(column..parent = e); + } + } + return super.visitSelectStatement(e, arg); + } + + @override + AstNode? visitDriftSpecificNode( + DriftSpecificNode e, NestedQueriesContainer arg) { + if (e is NestedQueryColumn) { + final child = arg.nestedQueries[e]; + if (child != null) { + e.transformChildren(this, child); + } + + // Remove nested query colums from the parent query + return null; + } + return super.visitDriftSpecificNode(e, arg); + } + + @override + AstNode? visitReference(Reference e, NestedQueriesContainer arg) { + final captured = arg is NestedQuery ? arg.capturedVariables[e] : null; + if (captured != null) { + return captured.introducedVariable; + } + return super.visitReference(e, arg); + } +} diff --git a/drift_dev/lib/src/analysis/resolver/queries/query_analyzer.dart b/drift_dev/lib/src/analysis/resolver/queries/query_analyzer.dart new file mode 100644 index 00000000..3f8c6b07 --- /dev/null +++ b/drift_dev/lib/src/analysis/resolver/queries/query_analyzer.dart @@ -0,0 +1,693 @@ +import 'package:drift/drift.dart' show DriftSqlType; +import 'package:drift/drift.dart' as drift; +import 'package:recase/recase.dart'; +import 'package:sqlparser/sqlparser.dart' hide ResultColumn; +import 'package:sqlparser/utils/find_referenced_tables.dart'; + +import '../../driver/driver.dart'; +import '../drift/sqlparser/drift_lints.dart'; +import '../drift/sqlparser/mapping.dart'; +import '../../results/results.dart'; +import 'nested_queries.dart'; +import 'required_variables.dart'; + +/// The context contains all data that is required to create an [SqlQuery]. This +/// class is simply there to bundle the data. +class _QueryHandlerContext { + final List foundElements; + final AstNode root; + final NestedQueriesContainer? nestedScope; + final String queryName; + final String? requestedResultClass; + + _QueryHandlerContext({ + required List foundElements, + required this.root, + required this.queryName, + required this.nestedScope, + this.requestedResultClass, + }) : foundElements = List.unmodifiable(foundElements); +} + +/// Maps an [AnalysisContext] from the sqlparser to a [SqlQuery] from this +/// generator package by determining its type, return columns, variables and so +/// on. +class QueryAnalyzer { + final AnalysisContext context; + final DriftAnalysisDriver driver; + final RequiredVariables requiredVariables; + final Map referencesByName; + + /// Found tables and views found need to be shared between the query and + /// all subqueries to not muss any updates when watching query. + late Set _foundTables; + late Set _foundViews; + + /// Used to create a unique name for every nested query. This needs to be + /// shared between queries, therefore this should not be part of the + /// context. + int nestedQueryCounter = 0; + + final List lints = []; + + QueryAnalyzer( + this.context, + this.driver, { + required List references, + this.requiredVariables = RequiredVariables.empty, + }) : referencesByName = { + for (final reference in references) + reference.id.name.toLowerCase(): reference, + }; + + E _lookupReference(String name) { + return referencesByName[name.toLowerCase()] as E; + } + + SqlQuery analyze(DriftQueryDeclaration declaration) { + final nestedAnalyzer = NestedQueryAnalyzer(); + NestedQueriesContainer? nestedScope; + + if (context.root is SelectStatement) { + nestedScope = nestedAnalyzer.analyzeRoot(context.root as SelectStatement); + } + + final foundElements = _extractElements( + ctx: context, + root: context.root, + required: requiredVariables, + nestedScope: nestedScope, + ); + _verifyNoSkippedIndexes(foundElements); + + final String? requestedResultClass; + if (declaration is DefinedSqlQuery) { + requestedResultClass = declaration.resultClassName; + } else { + requestedResultClass = null; + } + + final query = _mapToDrift(_QueryHandlerContext( + foundElements: foundElements, + queryName: declaration.name, + requestedResultClass: requestedResultClass, + root: context.root, + nestedScope: nestedScope, + )); + + final linter = DriftSqlLinter(context); + linter.collectLints(); + lints + ..addAll(context.errors) + ..addAll(linter.sqlParserErrors) + ..addAll(nestedAnalyzer.errors); + + return query; + } + + SqlQuery _mapToDrift(_QueryHandlerContext queryContext) { + if (queryContext.root is BaseSelectStatement) { + return _handleSelect(queryContext); + } else if (queryContext.root is UpdateStatement || + queryContext.root is DeleteStatement || + queryContext.root is InsertStatement) { + return _handleUpdate(queryContext); + } else { + throw StateError( + 'Unexpected sql: Got ${queryContext.root}, expected insert, select, ' + 'update or delete'); + } + } + + void _applyFoundTables(ReferencedTablesVisitor visitor) { + _foundTables = visitor.foundTables; + _foundViews = visitor.foundViews; + } + + UpdatingQuery _handleUpdate(_QueryHandlerContext queryContext) { + final root = queryContext.root; + + final updatedFinder = UpdatedTablesVisitor(); + root.acceptWithoutArg(updatedFinder); + _applyFoundTables(updatedFinder); + + final isInsert = root is InsertStatement; + + InferredResultSet? resultSet; + if (root is StatementReturningColumns) { + final columns = root.returnedResultSet?.resolvedColumns; + if (columns != null) { + resultSet = _inferResultSet(queryContext, columns); + } + } + + return UpdatingQuery( + queryContext.queryName, + context, + root, + queryContext.foundElements, + updatedFinder.writtenTables + .map((write) { + final table = _lookupReference(write.table.name); + drift.UpdateKind kind; + + switch (write.kind) { + case UpdateKind.insert: + kind = drift.UpdateKind.insert; + break; + case UpdateKind.update: + kind = drift.UpdateKind.update; + break; + case UpdateKind.delete: + kind = drift.UpdateKind.delete; + break; + } + + return table != null ? WrittenDriftTable(table, kind) : null; + }) + .whereType() + .toList(), + isInsert: isInsert, + hasMultipleTables: updatedFinder.foundTables.length > 1, + resultSet: resultSet, + ); + } + + SqlSelectQuery _handleSelect(_QueryHandlerContext queryContext) { + final tableFinder = ReferencedTablesVisitor(); + queryContext.root.acceptWithoutArg(tableFinder); + + _applyFoundTables(tableFinder); + + final driftTables = _foundTables + .map((tbl) => _lookupReference(tbl.name)) + .whereType() + .toList(); + final driftViews = _foundViews + .map((tbl) => _lookupReference(tbl.name)) + .whereType() + .toList(); + + final driftEntities = [...driftTables, ...driftViews]; + + return SqlSelectQuery( + queryContext.queryName, + context, + queryContext.root, + queryContext.foundElements, + driftEntities, + _inferResultSet( + queryContext, + (queryContext.root as BaseSelectStatement).resolvedColumns!, + ), + queryContext.requestedResultClass, + queryContext.nestedScope, + ); + } + + InferredResultSet _inferResultSet( + _QueryHandlerContext queryContext, + List rawColumns, + ) { + final candidatesForSingleTable = {..._foundTables, ..._foundViews}; + final columns = []; + + // First, go through regular result columns + for (final column in rawColumns) { + final type = context.typeOf(column).type; + final driftType = driver.typeMapping.sqlTypeToDrift(type); + AppliedTypeConverter? converter; + if (type?.hint is TypeConverterHint) { + converter = (type!.hint as TypeConverterHint).converter; + } + + columns.add(ResultColumn(column.name, driftType, type?.nullable ?? true, + typeConverter: converter, sqlParserColumn: column)); + + final resultSet = _resultSetOfColumn(column); + candidatesForSingleTable.removeWhere((t) => t != resultSet); + } + + final nestedResults = _findNestedResultTables(queryContext); + 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. + if (candidatesForSingleTable.length == 1) { + final table = candidatesForSingleTable.single; + final driftTable = + _lookupReference(table.name); + + if (driftTable == null) { + // References a table not declared in any drift api (dart or drift file). + // This can happen for internal sqlite tables + return InferredResultSet(null, columns); + } + + final resultEntryToColumn = {}; + final resultColumnNameToDrift = {}; + var matches = true; + + // go trough all columns of the table in question + for (final column in driftTable.columns) { + // check if this column from the table is present in the result set + final tableColumn = table.findColumn(column.nameInSql); + final inResultSet = + rawColumns.where((t) => _toTableOrViewColumn(t) == tableColumn); + + if (inResultSet.length == 1) { + // it is! Remember the correct getter name from the data class for + // later when we write the mapping code. + final columnIndex = rawColumns.indexOf(inResultSet.single); + final resultColumn = columns[columnIndex]; + + resultEntryToColumn[resultColumn] = column.nameInDart; + resultColumnNameToDrift[resultColumn.name] = column; + } else { + // it's not, so no match + matches = false; + break; + } + } + + // we have established that all columns in resultEntryToColumn do appear + // in the drift table. Now check for set equality. + if (rawColumns.length != driftTable.columns.length) { + matches = false; + } + + if (matches) { + final match = MatchingDriftTable(driftTable, resultColumnNameToDrift); + return InferredResultSet(match, columns) + ..forceDartNames(resultEntryToColumn); + } + } + + return InferredResultSet( + null, + columns, + nestedResults: nestedResults, + resultClassName: queryContext.requestedResultClass, + ); + } + + List _findNestedResultTables( + _QueryHandlerContext queryContext) { + // We don't currently support nested results for compound statements + if (queryContext.root is! SelectStatement) return const []; + final query = queryContext.root as SelectStatement; + + final nestedTables = []; + final analysis = JoinModel.of(query); + + for (final column in query.columns) { + if (column is NestedStarResultColumn) { + final originalResult = column.resultSet; + final result = originalResult?.unalias(); + if (result is! Table && result is! View) continue; + + final driftTable = _lookupReference( + (result as NamedResultSet).name); + final isNullable = + analysis == null || analysis.isNullableTable(originalResult!); + nestedTables.add(NestedResultTable( + column, + column.as ?? column.tableName, + driftTable, + isNullable: isNullable, + )); + } else if (column is NestedQueryColumn) { + final childScope = queryContext.nestedScope?.nestedQueries[column]; + + final foundElements = _extractElements( + ctx: context, + root: column.select, + required: requiredVariables, + nestedScope: childScope, + ); + _verifyNoSkippedIndexes(foundElements); + + final queryIndex = nestedQueryCounter++; + + final name = 'nested_query_$queryIndex'; + column.queryName = name; + + var resultClassName = ReCase(queryContext.queryName).pascalCase; + if (column.as != null) { + resultClassName += ReCase(column.as!).pascalCase; + } else { + resultClassName += 'NestedQuery$queryIndex'; + } + + nestedTables.add(NestedResultQuery( + from: column, + query: _handleSelect(_QueryHandlerContext( + queryName: name, + requestedResultClass: resultClassName, + root: column.select, + foundElements: foundElements, + nestedScope: childScope, + )), + )); + } + } + + return nestedTables; + } + + Column? _toTableOrViewColumn(Column? c) { + // ignore: literal_only_boolean_expressions + while (true) { + if (c is TableColumn || c is ViewColumn) { + return c; + } else if (c is ExpressionColumn) { + final expression = c.expression; + if (expression is Reference) { + final resolved = expression.resolved; + if (resolved is Column) { + c = resolved; + continue; + } + } + // Not a reference to a column + return null; + } else if (c is DelegatedColumn) { + c = c.innerColumn; + } else { + return null; + } + } + } + + ResultSet? _resultSetOfColumn(Column c) { + final mapped = _toTableOrViewColumn(c); + if (mapped == null) return null; + + if (mapped is ViewColumn) { + return mapped.view; + } else { + return (mapped as TableColumn).table; + } + } + + /// Extracts variables and Dart templates from the AST tree starting at + /// [root], but nested queries are excluded. Variables are sorted by their + /// ascending index. Placeholders are sorted by the position they have in the + /// query. When comparing variables and placeholders, the variable comes first + /// if the first variable with the same index appears before the placeholder. + /// + /// Additionally, the following assumptions can be made if this method returns + /// without throwing: + /// - array variables don't have an explicit index + /// - if an explicitly indexed variable appears AFTER an array variable or + /// a Dart placeholder, its indexed is LOWER than that element. This means + /// that elements can be expanded into multiple variables without breaking + /// variables that appear after them. + List _extractElements({ + required AnalysisContext ctx, + required AstNode root, + NestedQueriesContainer? nestedScope, + RequiredVariables required = RequiredVariables.empty, + }) { + final collector = _FindElements()..visit(root, nestedScope); + + // this contains variable references. For instance, SELECT :a = :a would + // contain two entries, both referring to the same variable. To do that, + // we use the fact that each variable has a unique index. + final variables = collector.variables; + final placeholders = collector.dartPlaceholders; + + final merged = _mergeVarsAndPlaceholders(variables, placeholders); + + final foundElements = []; + // 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. The initial high values can be arbitrary. + // We've chosen 999 because most sqlite binaries don't allow more variables. + var maxIndex = 999; + var currentIndex = 0; + + for (final used in merged) { + if (used is Variable) { + if (used.resolvedIndex == currentIndex) { + continue; // already handled, we only report a single variable / index + } + + currentIndex = used.resolvedIndex!; + final name = (used is ColonNamedVariable) ? used.name : null; + final explicitIndex = + (used is NumberedVariable) ? used.explicitIndex : null; + final forCapture = used.meta(); + + final internalType = + // If this variable was introduced to replace a reference from a + // `LIST` query to an outer query, use the type of the reference + // instead of the synthetic variable that we're replacing it with. + ctx.typeOf(forCapture != null ? forCapture.reference : used); + final type = driver.typeMapping.sqlTypeToDrift(internalType.type); + + if (forCapture != null) { + foundElements.add(FoundVariable.nestedQuery( + index: currentIndex, + name: name, + sqlType: type, + variable: used, + forCaptured: forCapture, + )); + + continue; + } + + final isArray = internalType.type?.isArray ?? false; + final isRequired = required.requiredNamedVariables.contains(name) || + required.requiredNumberedVariables.contains(used.resolvedIndex); + + if (explicitIndex != null && currentIndex >= maxIndex) { + throw ArgumentError( + 'Cannot have a variable with an index lower than that of an ' + 'array appearing after an array!'); + } + + AppliedTypeConverter? converter; + + // Recognizing type converters on variables is opt-in since it would + // break existing code. + if (driver.options.applyConvertersOnVariables && + internalType.type?.hint is TypeConverterHint) { + converter = (internalType.type!.hint as TypeConverterHint).converter; + } + + foundElements.add(FoundVariable( + index: currentIndex, + name: name, + sqlType: type, + nullable: internalType.type?.nullable ?? false, + variable: used, + isArray: isArray, + typeConverter: converter, + isRequired: isRequired, + )); + + // arrays cannot be indexed explicitly because they're expanded into + // multiple variables when executed + if (isArray && explicitIndex != null) { + throw ArgumentError( + 'Cannot use an array variable with an explicit index'); + } + if (isArray) { + maxIndex = used.resolvedIndex!; + } + } else if (used is DartPlaceholder) { + // we don't what index this placeholder has, so we can't allow _any_ + // explicitly indexed variables coming after this + maxIndex = 0; + foundElements.add(_extractPlaceholder(ctx, used)); + } + } + return foundElements; + } + + FoundDartPlaceholder _extractPlaceholder( + AnalysisContext context, DartPlaceholder placeholder) { + final name = placeholder.name; + + final type = placeholder.when( + isExpression: (e) { + final foundType = context.typeOf(e); + DriftSqlType? columnType; + if (foundType.type != null) { + columnType = driver.typeMapping.sqlTypeToDrift(foundType.type); + } + + final defaultValue = + context.stmtOptions.defaultValuesForPlaceholder[name]; + + return ExpressionDartPlaceholderType(columnType, defaultValue); + }, + isLimit: (_) => + SimpleDartPlaceholderType(SimpleDartPlaceholderKind.limit), + isOrderBy: (_) => + SimpleDartPlaceholderType(SimpleDartPlaceholderKind.orderBy), + isOrderingTerm: (_) => + SimpleDartPlaceholderType(SimpleDartPlaceholderKind.orderByTerm), + isInsertable: (_) { + final insert = placeholder.parents.whereType().first; + final table = insert.table.resultSet; + + return InsertableDartPlaceholderType( + table is Table ? _lookupReference(table.name) as DriftTable : null); + }, + ); + + final availableResults = placeholder.statementScope.allAvailableResultSets; + final availableDriftResults = []; + for (final available in availableResults) { + final aliasedResultSet = available.resultSet.resultSet; + final resultSet = aliasedResultSet?.unalias(); + String name; + if (aliasedResultSet is NamedResultSet) { + name = aliasedResultSet.name; + } else { + // If we don't have a name we can't include this result set. + continue; + } + + DriftElementWithResultSet driftEntity; + + if (resultSet is Table || resultSet is View) { + driftEntity = _lookupReference((resultSet as NamedResultSet).name) + as DriftElementWithResultSet; + } else { + // If this result set is an inner select statement or anything else we + // can't represent it in Dart. + continue; + } + + availableDriftResults + .add(AvailableDriftResultSet(name, driftEntity, available)); + } + + return FoundDartPlaceholder(type!, name, availableDriftResults) + ..astNode = placeholder; + } + + /// Merges [vars] and [placeholders] into a list that satisfies the order + /// described in [_extractElements]. + List _mergeVarsAndPlaceholders( + List vars, List placeholders) { + final groupVarsByIndex = >{}; + for (final variable in vars) { + groupVarsByIndex + .putIfAbsent(variable.resolvedIndex!, () => []) + .add(variable); + } + // sort each group by index + for (final group in groupVarsByIndex.values) { + group.sort((a, b) => a.resolvedIndex!.compareTo(b.resolvedIndex!)); + } + + late int Function(dynamic, dynamic) comparer; + comparer = (dynamic a, dynamic b) { + if (a is Variable && b is Variable) { + // variables are sorted by their index + return a.resolvedIndex!.compareTo(b.resolvedIndex!); + } else if (a is DartPlaceholder && b is DartPlaceholder) { + // placeholders by their position + return AnalysisContext.compareNodesByOrder(a, b); + } else { + // ok, one of them is a variable, the other one is a placeholder. Let's + // assume a is the variable. If not, we just switch results. + if (a is Variable) { + final placeholderB = b as DartPlaceholder; + final firstWithSameIndex = groupVarsByIndex[a.resolvedIndex]!.first; + + return firstWithSameIndex.firstPosition + .compareTo(placeholderB.firstPosition); + } else { + return -comparer(b, a); + } + } + }; + + final list = vars.cast().followedBy(placeholders).toList(); + return list..sort(comparer); + } + + /// We verify that no variable numbers are skipped in the query. For instance, + /// `SELECT * FROM tbl WHERE a = ?2 AND b = ?` would fail this check because + /// the index 1 is never used. + void _verifyNoSkippedIndexes(List foundElements) { + final variables = List.of(foundElements.whereType()) + ..sort((a, b) => a.index.compareTo(b.index)); + + var currentExpectedIndex = 1; + + for (var i = 0; i < variables.length; i++) { + final current = variables[i]; + if (current.index > currentExpectedIndex) { + throw StateError('This query skips some variable indexes: ' + 'We found no variable is at position $currentExpectedIndex, ' + 'even though a variable at index ${current.index} exists.'); + } + + if (i < variables.length - 1) { + final next = variables[i + 1]; + if (next.index > currentExpectedIndex) { + // if the next variable has a higher index, increment expected index + // by one because we expect that every index is present + currentExpectedIndex++; + } + } + } + } +} + +/// Finds variables, Dart placeholders and outgoing references from nested +/// queries (which are eventually turned into variables) inside a query. +/// +/// Nested children of this query are ignored, see `nested_queries.dart` for +/// details on nested queries and how they're implemented. +class _FindElements extends RecursiveVisitor { + final List variables = []; + final List dartPlaceholders = []; + + @override + void visitVariable(Variable e, NestedQueriesContainer? arg) { + variables.add(e); + super.visitVariable(e, arg); + } + + @override + void visitDriftSpecificNode( + DriftSpecificNode e, NestedQueriesContainer? arg) { + if (e is NestedQueryColumn) { + // If the node ist a nested query, return to avoid collecting elements + // inside of it + return; + } + + if (e is DartPlaceholder) { + dartPlaceholders.add(e); + } + + super.visitDriftSpecificNode(e, arg); + } + + @override + void visitReference(Reference e, NestedQueriesContainer? arg) { + if (arg is NestedQuery) { + final captured = arg.capturedVariables[e]; + if (captured != null) { + variables.add(captured.introducedVariable); + } + } + + super.visitReference(e, arg); + } +} diff --git a/drift_dev/lib/src/analysis/resolver/queries/required_variables.dart b/drift_dev/lib/src/analysis/resolver/queries/required_variables.dart new file mode 100644 index 00000000..fd99e6a2 --- /dev/null +++ b/drift_dev/lib/src/analysis/resolver/queries/required_variables.dart @@ -0,0 +1,9 @@ +class RequiredVariables { + final Set requiredNumberedVariables; + final Set requiredNamedVariables; + + const RequiredVariables( + this.requiredNumberedVariables, this.requiredNamedVariables); + + static const RequiredVariables empty = RequiredVariables({}, {}); +} diff --git a/drift_dev/lib/src/analysis/results/database.dart b/drift_dev/lib/src/analysis/results/database.dart index 93a68e70..eaf53d75 100644 --- a/drift_dev/lib/src/analysis/results/database.dart +++ b/drift_dev/lib/src/analysis/results/database.dart @@ -91,7 +91,8 @@ class DatabaseAccessor extends BaseDriftAccessor { /// analysis happens during code generation because intermediate state is hard /// to serialize and there are little benefits of analyzing queries early. @JsonSerializable() -class QueryOnAccessor { +class QueryOnAccessor implements DriftQueryDeclaration { + @override final String name; final String sql; diff --git a/drift_dev/lib/src/analysis/results/file_results.dart b/drift_dev/lib/src/analysis/results/file_results.dart new file mode 100644 index 00000000..0aff6cee --- /dev/null +++ b/drift_dev/lib/src/analysis/results/file_results.dart @@ -0,0 +1,18 @@ +import '../driver/error.dart'; +import '../driver/state.dart'; +import 'element.dart'; +import 'query.dart'; + +class FileAnalysisResult { + final List analysisErrors = []; + + final Map resolvedQueries = {}; + final Map resolvedDatabases = {}; +} + +class ResolvedDatabaseAccessor { + final Map definedQueries; + final List knownImports; + + ResolvedDatabaseAccessor(this.definedQueries, this.knownImports); +} diff --git a/drift_dev/lib/src/analysis/results/query.dart b/drift_dev/lib/src/analysis/results/query.dart index e31da482..5509df7a 100644 --- a/drift_dev/lib/src/analysis/results/query.dart +++ b/drift_dev/lib/src/analysis/results/query.dart @@ -1,4 +1,20 @@ +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart' show DriftSqlType, UpdateKind; +import 'package:recase/recase.dart'; +import 'package:sqlparser/sqlparser.dart'; + +import '../../analyzer/options.dart'; +import '../resolver/shared/column_name.dart'; +import 'column.dart'; import 'element.dart'; +import 'result_sets.dart'; +import 'table.dart'; +import 'types.dart'; +import 'view.dart'; + +abstract class DriftQueryDeclaration { + String get name; +} /// A named SQL query defined in a `.drift` file. A later compile step will /// further analyze this query and run analysis on it. @@ -11,10 +27,12 @@ import 'element.dart'; /// since they are local elements which can't be referenced by others, there's /// no clear advantage wrt. incremental compilation if queries are fully /// analyzed and serialized. So, we just do this in the generator. -class DefinedSqlQuery extends DriftElement { +class DefinedSqlQuery extends DriftElement implements DriftQueryDeclaration { /// The unmodified source of the declared SQL statement forming this query. final String sql; + final String? resultClassName; + /// The offset of [sql] in the source file, used to properly report errors /// later. final int sqlOffset; @@ -22,11 +40,823 @@ class DefinedSqlQuery extends DriftElement { @override final List references; + @override + String get name => id.name; + DefinedSqlQuery( super.id, super.declaration, { required this.references, required this.sql, required this.sqlOffset, + this.resultClassName, }); } + +/// A fully-resolved and analyzed SQL query. +abstract class SqlQuery { + final String name; + + AnalysisContext? get fromContext; + AstNode? get root; + + /// Whether this query was declared in a `.drift` file. + /// + /// At the moment, there is not much of a difference between drift-defined + /// queries and those defined on a database annotation. + /// However, with a legacy build option, additional `get` and `watch` method + /// are generated for Dart queries whereas drift-defined queries will only + /// generate a single method returning a `Selectable`. + bool declaredInDriftFile = false; + + String? get sql => fromContext?.sql; + + /// The result set of this statement, mapped to drift-generated classes. + /// + /// This is non-nullable for select queries. Updating queries might have a + /// result set if they have a `RETURNING` clause. + InferredResultSet? get resultSet; + + /// The variables that appear in the [sql] query. We support three kinds of + /// sql variables: The regular "?" variables, explicitly indexed "?xyz" + /// variables and colon-named variables. Even though this feature is not + /// supported by sqlite directly, we provide syntax sugar for expressions like + /// `column IN ?`, where the variable will have a [List] type at runtime and + /// expand to the appropriate tuple (e.g. `column IN (?, ?, ?)` when the + /// variable is bound to a list with three elements). To make the desugaring + /// easier at runtime, we require that: + /// + /// 1. Array arguments don't have an explicit index (` IN ?1` is + /// forbidden). The reason is that arrays get expanded to multiple + /// variables at runtime, so setting an explicit index doesn't make sense. + /// 2. We only allow explicitly-indexed variables to appear after an array + /// if their index is lower than that of the array (e.g `a = ?2 AND b IN ? + /// AND c IN ?1`. In other words, we can expand an array without worrying + /// about the variables that appear after that array. + late List variables; + + /// The placeholders in this query which are bound and converted to sql at + /// runtime. For instance, in `SELECT * FROM tbl WHERE $expr`, the `expr` is + /// going to be a [FoundDartPlaceholder] with the type + /// [ExpressionDartPlaceholderType] and [DriftSqlType.bool]. We will + /// generate a method which has a `Expression expr` parameter. + late List placeholders; + + /// Union of [variables] and [placeholders], but in the order in which they + /// appear inside the query. + final List elements; + + /// Whether the underlying sql statement of this query operates on more than + /// one table. In that case, column references in Dart placeholders have to + /// write their table name (e.g. `foo.bar` instead of just `bar`). + final bool hasMultipleTables; + + SqlQuery(this.name, this.elements, {bool? hasMultipleTables}) + : hasMultipleTables = hasMultipleTables ?? false { + variables = elements.whereType().toList(); + 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; + } + + 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) { + throw UnsupportedError('This result set does not introduce a class, ' + 'either because it has a matching table or because it only returns ' + 'one column.'); + } + + return resultSet.resultClassName ?? '${ReCase(name).pascalCase}Result'; + } + + /// Returns all found elements, from this query an all nested queries. The + /// elements returned by this method are in no particular order, thus they + /// can only be used to determine the method parameters. + /// + /// This method makes some effort to remove duplicated parameters. But only + /// by comparing the dart name. + List elementsWithNestedQueries() { + final elements = List.of(this.elements); + + final subQueries = resultSet?.nestedResults.whereType(); + for (final subQuery in subQueries ?? const []) { + for (final subElement in subQuery.query.elementsWithNestedQueries()) { + if (elements + .none((e) => e.dartParameterName == subElement.dartParameterName)) { + elements.add(subElement); + } + } + } + + return elements; + } +} + +class SqlSelectQuery extends SqlQuery { + final List readsFrom; + @override + final InferredResultSet resultSet; + @override + final AnalysisContext fromContext; + @override + final AstNode root; + + /// The name of the result class, as requested by the user. + // todo: Allow custom result classes for RETURNING as well? + final String? requestedResultClass; + + final NestedQueriesContainer? nestedContainer; + + /// Whether this query contains nested queries or not + bool get hasNestedQuery => + resultSet.nestedResults.any((e) => e is NestedResultQuery); + + @override + bool get needsAsyncMapping => hasNestedQuery || super.needsAsyncMapping; + + SqlSelectQuery( + String name, + this.fromContext, + this.root, + List elements, + this.readsFrom, + this.resultSet, + this.requestedResultClass, + this.nestedContainer, + ) : super(name, elements, hasMultipleTables: readsFrom.length > 1); + + Set get readsFromTables { + return { + for (final entity in readsFrom) + if (entity is DriftTable) + entity + else if (entity is DriftView) + ...entity.transitiveTableReferences, + }; + } + + /// Creates a copy of this [SqlSelectQuery] with a new [resultSet]. + /// + /// The copy won't have a [requestedResultClass]. + SqlSelectQuery replaceResultSet(InferredResultSet resultSet) { + return SqlSelectQuery( + name, + fromContext, + root, + elements, + readsFrom, + resultSet, + null, + nestedContainer, + ); + } +} + +/// Something that can contain nested queries. +/// +/// This contains the root select statement and all nested queries that appear +/// in a nested queries container. +class NestedQueriesContainer { + final SelectStatement select; + final Map nestedQueries = {}; + + NestedQueriesContainer(this.select); + + /// Columns that should be added to the [select] statement to read variables + /// captured by children. + /// + /// These columns aren't mounted to the same syntax tree as [select], they + /// will be mounted into the tree returned by [addHelperNodes]. + final List addedColumns = []; + + Iterable get variablesCapturedByChildren { + return nestedQueries.values + .expand((nested) => nested.capturedVariables.values); + } +} + +/// A nested query found in a SQL statement. +/// +/// See the `NestedQueryAnalyzer` for an overview on how nested queries work. +class NestedQuery extends NestedQueriesContainer { + final NestedQueryColumn queryColumn; + final NestedQueriesContainer parent; + + /// All references that read from a table only available in the outer + /// select statement. It will need to be transformed in a later step. + final Map capturedVariables = {}; + + NestedQuery(this.parent, this.queryColumn) : super(queryColumn.select); +} + +class CapturedVariable { + final Reference reference; + + /// A number uniquely identifying this captured variable in the select + /// statement analyzed. + /// + /// This is used to add the necessary helper column later. + final int queryGlobalId; + + /// The variable introduced to replace the original reference. + /// + /// This variable is not mounted to the same syntax tree as [reference], it + /// will be mounted into the tree returned by [addHelperNodes]. + final ColonNamedVariable introducedVariable; + + String get helperColumn => '\$n_$queryGlobalId'; + + CapturedVariable(this.reference, this.queryGlobalId) + : introducedVariable = ColonNamedVariable.synthetic(':r$queryGlobalId') { + introducedVariable.setMeta(this); + } +} + +class WrittenDriftTable { + final DriftTable table; + final UpdateKind kind; + + WrittenDriftTable(this.table, this.kind); +} + +class UpdatingQuery extends SqlQuery { + final List updates; + final bool isInsert; + @override + final InferredResultSet? resultSet; + @override + final AnalysisContext fromContext; + @override + final AstNode root; + + bool get isOnlyDelete => updates.every((w) => w.kind == UpdateKind.delete); + + bool get isOnlyUpdate => updates.every((w) => w.kind == UpdateKind.update); + + UpdatingQuery( + String name, + this.fromContext, + this.root, + List elements, + this.updates, { + this.isInsert = false, + bool? hasMultipleTables, + this.resultSet, + }) : super(name, elements, hasMultipleTables: hasMultipleTables); +} + +/// A special kind of query running multiple inner queries in a transaction. +class InTransactionQuery extends SqlQuery { + final List innerQueries; + + InTransactionQuery(this.innerQueries, String name) + : super(name, [for (final query in innerQueries) ...query.elements]); + + @override + InferredResultSet? get resultSet => null; + + @override + AnalysisContext? get fromContext => null; + + @override + AstNode? get root => null; +} + +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 MatchingDriftTable? matchingTable; + + /// Tables in the result set that should appear as a class. + /// + /// See [NestedResult] for further discussion and examples. + final List nestedResults; + Map? _expandedNestedPrefixes; + + final List columns; + final Map _dartNames = {}; + + /// The name of the Dart class generated to store this result set, or null if + /// it hasn't explicitly been set. + final String? resultClassName; + + /// Explicitly controls that no result class should be generated for this + /// result set. + /// + /// This is enabled on duplicate result sets caused by custom result class + /// names. + final bool dontGenerateResultClass; + + InferredResultSet( + this.matchingTable, + this.columns, { + this.nestedResults = const [], + this.resultClassName, + this.dontGenerateResultClass = false, + }); + + /// Whether a new class needs to be written to store the result of this query. + /// + /// We don't need to introduce result classes for queries which + /// - return an existing table model + /// - return exactly one column + /// + /// We always need to generate a class if the query contains nested results. + bool get needsOwnClass { + return matchingTable == null && + (columns.length > 1 || nestedResults.isNotEmpty) && + !dontGenerateResultClass; + } + + /// Whether this query returns a single column that should be returned + /// directly. + bool get singleColumn => + matchingTable == null && nestedResults.isEmpty && columns.length == 1; + + String? nestedPrefixFor(NestedResult table) { + if (_expandedNestedPrefixes == null) { + var index = 0; + _expandedNestedPrefixes = { + for (final nested in nestedResults) nested: 'nested_${index++}', + }; + } + + return _expandedNestedPrefixes![table]; + } + + void forceDartNames(Map names) { + _dartNames + ..clear() + ..addAll(names); + } + + /// Suggests an appropriate name that can be used as a dart field for the + /// [column]. + String dartNameFor(ResultColumn column) { + return _dartNames.putIfAbsent(column, () { + return dartNameForSqlColumn(column.name, + existingNames: _dartNames.values); + }); + } + + /// [hashCode] that matches [isCompatibleTo] instead of `==`. + int get compatibilityHashCode => Object.hash( + Object.hashAll(columns.map((e) => e.compatibilityHashCode)), + Object.hashAll(nestedResults.map((e) => e.compatibilityHashCode)), + ); + + /// Checks whether this and the [other] result set have the same columns and + /// nested result sets. + bool isCompatibleTo(InferredResultSet other) { + const columnsEquality = UnorderedIterableEquality(_ResultColumnEquality()); + const nestedEquality = UnorderedIterableEquality(_NestedResultEquality()); + + return columnsEquality.equals(columns, other.columns) && + nestedEquality.equals(nestedResults, other.nestedResults); + } +} + +/// Information about a matching table. A table matches a query if a query +/// selects all columns from that table, and nothing more. +/// +/// We still need to handle column aliases. +class MatchingDriftTable { + final DriftElementWithResultSet table; + final Map aliasToColumn; + + MatchingDriftTable(this.table, this.aliasToColumn); + + /// Whether the column alias can be ignored. + /// + /// This is the case if each result column name maps to a drift column with + /// the same SQL name. + bool get effectivelyNoAlias { + return !aliasToColumn.entries + .any((entry) => entry.key != entry.value.nameInSql); + } +} + +class ResultColumn implements HasType { + final String name; + @override + final DriftSqlType sqlType; + @override + final bool nullable; + + @override + final AppliedTypeConverter? typeConverter; + + /// The analyzed column from the `sqlparser` package. + final Column? sqlParserColumn; + + ResultColumn(this.name, this.sqlType, this.nullable, + {this.typeConverter, this.sqlParserColumn}); + + @override + bool get isArray => false; + + /// Hash-code that matching [compatibleTo], so that two compatible columns + /// will have the same [compatibilityHashCode]. + int get compatibilityHashCode { + return Object.hash(name, sqlType, nullable, typeConverter); + } + + /// Checks whether this column is compatible to the [other], meaning that they + /// have the same name and type. + bool compatibleTo(ResultColumn other) { + return other.name == name && + other.sqlType == sqlType && + other.nullable == nullable && + other.typeConverter == typeConverter; + } +} + +/// A nested result, could either be a NestedResultTable or a NestedQueryResult. +abstract class NestedResult { + /// [hashCode] that matches [isCompatibleTo] instead of `==`. + int get compatibilityHashCode; + + /// Checks whether this is compatible to the [other] nested result. + bool isCompatibleTo(NestedResult other); +} + +/// 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 extends NestedResult { + final bool isNullable; + final NestedStarResultColumn from; + final String name; + final DriftElementWithResultSet table; + + NestedResultTable(this.from, this.name, this.table, {this.isNullable = true}); + + String get dartFieldName => ReCase(name).camelCase; + + /// [hashCode] that matches [isCompatibleTo] instead of `==`. + @override + int get compatibilityHashCode { + return Object.hash(name, table); + } + + /// Checks whether this is compatible to the [other] nested result, which is + /// the case iff they have the same and read from the same table. + @override + bool isCompatibleTo(NestedResult other) { + if (other is! NestedResultTable) return false; + + return other.name == name && + other.table == table && + other.isNullable == isNullable; + } +} + +class NestedResultQuery extends NestedResult { + final NestedQueryColumn from; + + final SqlSelectQuery query; + + NestedResultQuery({ + required this.from, + required this.query, + }); + + String filedName() { + if (from.as != null) { + return from.as!; + } + + return ReCase(query.name).camelCase; + } + + // Because it is currently not possible to reuse result classes from queries + // that use nested queries, every instance should be different. Therefore + // the object hashCode and equality operator is just fine. + + @override + int get compatibilityHashCode => hashCode; + + @override + bool isCompatibleTo(NestedResult other) => this == other; +} + +/// Something in the query that needs special attention when generating code, +/// such as variables or Dart placeholders. +abstract class FoundElement { + String get dartParameterName; + + /// The name of this element as declared in the query + String? get name; + + bool get hasSqlName => name != null; + + /// If the element should be hidden from the parameter list + bool get hidden => false; +} + +/// A semantic interpretation of a [Variable] in a sql statement. +class FoundVariable extends FoundElement implements HasType { + /// The (unique) index of this variable in the sql query. For instance, the + /// query `SELECT * FROM tbl WHERE a = ? AND b = :xyz OR c = :xyz` contains + /// three [Variable]s in its AST, but only two [FoundVariable]s, where the + /// `?` will have index 1 and (both) `:xyz` variables will have index 2. We + /// only report one [FoundVariable] per index. + int index; + + /// The name of this variable, or null if it's not a named variable. + @override + String? name; + + /// The (inferred) type for this variable. + @override + final DriftSqlType sqlType; + + /// The type converter to apply before writing this value. + @override + final AppliedTypeConverter? typeConverter; + + @override + final bool nullable; + + /// The first [Variable] in the sql statement that has this [index]. + // todo: Do we really need to expose this? We only use [resolvedIndex], which + // should always be equal to [index]. + final Variable variable; + + /// 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. + @override + final bool isArray; + + final bool isRequired; + + @override + final bool hidden; + + /// When this variable is introduced for a nested query referencing something + /// from an outer query, contains the backing variable. + final CapturedVariable? forCaptured; + + FoundVariable({ + required this.index, + required this.name, + required this.sqlType, + required this.variable, + this.nullable = false, + this.isArray = false, + this.isRequired = false, + this.typeConverter, + }) : hidden = false, + forCaptured = null, + assert(variable.resolvedIndex == index); + + FoundVariable.nestedQuery({ + required this.index, + required this.name, + required this.sqlType, + required this.variable, + required this.forCaptured, + }) : typeConverter = null, + nullable = false, + isArray = false, + isRequired = true, + hidden = true; + + @override + String get dartParameterName { + if (name != null) { + return dartNameForSqlColumn(name!); + } else { + return 'var${variable.resolvedIndex}'; + } + } +} + +abstract class DartPlaceholderType {} + +enum SimpleDartPlaceholderKind { + limit, + orderByTerm, + orderBy, +} + +class SimpleDartPlaceholderType extends DartPlaceholderType { + final SimpleDartPlaceholderKind kind; + + SimpleDartPlaceholderType(this.kind); + + @override + int get hashCode => kind.hashCode; + + @override + bool operator ==(Object other) { + return other is SimpleDartPlaceholderType && other.kind == kind; + } +} + +class ExpressionDartPlaceholderType extends DartPlaceholderType { + /// The sql type of this expression. + final DriftSqlType? columnType; + final Expression? defaultValue; + + ExpressionDartPlaceholderType(this.columnType, this.defaultValue); + + @override + int get hashCode => Object.hash(columnType, defaultValue); + + @override + bool operator ==(Object other) { + return other is ExpressionDartPlaceholderType && + other.columnType == columnType && + other.defaultValue == defaultValue; + } +} + +class InsertableDartPlaceholderType extends DartPlaceholderType { + final DriftTable? table; + + InsertableDartPlaceholderType(this.table); + + @override + int get hashCode => table.hashCode; + + @override + bool operator ==(Object other) { + return other is InsertableDartPlaceholderType && other.table == table; + } +} + +/// A Dart placeholder that will be bound to a dynamically-generated SQL node +/// at runtime. +/// +/// Drift supports injecting expressions, order by terms and clauses and limit +/// clauses as placeholders. For insert statements, companions can be used +/// as a Dart placeholder too. +class FoundDartPlaceholder extends FoundElement { + final DartPlaceholderType type; + + /// All result sets that are available for this Dart placeholder. + /// + /// When queries are operating on multiple tables, especially if some of those + /// tables have aliases, it may be hard to reflect the name of those tables + /// at runtime. + /// For instance, consider this query: + /// + /// ```sql + /// myQuery: SELECT a.**, b.** FROM users a + /// INNER JOIN friends f ON f.a_id = a.id + /// INNER JOIN users b ON b.id = f.b_id + /// WHERE $expression; + /// ``` + /// + /// Here `$expression` is a Dart-defined expression evaluating to an sql + /// boolean. + /// Drift uses to add a `Expression` parameter to the generated query + /// method. Unfortunately, this puts the burden of picking the right table + /// name on the user. For instance, they may have to use + /// `alias('a', users).someColumn` to avoid getting an runtime exception. + /// With a new build option, drift instead generates a + /// `Expression Function(Users a, Users b, Friends f)` function as a + /// parameter. This allows users to access the right aliases right away, + /// reducing potential for misuse. + final List availableResultSets; + + @override + final String name; + DartPlaceholder? astNode; + + bool get hasDefault => + type is ExpressionDartPlaceholderType && + (type as ExpressionDartPlaceholderType).defaultValue != null; + + bool get hasDefaultOrImplicitFallback => + hasDefault || + (type is SimpleDartPlaceholderType && + (type as SimpleDartPlaceholderType).kind == + SimpleDartPlaceholderKind.orderBy); + + FoundDartPlaceholder(this.type, this.name, this.availableResultSets); + + @override + String get dartParameterName => name; + + @override + int get hashCode => Object.hashAll([type, name, ...availableResultSets]); + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is FoundDartPlaceholder && + other.type == type && + other.name == name && + const ListEquality() + .equals(other.availableResultSets, availableResultSets); + } + + /// Whether we should write this parameter as a function having available + /// result sets as parameters. + bool writeAsScopedFunction(DriftOptions options) { + return options.scopedDartComponents && + availableResultSets.isNotEmpty && + // Don't generate scoped functions for insertables, where the Dart type + // already defines which fields are available + type is! InsertableDartPlaceholderType; + } +} + +/// A table or view that is available in the position of a +/// [FoundDartPlaceholder]. +/// +/// For more information, see [FoundDartPlaceholder.availableResultSets]. +class AvailableDriftResultSet { + /// The (potentially aliased) name of this result set. + final String name; + + /// The table or view that is available. + final DriftElementWithResultSet entity; + + final ResultSetAvailableInStatement? source; + + AvailableDriftResultSet(this.name, this.entity, [this.source]); + + /// The argument type of this result set when used in a scoped function. + String get argumentType => entity.entityInfoName; + + @override + int get hashCode => Object.hash(name, entity); + + @override + bool operator ==(Object other) { + return other is AvailableDriftResultSet && + other.name == name && + other.entity == entity; + } +} + +class _ResultColumnEquality implements Equality { + const _ResultColumnEquality(); + + @override + bool equals(ResultColumn e1, ResultColumn e2) => e1.compatibleTo(e2); + + @override + int hash(ResultColumn e) => e.compatibilityHashCode; + + @override + bool isValidKey(Object? e) => e is ResultColumn; +} + +class _NestedResultEquality implements Equality { + const _NestedResultEquality(); + + @override + bool equals(NestedResult e1, NestedResult e2) { + return e1.isCompatibleTo(e2); + } + + @override + int hash(NestedResult e) => e.compatibilityHashCode; + + @override + bool isValidKey(Object? e) => e is NestedResultTable; +} diff --git a/drift_dev/lib/src/analysis/results/trigger.dart b/drift_dev/lib/src/analysis/results/trigger.dart index 9e5d3e2f..63120242 100644 --- a/drift_dev/lib/src/analysis/results/trigger.dart +++ b/drift_dev/lib/src/analysis/results/trigger.dart @@ -1,7 +1,5 @@ -import 'package:drift/drift.dart'; - import 'element.dart'; -import 'table.dart'; +import 'query.dart'; class DriftTrigger extends DriftElement { @override @@ -11,7 +9,7 @@ class DriftTrigger extends DriftElement { final String createStmt; /// Writes performed in the body of this trigger. - final List writes; + final List writes; DriftTrigger( super.id, @@ -21,16 +19,3 @@ class DriftTrigger extends DriftElement { required this.writes, }); } - -/// Information about a write performed by an `INSERT`, `UPDATE` or `DELETE` -/// statement inside a [DriftTrigger] on another [table]. -/// -/// This information is used to properly invalidate stream queries at runtime, -/// as triggers can cause changes to additional tables after a direct write to -/// another table. -class TriggerTableWrite { - final DriftTable table; - final UpdateKind kind; - - TriggerTableWrite(this.table, this.kind); -} diff --git a/drift_dev/lib/src/analysis/results/view.dart b/drift_dev/lib/src/analysis/results/view.dart index 7ac377a4..0bbeb553 100644 --- a/drift_dev/lib/src/analysis/results/view.dart +++ b/drift_dev/lib/src/analysis/results/view.dart @@ -4,6 +4,7 @@ import 'package:drift_dev/src/analysis/results/column.dart'; import 'element.dart'; import 'result_sets.dart'; +import 'table.dart'; class DriftView extends DriftElementWithResultSet { @override @@ -37,6 +38,21 @@ class DriftView extends DriftElementWithResultSet { required this.nameOfRowClass, required this.references, }); + + /// Obtains all tables transitively referenced by the declaration of this + /// view. + /// + /// This includes all tables in [references]. If this view references other + /// views, their [transitiveTableReferences] will be included as well. + Set get transitiveTableReferences { + return { + for (final reference in references) + if (reference is DriftTable) + reference + else if (reference is DriftView) + ...reference.transitiveTableReferences, + }; + } } abstract class DriftViewSource {} diff --git a/drift_dev/lib/src/analysis/serializer.dart b/drift_dev/lib/src/analysis/serializer.dart index d0941144..af1bff69 100644 --- a/drift_dev/lib/src/analysis/serializer.dart +++ b/drift_dev/lib/src/analysis/serializer.dart @@ -51,6 +51,7 @@ class ElementSerializer { 'type': 'query', 'sql': element.sql, 'offset': element.sqlOffset, + 'result_class': element.resultClassName, }; } else if (element is DriftTrigger) { additionalInformation = { @@ -404,6 +405,7 @@ class ElementDeserializer { references: references, sql: json['sql'] as String, sqlOffset: json['offset'] as int, + resultClassName: json['result_class'] as String?, ); case 'trigger': return DriftTrigger( @@ -413,7 +415,7 @@ class ElementDeserializer { createStmt: json['sql'] as String, writes: [ for (final write in json['writes']) - TriggerTableWrite( + WrittenDriftTable( await _readElementReference(write['table'] as Map) as DriftTable, UpdateKind.values.byName(write['kind'] as String),