1 | #include <Columns/ColumnConst.h> |
2 | #include <DataTypes/DataTypesNumber.h> |
3 | #include <DataTypes/DataTypeNullable.h> |
4 | #include <DataTypes/FieldToDataType.h> |
5 | #include <Processors/Formats/IRowInputFormat.h> |
6 | #include <Functions/FunctionsConversion.h> |
7 | #include <Functions/FunctionFactory.h> |
8 | #include <Interpreters/ExpressionAnalyzer.h> |
9 | #include <Interpreters/ReplaceQueryParameterVisitor.h> |
10 | #include <Interpreters/SyntaxAnalyzer.h> |
11 | #include <IO/ReadHelpers.h> |
12 | #include <Parsers/ASTExpressionList.h> |
13 | #include <Parsers/ASTFunction.h> |
14 | #include <Parsers/ASTIdentifier.h> |
15 | #include <Parsers/ASTLiteral.h> |
16 | #include <Parsers/ASTQueryParameter.h> |
17 | #include <Parsers/CommonParsers.h> |
18 | #include <Processors/Formats/Impl/ConstantExpressionTemplate.h> |
19 | #include <Parsers/ExpressionElementParsers.h> |
20 | #include <Interpreters/convertFieldToType.h> |
21 | #include <boost/functional/hash.hpp> |
22 | |
23 | |
24 | namespace DB |
25 | { |
26 | |
27 | namespace ErrorCodes |
28 | { |
29 | extern const int SYNTAX_ERROR; |
30 | } |
31 | |
32 | struct SpecialParserType |
33 | { |
34 | bool is_array = false; |
35 | bool is_nullable = false; |
36 | Field::Types::Which nested_type = Field::Types::Which::String; |
37 | |
38 | bool useDefaultParser() const { return nested_type == Field::Types::Which::String; } |
39 | }; |
40 | |
41 | struct LiteralInfo |
42 | { |
43 | typedef std::shared_ptr<ASTLiteral> ASTLiteralPtr; |
44 | LiteralInfo(const ASTLiteralPtr & literal_, const String & column_name_, bool force_nullable_) |
45 | : literal(literal_), dummy_column_name(column_name_), force_nullable(force_nullable_) { } |
46 | ASTLiteralPtr literal; |
47 | String dummy_column_name; |
48 | /// Make column nullable even if expression type is not. |
49 | /// (for literals in functions like ifNull and assumeNotNul, which never return NULL even for NULL arguments) |
50 | bool force_nullable; |
51 | |
52 | DataTypePtr type; |
53 | SpecialParserType special_parser; |
54 | }; |
55 | |
56 | /// Extracts ASTLiterals from expression, replaces them with ASTIdentifiers where needed |
57 | /// and deduces data types for dummy columns by field type of literal |
58 | class ReplaceLiteralsVisitor |
59 | { |
60 | public: |
61 | LiteralsInfo replaced_literals; |
62 | const Context & context; |
63 | |
64 | explicit ReplaceLiteralsVisitor(const Context & context_) : context(context_) { } |
65 | |
66 | void visit(ASTPtr & ast, bool force_nullable) |
67 | { |
68 | if (visitIfLiteral(ast, force_nullable)) |
69 | return; |
70 | if (auto function = ast->as<ASTFunction>()) |
71 | visit(*function, force_nullable); |
72 | else if (ast->as<ASTQueryParameter>()) |
73 | return; |
74 | else if (ast->as<ASTIdentifier>()) |
75 | throw DB::Exception("Identifier in constant expression" , ErrorCodes::SYNTAX_ERROR); |
76 | else |
77 | throw DB::Exception("Syntax error in constant expression" , ErrorCodes::SYNTAX_ERROR); |
78 | } |
79 | |
80 | private: |
81 | void visitChildren(ASTPtr & ast, const ColumnNumbers & dont_visit_children, const std::vector<char> & force_nullable) |
82 | { |
83 | for (size_t i = 0; i < ast->children.size(); ++i) |
84 | if (std::find(dont_visit_children.begin(), dont_visit_children.end(), i) == dont_visit_children.end()) |
85 | visit(ast->children[i], force_nullable[i]); |
86 | } |
87 | |
88 | void visit(ASTFunction & function, bool force_nullable) |
89 | { |
90 | if (function.name == "lambda" ) |
91 | return; |
92 | |
93 | FunctionOverloadResolverPtr builder = FunctionFactory::instance().get(function.name, context); |
94 | /// Do not replace literals which must be constant |
95 | ColumnNumbers dont_visit_children = builder->getArgumentsThatAreAlwaysConstant(); |
96 | /// Allow nullable arguments if function never returns NULL |
97 | ColumnNumbers can_always_be_nullable = builder->getArgumentsThatDontImplyNullableReturnType(function.arguments->children.size()); |
98 | |
99 | std::vector<char> force_nullable_arguments(function.arguments->children.size(), force_nullable); |
100 | for (auto & idx : can_always_be_nullable) |
101 | if (idx < force_nullable_arguments.size()) |
102 | force_nullable_arguments[idx] = true; |
103 | |
104 | visitChildren(function.arguments, dont_visit_children, force_nullable_arguments); |
105 | } |
106 | |
107 | bool visitIfLiteral(ASTPtr & ast, bool force_nullable) |
108 | { |
109 | auto literal = std::dynamic_pointer_cast<ASTLiteral>(ast); |
110 | if (!literal) |
111 | return false; |
112 | if (literal->begin && literal->end) |
113 | { |
114 | /// Do not replace empty array and array of NULLs |
115 | if (literal->value.getType() == Field::Types::Array) |
116 | { |
117 | const Array & array = literal->value.get<Array>(); |
118 | auto not_null = std::find_if_not(array.begin(), array.end(), [](const auto & elem) { return elem.isNull(); }); |
119 | if (not_null == array.end()) |
120 | return true; |
121 | } |
122 | String column_name = "_dummy_" + std::to_string(replaced_literals.size()); |
123 | replaced_literals.emplace_back(literal, column_name, force_nullable); |
124 | setDataType(replaced_literals.back()); |
125 | ast = std::make_shared<ASTIdentifier>(column_name); |
126 | } |
127 | return true; |
128 | } |
129 | |
130 | void setDataType(LiteralInfo & info) |
131 | { |
132 | /// Type (Field::Types:Which) of literal in AST can be: String, UInt64, Int64, Float64, Null or Array of simple literals (not of Arrays). |
133 | /// Null and empty Array literals are considered as tokens, because template with Nullable(Nothing) or Array(Nothing) is useless. |
134 | |
135 | Field::Types::Which field_type = info.literal->value.getType(); |
136 | |
137 | /// We have to use ParserNumber instead of type->deserializeAsTextQuoted() for arithmetic types |
138 | /// to check actual type of literal and avoid possible overflow and precision issues. |
139 | info.special_parser = SpecialParserType{false, false, field_type}; |
140 | |
141 | /// Do not use 8, 16 and 32 bit types, so template will match all integers |
142 | if (field_type == Field::Types::UInt64) |
143 | info.type = std::make_shared<DataTypeUInt64>(); |
144 | else if (field_type == Field::Types::Int64) |
145 | info.type = std::make_shared<DataTypeInt64>(); |
146 | else if (field_type == Field::Types::Float64) |
147 | info.type = std::make_shared<DataTypeFloat64>(); |
148 | else if (field_type == Field::Types::String) |
149 | info.type = std::make_shared<DataTypeString>(); |
150 | else if (field_type == Field::Types::Array) |
151 | { |
152 | info.special_parser.is_array = true; |
153 | info.type = applyVisitor(FieldToDataType(), info.literal->value); |
154 | auto nested_type = assert_cast<const DataTypeArray &>(*info.type).getNestedType(); |
155 | |
156 | /// It can be Array(Nullable(nested_type)) |
157 | bool array_of_nullable = false; |
158 | if (auto nullable = dynamic_cast<const DataTypeNullable *>(nested_type.get())) |
159 | { |
160 | nested_type = nullable->getNestedType(); |
161 | array_of_nullable = true; |
162 | } |
163 | |
164 | WhichDataType type_info{nested_type}; |
165 | /// Promote integers to 64 bit types |
166 | if (type_info.isNativeUInt()) |
167 | { |
168 | nested_type = std::make_shared<DataTypeUInt64>(); |
169 | info.special_parser.nested_type = Field::Types::UInt64; |
170 | } |
171 | else if (type_info.isNativeInt()) |
172 | { |
173 | nested_type = std::make_shared<DataTypeInt64>(); |
174 | info.special_parser.nested_type = Field::Types::Int64; |
175 | } |
176 | else if (type_info.isFloat64()) |
177 | { |
178 | info.special_parser.nested_type = Field::Types::Float64; |
179 | } |
180 | else if (type_info.isString()) |
181 | { |
182 | info.special_parser.nested_type = Field::Types::String; |
183 | } |
184 | else |
185 | throw Exception("Unexpected literal type inside Array: " + nested_type->getName() + ". It's a bug" , |
186 | ErrorCodes::LOGICAL_ERROR); |
187 | |
188 | if (array_of_nullable) |
189 | { |
190 | nested_type = std::make_shared<DataTypeNullable>(nested_type); |
191 | info.special_parser.is_nullable = true; |
192 | } |
193 | |
194 | info.type = std::make_shared<DataTypeArray>(nested_type); |
195 | } |
196 | else |
197 | throw Exception(String("Unexpected literal type " ) + info.literal->value.getTypeName() + ". It's a bug" , |
198 | ErrorCodes::LOGICAL_ERROR); |
199 | |
200 | /// Allow literal to be NULL, if result column has nullable type or if function never returns NULL |
201 | if (info.force_nullable && info.type->canBeInsideNullable()) |
202 | { |
203 | info.type = makeNullable(info.type); |
204 | info.special_parser.is_nullable = true; |
205 | } |
206 | } |
207 | }; |
208 | |
209 | |
210 | |
211 | /// Expression template is a sequence of tokens and data types of literals. |
212 | /// E.g. template of "position('some string', 'other string') != 0" is |
213 | /// ["position", "(", DataTypeString, ",", DataTypeString, ")", "!=", DataTypeUInt64] |
214 | ConstantExpressionTemplate::TemplateStructure::TemplateStructure(LiteralsInfo & replaced_literals, TokenIterator expression_begin, TokenIterator expression_end, |
215 | ASTPtr & expression, const IDataType & result_type, bool null_as_default_, const Context & context) |
216 | { |
217 | null_as_default = null_as_default_; |
218 | |
219 | std::sort(replaced_literals.begin(), replaced_literals.end(), [](const LiteralInfo & a, const LiteralInfo & b) |
220 | { |
221 | return a.literal->begin.value() < b.literal->begin.value(); |
222 | }); |
223 | |
224 | /// Make sequence of tokens and determine IDataType by Field::Types:Which for each literal. |
225 | token_after_literal_idx.reserve(replaced_literals.size()); |
226 | special_parser.resize(replaced_literals.size()); |
227 | |
228 | TokenIterator prev_end = expression_begin; |
229 | for (size_t i = 0; i < replaced_literals.size(); ++i) |
230 | { |
231 | const LiteralInfo & info = replaced_literals[i]; |
232 | if (info.literal->begin.value() < prev_end) |
233 | throw Exception("Cannot replace literals" , ErrorCodes::LOGICAL_ERROR); |
234 | |
235 | while (prev_end < info.literal->begin.value()) |
236 | { |
237 | tokens.emplace_back(prev_end->begin, prev_end->size()); |
238 | ++prev_end; |
239 | } |
240 | token_after_literal_idx.push_back(tokens.size()); |
241 | |
242 | special_parser[i] = info.special_parser; |
243 | |
244 | literals.insert({nullptr, info.type, info.dummy_column_name}); |
245 | |
246 | prev_end = info.literal->end.value(); |
247 | } |
248 | |
249 | while (prev_end < expression_end) |
250 | { |
251 | tokens.emplace_back(prev_end->begin, prev_end->size()); |
252 | ++prev_end; |
253 | } |
254 | |
255 | addNodesToCastResult(result_type, expression, null_as_default); |
256 | |
257 | auto syntax_result = SyntaxAnalyzer(context).analyze(expression, literals.getNamesAndTypesList()); |
258 | result_column_name = expression->getColumnName(); |
259 | actions_on_literals = ExpressionAnalyzer(expression, syntax_result, context).getActions(false); |
260 | } |
261 | |
262 | size_t ConstantExpressionTemplate::TemplateStructure::getTemplateHash(const ASTPtr & expression, |
263 | const LiteralsInfo & replaced_literals, |
264 | const DataTypePtr & result_column_type, |
265 | bool null_as_default, |
266 | const String & salt) |
267 | { |
268 | /// TODO distinguish expressions with the same AST and different tokens (e.g. "CAST(expr, 'Type')" and "CAST(expr AS Type)") |
269 | SipHash hash_state; |
270 | hash_state.update(result_column_type->getName()); |
271 | |
272 | expression->updateTreeHash(hash_state); |
273 | |
274 | for (const auto & info : replaced_literals) |
275 | hash_state.update(info.type->getName()); |
276 | hash_state.update(null_as_default); |
277 | |
278 | /// Allows distinguish expression in the last column in Values format |
279 | hash_state.update(salt); |
280 | |
281 | IAST::Hash res128; |
282 | hash_state.get128(res128.first, res128.second); |
283 | size_t res = 0; |
284 | boost::hash_combine(res, res128.first); |
285 | boost::hash_combine(res, res128.second); |
286 | return res; |
287 | } |
288 | |
289 | |
290 | |
291 | ConstantExpressionTemplate::TemplateStructurePtr |
292 | ConstantExpressionTemplate::Cache::getFromCacheOrConstruct(const DataTypePtr & result_column_type, |
293 | bool null_as_default, |
294 | TokenIterator expression_begin, |
295 | TokenIterator expression_end, |
296 | const ASTPtr & expression_, |
297 | const Context & context, |
298 | bool * found_in_cache, |
299 | const String & salt) |
300 | { |
301 | TemplateStructurePtr res; |
302 | ASTPtr expression = expression_->clone(); |
303 | ReplaceLiteralsVisitor visitor(context); |
304 | visitor.visit(expression, result_column_type->isNullable() || null_as_default); |
305 | ReplaceQueryParameterVisitor param_visitor(context.getQueryParameters()); |
306 | param_visitor.visit(expression); |
307 | |
308 | size_t template_hash = TemplateStructure::getTemplateHash(expression, visitor.replaced_literals, result_column_type, null_as_default, salt); |
309 | auto iter = cache.find(template_hash); |
310 | if (iter == cache.end()) |
311 | { |
312 | if (max_size <= cache.size()) |
313 | cache.clear(); |
314 | res = std::make_shared<TemplateStructure>(visitor.replaced_literals, expression_begin, expression_end, |
315 | expression, *result_column_type, null_as_default, context); |
316 | cache.insert({template_hash, res}); |
317 | if (found_in_cache) |
318 | *found_in_cache = false; |
319 | } |
320 | else |
321 | { |
322 | /// FIXME process collisions correctly |
323 | res = iter->second; |
324 | if (found_in_cache) |
325 | *found_in_cache = true; |
326 | } |
327 | |
328 | return res; |
329 | } |
330 | |
331 | bool ConstantExpressionTemplate::parseExpression(ReadBuffer & istr, const FormatSettings & settings) |
332 | { |
333 | size_t cur_column = 0; |
334 | try |
335 | { |
336 | if (tryParseExpression(istr, settings, cur_column)) |
337 | { |
338 | ++rows_count; |
339 | return true; |
340 | } |
341 | } |
342 | catch (DB::Exception & e) |
343 | { |
344 | for (size_t i = 0; i < cur_column; ++i) |
345 | columns[i]->popBack(1); |
346 | |
347 | if (!isParseError(e.code())) |
348 | throw; |
349 | |
350 | return false; |
351 | } |
352 | |
353 | for (size_t i = 0; i < cur_column; ++i) |
354 | columns[i]->popBack(1); |
355 | return false; |
356 | } |
357 | |
358 | bool ConstantExpressionTemplate::tryParseExpression(ReadBuffer & istr, const FormatSettings & settings, size_t & cur_column) |
359 | { |
360 | size_t cur_token = 0; |
361 | size_t num_columns = structure->literals.columns(); |
362 | while (cur_column < num_columns) |
363 | { |
364 | size_t skip_tokens_until = structure->token_after_literal_idx[cur_column]; |
365 | while (cur_token < skip_tokens_until) |
366 | { |
367 | /// TODO skip comments |
368 | skipWhitespaceIfAny(istr); |
369 | if (!checkString(structure->tokens[cur_token++], istr)) |
370 | return false; |
371 | } |
372 | skipWhitespaceIfAny(istr); |
373 | |
374 | const DataTypePtr & type = structure->literals.getByPosition(cur_column).type; |
375 | if (settings.values.accurate_types_of_literals && !structure->special_parser[cur_column].useDefaultParser()) |
376 | { |
377 | if (!parseLiteralAndAssertType(istr, type.get(), cur_column)) |
378 | return false; |
379 | } |
380 | else |
381 | type->deserializeAsTextQuoted(*columns[cur_column], istr, settings); |
382 | |
383 | ++cur_column; |
384 | } |
385 | while (cur_token < structure->tokens.size()) |
386 | { |
387 | skipWhitespaceIfAny(istr); |
388 | if (!checkString(structure->tokens[cur_token++], istr)) |
389 | return false; |
390 | } |
391 | |
392 | return true; |
393 | } |
394 | |
395 | bool ConstantExpressionTemplate::parseLiteralAndAssertType(ReadBuffer & istr, const IDataType * complex_type, size_t column_idx) |
396 | { |
397 | using Type = Field::Types::Which; |
398 | |
399 | /// TODO in case of type mismatch return some hints to deduce new template faster |
400 | if (istr.eof()) |
401 | return false; |
402 | |
403 | SpecialParserType type_info = structure->special_parser[column_idx]; |
404 | |
405 | /// If literal does not fit entirely in the buffer, parsing error will happen. |
406 | /// However, it's possible to deduce new template (or use template from cache) after error like it was template mismatch. |
407 | |
408 | if (type_info.is_array) |
409 | { |
410 | /// TODO faster way to check types without using Parsers |
411 | ParserArrayOfLiterals parser_array; |
412 | Tokens tokens_number(istr.position(), istr.buffer().end()); |
413 | IParser::Pos iterator(tokens_number); |
414 | Expected expected; |
415 | ASTPtr ast; |
416 | |
417 | if (!parser_array.parse(iterator, ast, expected)) |
418 | return false; |
419 | istr.position() = const_cast<char *>(iterator->begin); |
420 | |
421 | const Field & array = ast->as<ASTLiteral &>().value; |
422 | auto array_type = applyVisitor(FieldToDataType(), array); |
423 | auto nested_type = assert_cast<const DataTypeArray &>(*array_type).getNestedType(); |
424 | if (type_info.is_nullable) |
425 | if (auto nullable = dynamic_cast<const DataTypeNullable *>(nested_type.get())) |
426 | nested_type = nullable->getNestedType(); |
427 | |
428 | WhichDataType nested_type_info(nested_type); |
429 | if ((nested_type_info.isNativeUInt() && type_info.nested_type == Type::UInt64) || |
430 | (nested_type_info.isNativeInt() && type_info.nested_type == Type::Int64) || |
431 | (nested_type_info.isFloat64() && type_info.nested_type == Type::Float64)) |
432 | { |
433 | Field array_same_types = convertFieldToType(array, *complex_type, nullptr); |
434 | columns[column_idx]->insert(array_same_types); |
435 | return true; |
436 | } |
437 | return false; |
438 | } |
439 | else |
440 | { |
441 | Field number; |
442 | if (type_info.is_nullable && 4 <= istr.available() && 0 == strncasecmp(istr.position(), "NULL" , 4)) |
443 | istr.position() += 4; |
444 | else |
445 | { |
446 | /// ParserNumber::parse(...) is about 20x slower than strtod(...) |
447 | /// because of using ASTPtr, Expected and Tokens, which are not needed here. |
448 | /// Parse numeric literal in the same way, as ParserNumber does, but use strtod and strtoull directly. |
449 | bool negative = *istr.position() == '-'; |
450 | if (negative || *istr.position() == '+') |
451 | ++istr.position(); |
452 | |
453 | static constexpr size_t MAX_LENGTH_OF_NUMBER = 319; |
454 | char buf[MAX_LENGTH_OF_NUMBER + 1]; |
455 | size_t bytes_to_copy = std::min(istr.available(), MAX_LENGTH_OF_NUMBER); |
456 | memcpy(buf, istr.position(), bytes_to_copy); |
457 | buf[bytes_to_copy] = 0; |
458 | |
459 | char * pos_double = buf; |
460 | errno = 0; |
461 | Float64 float_value = std::strtod(buf, &pos_double); |
462 | if (pos_double == buf || errno == ERANGE || float_value < 0) |
463 | return false; |
464 | |
465 | if (negative) |
466 | float_value = -float_value; |
467 | |
468 | char * pos_integer = buf; |
469 | errno = 0; |
470 | UInt64 uint_value = std::strtoull(buf, &pos_integer, 0); |
471 | if (pos_integer == pos_double && errno != ERANGE && (!negative || uint_value <= (1ULL << 63))) |
472 | { |
473 | istr.position() += pos_integer - buf; |
474 | if (negative && type_info.nested_type == Type::Int64) |
475 | number = static_cast<Int64>(-uint_value); |
476 | else if (!negative && type_info.nested_type == Type::UInt64) |
477 | number = uint_value; |
478 | else |
479 | return false; |
480 | } |
481 | else if (type_info.nested_type == Type::Float64) |
482 | { |
483 | istr.position() += pos_double - buf; |
484 | number = float_value; |
485 | } |
486 | else |
487 | return false; |
488 | } |
489 | |
490 | columns[column_idx]->insert(number); |
491 | return true; |
492 | } |
493 | } |
494 | |
495 | ColumnPtr ConstantExpressionTemplate::evaluateAll(BlockMissingValues & nulls, size_t column_idx, size_t offset) |
496 | { |
497 | Block evaluated = structure->literals.cloneWithColumns(std::move(columns)); |
498 | columns = structure->literals.cloneEmptyColumns(); |
499 | if (!structure->literals.columns()) |
500 | evaluated.insert({ColumnConst::create(ColumnUInt8::create(1, 0), rows_count), std::make_shared<DataTypeUInt8>(), "_dummy" }); |
501 | structure->actions_on_literals->execute(evaluated); |
502 | |
503 | if (!evaluated || evaluated.rows() != rows_count) |
504 | throw Exception("Number of rows mismatch after evaluation of batch of constant expressions: got " + |
505 | std::to_string(evaluated.rows()) + " rows for " + std::to_string(rows_count) + " expressions" , |
506 | ErrorCodes::LOGICAL_ERROR); |
507 | |
508 | if (!evaluated.has(structure->result_column_name)) |
509 | throw Exception("Cannot evaluate template " + structure->result_column_name + ", block structure:\n" + evaluated.dumpStructure(), |
510 | ErrorCodes::LOGICAL_ERROR); |
511 | |
512 | rows_count = 0; |
513 | ColumnPtr res = evaluated.getByName(structure->result_column_name).column->convertToFullColumnIfConst(); |
514 | if (!structure->null_as_default) |
515 | return res; |
516 | |
517 | /// Extract column with evaluated expression and mask for NULLs |
518 | auto & tuple = assert_cast<const ColumnTuple &>(*res); |
519 | if (tuple.tupleSize() != 2) |
520 | throw Exception("Invalid tuple size, it'a a bug" , ErrorCodes::LOGICAL_ERROR); |
521 | auto & is_null = assert_cast<const ColumnUInt8 &>(tuple.getColumn(1)); |
522 | |
523 | for (size_t i = 0; i < is_null.size(); ++i) |
524 | if (is_null.getUInt(i)) |
525 | nulls.setBit(column_idx, offset + i); |
526 | |
527 | return tuple.getColumnPtr(0); |
528 | } |
529 | |
530 | void ConstantExpressionTemplate::TemplateStructure::addNodesToCastResult(const IDataType & result_column_type, ASTPtr & expr, bool null_as_default) |
531 | { |
532 | /// Replace "expr" with "CAST(expr, 'TypeName')" |
533 | /// or with "(CAST(assumeNotNull(expr as _expression), 'TypeName'), isNull(_expression))" if null_as_default is true |
534 | if (null_as_default) |
535 | { |
536 | expr->setAlias("_expression" ); |
537 | expr = makeASTFunction("assumeNotNull" , std::move(expr)); |
538 | } |
539 | |
540 | expr = makeASTFunction("CAST" , std::move(expr), std::make_shared<ASTLiteral>(result_column_type.getName())); |
541 | |
542 | if (null_as_default) |
543 | { |
544 | auto is_null = makeASTFunction("isNull" , std::make_shared<ASTIdentifier>("_expression" )); |
545 | expr = makeASTFunction("tuple" , std::move(expr), std::move(is_null)); |
546 | } |
547 | } |
548 | |
549 | } |
550 | |