Skip to content

Commit e049b9e

Browse files
tanjialiangmeta-codesync[bot]
authored andcommitted
fix(nimble): Route projector schema construction through velox-source buildProjectedNimbleType (facebookincubator#840)
Summary: Pull Request resolved: facebookincubator#840 `nimble::serde::Projector` and `nimble::NimbleIndexProjector` previously built their projected schemas by calling the **nimble-source** overload of `buildProjectedNimbleType(const Type*, subfields, &outOffsets)` in `dwio/nimble/velox/SchemaUtils.cpp`. That overload silently dropped missing FlatMap keys, which caused shift-by-N corruption when callers deserialized the projected blob against an expanded schema that included the dropped keys: the Deserializer read each output position through a stream offset that no longer corresponded to the source bytes it was meant to carry. Route both projectors through the **velox-source** overload instead. The velox-source `buildProjectedNimbleType` operates on the velox type derived from the source nimble (`convertToVeloxType` collapses FlatMap to plain `MAP<K,V>`, discarding the specific key list). `resolveVeloxSubfield` inserts any subscript key string into `selectedChildren` unconditionally — no presence check against any source key list — so the projected FlatMap gets one alphabetically-ordered child per requested key, with a value subtree cloned from the source's value type. **Missing-key handling falls out for free** because the velox-source path has no concept of "missing"; it just emits whatever keys the caller asked for. Each projector's constructor now does: 1. `auto veloxSource = convertToVeloxType(*sourceNimbleSchema_)` — existing helper. 2. `const auto encodings = deriveColumnEncodings(sourceNimbleSchema_->asRow())` — new helper that classifies top-level columns by `Kind` (FlatMap → `flatMapColumns`, ArrayWithOffsets → `dictionaryArrayColumns`, SlidingWindowMap → `deduplicatedMapColumns`) so the velox-source builder knows which top-level MAPs to materialize as FlatMaps. 3. `projectedSchema_ = buildProjectedNimbleType(veloxSource->asRow(), subfields, encodings)` — velox-source overload. 4. `computeSourceStreamOffsets(*sourceNimbleSchema_, subfields, encodings, inputStreamIndices_)` — new helper that walks the source nimble in the same DFS pre-order + FlatMap-alphabetical traversal the velox-source builder uses, emitting one source stream offset per projected stream position. Missing FlatMap keys emit `UINT32_MAX` for every descriptor under the value subtree plus the inMap, lining up positionally with the placeholder slots in the projected schema. The Projector's stream-copy guard (`Projector.cpp:358-365`) and `NimbleIndexProjector`'s equivalent already treat `streamIdx >= streamSizes.size()` as a 0-byte slot, so `UINT32_MAX` (larger than any real source offset) produces 0-byte placeholder slots in the projected blob automatically. The Deserializer's gap-fill (`Deserializer.cpp:467-515`) then turns those slots into null columns. This collapses the dual-API design to a single source of truth. Projector-side and Deserializer-side schema layouts now agree by construction (both call the same `buildProjectedNimbleType` overload with the same subfields) instead of by an implicit "both overloads must produce structurally identical alphabetical+DFS schemas" contract. The `NimbleMissingChildrenMap` typedef, `generateMissingKeyType` recursive cloner, `Entry` merging, and `buildProjectedType` private worker are no longer needed and have been deleted along with the nimble-source `buildProjectedNimbleType` overload itself. `SchemaUtilsTest` cross-equivalence cases that asserted both overloads produce identical schemas have been deleted — with only one overload remaining, the comparison is tautological. The "must have at least one real key per FlatMap" guard is preserved in `computeSourceStreamOffsets`: a projection that consists entirely of subscripts whose keys are all missing from the source throws `NimbleInternalError`, since this usually indicates caller misuse (e.g., a typo on every key) and silently projecting an all-null FlatMap would mask it. Reviewed By: xiaoxmeng Differential Revision: D107747094 fbshipit-source-id: 016e7da89ae975a024985b2eb8d65c5eb9b4f2cf
1 parent 11bc8cc commit e049b9e

5 files changed

Lines changed: 884 additions & 966 deletions

File tree

dwio/nimble/serializer/tests/ProjectorTest.cpp

Lines changed: 357 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,8 @@ TEST_F(ProjectorTest, unsupportedArraySubscript) {
763763
SerializerOptions serOpts{.version = SerializationVersion::kCompactRaw};
764764
auto inputSchema = getNimbleSchema(type, serOpts);
765765

766-
// Array subscripts are not supported.
766+
// Array subscripts are not supported — subscripts on non-FlatMap source
767+
// nodes are rejected during subfield resolution against the source schema.
767768
auto subfields = makeSubfields({"arr[0]"});
768769

769770
NIMBLE_ASSERT_THROW(
@@ -782,7 +783,9 @@ TEST_F(ProjectorTest, unsupportedMapKeyProjection) {
782783
SerializerOptions serOpts{.version = SerializationVersion::kCompactRaw};
783784
auto inputSchema = getNimbleSchema(type, serOpts);
784785

785-
// Regular map subscripts are not supported (would need re-encoding).
786+
// Regular map subscripts are not supported (would need re-encoding) — the
787+
// source's MAP is not encoded as FlatMap, so subfield resolution rejects
788+
// the subscript before we even reach schema building.
786789
auto subfields = makeSubfields({"m[\"key\"]"});
787790

788791
NIMBLE_ASSERT_THROW(
@@ -1521,7 +1524,9 @@ TEST_F(ProjectorTest, projectFlatMapMultipleKeys) {
15211524
}
15221525
}
15231526

1524-
// Test projecting FlatMap with non-existent key throws.
1527+
// Test projecting FlatMap with only non-existent keys: the projected schema
1528+
// contains a placeholder child per requested key, and the round-trip decoded
1529+
// output produces null values for those keys.
15251530
TEST_F(ProjectorTest, projectFlatMapNonExistentKey) {
15261531
auto type = ROW({
15271532
{"id", BIGINT()},
@@ -1577,18 +1582,358 @@ TEST_F(ProjectorTest, projectFlatMapNonExistentKey) {
15771582
.flatMapColumns = {{"features", {}}},
15781583
};
15791584
auto [serialized, inputSchema] = serializeWithSchema(vec, type, serOpts);
1580-
(void)serialized; // Not used, just needed to discover keys.
15811585

1582-
// Try to project a key that doesn't exist in schema.
1586+
// Project a key that does not exist in the source schema. The Projector
1587+
// succeeds: the projected FlatMap contains "999" as a synthetic child,
1588+
// and the byte-copy pipeline writes a 0-byte placeholder slot for it.
15831589
auto subfields = makeSubfields({"features[\"999\"]"});
1590+
Projector projector{
1591+
inputSchema,
1592+
subfields,
1593+
pool_.get(),
1594+
{.projectVersion = SerializationVersion::kCompactRaw}};
15841595

1585-
NIMBLE_ASSERT_THROW(
1586-
Projector(
1587-
inputSchema,
1588-
subfields,
1589-
pool_.get(),
1590-
{.projectVersion = SerializationVersion::kCompactRaw}),
1591-
"Cannot project entire FlatMap column without key subscripts");
1596+
// The projected schema has one child for the requested (missing) key.
1597+
const auto& projectedSchema = projector.projectedSchema();
1598+
ASSERT_EQ(Kind::Row, projectedSchema->kind());
1599+
ASSERT_EQ(1, projectedSchema->asRow().childrenCount());
1600+
ASSERT_EQ(Kind::FlatMap, projectedSchema->asRow().childAt(0)->kind());
1601+
const auto& projectedFlatMap =
1602+
projectedSchema->asRow().childAt(0)->asFlatMap();
1603+
ASSERT_EQ(1, projectedFlatMap.childrenCount());
1604+
EXPECT_EQ("999", projectedFlatMap.nameAt(0));
1605+
1606+
// Round-trip: deserialize the projected blob against the projected schema
1607+
// and confirm key 999 decodes to null for every row.
1608+
auto projected = projectInput(projector, serialized, /*useIOBuf=*/false);
1609+
auto projectedStr = toString(projected);
1610+
DeserializerOptions deserOpts{.hasHeader = true};
1611+
auto result = deserialize(projectedStr, projectedSchema, deserOpts);
1612+
ASSERT_EQ(numRows, result->size());
1613+
auto* features = result->as<RowVector>()->childAt(0)->as<MapVector>();
1614+
ASSERT_NE(nullptr, features);
1615+
for (vector_size_t i = 0; i < numRows; ++i) {
1616+
EXPECT_EQ(0, features->sizeAt(i))
1617+
<< "row " << i << " should have an empty map (all requested keys "
1618+
<< "missing in source)";
1619+
}
1620+
}
1621+
1622+
// All-missing-keys projection on a FlatMap whose value subtree is a Row.
1623+
// Exercises the `emitPlaceholderOffsets` Row branch end-to-end (Row.nulls +
1624+
// 2 inner scalars = 3 UINT32_MAX value slots + 1 inMap slot per missing key).
1625+
TEST_F(ProjectorTest, projectFlatMapNonExistentKey_RowValue) {
1626+
auto valueRowType = ROW({{"a", INTEGER()}, {"b", VARCHAR()}});
1627+
auto type =
1628+
ROW({{"id", BIGINT()}, {"features", MAP(INTEGER(), valueRowType)}});
1629+
1630+
// 2 rows × 2 entries (keys 1 and 2 — both real, so the source FlatMap has
1631+
// a non-empty value template for `convertToVeloxType` / `childAt(0)`).
1632+
const vector_size_t numRows = 2;
1633+
const int entriesPerRow = 2;
1634+
const int totalEntries = numRows * entriesPerRow;
1635+
1636+
auto ids = makeIntVector<int64_t>({100, 200});
1637+
auto mapOffsets = allocateOffsets(numRows, pool_.get());
1638+
auto mapSizes = allocateSizes(numRows, pool_.get());
1639+
auto* rawOffsets = mapOffsets->asMutable<vector_size_t>();
1640+
auto* rawSizes = mapSizes->asMutable<vector_size_t>();
1641+
for (vector_size_t i = 0; i < numRows; ++i) {
1642+
rawOffsets[i] = i * entriesPerRow;
1643+
rawSizes[i] = entriesPerRow;
1644+
}
1645+
std::vector<int32_t> keys;
1646+
std::vector<int32_t> aVals;
1647+
std::vector<std::string> bVals;
1648+
for (int i = 0; i < totalEntries; ++i) {
1649+
keys.push_back((i % entriesPerRow) + 1); // keys 1, 2
1650+
aVals.push_back(i);
1651+
bVals.push_back(fmt::format("v{}", i));
1652+
}
1653+
auto keysVec = makeIntVector(keys);
1654+
auto aVec = makeIntVector(aVals);
1655+
auto bVec = makeStringVector(bVals);
1656+
auto valueRows = std::make_shared<RowVector>(
1657+
pool_.get(),
1658+
valueRowType,
1659+
nullptr,
1660+
static_cast<vector_size_t>(totalEntries),
1661+
std::vector<VectorPtr>{aVec, bVec});
1662+
auto mapVector = std::make_shared<MapVector>(
1663+
pool_.get(),
1664+
MAP(INTEGER(), valueRowType),
1665+
nullptr,
1666+
numRows,
1667+
mapOffsets,
1668+
mapSizes,
1669+
keysVec,
1670+
valueRows);
1671+
auto vec = std::make_shared<RowVector>(
1672+
pool_.get(),
1673+
type,
1674+
nullptr,
1675+
numRows,
1676+
std::vector<VectorPtr>{ids, mapVector});
1677+
1678+
SerializerOptions serOpts{
1679+
.version = SerializationVersion::kCompactRaw,
1680+
.flatMapColumns = {{"features", {}}},
1681+
};
1682+
auto [serialized, inputSchema] = serializeWithSchema(vec, type, serOpts);
1683+
1684+
// Project a non-existent key (subscript 999 not in source). Should succeed:
1685+
// the projected FlatMap holds "999" as a synthetic child whose value-subtree
1686+
// is a clone of the source's value Row, with UINT32_MAX placeholders for
1687+
// every stream in that subtree plus the inMap.
1688+
auto subfields = makeSubfields({"features[\"999\"]"});
1689+
Projector projector{
1690+
inputSchema,
1691+
subfields,
1692+
pool_.get(),
1693+
{.projectVersion = SerializationVersion::kCompactRaw}};
1694+
1695+
const auto& projectedSchema = projector.projectedSchema();
1696+
ASSERT_EQ(Kind::Row, projectedSchema->kind());
1697+
ASSERT_EQ(1, projectedSchema->asRow().childrenCount());
1698+
const auto& projectedFlatMap =
1699+
projectedSchema->asRow().childAt(0)->asFlatMap();
1700+
ASSERT_EQ(1, projectedFlatMap.childrenCount());
1701+
EXPECT_EQ("999", projectedFlatMap.nameAt(0));
1702+
// Value subtree is a Row (cloned structurally from the source's value Row).
1703+
ASSERT_EQ(Kind::Row, projectedFlatMap.childAt(0)->kind());
1704+
EXPECT_EQ(2, projectedFlatMap.childAt(0)->asRow().childrenCount());
1705+
1706+
// Round-trip: decoded map should be empty for every row (the inMap stream
1707+
// is all-zero placeholder → gap-fill says "no rows have this key").
1708+
auto projected = projectInput(projector, serialized, /*useIOBuf=*/false);
1709+
DeserializerOptions deserOpts{.hasHeader = true};
1710+
auto result = deserialize(toString(projected), projectedSchema, deserOpts);
1711+
ASSERT_EQ(numRows, result->size());
1712+
auto* features = result->as<RowVector>()->childAt(0)->as<MapVector>();
1713+
ASSERT_NE(nullptr, features);
1714+
for (vector_size_t i = 0; i < numRows; ++i) {
1715+
EXPECT_EQ(0, features->sizeAt(i)) << "row " << i;
1716+
}
1717+
}
1718+
1719+
// All-missing-keys projection on a FlatMap whose value subtree is an Array.
1720+
// Exercises the `emitPlaceholderOffsets` Array branch end-to-end (Array
1721+
// lengths + 1 element scalar = 2 UINT32_MAX value slots + 1 inMap per key).
1722+
TEST_F(ProjectorTest, projectFlatMapNonExistentKey_ArrayValue) {
1723+
auto valueArrayType = ARRAY(INTEGER());
1724+
auto type =
1725+
ROW({{"id", BIGINT()}, {"features", MAP(INTEGER(), valueArrayType)}});
1726+
1727+
// 2 rows × 2 entries; each value is a 2-element array.
1728+
const vector_size_t numRows = 2;
1729+
const int entriesPerRow = 2;
1730+
const int totalEntries = numRows * entriesPerRow;
1731+
const int elementsPerArray = 2;
1732+
1733+
auto ids = makeIntVector<int64_t>({100, 200});
1734+
auto mapOffsets = allocateOffsets(numRows, pool_.get());
1735+
auto mapSizes = allocateSizes(numRows, pool_.get());
1736+
auto* rawMapOff = mapOffsets->asMutable<vector_size_t>();
1737+
auto* rawMapSz = mapSizes->asMutable<vector_size_t>();
1738+
for (vector_size_t i = 0; i < numRows; ++i) {
1739+
rawMapOff[i] = i * entriesPerRow;
1740+
rawMapSz[i] = entriesPerRow;
1741+
}
1742+
std::vector<int32_t> keys;
1743+
for (int i = 0; i < totalEntries; ++i) {
1744+
keys.push_back((i % entriesPerRow) + 1); // keys 1, 2
1745+
}
1746+
auto keysVec = makeIntVector(keys);
1747+
1748+
auto arrayOffsets = allocateOffsets(totalEntries, pool_.get());
1749+
auto arraySizes = allocateSizes(totalEntries, pool_.get());
1750+
auto* rawArrOff = arrayOffsets->asMutable<vector_size_t>();
1751+
auto* rawArrSz = arraySizes->asMutable<vector_size_t>();
1752+
for (int i = 0; i < totalEntries; ++i) {
1753+
rawArrOff[i] = i * elementsPerArray;
1754+
rawArrSz[i] = elementsPerArray;
1755+
}
1756+
std::vector<int32_t> elementVals;
1757+
for (int i = 0; i < totalEntries * elementsPerArray; ++i) {
1758+
elementVals.push_back(i);
1759+
}
1760+
auto elementsVec = makeIntVector(elementVals);
1761+
auto arrayValues = std::make_shared<ArrayVector>(
1762+
pool_.get(),
1763+
valueArrayType,
1764+
nullptr,
1765+
static_cast<vector_size_t>(totalEntries),
1766+
arrayOffsets,
1767+
arraySizes,
1768+
elementsVec);
1769+
auto mapVector = std::make_shared<MapVector>(
1770+
pool_.get(),
1771+
MAP(INTEGER(), valueArrayType),
1772+
nullptr,
1773+
numRows,
1774+
mapOffsets,
1775+
mapSizes,
1776+
keysVec,
1777+
arrayValues);
1778+
auto vec = std::make_shared<RowVector>(
1779+
pool_.get(),
1780+
type,
1781+
nullptr,
1782+
numRows,
1783+
std::vector<VectorPtr>{ids, mapVector});
1784+
1785+
SerializerOptions serOpts{
1786+
.version = SerializationVersion::kCompactRaw,
1787+
.flatMapColumns = {{"features", {}}},
1788+
};
1789+
auto [serialized, inputSchema] = serializeWithSchema(vec, type, serOpts);
1790+
1791+
auto subfields = makeSubfields({"features[\"999\"]"});
1792+
Projector projector{
1793+
inputSchema,
1794+
subfields,
1795+
pool_.get(),
1796+
{.projectVersion = SerializationVersion::kCompactRaw}};
1797+
1798+
const auto& projectedSchema = projector.projectedSchema();
1799+
ASSERT_EQ(Kind::Row, projectedSchema->kind());
1800+
const auto& projectedFlatMap =
1801+
projectedSchema->asRow().childAt(0)->asFlatMap();
1802+
ASSERT_EQ(1, projectedFlatMap.childrenCount());
1803+
EXPECT_EQ("999", projectedFlatMap.nameAt(0));
1804+
// Value subtree is an Array (cloned structurally from the source's value).
1805+
ASSERT_EQ(Kind::Array, projectedFlatMap.childAt(0)->kind());
1806+
1807+
auto projected = projectInput(projector, serialized, /*useIOBuf=*/false);
1808+
DeserializerOptions deserOpts{.hasHeader = true};
1809+
auto result = deserialize(toString(projected), projectedSchema, deserOpts);
1810+
ASSERT_EQ(numRows, result->size());
1811+
auto* features = result->as<RowVector>()->childAt(0)->as<MapVector>();
1812+
ASSERT_NE(nullptr, features);
1813+
for (vector_size_t i = 0; i < numRows; ++i) {
1814+
EXPECT_EQ(0, features->sizeAt(i)) << "row " << i;
1815+
}
1816+
}
1817+
1818+
// Verifies the placeholder-slot behavior for missing FlatMap keys in the
1819+
// production workflow:
1820+
// 1) Projector is constructed with the SOURCE schema (the schema the blob
1821+
// was actually serialized with — keys "1" and "3" only).
1822+
// 2) Caller projects subfields ["1", "2", "3"], where "2" is missing in
1823+
// the source.
1824+
// 3) Caller constructs an EXPANDED schema via the velox-based
1825+
// buildProjectedNimbleType containing all three keys, and uses it to
1826+
// deserialize the projected blob.
1827+
//
1828+
// The Projector's nimble-schema-based buildProjectedNimbleType emits key "2"
1829+
// as a synthetic child with UINT32_MAX input offsets, so the Projector
1830+
// writes 0-byte placeholder slots into the trailer at positions 4-5. The
1831+
// expanded schema's offsets line up with these positions, and the
1832+
// Deserializer's gap-fill produces a null/absent column for key "2" while
1833+
// keys "1" and "3" decode their real bytes.
1834+
//
1835+
// Asserted end-to-end semantics: data["1"]=10.0, data["2"] absent/null,
1836+
// data["3"]=30.0.
1837+
TEST_F(ProjectorTest, missingKeyInMiddleProducesPlaceholder) {
1838+
auto type = ROW({{"data", MAP(INTEGER(), DOUBLE())}});
1839+
1840+
// One row, two entries: keys 1 and 3 (key 2 is intentionally absent).
1841+
const vector_size_t numRows = 1;
1842+
const int entriesPerRow = 2;
1843+
const int totalEntries = numRows * entriesPerRow;
1844+
1845+
auto mapOffsets = allocateOffsets(numRows, pool_.get());
1846+
auto mapSizes = allocateSizes(numRows, pool_.get());
1847+
mapOffsets->asMutable<vector_size_t>()[0] = 0;
1848+
mapSizes->asMutable<vector_size_t>()[0] = entriesPerRow;
1849+
1850+
auto mapKeys = BaseVector::create<FlatVector<int32_t>>(
1851+
INTEGER(), totalEntries, pool_.get());
1852+
auto mapValues = BaseVector::create<FlatVector<double>>(
1853+
DOUBLE(), totalEntries, pool_.get());
1854+
mapKeys->set(0, 1);
1855+
mapKeys->set(1, 3);
1856+
mapValues->set(0, 10.0);
1857+
mapValues->set(1, 30.0);
1858+
1859+
auto mapVector = std::make_shared<MapVector>(
1860+
pool_.get(),
1861+
MAP(INTEGER(), DOUBLE()),
1862+
nullptr,
1863+
numRows,
1864+
mapOffsets,
1865+
mapSizes,
1866+
mapKeys,
1867+
mapValues);
1868+
1869+
auto vec = std::make_shared<RowVector>(
1870+
pool_.get(), type, nullptr, numRows, std::vector<VectorPtr>{mapVector});
1871+
1872+
// Serialize as FlatMap — blob will have streams only for keys "1" and "3".
1873+
SerializerOptions serOpts{
1874+
.version = SerializationVersion::kCompactRaw,
1875+
.flatMapColumns = {{"data", {}}},
1876+
};
1877+
auto [blob, sourceSchema] = serializeWithSchema(vec, type, serOpts);
1878+
1879+
// Caller asks for all three keys (key "2" is missing in source).
1880+
auto subfields = makeSubfields({"data[\"1\"]", "data[\"2\"]", "data[\"3\"]"});
1881+
1882+
// Build the EXPANDED schema via the velox-based API for use at deserialize
1883+
// time. It has children for all three keys with dense alphabetical offsets.
1884+
nimble::ColumnEncodings encodings;
1885+
encodings.flatMapColumns.insert("data");
1886+
auto expandedSchema =
1887+
nimble::buildProjectedNimbleType(type->asRow(), subfields, encodings);
1888+
1889+
// Production flow: construct the Projector with the SOURCE schema (the
1890+
// schema that actually matches the blob). The Projector currently silent-
1891+
// drops key "2" because it's not in the source.
1892+
Projector projector(
1893+
sourceSchema,
1894+
subfields,
1895+
pool_.get(),
1896+
{.projectVersion = SerializationVersion::kCompactRaw});
1897+
1898+
auto projected = projectInput(projector, blob, /*useIOBuf=*/false);
1899+
auto projectedStr = toString(projected);
1900+
1901+
// Deserialize the projected blob using the EXPANDED schema (which expects
1902+
// all three keys). The Projector emits a 0-byte placeholder slot for key 2
1903+
// so the expanded schema's offsets line up with the projected blob and the
1904+
// Deserializer's gap-fill produces a null column for the missing key.
1905+
DeserializerOptions deserOpts{.hasHeader = true};
1906+
auto result = deserialize(projectedStr, expandedSchema, deserOpts);
1907+
1908+
ASSERT_EQ(result->size(), 1);
1909+
auto resultRow = result->as<RowVector>();
1910+
auto dataMap = resultRow->childAt(0)->as<MapVector>();
1911+
ASSERT_NE(dataMap, nullptr);
1912+
1913+
// Build (key -> optional<value>) map for the single row by walking the
1914+
// MapVector. A key is "present" if it appears in the map; "absent" if it
1915+
// doesn't. We expect: 1 -> 10.0, 2 -> absent, 3 -> 30.0.
1916+
std::map<int32_t, std::optional<double>> got;
1917+
auto* keys = dataMap->mapKeys()->as<FlatVector<int32_t>>();
1918+
auto* values = dataMap->mapValues()->as<FlatVector<double>>();
1919+
ASSERT_NE(keys, nullptr);
1920+
ASSERT_NE(values, nullptr);
1921+
const auto offset = dataMap->offsetAt(0);
1922+
const auto size = dataMap->sizeAt(0);
1923+
for (vector_size_t i = offset; i < offset + size; ++i) {
1924+
const auto k = keys->valueAt(i);
1925+
if (values->isNullAt(i)) {
1926+
got[k] = std::nullopt;
1927+
} else {
1928+
got[k] = values->valueAt(i);
1929+
}
1930+
}
1931+
1932+
const std::map<int32_t, std::optional<double>> expected{
1933+
{1, 10.0},
1934+
{3, 30.0},
1935+
};
1936+
EXPECT_EQ(got, expected);
15921937
}
15931938

15941939
// Test stream indices are correct.

0 commit comments

Comments
 (0)