mirror of https://github.com/AMT-Cheif/drift.git
Port more tests over to new analyzer
This commit is contained in:
parent
49470e8361
commit
292dd9946d
|
@ -44,6 +44,8 @@ class FileState {
|
|||
.every((e) => elementIsAnalyzed(e.ownId));
|
||||
}
|
||||
|
||||
DriftElementId id(String name) => DriftElementId(ownUri, name);
|
||||
|
||||
bool elementIsAnalyzed(DriftElementId id) {
|
||||
return analysis[id]?.isUpToDate == true;
|
||||
}
|
||||
|
|
|
@ -197,24 +197,29 @@ class DriftTableResolver extends LocalElementResolver<DiscoveredDriftTable> {
|
|||
} else if (stmt is CreateVirtualTableStatement) {
|
||||
RecognizedVirtualTableModule? recognized;
|
||||
if (table is Fts5Table) {
|
||||
final errorLocation = stmt.tableNameToken ?? stmt;
|
||||
final errorLocation = stmt.arguments
|
||||
.firstWhereOrNull((e) => e.text.contains('content')) ??
|
||||
stmt.span;
|
||||
|
||||
final contentTable = table.contentTable != null
|
||||
? await resolveSqlReferenceOrReportError<DriftTable>(
|
||||
table.contentTable!,
|
||||
(msg) => DriftAnalysisError.inDriftFile(errorLocation,
|
||||
(msg) => DriftAnalysisError(errorLocation,
|
||||
'Could not find referenced content table: $msg'))
|
||||
: null;
|
||||
DriftColumn? contentRowId;
|
||||
|
||||
if (contentTable != null) {
|
||||
references.add(contentTable);
|
||||
final parserContentTable =
|
||||
resolver.driver.typeMapping.asSqlParserTable(contentTable);
|
||||
final rowId = parserContentTable.findColumn(table.contentRowId!);
|
||||
|
||||
if (rowId == null) {
|
||||
reportError(DriftAnalysisError.inDriftFile(
|
||||
errorLocation,
|
||||
var location = stmt.arguments
|
||||
.firstWhereOrNull((e) => e.text.contains('content_rowid'));
|
||||
reportError(DriftAnalysisError(
|
||||
location ?? errorLocation,
|
||||
'Invalid content rowid, `${table.contentRowId}` not found '
|
||||
'in `${contentTable.schemaName}`'));
|
||||
} else if (rowId is! RowId) {
|
||||
|
@ -226,8 +231,11 @@ class DriftTableResolver extends LocalElementResolver<DiscoveredDriftTable> {
|
|||
// Also, check that all columns referenced in the fts5 table exist in
|
||||
// the content table.
|
||||
for (final column in columns) {
|
||||
var location = stmt.arguments
|
||||
.firstWhereOrNull((e) => e.text == column.nameInSql);
|
||||
|
||||
if (parserContentTable.findColumn(column.nameInSql) == null) {
|
||||
reportError(DriftAnalysisError.inDriftFile(errorLocation,
|
||||
reportError(DriftAnalysisError(location ?? errorLocation,
|
||||
'The content table has no column `${column.nameInSql}`.'));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -157,7 +157,7 @@ class DriftResolver {
|
|||
if (candidates.isEmpty) {
|
||||
return InvalidReferenceResult(
|
||||
InvalidReferenceError.noElementWichSuchName,
|
||||
'This reference could not be found in any import.',
|
||||
'`$reference` could not be found in any import.',
|
||||
);
|
||||
} else if (candidates.length > 1) {
|
||||
final description =
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import 'package:drift/drift.dart' show DriftSqlType;
|
||||
import 'package:drift_dev/src/analysis/results/results.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../test_utils.dart';
|
||||
|
||||
void main() {
|
||||
test('recognizes CTE clause', () async {
|
||||
final backend = TestBackend.inTest({
|
||||
'a|lib/test.drift': '''
|
||||
test:
|
||||
WITH RECURSIVE
|
||||
cnt(x) AS (
|
||||
SELECT 1
|
||||
UNION ALL
|
||||
SELECT x+1 FROM cnt
|
||||
LIMIT 1000000
|
||||
)
|
||||
SELECT x FROM cnt;
|
||||
'''
|
||||
});
|
||||
|
||||
final file = await backend.analyze('package:a/test.drift');
|
||||
backend.expectNoErrors();
|
||||
|
||||
final query =
|
||||
file.fileAnalysis!.resolvedQueries.values.single as SqlSelectQuery;
|
||||
|
||||
expect(query.variables, isEmpty);
|
||||
expect(query.readsFrom, isEmpty);
|
||||
|
||||
final resultSet = query.resultSet;
|
||||
expect(resultSet.singleColumn, isTrue);
|
||||
expect(resultSet.needsOwnClass, isFalse);
|
||||
expect(resultSet.columns.map(resultSet.dartNameFor), ['x']);
|
||||
expect(resultSet.columns.map((c) => c.sqlType), [DriftSqlType.int]);
|
||||
});
|
||||
|
||||
test('finds the underlying table when aliased through CTE', () async {
|
||||
final backend = TestBackend.inTest({
|
||||
'a|lib/test.drift': '''
|
||||
CREATE TABLE foo (
|
||||
id INT NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
bar VARCHAR NOT NULL
|
||||
);
|
||||
|
||||
test2:
|
||||
WITH alias("first", second) AS (SELECT * FROM foo) SELECT * FROM alias;
|
||||
'''
|
||||
});
|
||||
|
||||
final file = await backend.analyze('package:a/test.drift');
|
||||
backend.expectNoErrors();
|
||||
|
||||
final query =
|
||||
file.fileAnalysis!.resolvedQueries.values.single as SqlSelectQuery;
|
||||
final resultSet = query.resultSet;
|
||||
|
||||
expect(resultSet.matchingTable, isNotNull);
|
||||
expect(resultSet.matchingTable!.table.schemaName, 'foo');
|
||||
expect(resultSet.needsOwnClass, isFalse);
|
||||
});
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
import 'package:analyzer/dart/element/type.dart';
|
||||
import 'package:drift_dev/src/analysis/results/results.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../test_utils.dart';
|
||||
|
||||
void main() {
|
||||
test('can use existing row classes in drift files', () async {
|
||||
final state = TestBackend.inTest({
|
||||
'a|lib/db.drift': '''
|
||||
import 'rows.dart';
|
||||
|
||||
CREATE TABLE custom_name (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
foo TEXT
|
||||
) AS MyCustomClass;
|
||||
|
||||
CREATE TABLE existing (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
foo TEXT
|
||||
) WITH ExistingRowClass;
|
||||
|
||||
CREATE VIEW existing_view WITH ExistingForView (foo, bar)
|
||||
AS SELECT 1, 2;
|
||||
''',
|
||||
'a|lib/rows.dart': '''
|
||||
class ExistingRowClass {
|
||||
ExistingRowClass(int id, String? foo);
|
||||
}
|
||||
|
||||
class ExistingForView {
|
||||
ExistingForView(int foo, int bar);
|
||||
}
|
||||
''',
|
||||
});
|
||||
|
||||
final file = await state.analyze('package:a/db.drift');
|
||||
state.expectNoErrors();
|
||||
|
||||
final customName =
|
||||
file.analysis[file.id('custom_name')]!.result! as DriftTable;
|
||||
final existing = file.analysis[file.id('existing')]!.result! as DriftTable;
|
||||
final existingView =
|
||||
file.analysis[file.id('existing_view')]!.result! as DriftView;
|
||||
|
||||
expect(customName.nameOfRowClass, 'MyCustomClass');
|
||||
expect(customName.existingRowClass, isNull);
|
||||
|
||||
expect(existing.nameOfRowClass, 'ExistingRowClass');
|
||||
expect(
|
||||
existing.existingRowClass!.targetClass.toString(), 'ExistingRowClass');
|
||||
|
||||
expect(existingView.nameOfRowClass, 'ExistingForView');
|
||||
expect(existingView.existingRowClass!.targetClass.toString(),
|
||||
'ExistingForView');
|
||||
});
|
||||
|
||||
test('can use generic row classes', () async {
|
||||
final state = TestBackend.inTest({
|
||||
'a|lib/generic.dart': '''
|
||||
//@dart=2.13
|
||||
typedef StringRow = GenericRow<String>;
|
||||
typedef IntRow = GenericRow<int>;
|
||||
|
||||
class GenericRow<T> {
|
||||
final T value;
|
||||
GenericRow(this.value);
|
||||
}
|
||||
''',
|
||||
'a|lib/generic.drift': '''
|
||||
import 'generic.dart';
|
||||
|
||||
CREATE TABLE drift_strings (
|
||||
value TEXT NOT NULL
|
||||
) WITH StringRow;
|
||||
|
||||
CREATE TABLE drift_ints (
|
||||
value INT NOT NULL
|
||||
) WITH IntRow;
|
||||
''',
|
||||
});
|
||||
|
||||
final file = await state.analyze('package:a/generic.drift');
|
||||
state.expectNoErrors();
|
||||
|
||||
final strings =
|
||||
file.analysis[file.id('drift_strings')]!.result! as DriftTable;
|
||||
final ints = file.analysis[file.id('drift_ints')]!.result! as DriftTable;
|
||||
|
||||
expect(
|
||||
strings.existingRowClass,
|
||||
isA<ExistingRowClass>().having((e) => e.targetType.toString(),
|
||||
'targetType', 'GenericRow<String>'));
|
||||
|
||||
expect(
|
||||
ints.existingRowClass,
|
||||
isA<ExistingRowClass>().having(
|
||||
(e) => e.targetType.toString(), 'targetType', 'GenericRow<int>'));
|
||||
});
|
||||
}
|
|
@ -1,77 +1,60 @@
|
|||
import 'package:drift_dev/src/analyzer/errors.dart';
|
||||
import 'package:drift_dev/src/analyzer/options.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils.dart';
|
||||
import '../../test_utils.dart';
|
||||
|
||||
const _options = DriftOptions.defaults(modules: [SqlModule.fts5]);
|
||||
|
||||
void main() {
|
||||
group('reports error', () {
|
||||
test('for missing content table', () async {
|
||||
final state = TestState.withContent({
|
||||
final state = TestBackend.inTest({
|
||||
'a|lib/main.drift': '''
|
||||
CREATE VIRTUAL TABLE fts USING fts5(a, c, content=tbl);
|
||||
''',
|
||||
}, options: _options);
|
||||
addTearDown(state.close);
|
||||
|
||||
final result = await state.analyze('package:a/main.drift');
|
||||
expect(result.errors.errors, [
|
||||
const TypeMatcher<ErrorInDriftFile>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('Content table `tbl` could not be found'),
|
||||
),
|
||||
expect(result.allErrors, [
|
||||
isDriftError('Could not find referenced content table: '
|
||||
'`tbl` could not be found in any import.')
|
||||
.withSpan('content=tbl'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('for invalid rowid of content table', () async {
|
||||
final state = TestState.withContent({
|
||||
final state = TestBackend.inTest({
|
||||
'a|lib/main.drift': '''
|
||||
CREATE TABLE tbl (a, b, c, my_pk INTEGER PRIMARY KEY);
|
||||
|
||||
CREATE VIRTUAL TABLE fts USING fts5(a, c, content=tbl, content_rowid=d);
|
||||
''',
|
||||
}, options: _options);
|
||||
addTearDown(state.close);
|
||||
|
||||
final result = await state.analyze('package:a/main.drift');
|
||||
expect(result.errors.errors, [
|
||||
const TypeMatcher<ErrorInDriftFile>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains(
|
||||
'no column `d`, but this fts5 table is declared to use it as a row '
|
||||
'id',
|
||||
),
|
||||
),
|
||||
expect(result.allErrors, [
|
||||
isDriftError('Invalid content rowid, `d` not found in `tbl`')
|
||||
.withSpan('content_rowid=d'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('when referencing an unknown column', () async {
|
||||
final state = TestState.withContent({
|
||||
final state = TestBackend.inTest({
|
||||
'a|lib/main.drift': '''
|
||||
CREATE TABLE tbl (a, b, c, d INTEGER PRIMARY KEY);
|
||||
|
||||
CREATE VIRTUAL TABLE fts USING fts5(e, c, content=tbl, content_rowid=d);
|
||||
''',
|
||||
}, options: _options);
|
||||
addTearDown(state.close);
|
||||
|
||||
final result = await state.analyze('package:a/main.drift');
|
||||
expect(result.errors.errors, [
|
||||
const TypeMatcher<ErrorInDriftFile>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('no column `e`, but this fts5 table references it'),
|
||||
),
|
||||
]);
|
||||
expect(result.allErrors,
|
||||
[isDriftError('The content table has no column `e`.').withSpan('e')]);
|
||||
});
|
||||
});
|
||||
|
||||
test('finds referenced table', () async {
|
||||
final state = TestState.withContent({
|
||||
final state = TestBackend.inTest({
|
||||
'a|lib/main.drift': '''
|
||||
CREATE TABLE tbl (a, b, c, d INTEGER PRIMARY KEY);
|
||||
|
||||
|
@ -79,11 +62,10 @@ CREATE VIRTUAL TABLE fts USING fts5(a, c, content=tbl, content_rowid=d);
|
|||
CREATE VIRTUAL TABLE fts2 USING fts5(a, c, content=tbl, content_rowid=rowid);
|
||||
''',
|
||||
}, options: _options);
|
||||
addTearDown(state.close);
|
||||
|
||||
final result = await state.analyze('package:a/main.drift');
|
||||
expect(result.errors.errors, isEmpty);
|
||||
final tables = result.currentResult!.declaredTables.toList();
|
||||
expect(result.allErrors, isEmpty);
|
||||
final tables = result.analyzedElements.toList();
|
||||
|
||||
expect(tables, hasLength(3));
|
||||
expect(tables[1].references, contains(tables[0]));
|
|
@ -1,86 +0,0 @@
|
|||
import 'package:build/build.dart';
|
||||
import 'package:drift_dev/moor_generator.dart';
|
||||
import 'package:drift_dev/src/analyzer/runner/file_graph.dart';
|
||||
import 'package:drift_dev/src/analyzer/runner/results.dart';
|
||||
import 'package:drift_dev/src/analyzer/runner/task.dart';
|
||||
import 'package:drift_dev/src/analyzer/session.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../utils/test_backend.dart';
|
||||
|
||||
void main() {
|
||||
late TestBackend backend;
|
||||
late MoorSession session;
|
||||
late Task task;
|
||||
|
||||
setUpAll(() {
|
||||
backend = TestBackend(
|
||||
{
|
||||
AssetId.parse('foo|lib/test.moor'): r'''
|
||||
CREATE TABLE foo (
|
||||
id INT NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
bar VARCHAR NOT NULL
|
||||
);
|
||||
|
||||
test:
|
||||
WITH RECURSIVE
|
||||
cnt(x) AS (
|
||||
SELECT 1
|
||||
UNION ALL
|
||||
SELECT x+1 FROM cnt
|
||||
LIMIT 1000000
|
||||
)
|
||||
SELECT x FROM cnt;
|
||||
|
||||
test2:
|
||||
WITH alias("first", second) AS (SELECT * FROM foo) SELECT * FROM alias;
|
||||
''',
|
||||
},
|
||||
);
|
||||
session = MoorSession(backend);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
final backendTask = backend.startTask(Uri.parse('package:foo/test.moor'));
|
||||
task = session.startTask(backendTask);
|
||||
await task.runTask();
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
backend.finish();
|
||||
});
|
||||
|
||||
test('recognizes CFE clause', () {
|
||||
final file = session.registerFile(Uri.parse('package:foo/test.moor'));
|
||||
|
||||
expect(file.state, FileState.analyzed);
|
||||
expect(file.errors.errors, isEmpty);
|
||||
|
||||
final result = file.currentResult as ParsedDriftFile;
|
||||
final query = result.resolvedQueries!.firstWhere((q) => q.name == 'test')
|
||||
as SqlSelectQuery;
|
||||
|
||||
expect(query.variables, isEmpty);
|
||||
expect(query.declaredInMoorFile, isTrue);
|
||||
expect(query.readsFrom, isEmpty);
|
||||
|
||||
final resultSet = query.resultSet;
|
||||
expect(resultSet.singleColumn, isTrue);
|
||||
expect(resultSet.needsOwnClass, isFalse);
|
||||
expect(resultSet.columns.map(resultSet.dartNameFor), ['x']);
|
||||
expect(resultSet.columns.map((c) => c.type), [DriftSqlType.int]);
|
||||
});
|
||||
|
||||
test('finds the underlying table when aliased through CFE', () {
|
||||
final file = session.registerFile(Uri.parse('package:foo/test.moor'));
|
||||
final result = file.currentResult as ParsedDriftFile;
|
||||
final query = result.resolvedQueries!.firstWhere((q) => q.name == 'test2')
|
||||
as SqlSelectQuery;
|
||||
|
||||
final resultSet = query.resultSet;
|
||||
|
||||
expect(resultSet.matchingTable, isNotNull);
|
||||
expect(resultSet.matchingTable!.table.displayName, 'foo');
|
||||
expect(resultSet.needsOwnClass, isFalse);
|
||||
});
|
||||
}
|
|
@ -1,127 +0,0 @@
|
|||
import 'package:analyzer/dart/element/type.dart';
|
||||
import 'package:drift_dev/moor_generator.dart';
|
||||
import 'package:drift_dev/src/analyzer/runner/results.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../utils.dart';
|
||||
|
||||
void main() {
|
||||
test('can use existing row classes in moor files', () async {
|
||||
final state = TestState.withContent({
|
||||
'a|lib/db.moor': '''
|
||||
import 'rows.dart';
|
||||
|
||||
CREATE TABLE custom_name (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
foo TEXT
|
||||
) AS MyCustomClass;
|
||||
|
||||
CREATE TABLE existing (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
foo TEXT
|
||||
) WITH ExistingRowClass;
|
||||
|
||||
CREATE VIEW existing_view WITH ExistingForView (foo, bar)
|
||||
AS SELECT 1, 2;
|
||||
''',
|
||||
'a|lib/rows.dart': '''
|
||||
class ExistingRowClass {
|
||||
ExistingRowClass(int id, String? foo);
|
||||
}
|
||||
|
||||
class ExistingForView {
|
||||
ExistingForView(int foo, int bar);
|
||||
}
|
||||
''',
|
||||
});
|
||||
addTearDown(state.close);
|
||||
|
||||
final file = await state.analyze('package:a/db.moor');
|
||||
expect(file.errors.errors, isEmpty);
|
||||
|
||||
final result = file.currentResult as ParsedDriftFile;
|
||||
final customName = result.declaredEntities
|
||||
.singleWhere((e) => e.displayName == 'custom_name') as DriftTable;
|
||||
final existing = result.declaredEntities
|
||||
.singleWhere((e) => e.displayName == 'existing') as DriftTable;
|
||||
final existingView = result.declaredEntities
|
||||
.singleWhere((e) => e.displayName == 'existing_view') as MoorView;
|
||||
|
||||
expect(customName.dartTypeName, 'MyCustomClass');
|
||||
expect(customName.existingRowClass, isNull);
|
||||
|
||||
expect(existing.dartTypeName, 'ExistingRowClass');
|
||||
expect(existing.existingRowClass!.targetClass.name, 'ExistingRowClass');
|
||||
|
||||
expect(existingView.dartTypeName, 'ExistingForView');
|
||||
expect(existingView.existingRowClass!.targetClass.name, 'ExistingForView');
|
||||
});
|
||||
|
||||
test('can use generic row classes', () async {
|
||||
final state = TestState.withContent({
|
||||
'a|lib/generic.dart': '''
|
||||
//@dart=2.13
|
||||
typedef StringRow = GenericRow<String>;
|
||||
typedef IntRow = GenericRow<int>;
|
||||
|
||||
class GenericRow<T> {
|
||||
final T value;
|
||||
GenericRow(this.value);
|
||||
}
|
||||
''',
|
||||
'a|lib/generic.moor': '''
|
||||
import 'generic.dart';
|
||||
|
||||
CREATE TABLE moor_strings (
|
||||
value TEXT NOT NULL
|
||||
) WITH StringRow;
|
||||
|
||||
CREATE TABLE moor_ints (
|
||||
value INT NOT NULL
|
||||
) WITH IntRow;
|
||||
''',
|
||||
});
|
||||
addTearDown(state.close);
|
||||
|
||||
final file = await state.analyze('package:a/generic.moor');
|
||||
expect(file.errors.errors, isEmpty);
|
||||
|
||||
final tables = (file.currentResult as ParsedDriftFile).declaredTables;
|
||||
final strings = tables.singleWhere((e) => e.sqlName == 'moor_strings');
|
||||
final ints = tables.singleWhere((e) => e.sqlName == 'moor_ints');
|
||||
|
||||
expect(
|
||||
strings.existingRowClass,
|
||||
isA<ExistingRowClass>()
|
||||
.having((e) => e.targetClass.name, 'targetClass.name', 'GenericRow')
|
||||
.having(
|
||||
(e) => e.typeInstantiation,
|
||||
'typeInstantiation',
|
||||
allOf(
|
||||
hasLength(1),
|
||||
anyElement(
|
||||
isA<DartType>().having(
|
||||
(e) => e.isDartCoreString, 'isDartCoreString', isTrue),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
ints.existingRowClass,
|
||||
isA<ExistingRowClass>()
|
||||
.having((e) => e.targetClass.name, 'targetClass.name', 'GenericRow')
|
||||
.having(
|
||||
(e) => e.typeInstantiation,
|
||||
'typeInstantiation',
|
||||
allOf(
|
||||
hasLength(1),
|
||||
anyElement(
|
||||
isA<DartType>()
|
||||
.having((e) => e.isDartCoreInt, 'isDartCoreInt', isTrue),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
|
@ -251,7 +251,20 @@ class NodeSqlBuilder extends AstVisitor<void, void> {
|
|||
void visitCommonTableExpression(CommonTableExpression e, void arg) {
|
||||
identifier(e.cteTableName);
|
||||
if (e.columnNames != null) {
|
||||
symbol('(${e.columnNames!.join(', ')})', spaceAfter: true);
|
||||
symbol('(', spaceBefore: true);
|
||||
|
||||
var first = true;
|
||||
for (final columnName in e.columnNames!) {
|
||||
if (!first) {
|
||||
symbol(',', spaceAfter: true);
|
||||
}
|
||||
|
||||
identifier(columnName, spaceBefore: !first, spaceAfter: false);
|
||||
|
||||
first = false;
|
||||
}
|
||||
|
||||
symbol(')', spaceAfter: true);
|
||||
}
|
||||
|
||||
_keyword(TokenType.as);
|
||||
|
|
|
@ -194,6 +194,11 @@ CREATE UNIQUE INDEX my_idx ON t1 (c1, c2, c3) WHERE c1 < c3;
|
|||
''');
|
||||
});
|
||||
|
||||
test('escapes CTEs', () {
|
||||
testFormat('WITH alias("first", second) AS (SELECT * FROM foo) '
|
||||
'SELECT * FROM alias');
|
||||
});
|
||||
|
||||
test('with materialized CTEs', () {
|
||||
testFormat('''
|
||||
WITH
|
||||
|
|
Loading…
Reference in New Issue