mirror of https://github.com/AMT-Cheif/drift.git
Tests for generalized nested columns
This commit is contained in:
parent
5b7e011021
commit
941409381b
|
@ -198,18 +198,6 @@ class _LintingVisitor extends RecursiveVisitor<void, void> {
|
|||
relevantNode: e,
|
||||
));
|
||||
}
|
||||
|
||||
// check that it actually refers to a table
|
||||
final result = e.resultSet?.unalias();
|
||||
if (result is! Table && result is! View) {
|
||||
linter.sqlParserErrors.add(AnalysisError(
|
||||
type: AnalysisErrorType.other,
|
||||
message: 'Nested star columns must refer to a table directly. They '
|
||||
"can't refer to a table-valued function or another select "
|
||||
'statement.',
|
||||
relevantNode: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (e is NestedQueryColumn) {
|
||||
|
|
|
@ -561,8 +561,11 @@ class InferredResultSet {
|
|||
) {
|
||||
return switch (column) {
|
||||
ScalarResultColumn() => column,
|
||||
NestedResultTable() => column.innerResultSet
|
||||
NestedResultTable() => StructuredFromNestedColumn(
|
||||
column,
|
||||
column.innerResultSet
|
||||
.mappingToRowClass(column.nameForGeneratedRowClass, options),
|
||||
),
|
||||
NestedResultQuery() => MappedNestedListQuery(
|
||||
column,
|
||||
column.query.queryRowType(options),
|
||||
|
|
|
@ -130,21 +130,40 @@ class QueryWriter {
|
|||
(sqlPrefix: prefix, isNullable: argument.nullable),
|
||||
);
|
||||
case MappedNestedListQuery():
|
||||
final queryRow = _emitter.drift('QueryRow');
|
||||
|
||||
_buffer.write('await ');
|
||||
_writeCustomSelectStatement(argument.column.query,
|
||||
includeMappingToDart: false);
|
||||
_buffer.write('.map(');
|
||||
_buffer.write('($queryRow row) => ');
|
||||
_writeArgumentExpression(argument.nestedType, resultSet, context);
|
||||
_buffer.write(').get()');
|
||||
final query = argument.column.query;
|
||||
_writeCustomSelectStatement(query);
|
||||
_buffer.write('.get()');
|
||||
case QueryRowType():
|
||||
final singleValue = argument.singleValue;
|
||||
if (singleValue != null) {
|
||||
return _writeArgumentExpression(singleValue, resultSet, context);
|
||||
}
|
||||
|
||||
if (context.isNullable) {
|
||||
// If this structed type is nullable, it's coming from an OUTER join
|
||||
// which means that, even if the individual components making up the
|
||||
// structure are non-nullable, they might all be null in SQL. We
|
||||
// detect this case by looking for a non-nullable column and, if it's
|
||||
// null, return null directly instead of creating the structured type.
|
||||
for (final arg in argument.positionalArguments
|
||||
.followedBy(argument.namedArguments.values)
|
||||
.whereType<ScalarResultColumn>()) {
|
||||
if (!arg.nullable) {
|
||||
final keyInMap = context.applyPrefix(arg.name);
|
||||
_buffer.write(
|
||||
'row.data[${asDartLiteral(keyInMap)}] == null ? null : ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final _ArgumentContext childContext = (
|
||||
sqlPrefix: context.sqlPrefix,
|
||||
// Individual fields making up this query row type aren't covered by
|
||||
// the outer nullability.
|
||||
isNullable: false,
|
||||
);
|
||||
|
||||
if (!argument.isRecord) {
|
||||
// We're writing a constructor, so let's start with the class name.
|
||||
_emitter.writeDart(argument.rowType);
|
||||
|
@ -159,12 +178,12 @@ class QueryWriter {
|
|||
|
||||
_buffer.write('(');
|
||||
for (final positional in argument.positionalArguments) {
|
||||
_writeArgumentExpression(positional, resultSet, context);
|
||||
_writeArgumentExpression(positional, resultSet, childContext);
|
||||
_buffer.write(', ');
|
||||
}
|
||||
argument.namedArguments.forEach((name, parameter) {
|
||||
_buffer.write('$name: ');
|
||||
_writeArgumentExpression(parameter, resultSet, context);
|
||||
_writeArgumentExpression(parameter, resultSet, childContext);
|
||||
_buffer.write(', ');
|
||||
});
|
||||
|
||||
|
@ -179,7 +198,12 @@ class QueryWriter {
|
|||
final specialName = _transformer.newNameFor(column.sqlParserColumn!);
|
||||
final isNullable = context.isNullable || column.nullable;
|
||||
|
||||
final dartLiteral = asDartLiteral(specialName ?? column.name);
|
||||
var name = specialName ?? column.name;
|
||||
if (context.sqlPrefix != null) {
|
||||
name = '${context.sqlPrefix}.$name';
|
||||
}
|
||||
|
||||
final dartLiteral = asDartLiteral(name);
|
||||
final method = isNullable ? 'readNullable' : 'read';
|
||||
final rawDartType =
|
||||
_emitter.dartCode(AnnotatedDartCode([dartTypeNames[column.sqlType]!]));
|
||||
|
@ -213,22 +237,20 @@ class QueryWriter {
|
|||
context.isNullable ? 'mapFromRowOrNull' : 'mapFromRow';
|
||||
final sqlPrefix = context.sqlPrefix;
|
||||
|
||||
_emitter.write('${table.dbGetterName}.$mappingMethod(row');
|
||||
_emitter.write('await ${table.dbGetterName}.$mappingMethod(row');
|
||||
if (sqlPrefix != null) {
|
||||
_emitter.write(', tablePrefix: ${asDartLiteral(sqlPrefix)}');
|
||||
}
|
||||
|
||||
_emitter.write(')');
|
||||
} else {
|
||||
final sqlPrefix = context.sqlPrefix;
|
||||
|
||||
// If the entire table can be nullable, we can check whether a non-nullable
|
||||
// column from the table is null. If it is, the entire table is null. This
|
||||
// can happen when the table comes from an outer join.
|
||||
if (context.isNullable) {
|
||||
for (final MapEntry(:key, :value) in match.aliasToColumn.entries) {
|
||||
if (!value.nullable) {
|
||||
final mapKey = sqlPrefix == null ? key : '$sqlPrefix.$key';
|
||||
final mapKey = context.applyPrefix(key);
|
||||
|
||||
_emitter
|
||||
.write('row.data[${asDartLiteral(mapKey)}] == null ? null : ');
|
||||
|
@ -239,13 +261,8 @@ class QueryWriter {
|
|||
_emitter.write('${table.dbGetterName}.mapFromRowWithAlias(row, const {');
|
||||
|
||||
for (final alias in match.aliasToColumn.entries) {
|
||||
var sqlKey = alias.key;
|
||||
if (sqlPrefix != null) {
|
||||
sqlKey = '$sqlPrefix.';
|
||||
}
|
||||
|
||||
_emitter
|
||||
..write(asDartLiteral(sqlKey))
|
||||
..write(asDartLiteral(context.applyPrefix(alias.key)))
|
||||
..write(': ')
|
||||
..write(asDartLiteral(alias.value.nameInSql))
|
||||
..write(', ');
|
||||
|
@ -280,14 +297,12 @@ class QueryWriter {
|
|||
_buffer.write(';\n}\n');
|
||||
}
|
||||
|
||||
void _writeCustomSelectStatement(SqlSelectQuery select,
|
||||
{bool includeMappingToDart = true}) {
|
||||
void _writeCustomSelectStatement(SqlSelectQuery select) {
|
||||
_buffer.write(' customSelect(${_queryCode(select)}, ');
|
||||
_writeVariables(select);
|
||||
_buffer.write(', ');
|
||||
_writeReadsFrom(select);
|
||||
|
||||
if (includeMappingToDart) {
|
||||
if (select.needsAsyncMapping) {
|
||||
_buffer.write(').asyncMap(');
|
||||
} else {
|
||||
|
@ -295,8 +310,6 @@ class QueryWriter {
|
|||
}
|
||||
|
||||
_writeMappingLambda(select);
|
||||
}
|
||||
|
||||
_buffer.write(')');
|
||||
}
|
||||
|
||||
|
@ -811,3 +824,12 @@ String? _defaultForDartPlaceholder(
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
extension on _ArgumentContext {
|
||||
String applyPrefix(String originalName) {
|
||||
return switch (sqlPrefix) {
|
||||
null => originalName,
|
||||
var s => '$s.$originalName',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import 'package:drift_dev/src/analysis/options.dart';
|
||||
import 'package:drift_dev/src/analysis/results/results.dart';
|
||||
import 'package:sqlparser/sqlparser.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../test_utils.dart';
|
||||
|
@ -225,16 +227,17 @@ class MyQueryRow {
|
|||
);
|
||||
});
|
||||
|
||||
test('nested - single column type', () async {
|
||||
group('nested column', () {
|
||||
test('single column into field', () async {
|
||||
final state = TestBackend.inTest({
|
||||
'a|lib/a.drift': '''
|
||||
import 'a.dart';
|
||||
|
||||
foo WITH MyQueryRow: SELECT 1 a, LIST(SELECT 2 AS b) c;
|
||||
foo WITH MyQueryRow: SELECT 1 AS a, b.** FROM (SELECT 2 AS b) b;
|
||||
''',
|
||||
'a|lib/a.dart': '''
|
||||
class MyQueryRow {
|
||||
MyQueryRow(int a, List<int> c);
|
||||
MyQueryRow(int a, int b);
|
||||
}
|
||||
''',
|
||||
});
|
||||
|
@ -249,10 +252,8 @@ class MyQueryRow {
|
|||
type: 'MyQueryRow',
|
||||
positional: [
|
||||
scalarColumn('a'),
|
||||
nestedListQuery(
|
||||
'c',
|
||||
structedFromNested(
|
||||
isExistingRowType(
|
||||
type: 'int',
|
||||
singleValue: scalarColumn('b'),
|
||||
),
|
||||
),
|
||||
|
@ -261,7 +262,88 @@ class MyQueryRow {
|
|||
);
|
||||
});
|
||||
|
||||
test('nested - table', () async {
|
||||
test('single column into single-element record', () async {
|
||||
final state = TestBackend.inTest({
|
||||
'a|lib/a.drift': '''
|
||||
import 'a.dart';
|
||||
|
||||
foo WITH MyQueryRow: SELECT 1 AS a, b.** FROM (SELECT 2 AS b) b;
|
||||
''',
|
||||
'a|lib/a.dart': '''
|
||||
class MyQueryRow {
|
||||
MyQueryRow(int a, (int) b);
|
||||
}
|
||||
''',
|
||||
});
|
||||
|
||||
final file = await state.analyze('package:a/a.drift');
|
||||
state.expectNoErrors();
|
||||
|
||||
final query = file.fileAnalysis!.resolvedQueries.values.single;
|
||||
expect(
|
||||
query.resultSet?.existingRowType,
|
||||
isExistingRowType(
|
||||
type: 'MyQueryRow',
|
||||
positional: [
|
||||
scalarColumn('a'),
|
||||
structedFromNested(
|
||||
isExistingRowType(
|
||||
positional: [scalarColumn('b')],
|
||||
isRecord: isTrue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('custom result set', () async {
|
||||
final state = TestBackend.inTest(
|
||||
{
|
||||
'a|lib/a.drift': '''
|
||||
import 'a.dart';
|
||||
|
||||
foo WITH MyQueryRow: SELECT 1 AS id, j.** FROM json_each('') AS j;
|
||||
''',
|
||||
'a|lib/a.dart': '''
|
||||
class MyQueryRow {
|
||||
MyQueryRow(int id, JsonStructure j);
|
||||
}
|
||||
|
||||
class JsonStructure {
|
||||
JsonStructure(DriftAny key, DriftAny value, String type);
|
||||
}
|
||||
''',
|
||||
},
|
||||
options: const DriftOptions.defaults(
|
||||
sqliteAnalysisOptions: SqliteAnalysisOptions(
|
||||
// Make sure json_each is supported
|
||||
version: SqliteVersion.v3(38),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final file = await state.analyze('package:a/a.drift');
|
||||
state.expectNoErrors();
|
||||
|
||||
final query = file.fileAnalysis!.resolvedQueries.values.single;
|
||||
expect(
|
||||
query.resultSet?.existingRowType,
|
||||
isExistingRowType(
|
||||
type: 'MyQueryRow',
|
||||
positional: [
|
||||
scalarColumn('id'),
|
||||
structedFromNested(
|
||||
isExistingRowType(
|
||||
type: 'JsonStructure',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('table', () async {
|
||||
final state = TestBackend.inTest({
|
||||
'a|lib/a.drift': '''
|
||||
import 'a.dart';
|
||||
|
@ -299,7 +381,7 @@ class MyRow {
|
|||
);
|
||||
});
|
||||
|
||||
test('nested - table as alternative to row class', () async {
|
||||
test('table as alternative to row class', () async {
|
||||
final state = TestBackend.inTest(
|
||||
{
|
||||
'a|lib/a.drift': '''
|
||||
|
@ -339,8 +421,46 @@ class MyRow {
|
|||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('nested - custom result set with class', () async {
|
||||
group('nested LIST query', () {
|
||||
test('single column type', () async {
|
||||
final state = TestBackend.inTest({
|
||||
'a|lib/a.drift': '''
|
||||
import 'a.dart';
|
||||
|
||||
foo WITH MyQueryRow: SELECT 1 a, LIST(SELECT 2 AS b) c;
|
||||
''',
|
||||
'a|lib/a.dart': '''
|
||||
class MyQueryRow {
|
||||
MyQueryRow(int a, List<int> c);
|
||||
}
|
||||
''',
|
||||
});
|
||||
|
||||
final file = await state.analyze('package:a/a.drift');
|
||||
state.expectNoErrors();
|
||||
|
||||
final query = file.fileAnalysis!.resolvedQueries.values.single;
|
||||
expect(
|
||||
query.resultSet?.existingRowType,
|
||||
isExistingRowType(
|
||||
type: 'MyQueryRow',
|
||||
positional: [
|
||||
scalarColumn('a'),
|
||||
nestedListQuery(
|
||||
'c',
|
||||
isExistingRowType(
|
||||
type: 'int',
|
||||
singleValue: scalarColumn('b'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('custom result set with class', () async {
|
||||
final state = TestBackend.inTest({
|
||||
'a|lib/a.drift': '''
|
||||
import 'a.dart';
|
||||
|
@ -382,7 +502,7 @@ class MyNestedTable {
|
|||
);
|
||||
});
|
||||
|
||||
test('nested - custom result set with record', () async {
|
||||
test('custom result set with record', () async {
|
||||
final state = TestBackend.inTest(
|
||||
{
|
||||
'a|lib/a.drift': '''
|
||||
|
@ -422,6 +542,7 @@ class MyRow {
|
|||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('into record', () async {
|
||||
final state = TestBackend.inTest(
|
||||
|
|
|
@ -75,22 +75,6 @@ q: SELECT * FROM t WHERE i IN ?1;
|
|||
expect(result.allErrors, isEmpty);
|
||||
});
|
||||
|
||||
test('warns when nested results refer to table-valued functions', () async {
|
||||
final result = await TestBackend.analyzeSingle(
|
||||
"a: SELECT json_each.** FROM json_each('');",
|
||||
options: DriftOptions.defaults(modules: [SqlModule.json1]),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.allErrors,
|
||||
[
|
||||
isDriftError(
|
||||
contains('Nested star columns must refer to a table directly.'))
|
||||
.withSpan('json_each.**')
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('warns about default values outside of expressions', () async {
|
||||
final state = TestBackend.inTest({
|
||||
'foo|lib/a.drift': r'''
|
||||
|
|
|
@ -108,6 +108,36 @@ query: SELECT foo.**, bar.** FROM my_view foo, my_view bar;
|
|||
);
|
||||
});
|
||||
|
||||
test('infers nested result sets for custom result sets', () async {
|
||||
final state = TestBackend.inTest({
|
||||
'foo|lib/main.drift': r'''
|
||||
query: SELECT 1 AS a, b.** FROM (SELECT 2 AS b, 3 AS c) AS b;
|
||||
''',
|
||||
});
|
||||
|
||||
final file = await state.analyze('package:foo/main.drift');
|
||||
state.expectNoErrors();
|
||||
|
||||
final query = file.fileAnalysis!.resolvedQueries.values.single;
|
||||
|
||||
expect(
|
||||
query.resultSet!.mappingToRowClass('Row', const DriftOptions.defaults()),
|
||||
isExistingRowType(
|
||||
type: 'Row',
|
||||
named: {
|
||||
'a': scalarColumn('a'),
|
||||
'b': isExistingRowType(
|
||||
type: 'QueryNestedColumn0',
|
||||
named: {
|
||||
'b': scalarColumn('b'),
|
||||
'c': scalarColumn('c'),
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
for (final dateTimeAsText in [false, true]) {
|
||||
test('analyzing date times (stored as text: $dateTimeAsText)', () async {
|
||||
final state = TestBackend.inTest(
|
||||
|
|
|
@ -33,6 +33,7 @@ TypeMatcher<QueryRowType> isExistingRowType({
|
|||
Object? singleValue,
|
||||
Object? positional,
|
||||
Object? named,
|
||||
Object? isRecord,
|
||||
}) {
|
||||
var matcher = isA<QueryRowType>();
|
||||
|
||||
|
@ -53,6 +54,9 @@ TypeMatcher<QueryRowType> isExistingRowType({
|
|||
if (named != null) {
|
||||
matcher = matcher.having((e) => e.namedArguments, 'namedArguments', named);
|
||||
}
|
||||
if (isRecord != null) {
|
||||
matcher = matcher.having((e) => e.isRecord, 'isRecord', isRecord);
|
||||
}
|
||||
|
||||
return matcher;
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'package:drift_dev/src/analysis/options.dart';
|
|||
import 'package:drift_dev/src/writer/import_manager.dart';
|
||||
import 'package:drift_dev/src/writer/queries/query_writer.dart';
|
||||
import 'package:drift_dev/src/writer/writer.dart';
|
||||
import 'package:sqlparser/sqlparser.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../analysis/test_utils.dart';
|
||||
|
@ -14,6 +15,7 @@ void main() {
|
|||
final state =
|
||||
TestBackend.inTest({'a|lib/main.drift': driftFile}, options: options);
|
||||
final file = await state.analyze('package:a/main.drift');
|
||||
state.expectNoErrors();
|
||||
|
||||
final writer = Writer(
|
||||
const DriftOptions.defaults(generateNamedParameters: true),
|
||||
|
@ -55,7 +57,8 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('generates correct name for renamed nested star columns', () async {
|
||||
group('nested star column', () {
|
||||
test('get renamed in SQL', () async {
|
||||
final generated = await generateForQueryInDriftFile('''
|
||||
CREATE TABLE tbl (
|
||||
id INTEGER NULL
|
||||
|
@ -72,15 +75,94 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('generates correct returning mapping', () async {
|
||||
test('makes single columns nullable if from outer join', () async {
|
||||
final generated = await generateForQueryInDriftFile('''
|
||||
query: SELECT 1 AS r, joined.** FROM (SELECT 1)
|
||||
LEFT OUTER JOIN (SELECT 2 AS b) joined;
|
||||
''');
|
||||
|
||||
expect(
|
||||
generated,
|
||||
allOf(
|
||||
contains("joined: row.readNullable<int>('nested_0.b')"),
|
||||
contains('final int? joined;'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('checks for nullable column in nested table', () async {
|
||||
final generated = await generateForQueryInDriftFile('''
|
||||
CREATE TABLE tbl (
|
||||
id INTEGER NULL
|
||||
);
|
||||
|
||||
query: SELECT 1 AS a, tbl.** FROM (SELECT 1) LEFT OUTER JOIN tbl;
|
||||
''');
|
||||
|
||||
expect(
|
||||
generated,
|
||||
allOf(
|
||||
contains(
|
||||
"tbl: await tbl.mapFromRowOrNull(row, tablePrefix: 'nested_0')"),
|
||||
contains('final TblData? tbl;'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('checks for nullable column in nested table with alias', () async {
|
||||
final generated = await generateForQueryInDriftFile('''
|
||||
CREATE TABLE tbl (
|
||||
id INTEGER NULL,
|
||||
col TEXT NOT NULL
|
||||
);
|
||||
|
||||
query: SELECT 1 AS a, tbl.** FROM (SELECT 1) LEFT OUTER JOIN (SELECT id AS a, col AS b from tbl) tbl;
|
||||
''');
|
||||
|
||||
expect(
|
||||
generated,
|
||||
allOf(
|
||||
contains("tbl: row.data['nested_0.b'] == null ? null : "
|
||||
'tbl.mapFromRowWithAlias(row'),
|
||||
contains('final TblData? tbl;'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('checks for nullable column in nested result set', () async {
|
||||
final generated = await generateForQueryInDriftFile('''
|
||||
query: SELECT 1 AS r, joined.** FROM (SELECT 1)
|
||||
LEFT OUTER JOIN (SELECT NULL AS b, 3 AS c) joined;
|
||||
''');
|
||||
|
||||
expect(
|
||||
generated,
|
||||
allOf(
|
||||
contains("joined: row.data['nested_0.c'] == null ? null : "
|
||||
"QueryNestedColumn0(b: row.readNullable<String>('nested_0.b'), "
|
||||
"c: row.read<int>('nested_0.c'), )"),
|
||||
contains('final QueryNestedColumn0? joined;'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('generates correct returning mapping', () async {
|
||||
final generated = await generateForQueryInDriftFile(
|
||||
'''
|
||||
CREATE TABLE tbl (
|
||||
id INTEGER,
|
||||
text TEXT
|
||||
);
|
||||
|
||||
query: INSERT INTO tbl (id, text) VALUES(10, 'test') RETURNING id;
|
||||
''');
|
||||
''',
|
||||
options: const DriftOptions.defaults(
|
||||
sqliteAnalysisOptions:
|
||||
// Assuming 3.35 because dso that returning works.
|
||||
SqliteAnalysisOptions(version: SqliteVersion.v3(35)),
|
||||
),
|
||||
);
|
||||
expect(generated, contains('.toList()'));
|
||||
});
|
||||
|
||||
|
@ -346,8 +428,7 @@ failQuery:
|
|||
],
|
||||
readsFrom: {
|
||||
t,
|
||||
}).asyncMap((i0.QueryRow row) async {
|
||||
return FailQueryResult(
|
||||
}).asyncMap((i0.QueryRow row) async => FailQueryResult(
|
||||
a: row.readNullable<double>('a'),
|
||||
b: row.readNullable<int>('b'),
|
||||
nestedQuery0: await customSelect(
|
||||
|
@ -358,8 +439,8 @@ failQuery:
|
|||
readsFrom: {
|
||||
t,
|
||||
}).asyncMap(t.mapFromRow).get(),
|
||||
);
|
||||
});
|
||||
));
|
||||
}
|
||||
'''))
|
||||
}, outputs.dartOutputs, outputs);
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue