From ca1e348c919d4ca232840239c7d3ba7c8ec03c58 Mon Sep 17 00:00:00 2001 From: silverweed Date: Mon, 13 Jul 2026 15:56:03 +0200 Subject: [PATCH] [ntuple] Add merging support for attributes --- tree/ntuple/inc/ROOT/RNTupleMerger.hxx | 26 +++- tree/ntuple/src/RNTupleMerger.cxx | 206 +++++++++++++++++++++++-- tree/ntuple/test/ntuple_merger.cxx | 132 +++++++++++++++- tree/ntuple/test/ntuple_test.hxx | 2 + 4 files changed, 345 insertions(+), 21 deletions(-) diff --git a/tree/ntuple/inc/ROOT/RNTupleMerger.hxx b/tree/ntuple/inc/ROOT/RNTupleMerger.hxx index da37a995264da..54d1cbd000d42 100644 --- a/tree/ntuple/inc/ROOT/RNTupleMerger.hxx +++ b/tree/ntuple/inc/ROOT/RNTupleMerger.hxx @@ -100,6 +100,11 @@ struct RNTupleMergeOptions { bool fExtraVerbose = false; }; +struct RAttrSetMergeData { + std::unique_ptr fSink; + std::unique_ptr fModel; +}; + // clang-format off /** * \class ROOT::Experimental::Internal::RNTupleMerger @@ -109,9 +114,21 @@ struct RNTupleMergeOptions { */ // clang-format on class RNTupleMerger final { +public: + // When merging Attributes we need to reseal (not just recompress) the pages of the _rangeStart column, since we + // need to patch up the offset of each attribute entry. To do so, we pass a resealing function to the merging + // pipeline, which causes the unsealing of the pages and runs the provided function before sealing it again. + // Note that we actually pass a higher order function that optionally returns the resealing function only for the + // correct column. + using PageResealingFn_t = std::function; + using GetPageResealingFn_t = std::function(const RColumnMergeInfo &)>; + +private: friend class ROOT::RNTuple; std::unique_ptr fDestination; + // Mapping { attrSetName => data needed to merge it } + std::unordered_map fAttributesMergeData; std::unique_ptr fPageAlloc; std::optional fTaskGroup; std::unique_ptr fModel; @@ -122,12 +139,17 @@ class RNTupleMerger final { std::span commonColumns, const ROOT::Internal::RCluster::ColumnSet_t &commonColumnSet, RSealedPageMergeData &sealedPageData, const RNTupleMergeData &mergeData, - ROOT::Internal::RPageAllocator &pageAlloc); + ROOT::Internal::RPageAllocator &pageAlloc, const GetPageResealingFn_t &getResealingFn); [[nodiscard]] ROOT::RResult MergeSourceClusters(ROOT::Internal::RPageSource &source, std::span commonColumns, - std::span extraDstColumns, RNTupleMergeData &mergeData); + std::span extraDstColumns, RNTupleMergeData &mergeData, + const GetPageResealingFn_t &resealingFn); + + [[nodiscard]] + ROOT::RResult MergeSourceAttributes(ROOT::Internal::RPageSource &source, RNTupleMergeData &mergeData, + ROOT::NTupleSize_t nDstEntriesAtPrevSource); /// Creates a RNTupleMerger with the given destination. /// The model must be given if and only if `destination` has been initialized with that model diff --git a/tree/ntuple/src/RNTupleMerger.cxx b/tree/ntuple/src/RNTupleMerger.cxx index 3b54445c39287..eb311772d6afa 100644 --- a/tree/ntuple/src/RNTupleMerger.cxx +++ b/tree/ntuple/src/RNTupleMerger.cxx @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -115,6 +116,31 @@ static std::optional ParseOptionVersionBehavior(con {"AbortOnHigherVersion", ENTupleMergeVersionBehavior::kAbortOnHigherVersion}, }); } + +static ROOT::RResult> +OpenAttributeSource(const ROOT::Experimental::RNTupleAttrSetDescriptor &attrSetDesc, + RAttrSetMergeData &attrSetMergeData, ROOT::Internal::RPageSource &source, + ROOT::Internal::RPageSink &destination, bool copyClusters = false) +{ + auto attrSource = source.OpenWithDifferentAnchor( + ROOT::Internal::RNTupleLink{attrSetDesc.GetAnchorLocator(), attrSetDesc.GetAnchorLength()}); + attrSource->Attach(); + + // If we never found this AttributeSet name yet, create its data. This data will stay alive for the rest of the + // merger's lifetime. + if (!attrSetMergeData.fSink) { + auto opts = ROOT::RNTupleWriteOptions{}; // TODO: maybe we want some specific option here. + attrSetMergeData.fSink = destination.CloneAsHidden(attrSetDesc.GetName(), opts); + if (auto *attrSink = dynamic_cast(attrSetMergeData.fSink.get())) { + attrSetMergeData.fModel = + attrSink->InitFromDescriptor(attrSource->GetSharedDescriptorGuard().GetRef(), copyClusters); + } else { + return R__FAIL("Merging attributes is currently only supported for TFile-based RNTuples."); + } + } + + return {std::move(attrSource)}; +} // ------------------------------------------------------------------------------------- // Entry point for TFileMerger. Internally calls RNTupleMerger::Merge(). @@ -228,12 +254,24 @@ try { writeOpts.SetCompression(*compression); auto destination = std::make_unique(ntupleName, *outFile, writeOpts); std::unique_ptr model; + std::unordered_map attrMergeData; // If we already have an existing RNTuple, copy over its descriptor to support incremental merging if (outNTuple) { auto outSource = RPageSourceFile::CreateFromAnchor(*outNTuple); outSource->Attach(RNTupleSerializer::EDescriptorDeserializeMode::kForWriting); auto desc = outSource->GetSharedDescriptorGuard(); + // For incremental merging we rewrite the metadata but don't touch the actual pages. Note that + // we don't merge the Footer's schema extension into the header, but we keep the two separate + // (hence why outSource gets attached with kForWriting). model = destination->InitFromDescriptor(desc.GetRef(), true /* copyClusters */); + + for (const auto &attrSet : desc.GetRef().GetAttrSetIterable()) { + auto res = OpenAttributeSource(attrSet, attrMergeData[attrSet.GetName()], *outSource, *destination, true); + if (!res) { + R__LOG_ERROR(NTupleMergeLog()) + << "Failed to read attribute set '" << attrSet.GetName() << "': " << res.GetError()->GetReport(); + } + } } // Interface conversion @@ -256,6 +294,7 @@ try { if (auto versionBehavior = ParseOptionVersionBehavior(mergeInfo->fOptions)) { mergerOpts.fVersionBehavior = *versionBehavior; } + merger.fAttributesMergeData = std::move(attrMergeData); merger.Merge(sourcePtrs, mergerOpts).ThrowOnError(); // Provide the caller with a merged anchor object (even though we've already @@ -310,6 +349,32 @@ struct RChangeCompressionFunc { } }; +struct RResealFunc { + const RColumnElementBase &fColElement; + std::uint32_t fCompressionSettings; + RPageStorage::RSealedPage &fSealedPage; + ROOT::Internal::RPageAllocator &fPageAlloc; + std::byte *fBuffer; + std::size_t fBufSize; + const ROOT::RNTupleWriteOptions &fWriteOpts; + const RColumnMergeInfo &fColumnInfo; + RNTupleMerger::PageResealingFn_t fPageResealingFn; + + void operator()() const + { + auto page = RPageSource::UnsealPage(fSealedPage, fColElement, fPageAlloc).Unwrap(); + fPageResealingFn(page); + RPageSink::RSealPageConfig sealConf; + sealConf.fElement = &fColElement; + sealConf.fPage = &page; + sealConf.fBuffer = fBuffer; + sealConf.fCompressionSettings = fCompressionSettings; + sealConf.fWriteChecksum = fSealedPage.GetHasChecksum(); + fSealedPage = RPageSink::SealPage(sealConf); + fSealedPage.ChecksumIfEnabled(); + } +}; + struct RTaskVisitor { std::optional &fGroup; @@ -895,7 +960,8 @@ RNTupleMerger::MergeCommonColumns(ROOT::Internal::RClusterPool &clusterPool, const ROOT::RClusterDescriptor &clusterDesc, std::span commonColumns, const RCluster::ColumnSet_t &commonColumnSet, RSealedPageMergeData &sealedPageData, - const RNTupleMergeData &mergeData, ROOT::Internal::RPageAllocator &pageAlloc) + const RNTupleMergeData &mergeData, ROOT::Internal::RPageAllocator &pageAlloc, + const GetPageResealingFn_t &getResealingFn) { const auto nCommonColumnsInCluster = commonColumnSet.size(); assert(nCommonColumnsInCluster <= commonColumns.size()); @@ -936,7 +1002,8 @@ RNTupleMerger::MergeCommonColumns(ROOT::Internal::RClusterPool &clusterPool, // L2: compression of dest doesn't match the src we must recompress the page. // Note that in no case do we need to re-encode the page, as if the encoding differs we simply // append a new column representation to the field. - const bool needsRecompressing = colRangeCompressionSettings != outCompression; + const auto resealingFn = getResealingFn(column); + const bool needsRecompressing = colRangeCompressionSettings != outCompression || resealingFn.has_value(); if (needsRecompressing && mergeData.fMergeOpts.fExtraVerbose) { R__LOG_INFO(NTupleMergeLog()) << "Recompressing column " << column.fColumnName @@ -995,17 +1062,33 @@ RNTupleMerger::MergeCommonColumns(ROOT::Internal::RClusterPool &clusterPool, // the actual data size after recompressing. buffer = MakeUninitArray(bufSize); - // clang-format off - RTaskVisitor{fTaskGroup}(RChangeCompressionFunc{ - *srcColElement, - outCompression, - sealedPage, - *fPageAlloc, - buffer.get(), - bufSize, - mergeData.fDestination.GetWriteOptions() - }); - // clang-format on + if (resealingFn) { + // clang-format off + RTaskVisitor{fTaskGroup}(RResealFunc{ + *srcColElement, + outCompression, + sealedPage, + *fPageAlloc, + buffer.get(), + bufSize, + mergeData.fDestination.GetWriteOptions(), + column, + *resealingFn + }); + // clang-format on + } else { + // clang-format off + RTaskVisitor{fTaskGroup}(RChangeCompressionFunc{ + *srcColElement, + outCompression, + sealedPage, + *fPageAlloc, + buffer.get(), + bufSize, + mergeData.fDestination.GetWriteOptions() + }); + // clang-format on + } } ++pageIdx; @@ -1030,7 +1113,8 @@ RNTupleMerger::MergeCommonColumns(ROOT::Internal::RClusterPool &clusterPool, // compression is unspecified or matches the original compression settings. ROOT::RResult RNTupleMerger::MergeSourceClusters(RPageSource &source, std::span commonColumns, std::span extraDstColumns, - RNTupleMergeData &mergeData) + RNTupleMergeData &mergeData, + const GetPageResealingFn_t &getResealingFn) { ROOT::Internal::RClusterPool clusterPool{source}; @@ -1106,7 +1190,7 @@ ROOT::RResult RNTupleMerger::MergeSourceClusters(RPageSource &source, std: RSealedPageMergeData sealedPageData; auto res = MergeCommonColumns(clusterPool, clusterDesc, commonColumns, commonColumnSet, sealedPageData, mergeData, - *fPageAlloc); + *fPageAlloc, getResealingFn); if (!res) return R__FORWARD_ERROR(res); @@ -1320,6 +1404,80 @@ static void AddColumnExtensionsInFieldOrder( } } +ROOT::RResult +ROOT::Experimental::Internal::RNTupleMerger::MergeSourceAttributes(RPageSource &source, RNTupleMergeData &mergeData, + ROOT::NTupleSize_t nDstEntriesAtPrevSource) +{ + // Merge each AttributeSet to the destination. + // NOTE: we always Union-merge attribute sets (meaning the output RNTuple will contain all the Attribute Sets + // from all sources.) + for (const auto &attrSetDesc : mergeData.fSrcDescriptor->GetAttrSetIterable()) { + // Load the AttributeSet RNTuple from the source file. + auto &attrSetMergeData = fAttributesMergeData[attrSetDesc.GetName()]; + auto attrSourceRes = OpenAttributeSource(attrSetDesc, attrSetMergeData, source, *fDestination); + if (!attrSourceRes) { + if (mergeData.fMergeOpts.fErrBehavior == ENTupleMergeErrBehavior::kAbort) { + return R__FORWARD_ERROR(attrSourceRes); + } else { + R__LOG_ERROR(NTupleMergeLog()) << "Failed to read attribute set '" << attrSetDesc.GetName() + << "': " << attrSourceRes.GetError()->GetReport(); + continue; + } + } + auto attrSource = attrSourceRes.Unwrap(); + // If we have no errors but no source either it means there are no AttributeSets to merge. + if (!attrSource) + return ROOT::RResult::Success(); + + // Verify schema compatibility. If two Attribute Sets have the same name we require them to have + // an exactly identical schema. This may be relaxed in the future. + auto attrSrcDesc = attrSource->GetSharedDescriptorGuard(); + const auto &attrDstDesc = attrSetMergeData.fSink->GetDescriptor(); + RDescriptorsComparison descCmp; + { + bool isCompatible = true; + auto res = CompareDescriptorStructure(attrDstDesc, attrSrcDesc.GetRef()); + if (res) { + descCmp = res.Unwrap(); + isCompatible = descCmp.fExtraDstFields.empty() && descCmp.fExtraSrcFields.empty(); + } else { + isCompatible = false; + } + if (!isCompatible) { + return R__FAIL("Source AttributeSet '" + attrSetDesc.GetName() + + "' has a schema incompatible with the destination AttributeSet with the same attrSetName."); + } + } + + RPageSource *attrSources[1] = {attrSource.get()}; + RNTupleMergeData attrMergeData{attrSources, *attrSetMergeData.fSink, mergeData.fMergeOpts}; + attrMergeData.fSrcDescriptor = &attrSrcDesc.GetRef(); + + auto colInfoGroup = GatherColumnInfos(descCmp, attrSrcDesc.GetRef(), attrMergeData); + + const auto getPageResealingFn = + [nDstEntriesAtPrevSource](const RColumnMergeInfo &colInfo) -> std::optional { + if (colInfo.fInputId == ROOT::Experimental::Internal::RNTupleAttributes::kMetaFieldIndex_RangeStart) { + return [nDstEntriesAtPrevSource](ROOT::Internal::RPage &page) { + // Note that _rangeStart is always column 0 due to how it's created in the RNTupleAttrSetWriter. + // Shift the page elements + assert(page.GetElementSize() == sizeof(ROOT::NTupleSize_t)); + auto *rangeStarts = reinterpret_cast(page.GetBuffer()); + for (auto i = 0u; i < page.GetNElements(); ++i) + rangeStarts[i] += nDstEntriesAtPrevSource; + }; + } else { + return std::nullopt; + } + }; + auto res = MergeSourceClusters(*attrSource, colInfoGroup.fCommonColumns, colInfoGroup.fExtraDstColumns, + attrMergeData, getPageResealingFn); + if (!res) + return R__FORWARD_ERROR(res); + } + return RResult::Success(); +} + RNTupleMerger::RNTupleMerger(std::unique_ptr destination, std::unique_ptr model) // TODO(gparolini): consider using an arena allocator instead, since we know the precise lifetime @@ -1501,16 +1659,32 @@ ROOT::RResult RNTupleMerger::Merge(std::span sources, const } } + const auto nInitialDstEntries = mergeData.fNumDstEntries; + // handle extra dst fields & common fields auto columnInfos = GatherColumnInfos(descCmp, srcDescriptor.GetRef(), mergeData); - auto res = MergeSourceClusters(*source, columnInfos.fCommonColumns, columnInfos.fExtraDstColumns, mergeData); + auto res = MergeSourceClusters(*source, columnInfos.fCommonColumns, columnInfos.fExtraDstColumns, mergeData, + [](auto &&) { return std::nullopt; }); if (!res) return R__FORWARD_ERROR(res); + + // merge Attributes + res = MergeSourceAttributes(*source, mergeData, nInitialDstEntries); + if (!res) { + SKIP_OR_ABORT(res.GetError()->GetReport()); + } } // end loop over sources if (fDestination->GetNEntries() == 0) R__LOG_WARNING(NTupleMergeLog()) << "Output RNTuple '" << fDestination->GetNTupleName() << "' has no entries."; + for (auto &[attrSetName, data] : fAttributesMergeData) { + auto &attrSink = data.fSink; + attrSink->CommitClusterGroup(); + auto anchorInfo = attrSink->CommitDataset(); + fDestination->CommitAttributeSet(attrSetName, anchorInfo); + } + // Commit the output fDestination->CommitClusterGroup(); fDestination->CommitDataset(); diff --git a/tree/ntuple/test/ntuple_merger.cxx b/tree/ntuple/test/ntuple_merger.cxx index b9f7e768d47f8..57eb881d2709b 100644 --- a/tree/ntuple/test/ntuple_merger.cxx +++ b/tree/ntuple/test/ntuple_merger.cxx @@ -2151,7 +2151,7 @@ TEST(RNTupleMerger, MergeAsymmetric1TFileMerger) diags.requiredDiag(kError, "TFileMerger::Merge", "error during merge", false); diags.requiredDiag(kError, "ROOT.NTuple.Merge", "missing the following field", false); diags.requiredDiag(kError, "TFileMerger::MergeRecursive", "Could NOT merge RNTuples!", false); - diags.optionalDiag(kWarning, "TFileMerger::MergeRecursive", "Merging RNTuples is experimental", false); + diags.requiredDiag(kWarning, "TFileMerger::MergeRecursive", "Merging RNTuples is experimental", false); auto res = fileMerger.Merge(); EXPECT_FALSE(res); } @@ -2167,7 +2167,7 @@ TEST(RNTupleMerger, MergeAsymmetric1TFileMerger) diags.requiredDiag(kError, "TFileMerger::Merge", "error during merge", false); diags.requiredDiag(kError, "ROOT.NTuple.Merge", "missing the following field", false); diags.requiredDiag(kError, "TFileMerger::MergeRecursive", "Could NOT merge RNTuples!", false); - diags.optionalDiag(kWarning, "TFileMerger::MergeRecursive", "Merging RNTuples is experimental", false); + diags.requiredDiag(kWarning, "TFileMerger::MergeRecursive", "Merging RNTuples is experimental", false); auto res = fileMerger.Merge(); EXPECT_FALSE(res); } @@ -2180,7 +2180,7 @@ TEST(RNTupleMerger, MergeAsymmetric1TFileMerger) fileMerger.AddFile(nt2.get()); fileMerger.SetMergeOptions(TString("rntuple.MergingMode=Union")); CheckDiagsRAII diags; - diags.optionalDiag(kWarning, "TFileMerger::MergeRecursive", "Merging RNTuples is experimental", false); + diags.requiredDiag(kWarning, "TFileMerger::MergeRecursive", "Merging RNTuples is experimental", false); auto res = fileMerger.Merge(); EXPECT_TRUE(res); } @@ -4582,3 +4582,129 @@ TEST(RNTupleMerger, MergeRealRegularQuantMixed) } } } + +TEST(RNTupleMerger, MergeThroughTFileMergerIncrementalWithAttributes) +{ + ROOT::TestSupport::CheckDiagsRAII diagsRAII; + diagsRAII.requiredDiag(kWarning, "ROOT.NTuple", "RNTuple Attributes are experimental", false); + + // Write two test ntuples to be merged. + // These files both have 2 attribute sets, one of which (AttrSet1) is common to both. + // We expect the output file to contain all 3 attribute sets, where AttrSet1 has the union of both sets' entries. + FileRaii fileGuardIn("test_ntuple_merge_in_attr.root"); + { + auto model = RNTupleModel::Create(); + auto fieldFoo = model->MakeField("foo"); + auto fieldBar = model->MakeField("bar"); + auto file = std::unique_ptr(TFile::Open(fileGuardIn.GetPath().c_str(), "RECREATE")); + auto writer = RNTupleWriter::Append(std::move(model), "ntuple", *file); + auto attrSetModel = RNTupleModel::Create(); + attrSetModel->MakeField("int"); + auto attrSet1 = writer->CreateAttributeSet(attrSetModel->Clone(), "AttrSet1"); + attrSetModel->MakeField("long"); + auto attrSet2 = writer->CreateAttributeSet(std::move(attrSetModel), "AttrSet2"); + auto &attrEntry1 = attrSet1->GetModel().GetDefaultEntry(); + auto &attrEntry2 = attrSet2->GetModel().GetDefaultEntry(); + auto attrRange1= attrSet1->BeginRange(); + auto attrRange2= attrSet2->BeginRange(); + *attrEntry1.GetPtr("int") = 1; + *attrEntry2.GetPtr("int") = 2; + *attrEntry2.GetPtr("long") = 3; + for (size_t i = 0; i < 10; ++i) { + *fieldFoo = i * 123; + *fieldBar = i * 321; + writer->Fill(); + } + attrSet1->CommitRange(std::move(attrRange1)); + attrSet2->CommitRange(std::move(attrRange2)); + } + + FileRaii fileGuardOut("test_ntuple_merge_out_attr.root"); + { + auto model = RNTupleModel::Create(); + auto fieldBar = model->MakeField("bar"); + auto fieldFoo = model->MakeField("foo"); + auto file = std::unique_ptr(TFile::Open(fileGuardOut.GetPath().c_str(), "RECREATE")); + auto writer = RNTupleWriter::Append(std::move(model), "ntuple", *file); + auto attrSetModel = RNTupleModel::Create(); + attrSetModel->MakeField("int"); + auto attrSet1 = writer->CreateAttributeSet(attrSetModel->Clone(), "AttrSet1"); + attrSetModel->MakeField("string"); + auto attrSet3 = writer->CreateAttributeSet(std::move(attrSetModel), "AttrSet3"); + auto &attrEntry1 = attrSet1->GetModel().GetDefaultEntry(); + auto &attrEntry3 = attrSet3->GetModel().GetDefaultEntry(); + auto attrRange1 = attrSet1->BeginRange(); + auto attrRange3 = attrSet3->BeginRange(); + *attrEntry1.GetPtr("int") = 4; + *attrEntry3.GetPtr("int") = 5; + *attrEntry3.GetPtr("string") = "6"; + for (size_t i = 0; i < 10; ++i) { + *fieldFoo = i * 567; + *fieldBar = i * 765; + writer->Fill(); + } + attrSet1->CommitRange(std::move(attrRange1)); + attrSet3->CommitRange(std::move(attrRange3)); + } + + { + diagsRAII.requiredDiag(kWarning, "TFileMerger::MergeRecursive", "Merging RNTuples is experimental"); + + // Now merge the inputs through TFileMerger + TFileMerger merger; + merger.SetMergeOptions(TString("rntuple.ExtraVerbose=true rntuple.AttrBehavior=Keep")); + merger.AddFile(fileGuardIn.GetPath().c_str()); + merger.OutputFile(fileGuardOut.GetPath().c_str(), "UPDATE"); + merger.PartialMerge(); + } + + // Now check some information + { + auto reader = RNTupleReader::Open("ntuple", fileGuardOut.GetPath()); + EXPECT_EQ(reader->GetNEntries(), 20); + EXPECT_EQ(reader->GetDescriptor().GetNAttributeSets(), 3); + + { + auto attrSet1 = reader->OpenAttributeSet("AttrSet1"); + auto attrEntry1 = attrSet1->CreateEntry(); + auto pInt1 = attrEntry1->GetPtr("int"); + EXPECT_EQ(attrSet1->GetNEntries(), 2); + for (auto idx : attrSet1->GetAttributes()) { + auto range1 = attrSet1->LoadEntry(idx, *attrEntry1); + EXPECT_EQ(range1.GetFirst(), 10 * idx); + EXPECT_EQ(range1.GetLast(), 10 * idx + 9); + // NOTE: since the output file is the base for the merging, for idx=0 we have its values and for idx=1 + // we have the values of the input file. + EXPECT_EQ(*pInt1, idx < 1 ? 4 : 1); + } + } + { + auto attrSet2 = reader->OpenAttributeSet("AttrSet2"); + auto attrEntry2 = attrSet2->CreateEntry(); + auto pInt2 = attrEntry2->GetPtr("int"); + auto pLong2 = attrEntry2->GetPtr("long"); + EXPECT_EQ(attrSet2->GetNEntries(), 1); + for (auto idx : attrSet2->GetAttributes()) { + auto range2 = attrSet2->LoadEntry(idx, *attrEntry2); + EXPECT_EQ(range2.GetFirst(), 10); + EXPECT_EQ(range2.GetLast(), 19); + EXPECT_EQ(*pInt2, 2); + EXPECT_EQ(*pLong2, 3); + } + } + { + auto attrSet3 = reader->OpenAttributeSet("AttrSet3"); + auto attrEntry3 = attrSet3->CreateEntry(); + auto pInt3 = attrEntry3->GetPtr("int"); + auto pStr3 = attrEntry3->GetPtr("string"); + EXPECT_EQ(attrSet3->GetNEntries(), 1); + for (auto idx : attrSet3->GetAttributes()) { + auto range3 = attrSet3->LoadEntry(idx, *attrEntry3); + EXPECT_EQ(range3.GetFirst(), 0); + EXPECT_EQ(range3.GetLast(), 9); + EXPECT_EQ(*pInt3, 5); + EXPECT_EQ(*pStr3, "6"); + } + } + } +} diff --git a/tree/ntuple/test/ntuple_test.hxx b/tree/ntuple/test/ntuple_test.hxx index 8c1b737b17413..b9238b1719dcc 100644 --- a/tree/ntuple/test/ntuple_test.hxx +++ b/tree/ntuple/test/ntuple_test.hxx @@ -29,6 +29,8 @@ #include #include #include +#include +#include #include #include