Analyze Dart tables with new analyzer

This commit is contained in:
Simon Binder 2022-09-10 16:34:17 +02:00
parent e88ec41555
commit 0f97b42a43
No known key found for this signature in database
GPG Key ID: 7891917E4147B8C0
14 changed files with 1035 additions and 44 deletions

View File

@ -9,6 +9,10 @@ abstract class DriftBackend {
Future<String> readAsString(Uri uri);
Future<Uri> uriOfDart(Element element) async {
return element.source!.uri;
}
Future<LibraryElement> readDart(Uri uri);
Future<AstNode?> loadElementDeclaration(Element element);
}

View File

@ -0,0 +1,466 @@
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:recase/recase.dart';
import 'package:sqlparser/sqlparser.dart' show ReferenceAction;
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 _startEnum = 'intEnum';
const String _startString = 'text';
const String _startBool = 'boolean';
const String _startDateTime = 'dateTime';
const String _startBlob = 'blob';
const String _startReal = 'real';
const Set<String> _starters = {
_startInt,
_startInt64,
_startEnum,
_startString,
_startBool,
_startDateTime,
_startBlob,
_startReal,
};
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;
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,
first,
'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.',
),
);
}
foundCustomConstraint =
readStringLiteral(remainingExpr.argumentList.arguments.first);
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 ?? ReCase(getter.name2.lexeme).snakeCase;
final sqlType = _startMethodToColumnType(foundStartMethod);
AppliedTypeConverter? converter;
if (mappedAs != null) {
converter = readTypeConverter(
element.library!,
mappedAs,
sqlType,
nullable,
(message) => _resolver.reportError(
DriftAnalysisError.inDartAst(element, mappedAs!, message)),
await _resolver.resolver.driver.loadKnownTypes(),
);
}
if (foundStartMethod == _startEnum) {
if (converter != null) {
_resolver.reportError(DriftAnalysisError.forDartElement(
element,
'Using $_startEnum will apply a custom converter by default, '
"so you can't add an additional converter",
));
}
final enumType = remainingExpr.typeArgumentTypes![0];
converter = readEnumConverter(
(msg) => DriftAnalysisError.inDartAst(element,
remainingExpr.typeArguments ?? remainingExpr.methodName, msg),
enumType,
);
}
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');
return PendingColumnInformation(
DriftColumn(
sqlType: sqlType,
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 _startMethodToColumnType(String name) {
return const {
_startBool: DriftSqlType.bool,
_startString: DriftSqlType.string,
_startInt: DriftSqlType.int,
_startInt64: DriftSqlType.bigInt,
_startEnum: DriftSqlType.int,
_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.element2.name == 'JsonKey';
});
if (object == null) return null;
return object.computeConstantValue()!.getField('key')!.toStringValue();
}
}
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});
}

View File

@ -2,6 +2,7 @@ import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/nullability_suffix.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:collection/collection.dart';
import '../../driver/driver.dart';
@ -75,6 +76,33 @@ Expression? returnExpressionOfMethod(MethodDeclaration method,
return body.expression;
}
String? readStringLiteral(Expression expression) {
if (expression is StringLiteral) {
final value = expression.stringValue;
if (value != null) {
return value;
}
}
return null;
}
int? readIntLiteral(Expression expression) {
if (expression is IntegerLiteral) {
return expression.value;
} else {
return null;
}
}
Expression? findNamedArgument(ArgumentList args, String argName) {
final argument = args.arguments.singleWhereOrNull(
(e) => e is NamedExpression && e.name.label.name == argName,
) as NamedExpression?;
return argument?.expression;
}
bool isColumn(DartType type) {
final name = type.nameIfInterfaceType;

View File

@ -10,6 +10,7 @@ import '../intermediate_state.dart';
import '../resolver.dart';
import '../shared/dart_types.dart';
import '../shared/data_class.dart';
import 'column.dart';
import 'helper.dart';
class DartTableResolver extends LocalElementResolver<DiscoveredDartTable> {
@ -19,7 +20,8 @@ class DartTableResolver extends LocalElementResolver<DiscoveredDartTable> {
Future<DriftElement> resolve() async {
final element = discovered.element;
final columns = (await _parseColumns(element)).toList();
final pendingColumns = (await _parseColumns(element)).toList();
final columns = [for (final column in pendingColumns) column.column];
final primaryKey = await _readPrimaryKey(element, columns);
final uniqueKeys = await _readUniqueKeys(element, columns);
@ -29,7 +31,7 @@ class DartTableResolver extends LocalElementResolver<DiscoveredDartTable> {
discovered.ownId,
DriftDeclaration.dartElement(element),
columns: columns,
dartTypeName: dataClassInfo.enforcedName,
nameOfRowClass: dataClassInfo.enforcedName,
existingRowClass: dataClassInfo.existingClass,
customParentClass: dataClassInfo.extending,
baseDartName: element.name,
@ -38,6 +40,18 @@ class DartTableResolver extends LocalElementResolver<DiscoveredDartTable> {
withoutRowId: await _overrideWithoutRowId(element) ?? false,
);
// Resolve local foreign key references in pending columns
for (final column in pendingColumns) {
if (column.referencesColumnInSameTable != null) {
final ref =
column.column.constraints.whereType<ForeignKeyReference>().first;
final referencedColumn = columns.firstWhere(
(e) => e.nameInDart == column.referencesColumnInSameTable);
ref.otherColumn = referencedColumn;
}
}
if (primaryKey != null &&
columns.any((c) => c.constraints.any((e) => e is PrimaryKeyColumn))) {
reportError(DriftAnalysisError.forDartElement(
@ -271,7 +285,8 @@ class DartTableResolver extends LocalElementResolver<DiscoveredDartTable> {
return null;
}
Future<Iterable<DriftColumn>> _parseColumns(ClassElement element) async {
Future<Iterable<PendingColumnInformation>> _parseColumns(
ClassElement element) async {
final columnNames = element.allSupertypes
.map((t) => t.element2)
.followedBy([element])
@ -290,8 +305,8 @@ class DartTableResolver extends LocalElementResolver<DiscoveredDartTable> {
});
final results = await Future.wait(fields.map((field) async {
final node = await resolver.driver.backend.loadElementDeclaration(element)
as MethodDeclaration;
final node = await resolver.driver.backend
.loadElementDeclaration(field.getter!) as MethodDeclaration;
return await _parseColumn(node, field.getter!);
}));
@ -299,8 +314,10 @@ class DartTableResolver extends LocalElementResolver<DiscoveredDartTable> {
return results.whereType();
}
Future<DriftColumn> _parseColumn(
MethodDeclaration declaration, Element element) async {}
Future<PendingColumnInformation?> _parseColumn(
MethodDeclaration declaration, Element element) async {
return ColumnParser(this).parse(declaration, element);
}
}
class _DataClassInformation {

View File

@ -51,9 +51,7 @@ class DriftTableResolver extends LocalElementResolver<DiscoveredDriftTable> {
// Note: Warnings about whether the referenced column exists or not
// are reported later, we just need to know dependencies before the
// lint step of the analysis.
final referenced =
await resolver.resolveReferenceOrReportError<DriftTable>(
this,
final referenced = await resolveSqlReferenceOrReportError<DriftTable>(
constraint.clause.foreignTable.tableName,
(msg) => DriftAnalysisError.inDriftFile(
constraint.clause.foreignTable.tableNameToken ?? constraint,
@ -140,9 +138,10 @@ class DriftTableResolver extends LocalElementResolver<DiscoveredDriftTable> {
),
columns: columns,
references: references.toList(),
dartTypeName: dataClassName,
nameOfRowClass: dataClassName,
baseDartName: dartTableName,
existingRowClass: existingRowClass,
withoutRowId: table.withoutRowId,
strict: table.isStrict,
);
}

View File

@ -1,3 +1,5 @@
import 'package:analyzer/dart/element/element.dart';
import '../driver/driver.dart';
import '../driver/error.dart';
import '../driver/state.dart';
@ -90,6 +92,14 @@ class DriftResolver {
'Unknown pending element $reference, this is a bug in drift_dev');
}
Future<ResolveReferencedElementResult> resolveDartReference(
DriftElementId owner, Element element) async {
final uri = await driver.backend.uriOfDart(element.library!);
final id = DriftElementId(uri, element.name!);
return resolveReferencedElement(owner, id);
}
Future<ResolveReferencedElementResult> resolveReference(
DriftElementId owner, String reference) async {
final candidates = <DriftElementId>[];
@ -125,29 +135,6 @@ class DriftResolver {
return resolveReferencedElement(owner, candidates.single);
}
Future<T?> resolveReferenceOrReportError<T extends DriftElement>(
LocalElementResolver owner,
String reference,
DriftAnalysisError Function(String msg) createError,
) async {
final result = await resolveReference(owner.discovered.ownId, reference);
if (result is ResolvedReferenceFound) {
final element = result.element;
if (element is T) {
return element;
} else {
// todo: Better type description in error message
owner.state.errorsDuringAnalysis.add(
createError('Expected a $T, but got a ${element.runtimeType}'));
}
} else if (result is InvalidReferenceResult) {
owner.state.errorsDuringAnalysis.add(createError(result.message));
}
return null;
}
}
abstract class LocalElementResolver<T extends DiscoveredElement> {
@ -162,6 +149,38 @@ abstract class LocalElementResolver<T extends DiscoveredElement> {
state.errorsDuringAnalysis.add(error);
}
Future<E?> resolveSqlReferenceOrReportError<E extends DriftElement>(
String reference,
DriftAnalysisError Function(String msg) createError,
) async {
final result = await resolver.resolveReference(discovered.ownId, reference);
if (result is ResolvedReferenceFound) {
final element = result.element;
if (element is E) {
return element;
} else {
// todo: Better type description in error message
reportError(
createError('Expected a $T, but got a ${element.runtimeType}'));
}
} else {
reportErrorForUnresolvedReference(result, createError);
}
return null;
}
void reportErrorForUnresolvedReference(ResolveReferencedElementResult result,
DriftAnalysisError Function(String msg) createError) {
if (result is InvalidReferenceResult) {
reportError(createError(result.message));
} else if (result is ReferencedElementCouldNotBeResolved) {
reportError(createError(
'The referenced element could not be analyzed due to a bug in drift.'));
}
}
Future<DriftElement> resolve();
}

View File

@ -220,6 +220,40 @@ AppliedTypeConverter? readTypeConverter(
);
}
AppliedTypeConverter readEnumConverter(
void Function(String) reportError,
DartType enumType,
) {
if (enumType is! InterfaceType) {
reportError('Not a class: `$enumType`');
}
final creatingClass = enumType.element2;
if (creatingClass is! EnumElement) {
reportError('Not an enum: `${creatingClass!.displayName}`');
}
// `const EnumIndexConverter<EnumType>(EnumType.values)`
final expression = AnnotatedDartCode.build((builder) {
builder
..addText('const ')
..addSymbol('EnumIndexConverter', AnnotatedDartCode.drift)
..addText('<')
..addDartType(enumType)
..addText('>(')
..addDartType(enumType)
..addText('.values)');
});
return AppliedTypeConverter(
expression: expression,
dartType: enumType,
sqlType: DriftSqlType.int,
dartTypeIsNullable: false,
sqlTypeIsNullable: false,
);
}
void _checkParameterType(ParameterElement element, DriftColumn column,
LocalElementResolver resolver) {
final type = element.type;

View File

@ -79,6 +79,9 @@ class DriftColumn implements HasType {
this.customConstraints,
});
/// Whether this column has a `GENERATED AS` column constraint.
bool get isGenerated => constraints.any((e) => e is ColumnGeneratedAs);
/// Whether this column was declared inside a `.drift` file.
bool get declaredInDriftFile => declaration.isDriftDeclaration;
@ -167,15 +170,50 @@ class PrimaryKeyColumn extends DriftColumnConstraint {
}
class ForeignKeyReference extends DriftColumnConstraint {
final DriftColumn otherColumn;
late final DriftColumn otherColumn;
final ReferenceAction? onUpdate;
final ReferenceAction? onDelete;
ForeignKeyReference(this.otherColumn, this.onUpdate, this.onDelete);
ForeignKeyReference.unresolved(this.onUpdate, this.onDelete);
@override
String toString() {
return 'ForeignKeyReference(to $otherColumn, onUpdate = $onUpdate, '
'onDelete = $onDelete)';
}
}
class ColumnGeneratedAs extends DriftColumnConstraint {
final AnnotatedDartCode dartExpression;
final bool stored;
ColumnGeneratedAs(this.dartExpression, this.stored);
}
/// A column with a `CHECK()` generated from a Dart expression.
class DartCheckExpression extends DriftColumnConstraint {
final AnnotatedDartCode dartExpression;
DartCheckExpression(this.dartExpression);
}
class LimitingTextLength extends DriftColumnConstraint {
final int? minLength;
final int? maxLength;
LimitingTextLength({this.minLength, this.maxLength});
@override
int get hashCode => minLength.hashCode ^ maxLength.hashCode;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
final typedOther = other as LimitingTextLength;
return typedOther.minLength == minLength &&
typedOther.maxLength == maxLength;
}
}

View File

@ -11,6 +11,8 @@ import 'package:json_annotation/json_annotation.dart';
part '../../generated/analysis/results/dart.g.dart';
class AnnotatedDartCode {
static final Uri drift = Uri.parse('package:drift/drift.dart');
final List<dynamic /* String|DartTopLevelSymbol */ > elements;
AnnotatedDartCode(this.elements);
@ -115,7 +117,7 @@ class DartTopLevelSymbol {
DartTopLevelSymbol(this.lexeme, this.importUri);
factory DartTopLevelSymbol.topLevelElement(Element element) {
assert(element.enclosingElement3 is LibraryElement);
assert(element.library?.topLevelElements.contains(element) == true);
// We're using this to recover the right import URI when using
// `package:build`:

View File

@ -1,3 +1,5 @@
import 'package:drift/drift.dart' show DriftSqlType;
import 'dart.dart';
import 'element.dart';
@ -27,7 +29,7 @@ class DriftTable extends DriftElementWithResultSet {
final String baseDartName;
/// The name for the data class associated with this table
final String dartTypeName;
final String nameOfRowClass;
final bool withoutRowId;
@ -38,7 +40,7 @@ class DriftTable extends DriftElementWithResultSet {
super.declaration, {
required this.columns,
required this.baseDartName,
required this.dartTypeName,
required this.nameOfRowClass,
this.references = const [],
this.existingRowClass,
this.customParentClass,
@ -53,6 +55,43 @@ class DriftTable extends DriftElementWithResultSet {
}
}
/// The primary key for this table, computed by looking at the
/// [primaryKeyFromTableConstraint] and primary key constraints applied to
/// individiual columns.
Set<DriftColumn> get fullPrimaryKey {
if (primaryKeyFromTableConstraint != null) {
return primaryKeyFromTableConstraint!;
}
return columns
.where((c) => c.constraints.any((f) => f is PrimaryKeyColumn))
.toSet();
}
/// Determines whether [column] would be required for inserts performed via
/// companions.
bool isColumnRequiredForInsert(DriftColumn column) {
assert(columns.contains(column));
if (column.defaultArgument != null ||
column.clientDefaultCode != null ||
column.nullable ||
column.isGenerated) {
// default value would be applied, so it's not required for inserts
return false;
}
// A column isn't required if it's an alias for the rowid, as explained
// at https://www.sqlite.org/lang_createtable.html#rowid
final fullPk = fullPrimaryKey;
final isAliasForRowId = !withoutRowId &&
column.sqlType == DriftSqlType.int &&
fullPk.length == 1 &&
fullPk.single == column;
return !isAliasForRowId;
}
@override
String get entityInfoName {
// if this table was parsed from sql, a user might want to refer to it
@ -61,7 +100,7 @@ class DriftTable extends DriftElementWithResultSet {
// "$UsersTable".
final name =
fixedEntityInfoName ?? _tableInfoNameForTableClass(baseDartName);
if (name == dartTypeName) {
if (name == nameOfRowClass) {
// resolve clashes if the table info class has the same name as the data
// class. This can happen because the data class name can be specified by
// the user.

View File

@ -32,6 +32,19 @@ class ElementSerializer {
for (final column in element.columns) _serializeColumn(column),
],
'existing_data_class': element.existingRowClass?.toJson(),
'primary_key_table_constraint': element.primaryKeyFromTableConstraint
?.map((e) => e.nameInSql)
.toList(),
'unique_keys_table_constraint': [
for (final unique in element.uniqueKeysFromTableConstraint)
[for (final column in unique) column.nameInSql]
],
'custom_parent_class': element.customParentClass?.toJson(),
'fixed_entity_info_name': element.fixedEntityInfoName,
'base_dart_name': element.baseDartName,
'row_class_name': element.nameOfRowClass,
'without_rowid': element.withoutRowId,
'strict': element.strict,
};
}
@ -225,17 +238,49 @@ abstract class ElementDeserializer {
switch (type) {
case 'table':
final columns = [
for (final rawColumn in json['columns'] as List)
await _readColumn(rawColumn as Map),
];
final columnByName = {
for (final column in columns) column.nameInSql: column,
};
Set<DriftColumn>? primaryKeyFromTableConstraint;
final serializedPk = json['primary_key_table_constraint'];
if (serializedPk != null) {
primaryKeyFromTableConstraint = {
for (final entry in serializedPk) columnByName[entry]!,
};
}
List<Set<DriftColumn>> uniqueKeysFromTableConstraint = const [];
final serializedUnique = json['unique_keys_table_constraint'];
if (serializedUnique != null) {
uniqueKeysFromTableConstraint = [
for (final entry in serializedUnique)
{for (final column in entry) columnByName[column]!},
];
}
return DriftTable(
id,
declaration,
references: references,
columns: [
for (final rawColumn in json['columns'] as List)
await _readColumn(rawColumn as Map),
],
columns: columns,
existingRowClass: json['existing_data_class'] != null
? ExistingRowClass.fromJson(json['existing_data_class'] as Map)
: null,
primaryKeyFromTableConstraint: primaryKeyFromTableConstraint,
uniqueKeysFromTableConstraint: uniqueKeysFromTableConstraint,
customParentClass: json['custom_parent_class'] != null
? AnnotatedDartCode.fromJson(json['custom_parent_class'] as Map)
: null,
fixedEntityInfoName: json['fixed_entity_info_name'] as String?,
baseDartName: json['base_dart_name'] as String,
nameOfRowClass: json['row_class_name'] as String,
withoutRowId: json['without_rowid'] as bool,
strict: json['strict'] as bool,
);
default:
throw UnimplementedError('Unsupported element type: $type');

View File

@ -25,6 +25,12 @@ class DriftBuildBackend extends DriftBackend {
return _buildStep.readAsString(AssetId.resolve(uri));
}
@override
Future<Uri> uriOfDart(Element element) async {
final id = await _buildStep.resolver.assetIdForElement(element);
return id.uri;
}
@override
Future<LibraryElement> readDart(Uri uri) {
return _buildStep.resolver.libraryFor(AssetId.resolve(uri));

View File

@ -0,0 +1,288 @@
import 'package:collection/collection.dart';
import 'package:drift_dev/src/analysis/driver/state.dart';
import 'package:drift_dev/src/analysis/results/results.dart';
import 'package:test/test.dart';
import '../../test_utils.dart';
void main() {
late TestBackend backend;
setUpAll(() {
backend = TestBackend.inTest({
'a|lib/main.dart': '''
import 'package:drift/drift.dart';
TypeConverter<Dart, SQL> typeConverter<Dart, SQL>() {
throw 'stub';
}
class TableWithCustomName extends Table {
@override String get tableName => 'my-fancy-table';
@override bool get withoutRowId => true;
}
class Users extends Table {
/// The user id
IntColumn get id => integer().autoIncrement()();
/// The username
///
/// The username must be between 6-32 characters
TextColumn get name => text().named("user_name").withLength(min: 6, max: 32)();
TextColumn get onlyMax => text().withLength(max: 100)();
DateTimeColumn get defaults => dateTime().withDefault(currentDate)();
}
class CustomPrimaryKey extends Table {
IntColumn get partA => integer()();
IntColumn get partB => integer().customConstraint('custom')();
@override
Set<Column> get primaryKey => {partA, partB};
}
class WrongName extends Table {
String constructTableName() => 'my-table-name';
String get tableName => constructTableName();
}
mixin IntIdTable on Table {
IntColumn get id => integer().autoIncrement()();
}
abstract class HasNameTable extends Table {
TextColumn get name => text()();
}
class Foos extends HasNameTable with IntIdTable {
TextColumn get name => text().nullable()();
}
class Socks extends Table {
TextColumn get name => text()();
IntColumn get id => integer()();
@override
Set<Column> get primaryKey => {id};
}
class ArchivedSocks extends Socks {
TextColumn get archivedBy => text()();
DateTimeColumn get archivedOn => dateTime()();
}
class WithAliasForRowId extends Table {
IntColumn get id => integer()();
TextColumn get name => text()();
@override
Set<Column> get primaryKey => {id};
}
class PrimaryKeyAndAutoIncrement extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get other => text()();
@override
Set<Column> get primaryKey => {other};
}
class InvalidConstraints extends Table {
IntColumn get a => integer().autoIncrement().customConstraint('foo')();
IntColumn get b => integer().customConstraint('a').customConstraint('b')();
}
''',
});
});
tearDownAll(() => backend.dispose());
final uri = Uri.parse('package:a/main.dart');
Future<ElementAnalysisState?> findTable(String dartName) async {
final state = await backend.driver.fullyAnalyze(uri);
return state.analysis.values.firstWhereOrNull((e) {
final result = e.result;
if (result is DriftTable) {
return result.baseDartName == dartName;
} else {
return false;
}
});
}
group('table names', () {
test('use overridden name', () async {
final result = await findTable('TableWithCustomName');
final table = result!.result as DriftTable;
expect(result.errorsDuringAnalysis, isEmpty);
expect(table.schemaName, 'my-fancy-table');
expect(table.withoutRowId, isTrue);
});
test('use re-cased class name', () async {
final parsed = await findTable('Users');
final table = parsed!.result as DriftTable;
expect(parsed.errorsDuringAnalysis, isEmpty);
expect(table.schemaName, 'users');
});
test('reports discovery error for table with wrong name', () async {
final state = await backend.driver.fullyAnalyze(uri);
expect(state.errorsDuringDiscovery, [
isDriftError(
contains('This getter must directly return a string literal'),
).withSpan('tableName'),
]);
});
});
group('Columns', () {
test('should use field name if no name has been set explicitly', () async {
final result = await findTable('Users');
final table = result!.result as DriftTable;
final idColumn =
table.columns.singleWhere((col) => col.nameInDart == 'id');
expect(idColumn.nameInSql, 'id');
});
test('should use explicit name, if it exists', () async {
final result = await findTable('Users');
final table = result!.result as DriftTable;
final idColumn =
table.columns.singleWhere((col) => col.nameInDart == 'name');
expect(idColumn.nameInSql, 'user_name');
});
test('should parse min and max length for text columns', () async {
final result = await findTable('Users');
final table = result!.result as DriftTable;
final idColumn =
table.columns.singleWhere((col) => col.nameInDart == 'name');
expect(idColumn.constraints,
contains(LimitingTextLength(minLength: 6, maxLength: 32)));
});
test('should only parse max length when relevant', () async {
final table = (await findTable('Users'))!.result as DriftTable;
final idColumn =
table.columns.singleWhere((col) => col.nameInDart == 'onlyMax');
expect(
idColumn.constraints, contains(LimitingTextLength(maxLength: 100)));
});
test('parses custom constraints', () async {
final table = (await findTable('CustomPrimaryKey'))!.result as DriftTable;
final partA = table.columns.singleWhere((c) => c.nameInDart == 'partA');
final partB = table.columns.singleWhere((c) => c.nameInDart == 'partB');
expect(partB.customConstraints, 'custom');
expect(partA.customConstraints, isNull);
});
test('parsed default values', () async {
final table = (await findTable('Users'))!.result as DriftTable;
final defaultsColumn =
table.columns.singleWhere((c) => c.nameInSql == 'defaults');
expect(defaultsColumn.defaultArgument.toString(), 'currentDate');
});
test('parses documentation comments', () async {
final table = (await findTable('Users'))!.result as DriftTable;
final idColumn =
table.columns.singleWhere((col) => col.nameInSql == 'id');
final usernameColumn =
table.columns.singleWhere((col) => col.nameInSql == 'user_name');
expect(idColumn.documentationComment, '/// The user id');
expect(
usernameColumn.documentationComment,
'/// The username\n///\n/// The username must be between 6-32 characters',
);
});
});
test('parses custom primary keys', () async {
final table = (await findTable('CustomPrimaryKey'))!.result as DriftTable;
expect(table.primaryKeyFromTableConstraint, containsAll(table.columns));
expect(
table.columns.any(
(column) => column.constraints.any((c) => c is PrimaryKeyColumn)),
isFalse,
);
});
test('warns when using primaryKey and autoIncrement()', () async {
final result = await findTable('PrimaryKeyAndAutoIncrement');
expect(
result!.errorsDuringAnalysis,
contains(isDriftError(
contains('override primaryKey and use autoIncrement()'))),
);
});
test('recognizes aliases for rowid', () async {
final table = (await findTable('WithAliasForRowId'))!.result as DriftTable;
final idColumn = table.columns.singleWhere((c) => c.nameInSql == 'id');
expect(table.isColumnRequiredForInsert(idColumn), isFalse);
});
group('inheritance', () {
test('from abstract classes or mixins', () async {
final table = (await findTable('Foos'))!.result as DriftTable;
expect(table.columns, hasLength(2));
expect(
table.columns.map((c) => c.nameInSql), containsAll(['id', 'name']));
});
test('from regular classes', () async {
final socks = (await findTable('Socks'))!.result as DriftTable;
final archivedSocks =
(await findTable('ArchivedSocks'))!.result as DriftTable;
expect(socks.columns, hasLength(2));
expect(socks.columns.map((c) => c.nameInSql), ['name', 'id']);
expect(archivedSocks.columns, hasLength(4));
expect(archivedSocks.columns.map((c) => c.nameInSql),
['name', 'id', 'archived_by', 'archived_on']);
expect(
archivedSocks.primaryKeyFromTableConstraint!.map((e) => e.nameInSql),
['id']);
});
});
test('reports errors around suspicous customConstraint uses', () async {
final result = await findTable('InvalidConstraints');
expect(
result!.errorsDuringAnalysis,
containsAll(
[
isDriftError(allOf(contains('both drift-defined constraints'),
contains('and a customConstraint()')))
.withSpan('a'),
isDriftError(contains(
"You've already set custom constraints on this column"))
.withSpan('customConstraint'),
],
),
);
});
}

View File

@ -137,6 +137,12 @@ Matcher get hasNoErrors => isA<FileState>()
.having((e) => e.analysis.values.expand((e) => e.errorsDuringAnalysis),
'(errors in analyzed elements)', isEmpty);
Matcher isDriftError(dynamic message) {
TypeMatcher<DriftAnalysisError> isDriftError(dynamic message) {
return isA<DriftAnalysisError>().having((e) => e.message, 'message', message);
}
extension DriftErrorMatchers on TypeMatcher<DriftAnalysisError> {
TypeMatcher<DriftAnalysisError> withSpan(lexemeMatcher) {
return having((e) => e.span?.text, 'span.text', lexemeMatcher);
}
}