diff options
Diffstat (limited to 'src/mongo/db/matcher')
| -rw-r--r-- | src/mongo/db/matcher/expression_algo.cpp | 76 | ||||
| -rw-r--r-- | src/mongo/db/matcher/expression_algo.h | 34 | ||||
| -rw-r--r-- | src/mongo/db/matcher/expression_algo_test.cpp | 42 | ||||
| -rw-r--r-- | src/mongo/db/matcher/expression_internal_bucket_geo_within.h | 5 | ||||
| -rw-r--r-- | src/mongo/db/matcher/expression_leaf.cpp | 3 | ||||
| -rw-r--r-- | src/mongo/db/matcher/expression_leaf.h | 11 | ||||
| -rw-r--r-- | src/mongo/db/matcher/expression_optimize_test.cpp | 21 | ||||
| -rw-r--r-- | src/mongo/db/matcher/expression_parser.cpp | 6 | ||||
| -rw-r--r-- | src/mongo/db/matcher/expression_tree.cpp | 27 |
9 files changed, 185 insertions, 40 deletions
diff --git a/src/mongo/db/matcher/expression_algo.cpp b/src/mongo/db/matcher/expression_algo.cpp index 5d56608227d..558ef533380 100644 --- a/src/mongo/db/matcher/expression_algo.cpp +++ b/src/mongo/db/matcher/expression_algo.cpp @@ -31,6 +31,7 @@ #include "mongo/platform/basic.h" #include "mongo/base/checked_cast.h" +#include "mongo/bson/unordered_fields_bsonobj_comparator.h" #include "mongo/db/matcher/expression.h" #include "mongo/db/matcher/expression_algo.h" #include "mongo/db/matcher/expression_array.h" @@ -374,7 +375,7 @@ unique_ptr<MatchExpression> createNorOfNodes(std::vector<unique_ptr<MatchExpress */ std::pair<unique_ptr<MatchExpression>, unique_ptr<MatchExpression>> splitMatchExpressionByFunction( unique_ptr<MatchExpression> expr, - const std::set<std::string>& fields, + const OrderedPathSet& fields, expression::ShouldSplitExprFunc shouldSplitOut) { if (shouldSplitOut(*expr, fields)) { // 'expr' satisfies our split condition and can be completely split out. @@ -440,7 +441,7 @@ std::pair<unique_ptr<MatchExpression>, unique_ptr<MatchExpression>> splitMatchEx bool pathDependenciesAreExact(StringData key, const MatchExpression* expr) { DepsTracker columnDeps; expr->addDependencies(&columnDeps); - return !columnDeps.needWholeDocument && columnDeps.fields == std::set{key.toString()}; + return !columnDeps.needWholeDocument && columnDeps.fields == OrderedPathSet{key.toString()}; } bool tryAddExprHelper(StringData path, @@ -769,7 +770,6 @@ bool isSubsetOf(const MatchExpression* lhs, const MatchExpression* rhs) { return false; } -// Checks if 'expr' has any children which do not have renaming implemented. bool hasOnlyRenameableMatchExpressionChildren(const MatchExpression& expr) { if (expr.matchType() == MatchExpression::MatchType::EXPRESSION) { return true; @@ -786,7 +786,33 @@ bool hasOnlyRenameableMatchExpressionChildren(const MatchExpression& expr) { return true; } -bool isIndependentOf(const MatchExpression& expr, const std::set<std::string>& pathSet) { +bool containsDependency(const OrderedPathSet& testSet, const OrderedPathSet& prefixCandidates) { + if (testSet.empty()) { + return false; + } + + PathComparator pathComparator; + auto i2 = testSet.begin(); + for (auto p1 : prefixCandidates) { + while (pathComparator(*i2, p1)) { + ++i2; + if (i2 == testSet.end()) { + return false; + } + } + // At this point we know that p1 <= *i2, so it may be identical or a path prefix. + if (p1 == *i2 || isPathPrefixOf(p1, *i2)) { + return true; + } + } + return false; +} + +bool areIndependent(const OrderedPathSet& pathSet1, const OrderedPathSet& pathSet2) { + return !containsDependency(pathSet1, pathSet2) && !containsDependency(pathSet2, pathSet1); +} + +bool isIndependentOf(const MatchExpression& expr, const OrderedPathSet& pathSet) { // Any expression types that do not have renaming implemented cannot have their independence // evaluated here. See applyRenamesToExpression(). if (!hasOnlyRenameableMatchExpressionChildren(expr)) { @@ -795,35 +821,43 @@ bool isIndependentOf(const MatchExpression& expr, const std::set<std::string>& p auto depsTracker = DepsTracker{}; expr.addDependencies(&depsTracker); - return std::none_of( - depsTracker.fields.begin(), depsTracker.fields.end(), [&pathSet](auto&& field) { - return pathSet.find(field) != pathSet.end() || - std::any_of(pathSet.begin(), pathSet.end(), [&field](auto&& path) { - return expression::isPathPrefixOf(field, path) || - expression::isPathPrefixOf(path, field); - }); - }); + // Match expressions that generate random numbers can't be safely split out and pushed down. + if (depsTracker.needRandomGenerator || depsTracker.needWholeDocument) { + return false; + } + return areIndependent(pathSet, depsTracker.fields); } -bool isOnlyDependentOn(const MatchExpression& expr, const std::set<std::string>& pathSet) { +bool isOnlyDependentOn(const MatchExpression& expr, const OrderedPathSet& pathSet) { // Any expression types that do not have renaming implemented cannot have their independence // evaluated here. See applyRenamesToExpression(). if (!hasOnlyRenameableMatchExpressionChildren(expr)) { return false; } - auto depsTracker = DepsTracker{}; - expr.addDependencies(&depsTracker); - return std::all_of(depsTracker.fields.begin(), depsTracker.fields.end(), [&](auto&& field) { - return std::any_of(pathSet.begin(), pathSet.end(), [&](auto&& path) { - return path == field || isPathPrefixOf(path, field); - }); - }); + // The approach below takes only O(n log n) time. + + // Find the unique dependencies of pathSet. + auto pathsDeps = + DepsTracker::simplifyDependencies(pathSet, DepsTracker::TruncateToRootLevel::no); + auto pathsDepsCopy = OrderedPathSet(pathsDeps.begin(), pathsDeps.end()); + + // Now add the match expression's paths and see if the dependencies are the same. + auto exprDepsTracker = DepsTracker{}; + expr.addDependencies(&exprDepsTracker); + // Match expressions that generate random numbers can't be safely split out and pushed down. + if (exprDepsTracker.needRandomGenerator) { + return false; + } + pathsDepsCopy.insert(exprDepsTracker.fields.begin(), exprDepsTracker.fields.end()); + + return pathsDeps == + DepsTracker::simplifyDependencies(pathsDepsCopy, DepsTracker::TruncateToRootLevel::no); } std::pair<unique_ptr<MatchExpression>, unique_ptr<MatchExpression>> splitMatchExpressionBy( unique_ptr<MatchExpression> expr, - const std::set<std::string>& fields, + const OrderedPathSet& fields, const StringMap<std::string>& renames, ShouldSplitExprFunc func /*= isIndependentOf */) { auto splitExpr = splitMatchExpressionByFunction(std::move(expr), fields, func); diff --git a/src/mongo/db/matcher/expression_algo.h b/src/mongo/db/matcher/expression_algo.h index 0fdbac756a0..fc42fca7814 100644 --- a/src/mongo/db/matcher/expression_algo.h +++ b/src/mongo/db/matcher/expression_algo.h @@ -34,6 +34,7 @@ #include <set> #include "mongo/base/string_data.h" +#include "mongo/db/pipeline/dependencies.h" #include "mongo/util/string_map.h" namespace mongo { @@ -53,6 +54,11 @@ using NodeTraversalFunc = std::function<void(MatchExpression*, std::string)>; bool hasExistencePredicateOnPath(const MatchExpression& expr, StringData path); /** + * Checks if 'expr' has any children which do not have renaming implemented. + */ +bool hasOnlyRenameableMatchExpressionChildren(const MatchExpression& expr); + +/** * Returns true if the documents matched by 'lhs' are a subset of the documents matched by * 'rhs', i.e. a document matched by 'lhs' must also be matched by 'rhs', and false otherwise. * @@ -85,17 +91,32 @@ bool isSubsetOf(const MatchExpression* lhs, const MatchExpression* rhs); * For example, {a: "foo", b: "bar"} is splittable by "b", while * {$or: [{a: {$eq: "foo"}}, {b: {$eq: "bar"}}]} is not splittable by "b", due to the $or. */ -bool isSplittableBy(const MatchExpression& expr, const std::set<std::string>& pathSet); +bool isSplittableBy(const MatchExpression& expr, const OrderedPathSet& pathSet); + +/** + * True if no path in either set is contained by a path in the other. Does not check for + * dependencies within each of the sets, just across sets. Runs in 0(n) time. + * + * areIndependent([a.b, b, a], [c]) --> true + * areIndependent([a.b, b, a], [a.b.f]) --> false + */ +bool areIndependent(const OrderedPathSet& pathSet1, const OrderedPathSet& pathSet2); + +/** + * Return true if any of the fieldPaths in prefixCandidates are identical to or an ancestor of any + * of the fieldpaths in testSet. The order of the parameters matters -- it's not commutative. + */ +bool containsDependency(const OrderedPathSet& testSet, const OrderedPathSet& prefixCandidates); /** * Determine if 'expr' is reliant upon any path from 'pathSet'. */ -bool isIndependentOf(const MatchExpression& expr, const std::set<std::string>& pathSet); +bool isIndependentOf(const MatchExpression& expr, const OrderedPathSet& pathSet); /** * Determine if 'expr' is reliant only upon paths from 'pathSet'. */ -bool isOnlyDependentOn(const MatchExpression& expr, const std::set<std::string>& pathSet); +bool isOnlyDependentOn(const MatchExpression& expr, const OrderedPathSet& pathSet); /** * Returns whether the path represented by 'first' is an prefix of the path represented by 'second'. @@ -122,8 +143,7 @@ bool bidirectionalPathPrefixOf(StringData first, StringData second); */ void mapOver(MatchExpression* expr, NodeTraversalFunc func, std::string path = ""); -using ShouldSplitExprFunc = - std::function<bool(const MatchExpression&, const std::set<std::string>&)>; +using ShouldSplitExprFunc = std::function<bool(const MatchExpression&, const OrderedPathSet&)>; /** * Attempt to split 'expr' into two MatchExpressions according to 'func'. 'func' describes the @@ -146,7 +166,7 @@ using ShouldSplitExprFunc = */ std::pair<std::unique_ptr<MatchExpression>, std::unique_ptr<MatchExpression>> splitMatchExpressionBy(std::unique_ptr<MatchExpression> expr, - const std::set<std::string>& fields, + const OrderedPathSet& fields, const StringMap<std::string>& renames, ShouldSplitExprFunc func = isIndependentOf); @@ -155,6 +175,8 @@ splitMatchExpressionBy(std::unique_ptr<MatchExpression> expr, * to the new values of those paths. For example, suppose the original match expression is * {old: {$gt: 3}} and 'renames' contains the mapping "old" => "new". At the end, 'expr' will be * {new: {$gt: 3}}. + * + * The caller should make sure that `expr` is renamable as a whole. */ void applyRenamesToExpression(MatchExpression* expr, const StringMap<std::string>& renames); diff --git a/src/mongo/db/matcher/expression_algo_test.cpp b/src/mongo/db/matcher/expression_algo_test.cpp index 8679c759cd5..3e0922bd9fc 100644 --- a/src/mongo/db/matcher/expression_algo_test.cpp +++ b/src/mongo/db/matcher/expression_algo_test.cpp @@ -181,6 +181,14 @@ TEST(ExpressionAlgoIsSubsetOf, CompareAnd_GT) { ASSERT_FALSE(expression::isSubsetOf(filter.get(), query.get())); } +TEST(ExpressionAlgoIsSubsetOf, CompareAnd_SingleField) { + ParsedMatchExpression filter("{a: {$gt: 5, $lt: 7}}"); + ParsedMatchExpression query("{a: {$gt: 5, $lt: 6}}"); + + ASSERT_TRUE(expression::isSubsetOf(query.get(), filter.get())); + ASSERT_FALSE(expression::isSubsetOf(filter.get(), query.get())); +} + TEST(ExpressionAlgoIsSubsetOf, CompareOr_LT) { ParsedMatchExpression lt5("{a: {$lt: 5}}"); ParsedMatchExpression eq2OrEq3("{$or: [{a: 2}, {a: 3}]}"); @@ -931,6 +939,15 @@ TEST(IsIndependent, NonRenameableExpressionIsNotIndependent) { } } +TEST(IsIndependent, EmptyDependencySetsPassIsOnlyDependentOn) { + BSONObj matchPredicate = fromjson("{}"); + boost::intrusive_ptr<ExpressionContextForTest> expCtx(new ExpressionContextForTest()); + auto swMatchExpression = MatchExpressionParser::parse(matchPredicate, std::move(expCtx)); + ASSERT_OK(swMatchExpression.getStatus()); + auto matchExpression = std::move(swMatchExpression.getValue()); + ASSERT_TRUE(expression::isOnlyDependentOn(*matchExpression.get(), {})); +} + TEST(SplitMatchExpression, AndWithSplittableChildrenIsSplittable) { BSONObj matchPredicate = fromjson("{$and: [{a: 1}, {b: 1}]}"); boost::intrusive_ptr<ExpressionContextForTest> expCtx(new ExpressionContextForTest()); @@ -1368,6 +1385,31 @@ TEST(SplitMatchExpression, ShouldMoveIndependentPredicateWhenThereAreMultipleRen ASSERT_FALSE(splitExpr.second.get()); } +TEST(SplitMatchExpression, ShouldNotSplitWhenRand) { + const auto randExpr = "{$expr: {$lt: [{$rand: {}}, {$const: 0.25}]}}"; + const auto assertMatchDoesNotSplit = [&](const std::string& exprString) { + BSONObj matchPredicate = fromjson(exprString); + boost::intrusive_ptr<ExpressionContextForTest> expCtx(new ExpressionContextForTest()); + auto matcher = MatchExpressionParser::parse(matchPredicate, std::move(expCtx)); + ASSERT_OK(matcher.getStatus()); + + auto&& [split, residual] = + expression::splitMatchExpressionBy(std::move(matcher.getValue()), {}, {}); + ASSERT_FALSE(split.get()); + ASSERT_TRUE(residual.get()); + + BSONObjBuilder oldBob; + residual->serialize(&oldBob, true); + ASSERT_BSONOBJ_EQ(oldBob.obj(), fromjson(randExpr)); + }; + + // We should not push down a $match with a $rand expression. + assertMatchDoesNotSplit(randExpr); + + // This is equivalent to 'randExpr'. + assertMatchDoesNotSplit("{$sampleRate: 0.25}"); +} + TEST(ApplyRenamesToExpression, ShouldApplyBasicRenamesForAMatchWithExpr) { BSONObj matchPredicate = fromjson("{$expr: {$eq: ['$a.b', '$c']}}"); boost::intrusive_ptr<ExpressionContextForTest> expCtx(new ExpressionContextForTest()); diff --git a/src/mongo/db/matcher/expression_internal_bucket_geo_within.h b/src/mongo/db/matcher/expression_internal_bucket_geo_within.h index b46b6c5076c..d2a6202744f 100644 --- a/src/mongo/db/matcher/expression_internal_bucket_geo_within.h +++ b/src/mongo/db/matcher/expression_internal_bucket_geo_within.h @@ -70,6 +70,7 @@ public: : MatchExpression(MatchExpression::INTERNAL_BUCKET_GEO_WITHIN, std::move(annotation)), _geoContainer(container), _indexField("data." + field), + _fieldRef(_indexField), _field(std::move(field)) {} void debugString(StringBuilder& debug, int indentationLevel) const final; @@ -127,8 +128,7 @@ public: } const FieldRef* fieldRef() const final { - MONGO_UNREACHABLE_TASSERT(5837104); - return nullptr; + return &_fieldRef; } void acceptVisitor(MatchExpressionMutableVisitor* visitor) final { @@ -155,6 +155,7 @@ private: std::shared_ptr<GeometryContainer> _geoContainer; std::string _indexField; + FieldRef _fieldRef; std::string _field; }; diff --git a/src/mongo/db/matcher/expression_leaf.cpp b/src/mongo/db/matcher/expression_leaf.cpp index 31157666e92..990f91c1796 100644 --- a/src/mongo/db/matcher/expression_leaf.cpp +++ b/src/mongo/db/matcher/expression_leaf.cpp @@ -434,6 +434,7 @@ std::unique_ptr<MatchExpression> InMatchExpression::shallowClone() const { } next->_hasNull = _hasNull; next->_hasEmptyArray = _hasEmptyArray; + next->_hasNonEmptyArrayOrObject = _hasNonEmptyArrayOrObject; next->_equalitySet = _equalitySet; next->_originalEqualityVector = _originalEqualityVector; next->_equalityStorage = _equalityStorage; @@ -577,6 +578,8 @@ Status InMatchExpression::setEqualities(std::vector<BSONElement> equalities) { _hasNull = true; } else if (equality.type() == BSONType::Array && equality.Obj().isEmpty()) { _hasEmptyArray = true; + } else if (equality.type() == BSONType::Array || equality.type() == BSONType::Object) { + _hasNonEmptyArrayOrObject = true; } } diff --git a/src/mongo/db/matcher/expression_leaf.h b/src/mongo/db/matcher/expression_leaf.h index 46a80aa5e91..a00ed9e31f1 100644 --- a/src/mongo/db/matcher/expression_leaf.h +++ b/src/mongo/db/matcher/expression_leaf.h @@ -723,6 +723,14 @@ public: return _hasEmptyArray; } + bool hasNonEmptyArrayOrObject() const { + return _hasNonEmptyArrayOrObject; + } + + bool hasNonScalarOrNonEmptyValues() const { + return hasNonEmptyArrayOrObject() || hasNull() || hasRegex(); + } + void acceptVisitor(MatchExpressionMutableVisitor* visitor) final { visitor->visit(this); } @@ -748,6 +756,9 @@ private: // Whether or not '_equalities' has an empty array element in it. bool _hasEmptyArray = false; + // Whether or not '_equalities' has a non-empty array or object element in it. + bool _hasNonEmptyArrayOrObject = false; + // Collator used to construct '_eltCmp'; const CollatorInterface* _collator = nullptr; diff --git a/src/mongo/db/matcher/expression_optimize_test.cpp b/src/mongo/db/matcher/expression_optimize_test.cpp index eaa606e06df..04d2bc29e11 100644 --- a/src/mongo/db/matcher/expression_optimize_test.cpp +++ b/src/mongo/db/matcher/expression_optimize_test.cpp @@ -495,5 +495,26 @@ TEST(ExpressionOptimizeTest, OrRewrittenToIn) { ASSERT_BSONOBJ_EQ(optimizeExpr(queries[10].first), fromjson(queries[10].second)); } +TEST(ExpressionOptimizeTest, NorRemovesAlwaysFalseChildren) { + BSONObj obj = fromjson("{$nor: [{a: 1}, {$alwaysFalse: 1}]}"); + std::unique_ptr<MatchExpression> matchExpression(parseMatchExpression(obj)); + auto optimizedMatchExpression = MatchExpression::optimize(std::move(matchExpression)); + ASSERT_BSONOBJ_EQ(optimizedMatchExpression->serialize(), fromjson("{a: {$not: {$eq: 1}}}")); +} + +TEST(ExpressionOptimizeTest, NorWithoutChildrenOptimizesToEmptyAnd) { + BSONObj obj = fromjson("{$nor: [{$alwaysFalse: 1}, {$alwaysFalse: 1}]}"); + std::unique_ptr<MatchExpression> matchExpression(parseMatchExpression(obj)); + auto optimizedMatchExpression = MatchExpression::optimize(std::move(matchExpression)); + ASSERT_TRUE(dynamic_cast<AndMatchExpression*>(optimizedMatchExpression.get())); + ASSERT_BSONOBJ_EQ(optimizedMatchExpression->serialize(), fromjson("{}")); +} + +TEST(ExpressionOptimizeTest, NorWithAlwaysTrueChildOptimizesToAlwaysFalse) { + BSONObj obj = fromjson("{$nor: [{a: 1}, {$alwaysTrue: 1}]}"); + std::unique_ptr<MatchExpression> matchExpression(parseMatchExpression(obj)); + auto optimizedMatchExpression = MatchExpression::optimize(std::move(matchExpression)); + ASSERT_BSONOBJ_EQ(optimizedMatchExpression->serialize(), fromjson("{$alwaysFalse: 1}")); +} } // namespace } // namespace mongo diff --git a/src/mongo/db/matcher/expression_parser.cpp b/src/mongo/db/matcher/expression_parser.cpp index 0e86cd4e06b..e6529908910 100644 --- a/src/mongo/db/matcher/expression_parser.cpp +++ b/src/mongo/db/matcher/expression_parser.cpp @@ -2205,15 +2205,15 @@ MONGO_INITIALIZER_WITH_PREREQUISITES(MatchExpressionCounters, if (name[0] == '_' || exceptionsSet.count(name) > 0) { continue; } - operatorCountersMatchExpressions.addMatchExprCounter("$" + name); + operatorCountersMatchExpressions.addCounter("$" + name); } for (auto&& [name, fn] : *pathlessOperatorMap) { if (name[0] == '_' || exceptionsSet.count(name) > 0) { continue; } - operatorCountersMatchExpressions.addMatchExprCounter("$" + name); + operatorCountersMatchExpressions.addCounter("$" + name); } - operatorCountersMatchExpressions.addMatchExprCounter("$not"); + operatorCountersMatchExpressions.addCounter("$not"); } diff --git a/src/mongo/db/matcher/expression_tree.cpp b/src/mongo/db/matcher/expression_tree.cpp index df70ad278c3..d95f347b50d 100644 --- a/src/mongo/db/matcher/expression_tree.cpp +++ b/src/mongo/db/matcher/expression_tree.cpp @@ -97,12 +97,13 @@ MatchExpression::ExpressionOptimizerFunc ListOfMatchExpression::getOptimizer() c std::back_inserter(children)); } - // Remove all children of AND that are $alwaysTrue and all children of OR that are + // Remove all children of AND that are $alwaysTrue and all children of OR and NOR that are // $alwaysFalse. - if (matchType == AND || matchType == OR) { + if (matchType == AND || matchType == OR || matchType == NOR) { for (auto& childExpression : children) if ((childExpression->isTriviallyTrue() && matchType == MatchExpression::AND) || - (childExpression->isTriviallyFalse() && matchType == MatchExpression::OR)) + (childExpression->isTriviallyFalse() && matchType == MatchExpression::OR) || + (childExpression->isTriviallyFalse() && matchType == MatchExpression::NOR)) childExpression = nullptr; // We replaced each destroyed child expression with nullptr. Now we remove those @@ -112,13 +113,17 @@ MatchExpression::ExpressionOptimizerFunc ListOfMatchExpression::getOptimizer() c // Check if the above optimizations eliminated all children. An OR with no children is // always false. - // TODO SERVER-34759 It is correct to replace this empty AND with an $alwaysTrue, but we - // need to make enhancements to the planner to make it understand an $alwaysTrue and an - // empty AND as the same thing. The planner can create inferior plans for $alwaysTrue which - // it would not produce for an AND with no children. if (children.empty() && matchType == MatchExpression::OR) { return std::make_unique<AlwaysFalseMatchExpression>(); } + // An AND with no children is always true and we need to return an + // EmptyExpression. This ensures that the empty $and[] will be returned that serializes to + // {} (SERVER-34759). A NOR with no children is always true. We treat an empty $nor[] + // similarly. + if (children.empty() && + (matchType == MatchExpression::AND || matchType == MatchExpression::NOR)) { + return std::make_unique<AndMatchExpression>(); + } if (children.size() == 1) { if ((matchType == AND || matchType == OR || matchType == INTERNAL_SCHEMA_XOR)) { @@ -136,7 +141,8 @@ MatchExpression::ExpressionOptimizerFunc ListOfMatchExpression::getOptimizer() c } } - if (matchType == MatchExpression::AND || matchType == MatchExpression::OR) { + if (matchType == MatchExpression::AND || matchType == MatchExpression::OR || + matchType == MatchExpression::NOR) { for (auto& childExpression : children) { // An AND containing an expression that always evaluates to false can be // optimized to a single $alwaysFalse expression. @@ -149,6 +155,11 @@ MatchExpression::ExpressionOptimizerFunc ListOfMatchExpression::getOptimizer() c if (childExpression->isTriviallyTrue() && matchType == MatchExpression::OR) { return std::make_unique<AlwaysTrueMatchExpression>(); } + // A NOR containing an expression that always evaluates to true can be + // optimized to a single $alwaysFalse expression. + if (childExpression->isTriviallyTrue() && matchType == MatchExpression::NOR) { + return std::make_unique<AlwaysFalseMatchExpression>(); + } } } |
