drift/drift_dev/lib/src/analysis/resolver/dart/column.dart

601 lines
20 KiB
Dart

import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show DriftSqlType;
import 'package:sqlparser/sqlparser.dart' show ReferenceAction;
import 'package:sqlparser/sqlparser.dart' as sql;
import '../../driver/error.dart';
import '../../results/results.dart';
import '../resolver.dart';
import '../shared/dart_types.dart';
import 'helper.dart';
import 'table.dart';
const String _startInt = 'integer';
const String _startInt64 = 'int64';
const String _startIntEnum = 'intEnum';
const String _startTextEnum = 'textEnum';
const String _startString = 'text';
const String _startBool = 'boolean';
const String _startDateTime = 'dateTime';
const String _startBlob = 'blob';
const String _startReal = 'real';
const String _startCustom = 'customType';
const Set<String> _starters = {
_startInt,
_startInt64,
_startIntEnum,
_startTextEnum,
_startString,
_startBool,
_startDateTime,
_startBlob,
_startReal,
_startCustom,
};
const String _methodNamed = 'named';
const String _methodReferences = 'references';
const String _methodAutoIncrement = 'autoIncrement';
const String _methodWithLength = 'withLength';
const String _methodNullable = 'nullable';
const String _methodUnique = 'unique';
const String _methodCustomConstraint = 'customConstraint';
const String _methodDefault = 'withDefault';
const String _methodClientDefault = 'clientDefault';
const String _methodMap = 'map';
const String _methodGenerated = 'generatedAs';
const String _methodCheck = 'check';
const Set<String> _addsSqlConstraint = {
_methodReferences,
_methodAutoIncrement,
_methodUnique,
_methodDefault,
_methodGenerated,
_methodCheck,
};
const String _errorMessage = 'This getter does not create a valid column that '
'can be parsed by drift. Please refer to the readme from drift to see how '
'columns are formed. If you have any questions, feel free to raise an '
'issue.';
/// Parses a single column defined in a Dart table. These columns are a chain
/// or [MethodInvocation]s. An example getter might look like this:
/// ```dart
/// IntColumn get id => integer().autoIncrement()();
/// ```
/// The last call `()` is a [FunctionExpressionInvocation], the entries for
/// before that (in this case `autoIncrement()` and `integer()` are a)
/// [MethodInvocation]. We work our way through that syntax until we hit a
/// method that starts the chain (contained in [starters]). By visiting all
/// the invocations on our way, we can extract the constraint for the column
/// (e.g. its name, whether it has auto increment, is a primary key and so on).
class ColumnParser {
final DartTableResolver _resolver;
ColumnParser(this._resolver);
Future<PendingColumnInformation?> parse(
MethodDeclaration getter, Element element) async {
final expr = returnExpressionOfMethod(getter);
if (expr is! FunctionExpressionInvocation) {
_resolver.reportError(
DriftAnalysisError.forDartElement(element, _errorMessage));
return null;
}
var remainingExpr = expr.function as MethodInvocation;
String? foundStartMethod;
String? foundExplicitName;
String? foundCustomConstraint;
Expression? customConstraintSource;
AnnotatedDartCode? foundDefaultExpression;
AnnotatedDartCode? clientDefaultExpression;
Expression? mappedAs;
String? referencesColumnInSameTable;
var nullable = false;
var hasDefaultConstraints = false;
final foundConstraints = <DriftColumnConstraint>[];
while (true) {
final methodName = remainingExpr.methodName.name;
if (_starters.contains(methodName)) {
foundStartMethod = methodName;
break;
}
if (_addsSqlConstraint.contains(methodName)) {
hasDefaultConstraints = true;
}
switch (methodName) {
case _methodNamed:
if (foundExplicitName != null) {
_resolver.reportError(
DriftAnalysisError.forDartElement(
element,
"You're setting more than one name here, the first will "
'be used',
),
);
}
foundExplicitName =
readStringLiteral(remainingExpr.argumentList.arguments.first);
if (foundExplicitName == null) {
_resolver.reportError(DriftAnalysisError.inDartAst(
element,
remainingExpr.argumentList,
'This table name is cannot be resolved! Please only use '
'a constant string as parameter for .named().'));
}
break;
case _methodReferences:
final args = remainingExpr.argumentList.arguments;
final first = args.first;
if (first is! Identifier) {
_resolver.reportError(DriftAnalysisError.inDartAst(
element,
first,
'This parameter should be a simple class name',
));
break;
}
final staticElement = first.staticElement;
if (staticElement is! ClassElement) {
_resolver.reportError(DriftAnalysisError.inDartAst(
element,
first,
'`${first.name}` is not a class!',
));
break;
}
final columnNameNode = args[1];
if (columnNameNode is! SymbolLiteral) {
_resolver.reportError(DriftAnalysisError.inDartAst(
element,
columnNameNode,
'This should be a symbol literal (`#columnName`)',
));
break;
}
final columnName =
columnNameNode.components.map((token) => token.lexeme).join('.');
ReferenceAction? onUpdate, onDelete;
ReferenceAction? parseAction(Expression expr) {
if (expr is! PrefixedIdentifier) {
_resolver.reportError(DriftAnalysisError.inDartAst(element, expr,
'Should be a direct enum reference (`KeyAction.cascade`)'));
return null;
}
final name = expr.identifier.name;
switch (name) {
case 'setNull':
return ReferenceAction.setNull;
case 'setDefault':
return ReferenceAction.setDefault;
case 'cascade':
return ReferenceAction.cascade;
case 'restrict':
return ReferenceAction.restrict;
case 'noAction':
default:
return ReferenceAction.noAction;
}
}
for (final expr in args) {
if (expr is! NamedExpression) continue;
final name = expr.name.label.name;
final value = expr.expression;
if (name == 'onUpdate') {
onUpdate = parseAction(value);
} else if (name == 'onDelete') {
onDelete = parseAction(value);
}
}
final referencedTable = await _resolver.resolver
.resolveDartReference(_resolver.discovered.ownId, staticElement);
if (referencedTable is ReferencesItself) {
// "Foreign" key to a column in the same table.
foundConstraints
.add(ForeignKeyReference.unresolved(onUpdate, onDelete));
referencesColumnInSameTable = columnName;
} else if (referencedTable is ResolvedReferenceFound) {
final driftElement = referencedTable.element;
if (driftElement is DriftTable) {
final column = driftElement.columns.firstWhereOrNull(
(element) => element.nameInDart == columnName);
if (column == null) {
_resolver.reportError(DriftAnalysisError.inDartAst(
element,
columnNameNode,
'The referenced table `${driftElement.schemaName}` has no '
'column named `$columnName` in Dart.',
));
} else {
foundConstraints
.add(ForeignKeyReference(column, onUpdate, onDelete));
}
} else {
_resolver.reportError(
DriftAnalysisError.inDartAst(element, first, 'Not a table'));
}
} else {
// Could not resolve foreign table, emit warning
_resolver.reportErrorForUnresolvedReference(referencedTable,
(msg) => DriftAnalysisError.inDartAst(element, first, msg));
}
break;
case _methodWithLength:
final args = remainingExpr.argumentList;
final minArg = findNamedArgument(args, 'min');
final maxArg = findNamedArgument(args, 'max');
foundConstraints.add(LimitingTextLength(
minLength: minArg != null ? readIntLiteral(minArg) : null,
maxLength: maxArg != null ? readIntLiteral(maxArg) : null,
));
break;
case _methodAutoIncrement:
foundConstraints.add(PrimaryKeyColumn(true));
break;
case _methodNullable:
nullable = true;
break;
case _methodUnique:
foundConstraints.add(const UniqueColumn());
break;
case _methodCustomConstraint:
if (foundCustomConstraint != null) {
_resolver.reportError(
DriftAnalysisError.inDartAst(
element,
remainingExpr.methodName,
"You've already set custom constraints on this column, "
'they will be overriden by this call.',
),
);
}
final stringLiteral = customConstraintSource =
remainingExpr.argumentList.arguments.first;
foundCustomConstraint = readStringLiteral(stringLiteral);
if (foundCustomConstraint == null) {
_resolver.reportError(DriftAnalysisError.forDartElement(
element,
'This constraint is cannot be resolved! Please only use '
'a constant string as parameter for .customConstraint().',
));
}
break;
case _methodDefault:
final args = remainingExpr.argumentList;
final expression = args.arguments.single;
foundDefaultExpression = AnnotatedDartCode.ast(expression);
break;
case _methodClientDefault:
clientDefaultExpression = AnnotatedDartCode.ast(
remainingExpr.argumentList.arguments.single);
break;
case _methodMap:
final args = remainingExpr.argumentList;
mappedAs = args.arguments.single;
break;
case _methodGenerated:
Expression? generatedExpression;
var stored = false;
for (final expr in remainingExpr.argumentList.arguments) {
if (expr is NamedExpression && expr.name.label.name == 'stored') {
final storedValue = expr.expression;
if (storedValue is BooleanLiteral) {
stored = storedValue.value;
} else {
_resolver.reportError(DriftAnalysisError.inDartAst(
element, expr, 'Must be a boolean literal'));
}
} else {
generatedExpression = expr;
}
}
if (generatedExpression != null) {
final code = AnnotatedDartCode.ast(generatedExpression);
foundConstraints.add(ColumnGeneratedAs(code, stored));
}
break;
case _methodCheck:
final expr = remainingExpr.argumentList.arguments.first;
foundConstraints
.add(DartCheckExpression(AnnotatedDartCode.ast(expr)));
}
// We're not at a starting method yet, so we need to go deeper!
final inner = remainingExpr.target as MethodInvocation;
remainingExpr = inner;
}
final sqlName = foundExplicitName ??
_resolver.resolver.driver.options.caseFromDartToSql
.apply(getter.name.lexeme);
ColumnType columnType;
final helper = _resolver.resolver.driver.knownTypes;
if (foundStartMethod == _startCustom) {
final expression = remainingExpr.argumentList.arguments.single;
final custom = readCustomType(
element.library!,
expression,
helper,
(message) => _resolver.reportError(
DriftAnalysisError.inDartAst(element, mappedAs!, message),
),
);
columnType = custom != null
? ColumnType.custom(custom)
// Fallback if we fail to read the custom type - we'll also emit an
// error int that case.
: ColumnType.drift(DriftSqlType.any);
} else {
columnType =
ColumnType.drift(_startMethodToBuiltinColumnType(foundStartMethod));
}
AppliedTypeConverter? converter;
if (mappedAs != null) {
converter = readTypeConverter(
element.library!,
mappedAs,
columnType,
nullable,
(message) => _resolver.reportError(
DriftAnalysisError.inDartAst(element, mappedAs!, message)),
helper,
);
}
if (foundStartMethod == _startIntEnum) {
if (converter != null) {
_resolver.reportError(DriftAnalysisError.forDartElement(
element,
'Using $_startIntEnum will apply a custom converter by default, '
"so you can't add an additional converter",
));
}
final enumType = remainingExpr.typeArgumentTypes!.first;
converter = readEnumConverter(
(msg) => _resolver.reportError(DriftAnalysisError.inDartAst(element,
remainingExpr.typeArguments ?? remainingExpr.methodName, msg)),
enumType,
EnumType.intEnum,
helper,
);
} else if (foundStartMethod == _startTextEnum) {
if (converter != null) {
_resolver.reportError(DriftAnalysisError.forDartElement(
element,
'Using $_startTextEnum will apply a custom converter by default, '
"so you can't add an additional converter",
));
}
final enumType = remainingExpr.typeArgumentTypes!.first;
converter = readEnumConverter(
(msg) => _resolver.reportError(DriftAnalysisError.inDartAst(element,
remainingExpr.typeArguments ?? remainingExpr.methodName, msg)),
enumType,
EnumType.textEnum,
helper,
);
}
if (foundDefaultExpression != null && clientDefaultExpression != null) {
_resolver.reportError(
DriftAnalysisError.forDartElement(
element,
'clientDefault() and withDefault() are mutually exclusive, '
"they can't both be used. Use clientDefault() for values that "
'are different for each row and withDefault() otherwise.',
),
);
}
if (foundConstraints.contains(const UniqueColumn()) &&
foundConstraints.any((e) => e is PrimaryKeyColumn)) {
_resolver.reportError(
DriftAnalysisError.forDartElement(
element,
'Primary key column cannot have UNIQUE constraint',
),
);
}
if (hasDefaultConstraints && foundCustomConstraint != null) {
_resolver.reportError(
DriftAnalysisError.forDartElement(
element,
'This column definition is using both drift-defined '
'constraints (like references, autoIncrement, ...) and a '
'customConstraint(). Only the custom constraint will be added '
'to the column in SQL!',
),
);
}
final docString =
getter.documentationComment?.tokens.map((t) => t.toString()).join('\n');
foundConstraints.addAll(await _driftConstraintsFromCustomConstraints(
isNullable: nullable,
customConstraints: foundCustomConstraint,
sourceForCustomConstraints: customConstraintSource,
));
return PendingColumnInformation(
DriftColumn(
sqlType: columnType,
nullable: nullable,
nameInSql: sqlName,
nameInDart: element.name!,
declaration: DriftDeclaration.dartElement(element),
typeConverter: converter,
clientDefaultCode: clientDefaultExpression,
defaultArgument: foundDefaultExpression,
overriddenJsonName: _readJsonKey(element),
documentationComment: docString,
constraints: foundConstraints,
customConstraints: foundCustomConstraint,
),
referencesColumnInSameTable: referencesColumnInSameTable,
);
}
DriftSqlType _startMethodToBuiltinColumnType(String name) {
return const {
_startBool: DriftSqlType.bool,
_startString: DriftSqlType.string,
_startInt: DriftSqlType.int,
_startInt64: DriftSqlType.bigInt,
_startIntEnum: DriftSqlType.int,
_startTextEnum: DriftSqlType.string,
_startDateTime: DriftSqlType.dateTime,
_startBlob: DriftSqlType.blob,
_startReal: DriftSqlType.double,
}[name]!;
}
String? _readJsonKey(Element getter) {
final annotations = getter.metadata;
final object = annotations.firstWhereOrNull((e) {
final value = e.computeConstantValue();
final valueType = value?.type;
return valueType is InterfaceType &&
isFromDrift(valueType) &&
valueType.element.name == 'JsonKey';
});
if (object == null) return null;
return object.computeConstantValue()!.getField('key')!.toStringValue();
}
Future<List<DriftColumnConstraint>> _driftConstraintsFromCustomConstraints({
required bool isNullable,
String? customConstraints,
AstNode? sourceForCustomConstraints,
}) async {
if (customConstraints == null) return const [];
final engine = _resolver.resolver.driver.newSqlEngine();
final parseResult = engine.parseColumnConstraints(customConstraints);
final constraints =
(parseResult.rootNode as sql.ColumnDefinition).constraints;
for (final error in parseResult.errors) {
_resolver.reportError(DriftAnalysisError(error.token.span,
'Parse error in customConstraint(): ${error.message}'));
}
// Constraints override all constraints that drift will add. So if the
// column is non-nullable, there should be a `NON NULL` constraint.
if (!isNullable && !constraints.any((e) => e is sql.NotNull)) {
_resolver.reportError(DriftAnalysisError.inDartAst(
_resolver.discovered.dartElement,
sourceForCustomConstraints!,
"This column is not declared to be `.nullable()`, but also doesn't "
'have `NOT NULL` in its custom constraints. Please explicitly declare '
'the column to be nullable in Dart, or add a `NOT NULL` constraint for '
'consistency.',
));
}
final parsedConstraints = <DriftColumnConstraint>[];
for (final constraint in constraints) {
if (constraint is sql.GeneratedAs) {
parsedConstraints.add(ColumnGeneratedAs.fromParser(constraint));
} else if (constraint is sql.PrimaryKeyColumn) {
parsedConstraints.add(PrimaryKeyColumn(constraint.autoIncrement));
} else if (constraint is sql.UniqueColumn) {
parsedConstraints.add(UniqueColumn());
} else if (constraint is sql.ForeignKeyColumnConstraint) {
final clause = constraint.clause;
final table =
await _resolver.resolveSqlReferenceOrReportError<DriftTable>(
clause.foreignTable.tableName,
(msg) => DriftAnalysisError.inDartAst(
_resolver.discovered.dartElement,
sourceForCustomConstraints!,
msg,
),
);
if (table != null) {
final columnName = clause.columnNames.first;
final column =
table.columnBySqlName[clause.columnNames.first.columnName];
if (column == null) {
_resolver.reportError(DriftAnalysisError.inDartAst(
_resolver.discovered.dartElement,
sourceForCustomConstraints!,
'The referenced table has no column named `$columnName`',
));
} else {
parsedConstraints.add(ForeignKeyReference(
column,
constraint.clause.onUpdate,
constraint.clause.onDelete,
));
}
}
}
}
return parsedConstraints;
}
}
class PendingColumnInformation {
final DriftColumn column;
/// If the returned column references another column in the same table, its
/// [ForeignKeyReference] is still unresolved when the local column resolver
/// returns.
///
/// It is the responsibility of the table resolver to patch the reference for
/// this column in that case.
final String? referencesColumnInSameTable;
PendingColumnInformation(this.column, {this.referencesColumnInSameTable});
}