/** * Copyright (C) 2018-present MongoDB, Inc. * * This program is free software: you can redistribute it and/or modify * it under the terms of the Server Side Public License, version 1, * as published by MongoDB, Inc. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * Server Side Public License for more details. * * You should have received a copy of the Server Side Public License * along with this program. If not, see * . * * As a special exception, the copyright holders give permission to link the * code of portions of this program with the OpenSSL library under certain * conditions as described in each individual source file and distribute * linked combinations including the program with the OpenSSL library. You * must comply with the Server Side Public License in all respects for * all of the code used other than as permitted herein. If you modify file(s) * with this exception, you may extend this exception to your version of the * file(s), but you are not obligated to do so. If you do not wish to do so, * delete this exception statement from your version. If you delete this * exception statement from all source files in the program, then also delete * it in the license file. */ #include "mongo/platform/basic.h" #include "mongo/db/jsobj.h" #include "mongo/db/json.h" #include "mongo/db/query/query_planner.h" #include "mongo/db/query/query_planner_test_fixture.h" namespace { using namespace mongo; // // Text // Creating an FTS index {a:1, b:"text", c:1} actually // creates an index with spec {a:1, _fts: "text", _ftsx: 1, c:1}. // So, the latter is what we pass in to the planner. // // PS. You can also do {a:1, b:"text", d:"text", c:1} and it will create an index with the same // key pattern. // // Basic test that it works. TEST_F(QueryPlannerTest, SimpleText) { addIndex(BSON("_fts" << "text" << "_ftsx" << 1)); runQuery(fromjson("{$text: {$search: 'blah'}}")); assertNumSolutions(1); assertSolutionExists("{text: {search: 'blah'}}"); } // If you create an index {a:1, b: "text"} you can't use it for queries on just 'a'. TEST_F(QueryPlannerTest, CantUseTextUnlessHaveTextPred) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1)); // No table scans allowed so there is no solution. runInvalidQuery(fromjson("{a:1}")); assertNoSolutions(); } // But if you create an index {a:1, b:"text"} you can use it if it has a pred on 'a' // and a text query. TEST_F(QueryPlannerTest, HaveOKPrefixOnTextIndex) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1)); runQuery(fromjson("{a:1, $text:{$search: 'blah'}}")); assertNumSolutions(1); assertSolutionExists("{text: {prefix: {a:1}, search: 'blah'}}}}"); // TODO: Do we want to $or a collection scan with a text search? // runQuery(fromjson("{$or: [{b:1}, {a:1, $text: {$search: 'blah'}}]}")); // assertNumSolutions(1); runQuery(fromjson("{$or: [{_id:1}, {a:1, $text: {$search: 'blah'}}]}")); assertNumSolutions(1); } // But the prefixes must be points. TEST_F(QueryPlannerTest, HaveBadPrefixOnTextIndex) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1)); runInvalidQuery(fromjson("{a:{$gt: 1}, $text:{$search: 'blah'}}")); runInvalidQuery(fromjson("{$text: {$search: 'blah'}}")); runInvalidQuery(fromjson("{$or: [{a:1}, {$text: {$search: 'blah'}}]}")); } // Outside predicates are not yet pushed into contained ORs for text indexes. TEST_F(QueryPlannerTest, PrefixOnTextIndexIsOutsidePred) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1)); addIndex(BSON("b" << 1)); runInvalidQuery(fromjson("{$and: [{a: 5}, {$or: [{$text: {$search: 'blah'}}, {b: 6}]}]}")); } // There can be more than one prefix, but they all require points. TEST_F(QueryPlannerTest, ManyPrefixTextIndex) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1 << "b" << 1 << "_fts" << "text" << "_ftsx" << 1)); // Both points. runQuery(fromjson("{a:1, b:1, $text:{$search: 'blah'}}")); assertSolutionExists("{text: {prefix: {a:1, b:1}, search: 'blah'}}"); assertNumSolutions(1); // Missing a. runInvalidQuery(fromjson("{b:1, $text:{$search: 'blah'}}")); // Missing b. runInvalidQuery(fromjson("{a:1, $text:{$search: 'blah'}}")); // a is not a point runInvalidQuery(fromjson("{a:{$gt: 1}, b:1, $text:{$search: 'blah'}}")); // b is not a point runInvalidQuery(fromjson("{a:1, b:{$gt: 1}, $text:{$search: 'blah'}}")); } // And, suffixes. They're optional and don't need to be points. TEST_F(QueryPlannerTest, SuffixOptional) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1 << "b" << 1)); runQuery(fromjson("{a:1, $text:{$search: 'blah'}}")); assertNumSolutions(1); assertSolutionExists("{text: {prefix: {a:1}, search: 'blah'}}}}"); runQuery(fromjson("{a:1, b:{$gt: 7}, $text:{$search: 'blah'}}")); assertSolutionExists("{text: {prefix: {a:1}, filter: {b: {$gt: 7}}, search: 'blah'}}}}"); assertNumSolutions(1); } TEST_F(QueryPlannerTest, RemoveFromSubtree) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1 << "b" << 1)); runQuery(fromjson("{a:1, $or: [{a:1}, {b:7}], $text:{$search: 'blah'}}")); assertNumSolutions(1); assertSolutionExists( "{fetch: {filter: {$or:[{a:1},{b:7}]}," "node: {text: {prefix: {a:1}, search: 'blah'}}}}"); } // Text is quite often multikey. None of the prefixes can be arrays, and suffixes are indexed // as-is, so we should compound even if it's multikey. TEST_F(QueryPlannerTest, CompoundPrefixEvenIfMultikey) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1 << "b" << 1 << "_fts" << "text" << "_ftsx" << 1), true); // Both points. runQuery(fromjson("{a:1, b:1, $text:{$search: 'blah'}}")); assertNumSolutions(1); assertSolutionExists("{text: {prefix: {a:1, b:1}, search: 'blah'}}"); } TEST_F(QueryPlannerTest, IndexOnOwnFieldButNotLeafPrefix) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1 << "b" << 1)); // 'a' is not an EQ so it doesn't compound w/the text pred. We also shouldn't use the text // index to satisfy it w/o the text query. runInvalidQuery(fromjson("{a:{$elemMatch:{$gt: 0, $lt: 2}}, $text:{$search: 'blah'}}")); } TEST_F(QueryPlannerTest, IndexOnOwnFieldButNotLeafSuffixNoPrefix) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("_fts" << "text" << "_ftsx" << 1 << "b" << 1)); runQuery(fromjson("{b:{$elemMatch:{$gt: 0, $lt: 2}}, $text:{$search: 'blah'}}")); assertNumSolutions(1); } TEST_F(QueryPlannerTest, TextInsideAndWithCompoundIndex) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1)); runQuery(fromjson("{$and: [{a: 3}, {$text: {$search: 'foo'}}], a: 3}")); assertNumSolutions(1U); assertSolutionExists("{text: {prefix: {a:3}, search: 'foo'}}"); } // SERVER-15639: Test that predicates on index prefix fields which are not assigned to the index // prefix are correctly included in the solution node filter. TEST_F(QueryPlannerTest, TextInsideAndWithCompoundIndexAndMultiplePredsOnIndexPrefix) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1)); runQuery(fromjson("{$and: [{a: 1}, {a: 2}, {$text: {$search: 'foo'}}]}")); assertNumSolutions(1U); assertSolutionExists("{text: {prefix: {a: 1}, search: 'foo', filter: {a: 2}}}"); } // SERVER-13039: Test that we don't generate invalid solutions when the TEXT node // is buried beneath a logical node. TEST_F(QueryPlannerTest, TextInsideOrBasic) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1)); addIndex(BSON("_fts" << "text" << "_ftsx" << 1)); runQuery(fromjson("{a: 0, $or: [{_id: 1}, {$text: {$search: 'foo'}}]}")); assertNumSolutions(1U); assertSolutionExists( "{fetch: {filter: {a:0}, node: {or: {nodes: [" "{text: {search: 'foo'}}, " "{ixscan: {filter: null, pattern: {_id: 1}}}]}}}}"); } // SERVER-13039 TEST_F(QueryPlannerTest, TextInsideOrWithAnotherOr) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1)); addIndex(BSON("_fts" << "text" << "_ftsx" << 1)); runQuery( fromjson("{$and: [{$or: [{a: 3}, {a: 4}]}, " "{$or: [{$text: {$search: 'foo'}}, {a: 5}]}]}")); assertNumSolutions(1U); assertSolutionExists( "{fetch: {filter: {$or: [{a: 3}, {a: 4}]}, node: " "{or: {nodes: [" "{text: {search: 'foo'}}, " "{ixscan: {filter: null, pattern: {a: 1}}}]}}}}"); } // SERVER-13039 TEST_F(QueryPlannerTest, TextInsideOrOfAnd) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1)); addIndex(BSON("_fts" << "text" << "_ftsx" << 1)); runQuery( fromjson("{$or: [{a: {$gt: 1, $gt: 2}}, " "{a: {$gt: 3}, $text: {$search: 'foo'}}]}")); assertNumSolutions(1U); assertSolutionExists( "{fetch: {filter: null, node: {or: {nodes: [" "{ixscan: {filter: null, pattern: {a:1}, bounds: " "{a: [[2,Infinity,false,true]]}}}, " "{fetch: {filter: {a:{$gt:3}}, node: " "{text: {search: 'foo'}}}}]}}}}"); } // SERVER-13039 TEST_F(QueryPlannerTest, TextInsideAndOrAnd) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1)); addIndex(BSON("b" << 1)); addIndex(BSON("_fts" << "text" << "_ftsx" << 1)); runQuery( fromjson("{a: 1, $or: [{a:2}, {b:2}, " "{a: 1, $text: {$search: 'foo'}}]}")); assertNumSolutions(1U); assertSolutionExists( "{fetch: {filter: {a:1}, node: {or: {nodes: [" "{ixscan: {filter: null, pattern: {a:1}}}, " "{fetch: {filter: {a:1}, node: {text: {search: 'foo'}}}}, " "{ixscan: {filter: null, pattern: {b:1}}}]}}}}"); } // SERVER-13039 TEST_F(QueryPlannerTest, TextInsideAndOrAndOr) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1)); addIndex(BSON("_fts" << "text" << "_ftsx" << 1)); runQuery( fromjson("{$or: [{a: {$gt: 1, $gt: 2}}, " "{a: {$gt: 3}, $or: [{$text: {$search: 'foo'}}, " "{a: 6}]}], " "a: 5}")); assertNumSolutions(1U); assertSolutionExists( "{fetch: {filter: {a:5}, node: {or: {nodes: [" "{ixscan: {filter: null, pattern: {a: 1}}}, " "{fetch: {filter: {a:{$gt:3}}, node: {or: {nodes: [" "{text: {search: 'foo'}}, " "{ixscan: {filter: null, pattern: {a: 1}}}]}}}}]}}}}"); } // If only one branch of the $or can be indexed, then no indexed // solutions are generated, even if one branch is $text. TEST_F(QueryPlannerTest, TextInsideOrOneBranchNotIndexed) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1)); addIndex(BSON("_fts" << "text" << "_ftsx" << 1)); runInvalidQuery(fromjson("{a: 1, $or: [{b: 2}, {$text: {$search: 'foo'}}]}")); assertNoSolutions(); } // If the unindexable $or is not the one containing the $text predicate, // then we should still be able to generate an indexed solution. TEST_F(QueryPlannerTest, TextInsideOrWithAnotherUnindexableOr) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1)); addIndex(BSON("_fts" << "text" << "_ftsx" << 1)); runQuery( fromjson("{$and: [{$or: [{a: 1}, {b: 1}]}, " "{$or: [{a: 2}, {$text: {$search: 'foo'}}]}]}")); assertNumSolutions(1U); assertSolutionExists( "{fetch: {filter: {$or:[{a:1},{b:1}]}, node: {or: {nodes: [" "{text: {search: 'foo'}}, " "{ixscan: {filter: null, pattern: {a:1}}}]}}}}"); } TEST_F(QueryPlannerTest, AndTextWithGeoNonNear) { addIndex(BSON("_fts" << "text" << "_ftsx" << 1)); runQuery( fromjson("{$text: {$search: 'foo'}, a: {$geoIntersects: {$geometry: " "{type: 'Point', coordinates: [3.0, 1.0]}}}}")); // Mandatory text index is used, and geo predicate becomes a filter. assertNumSolutions(1U); assertSolutionExists("{fetch: {node: {text: {search: 'foo'}}}}"); } // SERVER-13960: $text beneath $or with exact predicates. TEST_F(QueryPlannerTest, OrTextExact) { addIndex(BSON("pre" << 1 << "_fts" << "text" << "_ftsx" << 1)); addIndex(BSON("other" << 1)); runQuery(fromjson("{$or: [{$text: {$search: 'dave'}, pre: 3}, {other: 2}]}")); assertNumSolutions(1U); assertSolutionExists( "{fetch: {filter: null, node: {or: {nodes: [" "{text: {search: 'dave', prefix: {pre: 3}}}," "{ixscan: {filter: null, pattern: {other: 1}}}]}}}}"); } // SERVER-13960: $text beneath $or with an inexact covered predicate. TEST_F(QueryPlannerTest, OrTextInexactCovered) { addIndex(BSON("pre" << 1 << "_fts" << "text" << "_ftsx" << 1)); addIndex(BSON("other" << 1)); runQuery(fromjson("{$or: [{$text: {$search: 'dave'}, pre: 3}, {other: /bar/}]}")); assertNumSolutions(1U); assertSolutionExists( "{fetch: {filter: null, node: {or: {nodes: [" "{text: {search: 'dave', prefix: {pre: 3}}}," "{ixscan: {filter: {$or: [{other: /bar/}]}, " "pattern: {other: 1}}}]}}}}"); } TEST_F(QueryPlannerTest, TextCaseSensitive) { addIndex(BSON("_fts" << "text" << "_ftsx" << 1)); runQuery(fromjson("{$text: {$search: 'blah', $caseSensitive: true}}")); assertNumSolutions(1); assertSolutionExists("{text: {search: 'blah', caseSensitive: true}}"); } TEST_F(QueryPlannerTest, TextDiacriticSensitive) { addIndex(BSON("_fts" << "text" << "_ftsx" << 1)); runQuery(fromjson("{$text: {$search: 'blah', $diacriticSensitive: true}}")); assertNumSolutions(1); assertSolutionExists("{text: {search: 'blah', diacriticSensitive: true}}"); } TEST_F(QueryPlannerTest, SortKeyMetaProjectionWithTextScoreMetaSort) { addIndex(BSON("_fts" << "text" << "_ftsx" << 1)); runQuerySortProj(fromjson("{$text: {$search: 'foo'}}"), fromjson("{a: {$meta: 'textScore'}}"), fromjson("{a: {$meta: 'textScore'}, b: {$meta: 'sortKey'}}")); assertNumSolutions(1U); assertSolutionExists( "{proj: {spec: {a: {$meta: 'textScore'}, b: {$meta: 'sortKey'}}, node: " "{sort: {limit: 0, pattern: {a: {$meta: 'textScore'}}, type: 'default', node: " "{text: {search: 'foo'}}}}}}"); } TEST_F(QueryPlannerTest, PredicatesOverLeadingFieldsWithSharedPathPrefixHandledCorrectly) { const bool multikey = true; addIndex(BSON("a.x" << 1 << "a.y" << 1 << "b.x" << 1 << "b.y" << 1 << "_fts" << "text" << "_ftsx" << 1), multikey); runQuery(fromjson("{'a.x': 1, 'a.y': 2, 'b.x': 3, 'b.y': 4, $text: {$search: 'foo'}}")); assertNumSolutions(1U); assertSolutionExists( "{text: {search: 'foo', prefix: {'a.x': 1, 'a.y': 2, 'b.x': 3, 'b.y': 4}}}"); } TEST_F(QueryPlannerTest, EqualityToArrayOverLeadingFieldHandledCorrectly) { addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1)); runQuery(fromjson("{a: [1, 2, 3], $text: {$search: 'foo'}}")); assertNumSolutions(1U); assertSolutionExists("{text: {search: 'foo', prefix: {a: [1, 2, 3]}}}"); } TEST_F(QueryPlannerTest, EqualityToArrayOverLeadingFieldHandledCorrectlyWithMultikeyTrue) { const bool multikey = true; addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1), multikey); runQuery(fromjson("{a: [1, 2, 3], $text: {$search: 'foo'}}")); assertNumSolutions(1U); assertSolutionExists("{text: {search: 'foo', prefix: {a: [1, 2, 3]}}}"); } TEST_F(QueryPlannerTest, InexactFetchPredicateOverTrailingFieldHandledCorrectly) { addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1 << "b" << 1)); runQuery(fromjson("{a: 3, $text: {$search: 'foo'}, b: {$exists: true}}")); assertNumSolutions(1U); assertSolutionExists( "{fetch: {filter: {b: {$exists: true}}, node: {text: {search: 'foo', prefix: {a: 3}}}}}"); } TEST_F(QueryPlannerTest, InexactFetchPredicateOverTrailingFieldHandledCorrectlyMultikeyTrue) { const bool multikey = true; addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1 << "b" << 1), multikey); runQuery(fromjson("{a: 3, $text: {$search: 'foo'}, b: {$exists: true}}")); assertNumSolutions(1U); assertSolutionExists( "{fetch: {filter: {b: {$exists: true}}, node: {text: {search: 'foo', prefix: {a: 3}}}}}"); } TEST_F(QueryPlannerTest, ExprEqCannotUsePrefixOfTextIndex) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("a" << 1 << "_fts" << "text" << "_ftsx" << 1)); runInvalidQuery(fromjson("{a: {$_internalExprEq: 3}, $text: {$search: 'blah'}}")); } TEST_F(QueryPlannerTest, ExprEqCanUseSuffixOfTextIndex) { params.options = QueryPlannerParams::NO_TABLE_SCAN; addIndex(BSON("_fts" << "text" << "_ftsx" << 1 << "a" << 1)); runQuery(fromjson("{a: {$_internalExprEq: 3}, $text: {$search: 'blah'}}")); assertNumSolutions(1U); assertSolutionExists( "{text: {search: 'blah', prefix: {}, filter: {a: {$_internalExprEq: 3}}}}"); } TEST_F(QueryPlannerTest, CantExplodeMetaSort) { addIndex(BSON("a" << 1 << "b" << 1 << "_fts" << "text" << "_ftsx" << 1)); runInvalidQuerySortProj( fromjson("{$text: {$search: 'keyword'}, a: {$in: [1, 2]}, b: {$in: [3, 4]}}"), fromjson("{c: {$meta: 'textScore'}}"), fromjson("{c: {$meta: 'textScore'}}")); } } // namespace