diff --git a/include/internal/data_frame/data_frame.hpp b/include/internal/data_frame/data_frame.hpp index 115c5e4e..fe467337 100644 --- a/include/internal/data_frame/data_frame.hpp +++ b/include/internal/data_frame/data_frame.hpp @@ -259,21 +259,28 @@ namespace csv { * * CSVRow copies share the underlying parsed row storage, so this is intended for * filtered document views that should avoid reparsing or rematerializing fields. + * Existing cell edits are copied; subsequent edits to either frame are independent. */ DataFrame selected_rows(const std::vector& include_rows) const { if (include_rows.size() != this->rows.size()) { throw std::invalid_argument("selected row mask size must match DataFrame row count"); } - std::vector selected; - selected.reserve(this->rows.size()); + DataFrame result; + result.rows.reserve(this->rows.size()); for (size_t row_index = 0; row_index < this->rows.size(); ++row_index) { if (include_rows[row_index]) { - selected.push_back(this->rows[row_index]); + result.rows.push_back(this->rows[row_index]); + result.edits.emplace_back(); + if (const RowOverlay* overlay = this->find_row_edits(row_index)) { + RowOverlay snapshot = overlay->snapshot(); + if (!snapshot.empty()) { + *result.edits.back().ensure() = std::move(snapshot); + } + } } } - DataFrame result(std::move(selected)); result.col_names_ = this->col_names_; result.physical_col_names_ = this->physical_col_names_; result.column_indices_ = this->column_indices_; diff --git a/include/internal/data_frame/row_overlay.hpp b/include/internal/data_frame/row_overlay.hpp index 97b3e63c..820b2013 100644 --- a/include/internal/data_frame/row_overlay.hpp +++ b/include/internal/data_frame/row_overlay.hpp @@ -66,6 +66,13 @@ namespace csv { return values.empty(); } + RowOverlay snapshot() const { + row_overlay_lock_guard lock(this); + RowOverlay result; + result.values = values; + return result; + } + private: struct row_overlay_lock_guard { explicit row_overlay_lock_guard(const RowOverlay* overlay) diff --git a/tests/test_data_frame.cpp b/tests/test_data_frame.cpp index 486ffb05..6db54e0e 100644 --- a/tests/test_data_frame.cpp +++ b/tests/test_data_frame.cpp @@ -946,3 +946,129 @@ TEST_CASE("DataFrame: const row erase is rejected", "[data_frame]") { REQUIRE_THROWS_AS(row.erase(), std::runtime_error); REQUIRE_THROWS_AS(row["name"] = "Blocked", std::runtime_error); } + +TEST_CASE("DataFrame: selected rows preserve independent sparse edits", "[data_frame][selected_rows]") { + std::istringstream input( + "id,name,value\n" + "1,Alice,10\n" + "2,Bob,20\n" + "3,Carol,30\n" + "4,David,40\n" + ); + CSVReader reader(input); + DataFrame<> frame; + SECTION("Unkeyed source") { frame = DataFrame<>(reader); } + SECTION("Keyed source") { frame = DataFrame<>(reader, "id"); } + + frame.at(0)["name"] = "Excluded"; + frame.at(1)["name"] = "Robert"; + frame.at(1)["value"] = "95"; + frame.at(3)["value"] = "45"; + + const auto& source = frame; + auto selected = source.selected_rows({0, 1, 0, 2}); + REQUIRE(selected.size() == 2); + REQUIRE(selected.columns() == frame.columns()); + REQUIRE_THROWS_AS(selected["2"], std::runtime_error); + REQUIRE(std::vector(selected.at(0)) == std::vector{"2", "Robert", "95"}); + REQUIRE(std::vector(selected.at(1)) == std::vector{"4", "David", "45"}); + REQUIRE(selected.column("value") == std::vector{"95", "45"}); + REQUIRE(selected.column_view(1)[0].get() == "Robert"); + REQUIRE(selected.at(0).to_json() == "{\"id\":2,\"name\":\"Robert\",\"value\":95}"); + REQUIRE(selected.at(0).to_json_array() == "[2,\"Robert\",95]"); + + // Selection should still share parsed bytes, not materialize edited rows. + REQUIRE(selected.at(0).get_underlying_row()["name"].get_sv().data() + == source.at(1).get_underlying_row()["name"].get_sv().data()); + + std::stringstream output; + auto writer = make_csv_writer(output); + writer << selected.columns(); + for (const auto& row : selected) { +#ifdef CSV_HAS_CXX20 + writer << row; +#else + writer << std::vector(row); +#endif + } + REQUIRE(output.str() == "id,name,value\n2,Robert,95\n4,David,45\n"); + + const auto& const_selected = selected; + REQUIRE_THROWS_AS(const_selected.at(0)["name"] = "Blocked", std::runtime_error); + REQUIRE_THROWS_AS(const_selected.column_view("name")[0] = "Blocked", std::runtime_error); + + // Edits on either frame must not leak into the other frame's overlay. + frame.at(1)["name"] = "Source only"; + REQUIRE(selected.at(0)["name"].get() == "Robert"); + selected.at(0)["value"] = "100"; + REQUIRE(frame.at(1)["value"].get() == "95"); + + auto repeated = selected.selected_rows({1, 0}); + REQUIRE(std::vector(repeated.at(0)) == std::vector{"2", "Robert", "100"}); + selected.at(0)["name"] = "Selected only"; + REQUIRE(repeated.at(0)["name"].get() == "Robert"); +} + +TEST_CASE("DataFrame: selected rows preserve physical edit indices after structural changes", "[data_frame][selected_rows]") { + std::istringstream input("id,name,value\n1,Alice,10\n2,Bob,20\n3,Carol,30\n"); + CSVFormat format; + format.column_names_policy(ColumnNamePolicy::CASE_INSENSITIVE); + CSVReader reader(input, format); + DataFrame<> frame(reader, "id"); + frame.at(0)["name"] = "Hidden"; + frame.at(1)["value"] = "95"; + frame.at(2)["value"] = ""; + REQUIRE(frame.column_view("name").erase()); + REQUIRE(frame.at(0).erase()); + frame.insert_row(0, {"4", "40"}); + + auto selected = frame.selected_rows({0, 1, 1}); + REQUIRE(selected.columns() == std::vector{"id", "value"}); + REQUIRE(selected.index_of("VALUE") == 1); + REQUIRE_FALSE(selected.has_column("name")); + REQUIRE(std::vector(selected.at(0)) == std::vector{"2", "95"}); + REQUIRE(std::vector(selected.at(1)) == std::vector{"3", ""}); + REQUIRE(selected.column_view(1)[0].get() == "95"); + REQUIRE(selected.at(0).to_json() == "{\"id\":2,\"value\":95}"); + REQUIRE(selected.at(1)["VALUE"].get().empty()); + + std::stringstream output; + auto writer = make_csv_writer(output); + writer << selected.columns(); + for (const auto& row : selected) { +#ifdef CSV_HAS_CXX20 + writer << row; +#else + writer << std::vector(row); +#endif + } + REQUIRE(output.str() == "id,value\n2,95\n3,\n"); + + auto repeated = selected.selected_rows({0, 1}); + REQUIRE(std::vector(repeated.at(0)) == std::vector{"3", ""}); +} + +TEST_CASE("DataFrame: selected row masks keep boundary behavior", "[data_frame][selected_rows]") { + auto input = make_people_stream(); + CSVReader reader(input); + DataFrame<> frame(reader); + // The unedited path must continue to work without any allocated edits. + auto all = frame.selected_rows({1, 1, 1}); + REQUIRE(all.column("name") == std::vector{"Alice", "Bob", "Carol"}); + frame.at(1)["value"] = "95"; + + REQUIRE_THROWS_AS(frame.selected_rows({1, 0}), std::invalid_argument); + REQUIRE_THROWS_AS(frame.selected_rows({1, 0, 1, 0}), std::invalid_argument); + REQUIRE_THROWS_AS(frame.selected_rows({}), std::invalid_argument); + REQUIRE(frame.at(1)["value"].get() == "95"); + auto none = frame.selected_rows({0, 0, 0}); + REQUIRE(none.empty()); + REQUIRE(none.columns() == frame.columns()); + REQUIRE(none.selected_rows({}).columns() == frame.columns()); + auto edited_all = frame.selected_rows({1, 1, 1}); + REQUIRE(edited_all.column("value") == std::vector{"10", "95", "30"}); + + DataFrame<> empty; + REQUIRE(empty.selected_rows({}).empty()); + REQUIRE_THROWS_AS(empty.selected_rows({1}), std::invalid_argument); +}