2019-02-11 14:19:09 +00:00
|
|
|
#include <Poco/String.h>
|
|
|
|
|
2018-09-27 19:25:18 +00:00
|
|
|
#include <Interpreters/TranslateQualifiedNamesVisitor.h>
|
2019-01-25 15:42:24 +00:00
|
|
|
#include <Interpreters/IdentifierSemantic.h>
|
2019-02-20 12:12:36 +00:00
|
|
|
#include <Interpreters/AsteriskSemantic.h>
|
2018-09-27 19:25:18 +00:00
|
|
|
|
2018-12-06 15:29:55 +00:00
|
|
|
#include <Common/typeid_cast.h>
|
2020-04-12 03:20:15 +00:00
|
|
|
#include <Common/StringUtils/StringUtils.h>
|
2018-11-02 18:53:23 +00:00
|
|
|
#include <Core/Names.h>
|
2018-09-28 15:01:13 +00:00
|
|
|
|
2018-09-27 19:25:18 +00:00
|
|
|
#include <Parsers/ASTIdentifier.h>
|
2019-02-11 14:19:09 +00:00
|
|
|
#include <Parsers/ASTAsterisk.h>
|
2018-09-27 19:25:18 +00:00
|
|
|
#include <Parsers/ASTQualifiedAsterisk.h>
|
|
|
|
#include <Parsers/ASTSelectQuery.h>
|
|
|
|
#include <Parsers/ASTSelectWithUnionQuery.h>
|
|
|
|
#include <Parsers/ASTTablesInSelectQuery.h>
|
2019-02-11 14:19:09 +00:00
|
|
|
#include <Parsers/ASTExpressionList.h>
|
|
|
|
#include <Parsers/ASTLiteral.h>
|
|
|
|
#include <Parsers/ASTFunction.h>
|
2019-07-21 17:03:58 +00:00
|
|
|
#include <Parsers/ASTColumnsMatcher.h>
|
2018-09-27 19:25:18 +00:00
|
|
|
|
2018-11-02 18:53:23 +00:00
|
|
|
|
2018-09-27 19:25:18 +00:00
|
|
|
namespace DB
|
|
|
|
{
|
|
|
|
|
|
|
|
namespace ErrorCodes
|
|
|
|
{
|
|
|
|
extern const int UNKNOWN_IDENTIFIER;
|
2018-12-06 15:29:55 +00:00
|
|
|
extern const int LOGICAL_ERROR;
|
2018-09-27 19:25:18 +00:00
|
|
|
}
|
|
|
|
|
2019-11-13 16:49:29 +00:00
|
|
|
bool TranslateQualifiedNamesMatcher::Data::unknownColumn(size_t table_pos, const ASTIdentifier & identifier) const
|
|
|
|
{
|
2019-12-13 18:46:55 +00:00
|
|
|
const auto & table = tables[table_pos].table;
|
2019-11-13 16:49:29 +00:00
|
|
|
auto nested1 = IdentifierSemantic::extractNestedName(identifier, table.table);
|
|
|
|
auto nested2 = IdentifierSemantic::extractNestedName(identifier, table.alias);
|
|
|
|
|
2020-03-18 03:27:32 +00:00
|
|
|
const String & short_name = identifier.shortName();
|
2019-12-13 18:46:55 +00:00
|
|
|
const Names & column_names = tables[table_pos].columns;
|
2020-04-22 06:01:33 +00:00
|
|
|
for (const auto & known_name : column_names)
|
2019-11-13 16:49:29 +00:00
|
|
|
{
|
|
|
|
if (short_name == known_name)
|
|
|
|
return false;
|
|
|
|
if (nested1 && *nested1 == known_name)
|
|
|
|
return false;
|
|
|
|
if (nested2 && *nested2 == known_name)
|
|
|
|
return false;
|
|
|
|
}
|
2019-12-13 18:46:55 +00:00
|
|
|
|
|
|
|
const Names & hidden_names = tables[table_pos].hidden_columns;
|
2020-04-22 06:01:33 +00:00
|
|
|
for (const auto & known_name : hidden_names)
|
2019-12-13 18:46:55 +00:00
|
|
|
{
|
|
|
|
if (short_name == known_name)
|
|
|
|
return false;
|
|
|
|
if (nested1 && *nested1 == known_name)
|
|
|
|
return false;
|
|
|
|
if (nested2 && *nested2 == known_name)
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2019-11-13 16:49:29 +00:00
|
|
|
return !column_names.empty();
|
|
|
|
}
|
|
|
|
|
2018-12-06 15:29:55 +00:00
|
|
|
bool TranslateQualifiedNamesMatcher::needChildVisit(ASTPtr & node, const ASTPtr & child)
|
2018-09-27 19:25:18 +00:00
|
|
|
{
|
2018-12-06 15:29:55 +00:00
|
|
|
/// Do not go to FROM, JOIN, subqueries.
|
2019-03-11 13:22:51 +00:00
|
|
|
if (child->as<ASTTableExpression>() || child->as<ASTSelectWithUnionQuery>())
|
2018-12-06 15:29:55 +00:00
|
|
|
return false;
|
|
|
|
|
|
|
|
/// Processed nodes. Do not go into children.
|
2019-03-11 13:22:51 +00:00
|
|
|
if (node->as<ASTQualifiedAsterisk>() || node->as<ASTTableJoin>())
|
2018-12-06 15:29:55 +00:00
|
|
|
return false;
|
|
|
|
|
|
|
|
/// ASTSelectQuery + others
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2019-02-22 13:33:56 +00:00
|
|
|
void TranslateQualifiedNamesMatcher::visit(ASTPtr & ast, Data & data)
|
2018-12-06 15:29:55 +00:00
|
|
|
{
|
2019-03-11 13:22:51 +00:00
|
|
|
if (auto * t = ast->as<ASTIdentifier>())
|
2019-02-22 13:33:56 +00:00
|
|
|
visit(*t, ast, data);
|
2019-03-11 13:22:51 +00:00
|
|
|
if (auto * t = ast->as<ASTTableJoin>())
|
2019-02-22 13:33:56 +00:00
|
|
|
visit(*t, ast, data);
|
2019-03-11 13:22:51 +00:00
|
|
|
if (auto * t = ast->as<ASTSelectQuery>())
|
2019-02-22 13:33:56 +00:00
|
|
|
visit(*t, ast, data);
|
2019-03-11 13:22:51 +00:00
|
|
|
if (auto * node = ast->as<ASTExpressionList>())
|
2019-02-11 14:19:09 +00:00
|
|
|
visit(*node, ast, data);
|
2019-03-11 13:22:51 +00:00
|
|
|
if (auto * node = ast->as<ASTFunction>())
|
2019-02-11 14:19:09 +00:00
|
|
|
visit(*node, ast, data);
|
2018-12-06 15:29:55 +00:00
|
|
|
}
|
|
|
|
|
2019-02-22 13:33:56 +00:00
|
|
|
void TranslateQualifiedNamesMatcher::visit(ASTIdentifier & identifier, ASTPtr &, Data & data)
|
2018-12-06 15:29:55 +00:00
|
|
|
{
|
2019-01-25 15:42:24 +00:00
|
|
|
if (IdentifierSemantic::getColumnName(identifier))
|
2018-09-27 19:25:18 +00:00
|
|
|
{
|
2019-10-18 16:16:57 +00:00
|
|
|
String short_name = identifier.shortName();
|
|
|
|
bool allow_ambiguous = data.join_using_columns.count(short_name);
|
2020-03-08 11:07:05 +00:00
|
|
|
if (auto best_pos = IdentifierSemantic::chooseTable(identifier, data.tables, allow_ambiguous))
|
2019-10-18 16:16:57 +00:00
|
|
|
{
|
2020-03-08 11:07:05 +00:00
|
|
|
size_t table_pos = *best_pos;
|
2019-11-13 16:49:29 +00:00
|
|
|
if (data.unknownColumn(table_pos, identifier))
|
2019-11-13 15:00:21 +00:00
|
|
|
{
|
2019-12-13 18:46:55 +00:00
|
|
|
String table_name = data.tables[table_pos].table.getQualifiedNamePrefix(false);
|
2019-11-13 15:00:21 +00:00
|
|
|
throw Exception("There's no column '" + identifier.name + "' in table '" + table_name + "'",
|
|
|
|
ErrorCodes::UNKNOWN_IDENTIFIER);
|
|
|
|
}
|
|
|
|
|
2019-10-18 16:16:57 +00:00
|
|
|
IdentifierSemantic::setMembership(identifier, table_pos);
|
|
|
|
|
|
|
|
/// In case if column from the joined table are in source columns, change it's name to qualified.
|
2020-04-12 03:20:15 +00:00
|
|
|
/// Also always leave unusual identifiers qualified.
|
2020-04-22 06:01:33 +00:00
|
|
|
const auto & table = data.tables[table_pos].table;
|
2020-04-12 03:20:15 +00:00
|
|
|
if (table_pos && (data.hasColumn(short_name) || !isValidIdentifierBegin(short_name.at(0))))
|
2019-10-18 16:16:57 +00:00
|
|
|
IdentifierSemantic::setColumnLongName(identifier, table);
|
|
|
|
else
|
|
|
|
IdentifierSemantic::setColumnShortName(identifier, table);
|
|
|
|
}
|
2018-09-27 19:25:18 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-02-11 14:19:09 +00:00
|
|
|
/// As special case, treat count(*) as count(), not as count(list of all columns).
|
|
|
|
void TranslateQualifiedNamesMatcher::visit(ASTFunction & node, const ASTPtr &, Data &)
|
|
|
|
{
|
|
|
|
ASTPtr & func_arguments = node.arguments;
|
|
|
|
|
|
|
|
String func_name_lowercase = Poco::toLower(node.name);
|
|
|
|
if (func_name_lowercase == "count" &&
|
|
|
|
func_arguments->children.size() == 1 &&
|
2019-03-11 13:22:51 +00:00
|
|
|
func_arguments->children[0]->as<ASTAsterisk>())
|
2019-02-11 14:19:09 +00:00
|
|
|
func_arguments->children.clear();
|
|
|
|
}
|
|
|
|
|
2020-04-12 03:20:15 +00:00
|
|
|
void TranslateQualifiedNamesMatcher::visit(const ASTQualifiedAsterisk &, const ASTPtr & ast, Data & data)
|
2018-09-27 19:25:18 +00:00
|
|
|
{
|
|
|
|
if (ast->children.size() != 1)
|
|
|
|
throw Exception("Logical error: qualified asterisk must have exactly one child", ErrorCodes::LOGICAL_ERROR);
|
|
|
|
|
2019-01-16 12:08:43 +00:00
|
|
|
auto & ident = ast->children[0];
|
2018-09-27 19:25:18 +00:00
|
|
|
|
2019-01-16 12:08:43 +00:00
|
|
|
/// @note it could contain table alias as table name.
|
|
|
|
DatabaseAndTableWithAlias db_and_table(ident);
|
2018-10-29 19:04:28 +00:00
|
|
|
|
2019-01-16 12:08:43 +00:00
|
|
|
for (const auto & known_table : data.tables)
|
2019-12-13 18:46:55 +00:00
|
|
|
if (db_and_table.satisfies(known_table.table, true))
|
2019-02-22 13:33:56 +00:00
|
|
|
return;
|
2018-09-27 19:25:18 +00:00
|
|
|
|
|
|
|
throw Exception("Unknown qualified identifier: " + ident->getAliasOrColumnName(), ErrorCodes::UNKNOWN_IDENTIFIER);
|
|
|
|
}
|
|
|
|
|
2019-02-22 13:33:56 +00:00
|
|
|
void TranslateQualifiedNamesMatcher::visit(ASTTableJoin & join, const ASTPtr & , Data & data)
|
2018-09-27 19:25:18 +00:00
|
|
|
{
|
2018-11-01 17:07:20 +00:00
|
|
|
if (join.using_expression_list)
|
2019-04-09 14:22:35 +00:00
|
|
|
Visitor(data).visit(join.using_expression_list);
|
2019-02-07 19:18:40 +00:00
|
|
|
else if (join.on_expression)
|
2019-04-09 14:22:35 +00:00
|
|
|
Visitor(data).visit(join.on_expression);
|
2018-09-27 19:25:18 +00:00
|
|
|
}
|
|
|
|
|
2019-02-22 13:33:56 +00:00
|
|
|
void TranslateQualifiedNamesMatcher::visit(ASTSelectQuery & select, const ASTPtr & , Data & data)
|
2018-09-27 19:25:18 +00:00
|
|
|
{
|
2020-04-22 06:01:33 +00:00
|
|
|
if (const auto * join = select.join())
|
2019-02-11 14:19:09 +00:00
|
|
|
extractJoinUsingColumns(join->table_join, data);
|
2019-04-09 14:59:06 +00:00
|
|
|
|
2019-01-22 19:56:53 +00:00
|
|
|
/// If the WHERE clause or HAVING consists of a single qualified column, the reference must be translated not only in children,
|
2018-09-27 19:25:18 +00:00
|
|
|
/// but also in where_expression and having_expression.
|
2019-04-09 14:22:35 +00:00
|
|
|
if (select.prewhere())
|
2019-04-09 14:59:06 +00:00
|
|
|
Visitor(data).visit(select.refPrewhere());
|
2019-04-09 14:22:35 +00:00
|
|
|
if (select.where())
|
2019-04-09 14:59:06 +00:00
|
|
|
Visitor(data).visit(select.refWhere());
|
2019-04-09 14:22:35 +00:00
|
|
|
if (select.having())
|
2019-04-09 14:59:06 +00:00
|
|
|
Visitor(data).visit(select.refHaving());
|
2018-09-27 19:25:18 +00:00
|
|
|
}
|
|
|
|
|
2019-10-18 16:16:57 +00:00
|
|
|
static void addIdentifier(ASTs & nodes, const DatabaseAndTableWithAlias & table, const String & column_name,
|
|
|
|
AsteriskSemantic::RevertedAliasesPtr aliases)
|
2019-02-20 12:12:36 +00:00
|
|
|
{
|
2019-10-18 16:16:57 +00:00
|
|
|
String table_name = table.getQualifiedNamePrefix(false);
|
2019-03-04 19:40:58 +00:00
|
|
|
auto identifier = std::make_shared<ASTIdentifier>(std::vector<String>{table_name, column_name});
|
2019-02-20 12:12:36 +00:00
|
|
|
|
|
|
|
bool added = false;
|
2019-03-04 19:40:58 +00:00
|
|
|
if (aliases && aliases->count(identifier->name))
|
2019-02-20 12:12:36 +00:00
|
|
|
{
|
2019-03-04 19:40:58 +00:00
|
|
|
for (const String & alias : (*aliases)[identifier->name])
|
2019-02-20 12:12:36 +00:00
|
|
|
{
|
|
|
|
nodes.push_back(identifier->clone());
|
|
|
|
nodes.back()->setAlias(alias);
|
|
|
|
added = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!added)
|
|
|
|
nodes.emplace_back(identifier);
|
|
|
|
}
|
|
|
|
|
2019-02-11 14:19:09 +00:00
|
|
|
/// Replace *, alias.*, database.table.* with a list of columns.
|
|
|
|
void TranslateQualifiedNamesMatcher::visit(ASTExpressionList & node, const ASTPtr &, Data & data)
|
|
|
|
{
|
|
|
|
const auto & tables_with_columns = data.tables;
|
|
|
|
|
|
|
|
ASTs old_children;
|
|
|
|
if (data.processAsterisks())
|
|
|
|
{
|
|
|
|
bool has_asterisk = false;
|
|
|
|
for (const auto & child : node.children)
|
|
|
|
{
|
2019-07-21 17:03:58 +00:00
|
|
|
if (child->as<ASTAsterisk>() || child->as<ASTColumnsMatcher>())
|
2019-02-11 14:19:09 +00:00
|
|
|
{
|
|
|
|
if (tables_with_columns.empty())
|
|
|
|
throw Exception("An asterisk cannot be replaced with empty columns.", ErrorCodes::LOGICAL_ERROR);
|
|
|
|
has_asterisk = true;
|
|
|
|
break;
|
|
|
|
}
|
2019-03-11 13:22:51 +00:00
|
|
|
else if (const auto * qa = child->as<ASTQualifiedAsterisk>())
|
2019-02-11 14:19:09 +00:00
|
|
|
{
|
|
|
|
visit(*qa, child, data); /// check if it's OK before rewrite
|
|
|
|
has_asterisk = true;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (has_asterisk)
|
|
|
|
{
|
|
|
|
old_children.swap(node.children);
|
|
|
|
node.children.reserve(old_children.size());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const auto & child : old_children)
|
|
|
|
{
|
2019-03-11 13:22:51 +00:00
|
|
|
if (const auto * asterisk = child->as<ASTAsterisk>())
|
2019-02-11 14:19:09 +00:00
|
|
|
{
|
|
|
|
bool first_table = true;
|
2019-12-13 18:46:55 +00:00
|
|
|
for (const auto & table : tables_with_columns)
|
2019-02-11 14:19:09 +00:00
|
|
|
{
|
2019-12-13 18:46:55 +00:00
|
|
|
for (const auto & column_name : table.columns)
|
2019-02-20 12:12:36 +00:00
|
|
|
{
|
2019-02-11 14:19:09 +00:00
|
|
|
if (first_table || !data.join_using_columns.count(column_name))
|
|
|
|
{
|
2019-12-13 18:46:55 +00:00
|
|
|
addIdentifier(node.children, table.table, column_name, AsteriskSemantic::getAliases(*asterisk));
|
2019-02-11 14:19:09 +00:00
|
|
|
}
|
2019-02-20 12:12:36 +00:00
|
|
|
}
|
2019-02-11 14:19:09 +00:00
|
|
|
|
|
|
|
first_table = false;
|
|
|
|
}
|
|
|
|
}
|
2019-07-21 17:03:58 +00:00
|
|
|
else if (const auto * asterisk_pattern = child->as<ASTColumnsMatcher>())
|
2019-07-12 11:17:38 +00:00
|
|
|
{
|
|
|
|
bool first_table = true;
|
2019-12-13 18:46:55 +00:00
|
|
|
for (const auto & table : tables_with_columns)
|
2019-07-12 11:17:38 +00:00
|
|
|
{
|
2019-12-13 18:46:55 +00:00
|
|
|
for (const auto & column_name : table.columns)
|
2019-07-12 11:17:38 +00:00
|
|
|
{
|
|
|
|
if (asterisk_pattern->isColumnMatching(column_name) && (first_table || !data.join_using_columns.count(column_name)))
|
|
|
|
{
|
2019-12-13 18:46:55 +00:00
|
|
|
addIdentifier(node.children, table.table, column_name, AsteriskSemantic::getAliases(*asterisk_pattern));
|
2019-07-12 11:17:38 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
first_table = false;
|
|
|
|
}
|
|
|
|
}
|
2019-03-11 13:22:51 +00:00
|
|
|
else if (const auto * qualified_asterisk = child->as<ASTQualifiedAsterisk>())
|
2019-02-11 14:19:09 +00:00
|
|
|
{
|
|
|
|
DatabaseAndTableWithAlias ident_db_and_name(qualified_asterisk->children[0]);
|
|
|
|
|
2019-12-13 18:46:55 +00:00
|
|
|
for (const auto & table : tables_with_columns)
|
2019-02-11 14:19:09 +00:00
|
|
|
{
|
2019-12-13 18:46:55 +00:00
|
|
|
if (ident_db_and_name.satisfies(table.table, true))
|
2019-02-11 14:19:09 +00:00
|
|
|
{
|
2019-12-13 18:46:55 +00:00
|
|
|
for (const auto & column_name : table.columns)
|
2019-02-11 14:19:09 +00:00
|
|
|
{
|
2019-12-13 18:46:55 +00:00
|
|
|
addIdentifier(node.children, table.table, column_name, AsteriskSemantic::getAliases(*qualified_asterisk));
|
2019-02-11 14:19:09 +00:00
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else
|
|
|
|
node.children.emplace_back(child);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 'select * from a join b using id' should result one 'id' column
|
|
|
|
void TranslateQualifiedNamesMatcher::extractJoinUsingColumns(const ASTPtr ast, Data & data)
|
|
|
|
{
|
2019-03-15 16:14:13 +00:00
|
|
|
const auto & table_join = ast->as<ASTTableJoin &>();
|
2019-02-11 14:19:09 +00:00
|
|
|
|
2019-03-15 16:14:13 +00:00
|
|
|
if (table_join.using_expression_list)
|
2019-02-11 14:19:09 +00:00
|
|
|
{
|
2019-03-15 16:14:13 +00:00
|
|
|
const auto & keys = table_join.using_expression_list->as<ASTExpressionList &>();
|
|
|
|
for (const auto & key : keys.children)
|
2019-08-08 20:02:30 +00:00
|
|
|
if (auto opt_column = tryGetIdentifierName(key))
|
2019-02-11 14:19:09 +00:00
|
|
|
data.join_using_columns.insert(*opt_column);
|
2019-03-11 13:22:51 +00:00
|
|
|
else if (key->as<ASTLiteral>())
|
2019-02-11 14:19:09 +00:00
|
|
|
data.join_using_columns.insert(key->getColumnName());
|
|
|
|
else
|
|
|
|
{
|
|
|
|
String alias = key->tryGetAlias();
|
|
|
|
if (alias.empty())
|
|
|
|
throw Exception("Logical error: expected identifier or alias, got: " + key->getID(), ErrorCodes::LOGICAL_ERROR);
|
|
|
|
data.join_using_columns.insert(alias);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-01 14:21:37 +00:00
|
|
|
|
|
|
|
void RestoreQualifiedNamesMatcher::Data::changeTable(ASTIdentifier & identifier) const
|
|
|
|
{
|
|
|
|
auto match = IdentifierSemantic::canReferColumnToTable(identifier, distributed_table);
|
|
|
|
switch (match)
|
|
|
|
{
|
|
|
|
case IdentifierSemantic::ColumnMatch::AliasedTableName:
|
|
|
|
case IdentifierSemantic::ColumnMatch::TableName:
|
|
|
|
case IdentifierSemantic::ColumnMatch::DbAndTable:
|
|
|
|
IdentifierSemantic::setColumnLongName(identifier, remote_table);
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
bool RestoreQualifiedNamesMatcher::needChildVisit(ASTPtr &, const ASTPtr & child)
|
|
|
|
{
|
|
|
|
/// Do not go into subqueries
|
|
|
|
if (child->as<ASTSelectWithUnionQuery>())
|
|
|
|
return false; // NOLINT
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
void RestoreQualifiedNamesMatcher::visit(ASTPtr & ast, Data & data)
|
|
|
|
{
|
|
|
|
if (auto * t = ast->as<ASTIdentifier>())
|
|
|
|
visit(*t, ast, data);
|
|
|
|
}
|
|
|
|
|
|
|
|
void RestoreQualifiedNamesMatcher::visit(ASTIdentifier & identifier, ASTPtr &, Data & data)
|
2019-07-26 17:43:42 +00:00
|
|
|
{
|
2019-10-18 16:16:57 +00:00
|
|
|
if (IdentifierSemantic::getColumnName(identifier))
|
2019-07-26 17:43:42 +00:00
|
|
|
{
|
2019-10-21 11:22:22 +00:00
|
|
|
if (IdentifierSemantic::getMembership(identifier))
|
2019-10-18 16:16:57 +00:00
|
|
|
{
|
2020-04-01 14:21:37 +00:00
|
|
|
identifier.restoreCompoundName();
|
|
|
|
if (data.rename)
|
|
|
|
data.changeTable(identifier);
|
2019-10-18 16:16:57 +00:00
|
|
|
}
|
2019-07-26 17:43:42 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-09-27 19:25:18 +00:00
|
|
|
}
|