SERVER-125058 Forward LiteParsed subpipeline from lookup stage to full DocumentSourceLookup (#52408)

GitOrigin-RevId: ba4fd486e300131517ebbad3171f8f0fbee96849
This commit is contained in:
Finley Lau 2026-04-27 14:43:05 -04:00 committed by MongoDB Bot
parent 618e4ff87e
commit 3f75f45a77
14 changed files with 834 additions and 202 deletions

View File

@ -45,7 +45,7 @@ const unstablePipelines = [
[{$listSessions: {}}],
[{$planCacheStats: {}}],
[{$unionWith: {coll: "coll2", pipeline: [{$collStats: {latencyStats: {}}}]}}],
[{$lookup: {from: "coll2", pipeline: [{$indexStats: {}}]}}],
[{$lookup: {from: "coll2", as: "out", pipeline: [{$indexStats: {}}]}}],
[{$facet: {field1: [], field2: [{$indexStats: {}}]}}],
[{$rankFusion: {input: {pipelines: {field1: [{$sort: {foo: 1}}]}}}}],
[{$score: {score: 10}}],

View File

@ -104,26 +104,31 @@ const kNotAllowedInFacetErrorCode = 40600;
kNotAllowedInUnionWithErrorCode,
"Using $unionWith with $testFoo in sub-pipeline should be rejected",
);
// Outer $lookup may reject the inner $unionWith via strictness propagation before the
// $unionWith's own validator runs, so accept either code.
assertErrorCode(
coll,
[{$lookup: {from: other.getName(), pipeline: unionWithPipeline, as: "joined"}}],
kNotAllowedInUnionWithErrorCode,
[kNotAllowedInUnionWithErrorCode, kNotAllowedInLookupErrorCode],
"Using $unionWith with $testFoo in sub-pipeline should be rejected",
);
// See comment above: $facet may reject the inner $unionWith via strictness propagation first.
assertErrorCode(
coll,
[{$facet: {facetPipe: unionWithPipeline}}],
kNotAllowedInUnionWithErrorCode,
[kNotAllowedInUnionWithErrorCode, kNotAllowedInFacetErrorCode],
"Using $unionWith with $testFoo in sub-pipeline should be rejected",
);
assert.commandFailedWithCode(
// View creation may validate pre-desugar (lenient) and defer the real rejection to query time.
assert.commandWorkedOrFailedWithCode(
db.runCommand({
create: jsTestName() + "_view",
viewOn: coll.getName(),
pipeline: unionWithPipeline,
}),
kNotAllowedInUnionWithErrorCode,
[kNotAllowedInUnionWithErrorCode, kNotAllowedInLookupErrorCode, kNotAllowedInFacetErrorCode],
);
db[jsTestName() + "_view"].drop();
{
const viewName = "testFoo_in_def";
assert.commandWorked(db.createView(viewName, other.getName(), [{$testFoo: {}}]));

View File

@ -1048,7 +1048,8 @@ TEST_F(AuthorizationSessionTest, CannotAggregateLookupWithoutFindOnJoinedNamespa
authzSession->assumePrivilegesForDB(Privilege(rsrcFoo, ActionType::find), nssFoo.dbName());
BSONArray pipeline = BSON_ARRAY(BSON("$lookup" << BSON("from" << nssBar.coll())));
BSONArray pipeline = BSON_ARRAY(BSON("$lookup" << BSON("from" << nssBar.coll() << "as"
<< "out")));
auto aggReq = buildAggReq(nssFoo, pipeline);
PrivilegeVector privileges = uassertStatusOK(auth::getPrivilegesForAggregate(
_opCtx.get(), authzSession.get(), nssFoo, aggReq, false));
@ -1070,7 +1071,8 @@ TEST_F(AuthorizationSessionTest, CanAggregateLookupWithFindOnJoinedNamespace) {
{Privilege(rsrcFoo, ActionType::find), Privilege(rsrcBar, ActionType::find)},
nssFoo.dbName());
BSONArray pipeline = BSON_ARRAY(BSON("$lookup" << BSON("from" << nssBar.coll())));
BSONArray pipeline = BSON_ARRAY(BSON("$lookup" << BSON("from" << nssBar.coll() << "as"
<< "out")));
auto aggReq = buildAggReq(nssFoo, pipeline);
PrivilegeVector privileges = uassertStatusOK(auth::getPrivilegesForAggregate(
_opCtx.get(), authzSession.get(), nssFoo, aggReq, true));
@ -1094,9 +1096,11 @@ TEST_F(AuthorizationSessionTest, CannotAggregateLookupWithoutFindOnNestedJoinedN
{Privilege(rsrcFoo, ActionType::find), Privilege(rsrcBar, ActionType::find)},
nssFoo.dbName());
BSONArray nestedPipeline = BSON_ARRAY(BSON("$lookup" << BSON("from" << nssQux.coll())));
BSONArray nestedPipeline = BSON_ARRAY(BSON("$lookup" << BSON("from" << nssQux.coll() << "as"
<< "out")));
BSONArray pipeline = BSON_ARRAY(
BSON("$lookup" << BSON("from" << nssBar.coll() << "pipeline" << nestedPipeline)));
BSON("$lookup" << BSON("from" << nssBar.coll() << "pipeline" << nestedPipeline << "as"
<< "out")));
auto aggReq = buildAggReq(nssFoo, pipeline);
PrivilegeVector privileges = uassertStatusOK(auth::getPrivilegesForAggregate(
_opCtx.get(), authzSession.get(), nssFoo, aggReq, false));
@ -1121,9 +1125,11 @@ TEST_F(AuthorizationSessionTest, CanAggregateLookupWithFindOnNestedJoinedNamespa
Privilege(rsrcQux, ActionType::find)},
nssFoo.dbName());
BSONArray nestedPipeline = BSON_ARRAY(BSON("$lookup" << BSON("from" << nssQux.coll())));
BSONArray nestedPipeline = BSON_ARRAY(BSON("$lookup" << BSON("from" << nssQux.coll() << "as"
<< "out")));
BSONArray pipeline = BSON_ARRAY(
BSON("$lookup" << BSON("from" << nssBar.coll() << "pipeline" << nestedPipeline)));
BSON("$lookup" << BSON("from" << nssBar.coll() << "pipeline" << nestedPipeline << "as"
<< "out")));
auto aggReq = buildAggReq(nssFoo, pipeline);
PrivilegeVector privileges = uassertStatusOK(auth::getPrivilegesForAggregate(
_opCtx.get(), authzSession.get(), nssFoo, aggReq, false));
@ -1241,9 +1247,9 @@ TEST_F(AuthorizationSessionTest,
// We only have find on the aggregation namespace.
authzSession->assumePrivilegesForDB(Privilege(rsrcFoo, ActionType::find), nssFoo.dbName());
BSONArray pipeline =
BSON_ARRAY(fromjson("{$facet: {lookup: [{$lookup: {from: 'bar'}}], graphLookup: "
"[{$graphLookup: {from: 'qux'}}]}}"));
BSONArray pipeline = BSON_ARRAY(
fromjson("{$facet: {lookup: [{$lookup: {from: 'bar', as: 'out'}}], graphLookup: "
"[{$graphLookup: {from: 'qux'}}]}}"));
auto aggReq = buildAggReq(nssFoo, pipeline);
PrivilegeVector privileges = uassertStatusOK(auth::getPrivilegesForAggregate(
_opCtx.get(), authzSession.get(), nssFoo, aggReq, false));
@ -1281,9 +1287,9 @@ TEST_F(AuthorizationSessionTest,
Privilege(rsrcQux, ActionType::find)},
nssFoo.dbName());
BSONArray pipeline =
BSON_ARRAY(fromjson("{$facet: {lookup: [{$lookup: {from: 'bar'}}], graphLookup: "
"[{$graphLookup: {from: 'qux'}}]}}"));
BSONArray pipeline = BSON_ARRAY(
fromjson("{$facet: {lookup: [{$lookup: {from: 'bar', as: 'out'}}], graphLookup: "
"[{$graphLookup: {from: 'qux'}}]}}"));
auto aggReq = buildAggReq(nssFoo, pipeline);
PrivilegeVector privileges = uassertStatusOK(auth::getPrivilegesForAggregate(

View File

@ -249,7 +249,7 @@ protected:
NamespaceString nss = NamespaceString::createNamespaceString_forTest("test", main);
NamespaceString nss2 = NamespaceString::createNamespaceString_forTest("test", secondary);
BSONObj lookup = BSON("$lookup" << BSON("from" << nss2.coll()));
BSONObj lookup = BSON("$lookup" << BSON("from" << nss2.coll() << "as" << "out"));
BSONArray pipeline = BSON_ARRAY(lookup);
_cmdObj = BSON("aggregate" << main << "pipeline" << pipeline << "cursor" << BSONObj{});
_request =

View File

@ -995,7 +995,8 @@ SecondParseRequirement maybeApplyViewPipeline(const AggExState& aggExState,
desugaredLPP,
aggExState.getResolvedView(),
aggExState.getOriginalNss(),
uassertStatusOK(aggCatalogState.resolveInvolvedNamespaces(aggExState.getOpCtx())));
uassertStatusOK(aggCatalogState.resolveInvolvedNamespaces(aggExState.getOpCtx())),
LiteParserOptions{.ifrContext = aggExState.getIfrContext()});
return SecondParseRequirement::kReparseFromLPP;
}

View File

@ -430,6 +430,7 @@ mongo_cc_library(
"group_processor_base.cpp",
"hybrid_search_pipeline_builder.cpp",
"lite_parsed_graph_lookup.cpp",
"lite_parsed_lookup.cpp",
"lite_parsed_rank_fusion.cpp",
"lite_parsed_score_fusion.cpp",
"lite_parsed_union_with.cpp",
@ -1377,6 +1378,7 @@ mongo_cc_unit_test(
"document_source_test.cpp",
"document_source_union_with_test.cpp",
"document_source_unwind_test.cpp",
"lite_parsed_lookup_test.cpp",
"lite_parsed_rank_fusion_test.cpp",
"lite_parsed_score_fusion_test.cpp",
"serverless_aggregation_context_fixture.cpp",

View File

@ -54,6 +54,7 @@
#include "mongo/db/pipeline/document_source_queue.h"
#include "mongo/db/pipeline/document_source_unwind.h"
#include "mongo/db/pipeline/expression_context_builder.h"
#include "mongo/db/pipeline/lite_parsed_desugarer.h"
#include "mongo/db/pipeline/optimization/optimize.h"
#include "mongo/db/pipeline/pipeline.h"
#include "mongo/db/pipeline/pipeline_factory.h"
@ -61,33 +62,16 @@
#include "mongo/db/pipeline/sort_reorder_helpers.h"
#include "mongo/db/pipeline/variable_validation.h"
#include "mongo/db/query/allowed_contexts.h"
#include "mongo/db/query/query_feature_flags_gen.h"
#include "mongo/db/shard_role/shard_catalog/raw_data_operation.h"
#include "mongo/db/stats/counters.h"
#include "mongo/db/topology/sharding_state.h"
#include "mongo/db/views/pipeline_resolver.h"
#include "mongo/db/views/resolved_view.h"
#include "mongo/idl/idl_parser.h"
#include "mongo/util/str.h"
namespace mongo {
namespace {
/**
* Constructs a query of the following shape:
* {$or: [
* {'fieldName': {$eq: 'values[0]'}},
* {'fieldName': {$eq: 'values[1]'}},
* ...
* ]}
*/
BSONObj buildEqualityOrQuery(const std::string& fieldName, const BSONArray& values) {
BSONObjBuilder orBuilder;
{
BSONArrayBuilder orPredicatesBuilder(orBuilder.subarrayStart("$or"));
for (auto&& value : values) {
orPredicatesBuilder.append(BSON(fieldName << BSON("$eq" << value)));
}
}
return orBuilder.obj();
}
// Parses $lookup 'from' field. The 'from' field must be a string or one of the following
// exceptions:
@ -98,8 +82,8 @@ NamespaceString parseLookupFromAndResolveNamespace(const BSONElement& elem,
bool allowGenericForeignDbLookup,
// usingMongos is assumed false any time there is
// no expCtx available.
bool usingMongos = false,
bool isParsingViewDefinition = false) {
bool usingMongos,
bool isParsingViewDefinition) {
// The 'from' field must be a string or an object. Since we now support the object form
// any time we are connected directly to mongod, we include it in the error message.
uassert(ErrorCodes::FailedToParse,
@ -150,10 +134,31 @@ NamespaceString parseLookupFromAndResolveNamespace(const BSONElement& elem,
return nss;
}
namespace {
/**
* Constructs a query of the following shape:
* {$or: [
* {'fieldName': {$eq: 'values[0]'}},
* {'fieldName': {$eq: 'values[1]'}},
* ...
* ]}
*/
BSONObj buildEqualityOrQuery(const std::string& fieldName, const BSONArray& values) {
BSONObjBuilder orBuilder;
{
BSONArrayBuilder orPredicatesBuilder(orBuilder.subarrayStart("$or"));
for (auto&& value : values) {
orPredicatesBuilder.append(BSON(fieldName << BSON("$eq" << value)));
}
}
return orBuilder.obj();
}
// Creates the conditions for joining the local and foreign fields inside of a $match.
static BSONObj createMatchStageJoinObj(const Document& input,
const FieldPath& localFieldPath,
const std::string& foreignFieldName) {
BSONObj createMatchStageJoinObj(const Document& input,
const FieldPath& localFieldPath,
const std::string& foreignFieldName) {
// Add the 'localFieldPath' of 'input' into 'localFieldList'. If 'localFieldPath' references a
// field with an array in its path, we may need to join on multiple values, so we add each
// element to 'localFieldList'.
@ -369,15 +374,7 @@ DocumentSourceLookUp::DocumentSourceLookUp(
_userPipeline = std::move(pipeline);
for (auto&& varElem : letVariables) {
const auto varName = varElem.fieldNameStringData();
variableValidation::validateNameForUserWrite(varName);
_letVariables.emplace_back(
std::string{varName},
Expression::parseOperand(expCtx.get(), varElem, expCtx->variablesParseState),
_variablesParseState.defineVariable(varName));
}
parseAndDefineLetVariables(letVariables, expCtx);
// Initialize the introspection pipeline before we insert the $match (if applicable). This is
// okay because we only use the introspection pipeline for reference while doing query analysis
@ -388,12 +385,158 @@ DocumentSourceLookUp::DocumentSourceLookUp(
// $match.
initializeResolvedIntrospectionPipeline();
// Finally, insert the $match placeholder if we need it.
if (_fieldMatchPipelineIdx) {
_sharedState->resolvedPipeline.insert(_sharedState->resolvedPipeline.begin() +
*_fieldMatchPipelineIdx,
BSON("$match" << BSONObj()));
insertFieldMatchPlaceholder();
}
DocumentSourceLookUp::DocumentSourceLookUp(
NamespaceString fromNs,
std::string as,
std::vector<BSONObj> userPipeline,
LiteParsedPipeline desugaredPipeline,
BSONObj letVariables,
boost::optional<std::pair<std::string, std::string>> localForeignFields,
boost::optional<BSONObj> unwindSpec,
const boost::intrusive_ptr<ExpressionContext>& pExpCtx)
: DocumentSourceLookUp(fromNs, as, pExpCtx) {
resolvedPipelineHelper(fromNs, userPipeline, localForeignFields, pExpCtx);
parseAndDefineLetVariables(letVariables, pExpCtx);
_variables.copyToExpCtx(_variablesParseState, _fromExpCtx.get());
_fromExpCtx->startExpressionCounters();
const auto& resolvedNamespaces = pExpCtx->getResolvedNamespaces();
auto it = resolvedNamespaces.find(_fromNs);
if (it != resolvedNamespaces.end() && !it->second.pipeline.empty()) {
_sharedState->resolvedIntrospectionPipeline = parsePipelineFromLPPWithMaybeViewDefinition(
_fromExpCtx, it->second, desugaredPipeline, userPipeline, _fromNs);
} else {
_sharedState->resolvedIntrospectionPipeline =
Pipeline::parseFromLiteParsed(desugaredPipeline, _fromExpCtx, lookupPipeValidator);
}
_fromExpCtx->stopExpressionCounters();
_userPipeline = std::move(userPipeline);
insertFieldMatchPlaceholder();
// Absorb an $unwind spec if one was provided via LookUpStageParams.
if (unwindSpec && !unwindSpec->isEmpty()) {
_unwindSrc = boost::dynamic_pointer_cast<DocumentSourceUnwind>(
DocumentSourceUnwind::createFromBson(unwindSpec->firstElement(), pExpCtx));
}
}
DocumentSourceContainer DocumentSourceLookUp::createFromStageParams(
LookUpStageParams& params, const boost::intrusive_ptr<ExpressionContext>& expCtx) {
if (params.hasForeignDB) {
// TODO SERVER-125518 Remove this validation when we can bind to an involved namespace in
// LiteParsedLookup. It should be moved to the validate() override.
// Re-run the resolver now that expCtx is available. It encodes the
// allow-list for cross-db $lookup (config.collections, config.chunks, oplog,
// cache.chunks.*) and the mongos/view gating, matching the legacy createFromBson path.
parseLookupFromAndResolveNamespace(params.getOriginalBson().Obj().getField("from"),
expCtx->getNamespaceString().dbName(),
expCtx->getAllowGenericForeignDbLookup(),
expCtx->getInRouter() || expCtx->getFromRouter(),
expCtx->getIsParsingViewDefinition());
}
// TODO SERVER-121091 This can be removed once hybrid search desugars into the internal hybrid
// search stage.
if (params.isHybridSearch || hybrid_scoring_util::isHybridSearchPipeline(params.pipeline)) {
hybrid_scoring_util::assertForeignCollectionIsNotTimeseries(params.fromNss, expCtx);
}
const bool hasLocal = params.localField.has_value();
const bool hasForeign = params.foreignField.has_value();
uassert(ErrorCodes::FailedToParse,
"$lookup requires both or neither of 'localField' and 'foreignField' to be specified",
hasLocal == hasForeign);
boost::optional<std::pair<std::string, std::string>> localForeignFields;
if (hasLocal) {
localForeignFields =
std::pair(std::move(*params.localField), std::move(*params.foreignField));
}
// Without a subpipeline there is no desugared LPP to forward.
if (!params.liteParsedPipeline) {
return {DocumentSourceLookUp::createFromBson(params.getOriginalBson(), expCtx)};
}
return {make_intrusive<DocumentSourceLookUp>(std::move(params.fromNss),
std::move(params.as),
std::move(params.pipeline),
std::move(*params.liteParsedPipeline),
std::move(params.letVariables),
std::move(localForeignFields),
std::move(params.unwindSpec),
expCtx)};
}
DocumentSourceContainer lookupStageParamsToDocumentSourceFn(
const std::unique_ptr<StageParams>& stageParams,
const boost::intrusive_ptr<ExpressionContext>& expCtx) {
auto* typedParams = dynamic_cast<LookUpStageParams*>(stageParams.get());
tassert(11786210, "Expected LookUpStageParams for lookup stage", typedParams != nullptr);
// TODO SERVER-121094 Remove when feature flag is removed.
auto ifrCtx = expCtx->getIfrContext();
auto hybridSearchFlagEnabled = ifrCtx &&
ifrCtx->getSavedFlagValue(feature_flags::gFeatureFlagExtensionsInsideHybridSearch);
if (!hybridSearchFlagEnabled) {
return {DocumentSourceLookUp::createFromBson(typedParams->getOriginalBson(), expCtx)};
}
return DocumentSourceLookUp::createFromStageParams(*typedParams, expCtx);
}
ALLOCATE_AND_REGISTER_STAGE_PARAMS(lookup, LookUpStageParams)
// TODO SERVER-125518 Move this function into LiteParsed.
std::unique_ptr<Pipeline> DocumentSourceLookUp::parsePipelineFromLPPWithMaybeViewDefinition(
const boost::intrusive_ptr<ExpressionContext>& expCtx,
const ResolvedNamespace& resolvedNs,
LiteParsedPipeline& desugaredPipeline,
const std::vector<BSONObj>& rawPipeline,
const NamespaceString& userNss) {
if (resolvedNs.ns.isTimeseriesBucketsCollection() &&
isRawDataOperation(expCtx->getOperationContext())) {
// Raw Data operations on timeseries collections operate without the timeseries view.
return Pipeline::parseFromLiteParsed(desugaredPipeline, expCtx, lookupPipeValidator);
}
if (resolvedNs.pipeline.empty()) {
return Pipeline::parseFromLiteParsed(desugaredPipeline, expCtx, lookupPipeValidator);
}
// For search views, fall back to the BSON-based path since search view handling
// requires raw BSON pipeline inspection.
if (search_helper_bson_obj::isMongotPipeline(expCtx->getIfrContext(), rawPipeline)) {
auto opts = pipeline_factory::kDesugarOnly;
opts.validator = lookupPipeValidator;
return pipeline_factory::makePipelineFromViewDefinition(
expCtx, resolvedNs, std::vector<BSONObj>(rawPipeline), opts, userNss);
}
{
// Add resolved namespaces from view pipeline.
LiteParsedPipeline viewLiteParsedPipeline(resolvedNs.ns, resolvedNs.pipeline);
expCtx->addResolvedNamespaces(viewLiteParsedPipeline.getInvolvedNamespaces());
}
// Apply the view to the desugared LPP.
const ResolvedView resolvedView{resolvedNs.ns, resolvedNs.pipeline, BSONObj()};
PipelineResolver::applyViewToLiteParsed(
&desugaredPipeline,
resolvedView,
userNss,
expCtx->getResolvedNamespaces(),
LiteParserOptions{.ifrContext = expCtx->getIfrContext()});
// Parse from the modified LiteParsedPipeline (already desugared, view already applied).
return Pipeline::parseFromLiteParsed(desugaredPipeline, expCtx, lookupPipeValidator);
}
DocumentSourceLookUp::DocumentSourceLookUp(const DocumentSourceLookUp& original,
@ -445,87 +588,6 @@ void DocumentSourceLookUp::copyLetVariablesWithNewExpCtx(const std::vector<LetVa
}
}
void validateLookupCollectionlessPipeline(const std::vector<BSONObj>& pipeline) {
uassert(ErrorCodes::FailedToParse,
"$lookup stage without explicit collection must have a pipeline with $documents as "
"first stage",
pipeline.size() > 0 &&
!pipeline[0].getField(DocumentSourceDocuments::kStageName).eoo());
}
void validateLookupCollectionlessPipeline(const BSONElement& pipeline) {
uassert(ErrorCodes::FailedToParse, "must specify 'pipeline' when 'from' is empty", pipeline);
auto parsedPipeline = parsePipelineFromBSON(pipeline);
validateLookupCollectionlessPipeline(parsedPipeline);
}
std::unique_ptr<DocumentSourceLookUp::LiteParsed> DocumentSourceLookUp::LiteParsed::parse(
const NamespaceString& nss, const BSONElement& spec, const LiteParserOptions& options) {
uassert(ErrorCodes::FailedToParse,
str::stream() << "the $lookup stage specification must be an object, but found "
<< typeName(spec.type()),
spec.type() == BSONType::object);
auto specObj = spec.Obj();
auto fromElement = specObj["from"];
auto pipelineElem = specObj["pipeline"];
NamespaceString fromNss;
if (!fromElement) {
validateLookupCollectionlessPipeline(pipelineElem);
fromNss = NamespaceString::makeCollectionlessAggregateNSS(nss.dbName());
} else {
fromNss = parseLookupFromAndResolveNamespace(
fromElement, nss.dbName(), options.allowGenericForeignDbLookup);
}
uassert(ErrorCodes::InvalidNamespace,
str::stream() << "invalid $lookup namespace: " << fromNss.toStringForErrorMsg(),
fromNss.isValid());
// Recursively lite parse the nested pipeline, if one exists.
boost::optional<LiteParsedPipeline> liteParsedPipeline;
if (pipelineElem) {
auto pipeline = parsePipelineFromBSON(pipelineElem);
auto optsCopy = options;
optsCopy.makeSubpipelineOwned = true;
liteParsedPipeline = LiteParsedPipeline(fromNss, pipeline, false, optsCopy);
}
return std::make_unique<DocumentSourceLookUp::LiteParsed>(
spec, std::move(fromNss), std::move(liteParsedPipeline));
}
PrivilegeVector DocumentSourceLookUp::LiteParsed::requiredPrivileges(
bool isMongos, bool bypassDocumentValidation) const {
PrivilegeVector requiredPrivileges;
tassert(11282983,
str::stream() << "$lookup only supports 1 subpipeline, got " << _pipelines.size(),
_pipelines.size() <= 1);
tassert(11282982, "Missing foreignNss", _foreignNss);
// If no pipeline is specified or the local/foreignField syntax was used, then assume that we're
// reading directly from the collection.
if (_pipelines.empty() || !_pipelines[0].startsWithInitialSource()) {
Privilege::addPrivilegeToPrivilegeVector(
&requiredPrivileges,
Privilege(ResourcePattern::forExactNamespace(*_foreignNss), ActionType::find));
}
// Add the sub-pipeline privileges, if one was specified.
if (!_pipelines.empty()) {
const LiteParsedPipeline& pipeline = _pipelines[0];
Privilege::addPrivilegesToPrivilegeVector(
&requiredPrivileges, pipeline.requiredPrivileges(isMongos, bypassDocumentValidation));
}
return requiredPrivileges;
}
REGISTER_LITE_PARSED_DOCUMENT_SOURCE(lookup,
DocumentSourceLookUp::LiteParsed::parse,
AllowedWithApiStrict::kConditionally);
REGISTER_DOCUMENT_SOURCE_WITH_STAGE_PARAMS_DEFAULT(lookup, DocumentSourceLookUp, LookUpStageParams);
ALLOCATE_DOCUMENT_SOURCE_ID(lookup, DocumentSourceLookUp::id)
const char* DocumentSourceLookUp::getSourceName() const {
@ -813,6 +875,27 @@ BSONObj DocumentSourceLookUp::makeMatchStageFromInput(const Document& input,
return match.obj();
}
void DocumentSourceLookUp::parseAndDefineLetVariables(
const BSONObj& letVariables, const boost::intrusive_ptr<ExpressionContext>& expCtx) {
for (auto&& varElem : letVariables) {
const auto varName = varElem.fieldNameStringData();
variableValidation::validateNameForUserWrite(varName);
_letVariables.emplace_back(
std::string{varName},
Expression::parseOperand(expCtx.get(), varElem, expCtx->variablesParseState),
_variablesParseState.defineVariable(varName));
}
}
void DocumentSourceLookUp::insertFieldMatchPlaceholder() {
if (_fieldMatchPipelineIdx) {
_sharedState->resolvedPipeline.insert(_sharedState->resolvedPipeline.begin() +
*_fieldMatchPipelineIdx,
BSON("$match" << BSONObj()));
}
}
void DocumentSourceLookUp::initializeResolvedIntrospectionPipeline() {
_variables.copyToExpCtx(_variablesParseState, _fromExpCtx.get());
_fromExpCtx->startExpressionCounters();
@ -1160,7 +1243,7 @@ boost::intrusive_ptr<DocumentSource> DocumentSourceLookUp::createFromBson(
}
if (fromNs.isEmpty()) {
validateLookupCollectionlessPipeline(pipeline);
LiteParsedLookUp::validateLookupCollectionlessPipeline(pipeline);
fromNs =
NamespaceString::makeCollectionlessAggregateNSS(pExpCtx->getNamespaceString().dbName());
}

View File

@ -35,6 +35,7 @@
#include "mongo/bson/bsonelement.h"
#include "mongo/bson/bsonobj.h"
#include "mongo/db/auth/privilege.h"
#include "mongo/db/database_name.h"
#include "mongo/db/exec/agg/exec_pipeline.h"
#include "mongo/db/exec/document_value/document.h"
#include "mongo/db/exec/document_value/value.h"
@ -47,6 +48,7 @@
#include "mongo/db/pipeline/field_path.h"
#include "mongo/db/pipeline/lite_parsed_document_source.h"
#include "mongo/db/pipeline/lite_parsed_document_source_nested_pipelines.h"
#include "mongo/db/pipeline/lite_parsed_lookup.h"
#include "mongo/db/pipeline/lite_parsed_pipeline.h"
#include "mongo/db/pipeline/pipeline.h"
#include "mongo/db/pipeline/stage_constraints.h"
@ -92,7 +94,14 @@ struct LookUpSharedState {
void lookupPipeValidator(const Pipeline& pipeline);
DECLARE_STAGE_PARAMS_DERIVED_DEFAULT(LookUp);
// Parses $lookup's 'from' field. Accepts a string or a '{db, coll}' object with specific
// exceptions. `usingMongos` and `isParsingViewDefinition` tighten validation; lite-parse omits
// them because expCtx is unavailable.
NamespaceString parseLookupFromAndResolveNamespace(const BSONElement& elem,
const DatabaseName& defaultDb,
bool allowGenericForeignDbLookup,
bool usingMongos = false,
bool isParsingViewDefinition = false);
/**
* Queries separate collection for equality matches with documents in the pipeline collection.
@ -107,59 +116,6 @@ public:
static constexpr StringData kPipelineField = "pipeline"_sd;
static constexpr StringData kAsField = "as"_sd;
class LiteParsed final : public LiteParsedDocumentSourceNestedPipelines<LiteParsed> {
public:
static std::unique_ptr<LiteParsed> parse(const NamespaceString& nss,
const BSONElement& spec,
const LiteParserOptions& options);
LiteParsed(const BSONElement& spec,
NamespaceString foreignNss,
boost::optional<LiteParsedPipeline> pipeline)
: LiteParsedDocumentSourceNestedPipelines(
spec, std::move(foreignNss), std::move(pipeline)) {}
/**
* Lookup from a sharded collection may not be allowed.
*/
Status checkShardedForeignCollAllowed(const NamespaceString& nss,
bool inMultiDocumentTransaction) const final {
const auto fcvSnapshot = serverGlobalParams.mutableFCV.acquireFCVSnapshot();
if (!inMultiDocumentTransaction ||
gFeatureFlagAllowAdditionalParticipants.isEnabled(fcvSnapshot)) {
return Status::OK();
}
auto involvedNss = getInvolvedNamespaces();
if (involvedNss.find(nss) == involvedNss.end()) {
return Status::OK();
}
return Status(ErrorCodes::NamespaceCannotBeSharded,
"Sharded $lookup is not allowed within a multi-document transaction");
}
void getForeignExecutionNamespaces(
stdx::unordered_set<NamespaceString>& nssSet) const final {
tassert(6235100, "Expected foreignNss to be initialized for $lookup", _foreignNss);
nssSet.emplace(*_foreignNss);
}
PrivilegeVector requiredPrivileges(bool isMongos,
bool bypassDocumentValidation) const final;
bool requiresAuthzChecks() const override {
return false;
}
bool hasExtensionSearchStage() const override {
return !_pipelines.empty() && _pipelines[0].hasExtensionSearchStage();
}
std::unique_ptr<StageParams> getStageParams() const override {
return std::make_unique<LookUpStageParams>(_originalBson);
}
};
/**
* Copy constructor used for clone().
*/
@ -206,6 +162,33 @@ public:
static boost::intrusive_ptr<DocumentSource> createFromBson(
BSONElement elem, const boost::intrusive_ptr<ExpressionContext>& expCtx);
// Build a $lookup from pre-parsed StageParams. Performs expCtx-dependent validation
// (cross-db on mongos / view definition, hybrid-search timeseries) and forwards the
// desugared LPP into the LPP-accepting constructor when a subpipeline is present.
static DocumentSourceContainer createFromStageParams(
LookUpStageParams& params, const boost::intrusive_ptr<ExpressionContext>& expCtx);
/**
* Constructor accepting a pre-desugared LiteParsedPipeline. Avoids the per-construction
* re-parse of the subpipeline's BSON that createFromBson does. Used by createFromStageParams
* when LookUpStageParams::liteParsedPipeline is present.
*/
DocumentSourceLookUp(NamespaceString fromNs,
std::string as,
std::vector<BSONObj> userPipeline,
LiteParsedPipeline desugaredPipeline,
BSONObj letVariables,
boost::optional<std::pair<std::string, std::string>> localForeignFields,
boost::optional<BSONObj> unwindSpec,
const boost::intrusive_ptr<ExpressionContext>& pExpCtx);
static std::unique_ptr<Pipeline> parsePipelineFromLPPWithMaybeViewDefinition(
const boost::intrusive_ptr<ExpressionContext>& expCtx,
const ResolvedNamespace& resolvedNs,
LiteParsedPipeline& desugaredPipeline,
const std::vector<BSONObj>& rawPipeline,
const NamespaceString& userNss);
void resolvedPipelineHelper(
NamespaceString fromNs,
std::vector<BSONObj> pipeline,
@ -279,6 +262,10 @@ public:
return _variablesParseState;
}
inline const std::vector<BSONObj>& getResolvedPipelineForTest() const {
return _sharedState->resolvedPipeline;
}
const DocumentSourceContainer* getSubPipeline() const final {
tassert(6080015,
"$lookup expected to have a resolved pipeline, but didn't",
@ -396,6 +383,19 @@ private:
*/
void initializeResolvedIntrospectionPipeline();
/**
* Validates each name in 'letVariables' and appends a corresponding entry to '_letVariables'
* (parsed expression + variable id from '_variablesParseState').
*/
void parseAndDefineLetVariables(const BSONObj& letVariables,
const boost::intrusive_ptr<ExpressionContext>& expCtx);
/**
* If '_fieldMatchPipelineIdx' is set, inserts an empty $match placeholder into
* '_sharedState->resolvedPipeline' at that index.
*/
void insertFieldMatchPlaceholder();
/**
* Given a mutable document, appends execution stats such as 'totalDocsExamined',
* 'totalKeysExamined', 'collectionScans', 'indexesUsed', etc. to it.

View File

@ -263,7 +263,7 @@ TEST_F(DocumentSourceLookUpTest, LiteParsedDocumentSourceLookupContainsExpectedN
auto expCtx = getExpCtx();
std::vector<BSONObj> pipeline;
auto liteParsedLookup = DocumentSourceLookUp::LiteParsed::parse(
auto liteParsedLookup = LiteParsedLookUp::parse(
expCtx->getNamespaceString(), stageSpec.firstElement(), LiteParserOptions{});
auto namespaceSet = liteParsedLookup->getInvolvedNamespaces();
@ -1066,7 +1066,7 @@ TEST_F(DocumentSourceLookUpTest, AllowsPipelineFromDBAndCollWithContextFlag) {
{
// Lite parsing
LiteParserOptions options{.allowGenericForeignDbLookup = true};
auto liteParsedLookup = DocumentSourceLookUp::LiteParsed::parse(
auto liteParsedLookup = LiteParsedLookUp::parse(
expCtx->getNamespaceString(), stageSpec.firstElement(), options);
auto namespaceSet = liteParsedLookup->getInvolvedNamespaces();

View File

@ -272,6 +272,11 @@ DocumentSourceUnionWith::DocumentSourceUnionWith(
bool hasForeignDB)
: DocumentSource(kStageName, expCtx) {
_hasForeignDB = hasForeignDB;
// TODO SERVER-121094 Remove when feature flag is removed.
auto ifrContext = expCtx->getIfrContext();
if (!ifrContext->getSavedFlagValue(feature_flags::gFeatureFlagExtensionsInsideHybridSearch)) {
LiteParsedDesugarer::desugar(&desugaredPipeline, expCtx->getIfrContext());
}
boost::optional<ResolvedNamespace> resolvedUnionNs;
try {
auto resolvedNamespaces = expCtx->getResolvedNamespaces();
@ -694,15 +699,10 @@ std::unique_ptr<Pipeline> DocumentSourceUnionWith::parsePipelineFromLPPWithMaybe
LiteParsedPipeline& desugaredPipeline,
const std::vector<BSONObj>& rawPipeline,
const NamespaceString& userNss) {
boost::intrusive_ptr<ExpressionContext> subExpCtx = makeCopyForSubPipelineFromExpressionContext(
expCtx, resolvedNs.ns, resolvedNs.uuid, userNss);
subExpCtx->setInUnionWith(true);
// TODO SERVER-117882 Remove this call once $lookup forwards its desugared LPP subpipeline in
// StageParams.
LiteParsedDesugarer::desugar(&desugaredPipeline, expCtx->getIfrContext());
if (resolvedNs.ns.isTimeseriesBucketsCollection() &&
isRawDataOperation(expCtx->getOperationContext())) {
// Raw Data operations on timeseries collections operate without the timeseries view.

View File

@ -0,0 +1,232 @@
/**
* Copyright (C) 2026-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
* <http://www.mongodb.com/licensing/server-side-public-license>.
*
* 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/db/pipeline/lite_parsed_lookup.h"
#include "mongo/db/pipeline/aggregation_request_helper.h"
#include "mongo/db/pipeline/document_source_documents.h"
#include "mongo/db/pipeline/document_source_lookup.h" // parseLookupFromAndResolveNamespace
#include "mongo/db/pipeline/document_source_lookup_gen.h" // DocumentSourceLookupSpec IDL
#include "mongo/db/pipeline/document_source_queue.h"
#include "mongo/db/pipeline/stage_params_to_document_source_registry.h"
#include "mongo/db/query/allowed_contexts.h"
#include "mongo/db/query/query_feature_flags_gen.h"
#include "mongo/db/server_options.h"
#include "mongo/idl/idl_parser.h"
#include "mongo/logv2/log.h"
#include "mongo/util/str.h"
#include <boost/optional/optional.hpp>
#include <boost/smart_ptr/intrusive_ptr.hpp>
#define MONGO_LOGV2_DEFAULT_COMPONENT ::mongo::logv2::LogComponent::kQuery
namespace mongo {
REGISTER_LITE_PARSED_DOCUMENT_SOURCE(lookup,
LiteParsedLookUp::parse,
AllowedWithApiStrict::kConditionally);
LiteParsedLookUp::LiteParsedLookUp(const BSONElement& spec,
NamespaceString foreignNss,
boost::optional<LiteParsedPipeline> pipeline,
std::vector<BSONObj> rawPipeline,
std::string as,
BSONObj letVariables,
boost::optional<std::string> localField,
boost::optional<std::string> foreignField,
boost::optional<BSONObj> unwindSpec,
bool hasForeignDB,
bool isHybridSearch)
: LiteParsedDocumentSourceNestedPipelines(spec, std::move(foreignNss), std::move(pipeline)),
_rawPipeline(std::move(rawPipeline)),
_as(std::move(as)),
_letVariables(std::move(letVariables)),
_localField(std::move(localField)),
_foreignField(std::move(foreignField)),
_unwindSpec(std::move(unwindSpec)),
_hasForeignDB(hasForeignDB),
_isHybridSearch(isHybridSearch) {}
std::unique_ptr<LiteParsedLookUp> LiteParsedLookUp::parse(const NamespaceString& nss,
const BSONElement& spec,
const LiteParserOptions& options) {
uassert(ErrorCodes::FailedToParse,
str::stream() << "the $lookup stage specification must be an object, but found "
<< typeName(spec.type()),
spec.type() == BSONType::object);
auto specObj = spec.Obj();
auto lookupSpec = DocumentSourceLookupSpec::parse(specObj, IDLParserContext(kStageName));
NamespaceString fromNss;
bool hasForeignDB = false;
if (lookupSpec.getFrom().has_value()) {
auto fromElem = lookupSpec.getFrom().value().getElement();
fromNss = parseLookupFromAndResolveNamespace(
fromElem, nss.dbName(), options.allowGenericForeignDbLookup);
if (fromElem.type() == BSONType::object) {
auto specAsNs = NamespaceSpec::parse(fromElem.embeddedObject(),
IDLParserContext{fromElem.fieldNameStringData()});
hasForeignDB = specAsNs.getDb().has_value();
}
} else {
uassert(ErrorCodes::FailedToParse,
"must specify 'pipeline' when 'from' is empty",
lookupSpec.getPipeline().has_value());
validateLookupCollectionlessPipeline(lookupSpec.getPipeline().value());
fromNss = NamespaceString::makeCollectionlessAggregateNSS(nss.dbName());
}
uassert(ErrorCodes::InvalidNamespace,
str::stream() << "invalid $lookup namespace: " << fromNss.toStringForErrorMsg(),
fromNss.isValid());
boost::optional<LiteParsedPipeline> liteParsedPipeline;
std::vector<BSONObj> rawPipeline;
if (lookupSpec.getPipeline().has_value()) {
rawPipeline = lookupSpec.getPipeline().value();
auto optsCopy = options;
optsCopy.makeSubpipelineOwned = true;
liteParsedPipeline = LiteParsedPipeline(fromNss, rawPipeline, false, optsCopy);
}
std::string as = std::string{lookupSpec.getAs()};
BSONObj letVariables =
lookupSpec.getLetVars().has_value() ? lookupSpec.getLetVars()->getOwned() : BSONObj();
boost::optional<std::string> localField;
boost::optional<std::string> foreignField;
if (lookupSpec.getLocalField().has_value()) {
localField = std::string{*lookupSpec.getLocalField()};
}
if (lookupSpec.getForeignField().has_value()) {
foreignField = std::string{*lookupSpec.getForeignField()};
}
boost::optional<BSONObj> unwindSpec;
if (lookupSpec.getUnwindSpec().has_value()) {
unwindSpec = lookupSpec.getUnwindSpec()->getOwned();
}
const bool isHybridSearch = lookupSpec.getIsHybridSearch().value_or(false);
return std::make_unique<LiteParsedLookUp>(spec,
std::move(fromNss),
std::move(liteParsedPipeline),
std::move(rawPipeline),
std::move(as),
std::move(letVariables),
std::move(localField),
std::move(foreignField),
std::move(unwindSpec),
hasForeignDB,
isHybridSearch);
}
PrivilegeVector LiteParsedLookUp::requiredPrivileges(bool isMongos,
bool bypassDocumentValidation) const {
PrivilegeVector requiredPrivileges;
tassert(11282983,
str::stream() << "$lookup only supports 1 subpipeline, got " << _pipelines.size(),
_pipelines.size() <= 1);
tassert(11282982, "Missing foreignNss", _foreignNss);
if (_pipelines.empty() || !_pipelines[0].startsWithInitialSource()) {
Privilege::addPrivilegeToPrivilegeVector(
&requiredPrivileges,
Privilege(ResourcePattern::forExactNamespace(*_foreignNss), ActionType::find));
}
if (!_pipelines.empty()) {
const LiteParsedPipeline& pipeline = _pipelines[0];
Privilege::addPrivilegesToPrivilegeVector(
&requiredPrivileges, pipeline.requiredPrivileges(isMongos, bypassDocumentValidation));
}
return requiredPrivileges;
}
Status LiteParsedLookUp::checkShardedForeignCollAllowed(const NamespaceString& nss,
bool inMultiDocumentTransaction) const {
const auto fcvSnapshot = serverGlobalParams.mutableFCV.acquireFCVSnapshot();
if (!inMultiDocumentTransaction ||
gFeatureFlagAllowAdditionalParticipants.isEnabled(fcvSnapshot)) {
return Status::OK();
}
auto involvedNss = getInvolvedNamespaces();
if (involvedNss.find(nss) == involvedNss.end()) {
return Status::OK();
}
return Status(ErrorCodes::NamespaceCannotBeSharded,
"Sharded $lookup is not allowed within a multi-document transaction");
}
void LiteParsedLookUp::getForeignExecutionNamespaces(
stdx::unordered_set<NamespaceString>& nssSet) const {
tassert(6235100, "Expected foreignNss to be initialized for $lookup", _foreignNss);
nssSet.emplace(*_foreignNss);
}
bool LiteParsedLookUp::hasExtensionSearchStage() const {
return !_pipelines.empty() && _pipelines[0].hasExtensionSearchStage();
}
std::unique_ptr<StageParams> LiteParsedLookUp::getStageParams() const {
boost::optional<LiteParsedPipeline> lpp;
if (!_pipelines.empty()) {
lpp = _pipelines[0].clone();
}
return std::make_unique<LookUpStageParams>(*_foreignNss,
_as,
_rawPipeline,
_letVariables,
_localField,
_foreignField,
_unwindSpec,
_hasForeignDB,
_isHybridSearch,
getOriginalBson(),
std::move(lpp));
}
void LiteParsedLookUp::validateLookupCollectionlessPipeline(const std::vector<BSONObj>& pipeline) {
uassert(ErrorCodes::FailedToParse,
"$lookup stage without explicit collection must have a pipeline with $documents as "
"first stage",
pipeline.size() > 0 &&
!pipeline[0].getField(DocumentSourceDocuments::kStageName).eoo());
}
void LiteParsedLookUp::validateLookupCollectionlessPipeline(const BSONElement& pipeline) {
uassert(ErrorCodes::FailedToParse, "must specify 'pipeline' when 'from' is empty", pipeline);
auto parsedPipeline = parsePipelineFromBSON(pipeline);
validateLookupCollectionlessPipeline(parsedPipeline);
}
} // namespace mongo

View File

@ -0,0 +1,153 @@
/**
* Copyright (C) 2026-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
* <http://www.mongodb.com/licensing/server-side-public-license>.
*
* 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.
*/
#pragma once
#include "mongo/base/string_data.h"
#include "mongo/bson/bsonelement.h"
#include "mongo/bson/bsonobj.h"
#include "mongo/db/auth/privilege.h"
#include "mongo/db/namespace_string.h"
#include "mongo/db/pipeline/lite_parsed_document_source.h"
#include "mongo/db/pipeline/lite_parsed_document_source_nested_pipelines.h"
#include "mongo/db/pipeline/lite_parsed_pipeline.h"
#include "mongo/db/pipeline/stage_params.h"
#include "mongo/util/modules.h"
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include <boost/none.hpp>
#include <boost/optional.hpp>
#include <boost/optional/optional.hpp>
namespace MONGO_MOD_NEEDS_REPLACEMENT mongo {
class LookUpStageParams : public DefaultStageParams {
public:
LookUpStageParams(NamespaceString fromNss,
std::string as,
std::vector<BSONObj> pipeline,
BSONObj letVariables,
boost::optional<std::string> localField,
boost::optional<std::string> foreignField,
boost::optional<BSONObj> unwindSpec,
bool hasForeignDB,
bool isHybridSearch,
// TODO SERVER-121262 Have the StageParams be owner of the BSONObj instead.
BSONElement originalBson,
boost::optional<LiteParsedPipeline> liteParsedPipeline = boost::none)
: DefaultStageParams(originalBson),
fromNss(std::move(fromNss)),
as(std::move(as)),
pipeline(std::move(pipeline)),
letVariables(std::move(letVariables)),
localField(std::move(localField)),
foreignField(std::move(foreignField)),
unwindSpec(std::move(unwindSpec)),
hasForeignDB(hasForeignDB),
isHybridSearch(isHybridSearch),
liteParsedPipeline(std::move(liteParsedPipeline)) {}
static const Id& id;
Id getId() const final {
return id;
}
NamespaceString fromNss;
std::string as;
std::vector<BSONObj> pipeline;
BSONObj letVariables;
boost::optional<std::string> localField;
boost::optional<std::string> foreignField;
boost::optional<BSONObj> unwindSpec;
bool hasForeignDB;
// TODO SERVER-121091 This can be removed once hybrid search desugars into the internal hybrid
// search stage.
bool isHybridSearch;
// The desugared LiteParsedPipeline for the subpipeline. Absent when $lookup is used without a
// 'pipeline' field (local/foreignField-only form).
boost::optional<LiteParsedPipeline> liteParsedPipeline;
};
class LiteParsedLookUp final : public LiteParsedDocumentSourceNestedPipelines<LiteParsedLookUp> {
public:
static constexpr StringData kStageName = "$lookup"_sd;
static std::unique_ptr<LiteParsedLookUp> parse(const NamespaceString& nss,
const BSONElement& spec,
const LiteParserOptions& options);
LiteParsedLookUp(const BSONElement& spec,
NamespaceString foreignNss,
boost::optional<LiteParsedPipeline> pipeline,
std::vector<BSONObj> rawPipeline,
std::string as,
BSONObj letVariables,
boost::optional<std::string> localField,
boost::optional<std::string> foreignField,
boost::optional<BSONObj> unwindSpec,
bool hasForeignDB,
bool isHybridSearch);
Status checkShardedForeignCollAllowed(const NamespaceString& nss,
bool inMultiDocumentTransaction) const final;
void getForeignExecutionNamespaces(stdx::unordered_set<NamespaceString>& nssSet) const final;
PrivilegeVector requiredPrivileges(bool isMongos, bool bypassDocumentValidation) const final;
bool requiresAuthzChecks() const override {
return false;
}
bool hasExtensionSearchStage() const override;
std::unique_ptr<StageParams> getStageParams() const override;
// Moved from document_source_lookup.cpp so createFromBson can still call it.
static void validateLookupCollectionlessPipeline(const std::vector<BSONObj>& pipeline);
static void validateLookupCollectionlessPipeline(const BSONElement& pipelineElem);
private:
std::vector<BSONObj> _rawPipeline;
std::string _as;
BSONObj _letVariables;
boost::optional<std::string> _localField;
boost::optional<std::string> _foreignField;
boost::optional<BSONObj> _unwindSpec;
bool _hasForeignDB;
bool _isHybridSearch;
};
} // namespace MONGO_MOD_NEEDS_REPLACEMENT mongo

View File

@ -0,0 +1,150 @@
/**
* Copyright (C) 2026-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
* <http://www.mongodb.com/licensing/server-side-public-license>.
*
* 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/db/pipeline/lite_parsed_lookup.h"
#include "mongo/bson/json.h"
#include "mongo/db/pipeline/aggregation_context_fixture.h"
#include "mongo/unittest/unittest.h"
namespace mongo {
namespace {
class LiteParsedLookUpTest : public AggregationContextFixture {
protected:
const NamespaceString nss = NamespaceString::createNamespaceString_forTest("test.local");
std::unique_ptr<LiteParsedLookUp> parse(StringData json,
LiteParserOptions options = LiteParserOptions{}) {
auto spec = fromjson(json);
return LiteParsedLookUp::parse(nss, spec.firstElement(), options);
}
LookUpStageParams* parseAndGetParams(StringData json,
LiteParserOptions options = LiteParserOptions{}) {
_lastParsed = parse(json, options);
_lastParams = _lastParsed->getStageParams();
auto* typed = dynamic_cast<LookUpStageParams*>(_lastParams.get());
ASSERT_TRUE(typed);
return typed;
}
private:
std::unique_ptr<LiteParsedLookUp> _lastParsed;
std::unique_ptr<StageParams> _lastParams;
};
TEST_F(LiteParsedLookUpTest, ParsesPipelineOnlyForm) {
auto lp = parse(R"({$lookup: {from: "foreign", as: "a", pipeline: [{$match: {x: 1}}]}})");
auto involved = lp->getInvolvedNamespaces();
ASSERT_EQ(involved.size(), 1u);
ASSERT_TRUE(involved.contains(NamespaceString::createNamespaceString_forTest("test.foreign")));
}
TEST_F(LiteParsedLookUpTest, ParsesLocalForeignFieldForm) {
auto lp = parse(R"({$lookup: {from: "foreign", as: "a", localField: "x", foreignField: "y"}})");
ASSERT_EQ(lp->getInvolvedNamespaces().size(), 1u);
}
TEST_F(LiteParsedLookUpTest, ParsesCrossDbForm) {
LiteParserOptions opts;
opts.allowGenericForeignDbLookup = true;
auto* typed = parseAndGetParams(
R"({$lookup: {from: {db: "other", coll: "c"}, as: "a", pipeline: []}})", opts);
ASSERT_TRUE(typed->hasForeignDB);
}
TEST_F(LiteParsedLookUpTest, ParsesCollectionlessForm) {
auto lp = parse(R"({$lookup: {as: "a", pipeline: [{$documents: [{}]}]}})");
ASSERT_FALSE(lp->getInvolvedNamespaces().empty());
ASSERT_TRUE(lp->getInvolvedNamespaces().begin()->isCollectionlessAggregateNS());
}
// ---- Parse failures ----
TEST_F(LiteParsedLookUpTest, RejectsNonObjectSpec) {
ASSERT_THROWS_CODE(
parse(R"({$lookup: "just-a-string"})"), AssertionException, ErrorCodes::FailedToParse);
}
TEST_F(LiteParsedLookUpTest, RejectsCollectionlessWithoutDocuments) {
ASSERT_THROWS_CODE(parse(R"({$lookup: {as: "a", pipeline: [{$match: {}}]}})"),
AssertionException,
ErrorCodes::FailedToParse);
}
TEST_F(LiteParsedLookUpTest, StageParamsCarriesDesugaredPipelineWhenPresent) {
auto* typed =
parseAndGetParams(R"({$lookup: {from: "foreign", as: "a", pipeline: [{$match: {}}]}})");
ASSERT_TRUE(typed->liteParsedPipeline.has_value());
ASSERT_EQ(typed->pipeline.size(), 1u);
ASSERT_EQ(typed->as, "a");
ASSERT_FALSE(typed->localField.has_value());
ASSERT_FALSE(typed->foreignField.has_value());
ASSERT_FALSE(typed->hasForeignDB);
}
TEST_F(LiteParsedLookUpTest, StageParamsOmitsLppForLocalForeignFieldForm) {
auto* typed = parseAndGetParams(
R"({$lookup: {from: "foreign", as: "a", localField: "x", foreignField: "y"}})");
ASSERT_FALSE(typed->liteParsedPipeline.has_value());
ASSERT_TRUE(typed->localField.has_value());
ASSERT_EQ(*typed->localField, "x");
ASSERT_TRUE(typed->foreignField.has_value());
ASSERT_EQ(*typed->foreignField, "y");
}
TEST_F(LiteParsedLookUpTest, StageParamsForwardsIsHybridSearchFlag) {
auto* typed = parseAndGetParams(R"({
$lookup: {from: "foreign", as: "a", pipeline: [{$match: {}}], $_internalIsHybridSearch: true}
})");
ASSERT_TRUE(typed->isHybridSearch);
}
TEST_F(LiteParsedLookUpTest, StageParamsCarriesLetVarsAndUnwindSpec) {
auto* typed = parseAndGetParams(R"({
$lookup: {
from: "foreign", as: "a", pipeline: [{$match: {}}],
let: {v: "$x", w: "$y"},
$_internalUnwind: {$unwind: "$a"}
}
})");
ASSERT_BSONOBJ_EQ(typed->letVariables, fromjson(R"({v: "$x", w: "$y"})"));
ASSERT_TRUE(typed->unwindSpec.has_value());
ASSERT_BSONOBJ_EQ(*typed->unwindSpec, fromjson(R"({$unwind: "$a"})"));
}
TEST_F(LiteParsedLookUpTest, RequiredPrivilegesIncludesForeignFind) {
auto lp = parse(R"({$lookup: {from: "foreign", as: "a", pipeline: []}})");
auto privs = lp->requiredPrivileges(false /*isMongos*/, false /*bypass*/);
ASSERT_EQ(privs.size(), 1u);
}
} // namespace
} // namespace mongo

View File

@ -561,7 +561,7 @@ std::unique_ptr<Pipeline> parsePipelineAndRegisterQueryStats(
// optimize out the reparse.
auto clonedLPP = liteParsedPipeline.clone();
const auto wasDesugaredHere =
!alreadyDesugared && LiteParsedDesugarer::desugar(&clonedLPP, ifrContext);
!alreadyDesugared && LiteParsedDesugarer::desugar(&clonedLPP, expCtx->getIfrContext());
auto ifrCtx = expCtx->getIfrContext();
auto hybridSearchFlagEnabled = ifrCtx &&