Copilot commented on code in PR #701:
URL: https://github.com/apache/iceberg-cpp/pull/701#discussion_r3636012117


##########
src/iceberg/expression/sanitize_expression.cc:
##########
@@ -0,0 +1,353 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "iceberg/expression/sanitize_expression.h"
+
+#include <chrono>
+#include <cmath>
+#include <cstdint>
+#include <format>
+#include <limits>
+#include <regex>
+#include <string>
+#include <string_view>
+#include <utility>
+#include <vector>
+
+#include "iceberg/expression/binder.h"
+#include "iceberg/expression/literal.h"
+#include "iceberg/expression/predicate.h"
+#include "iceberg/expression/term.h"
+#include "iceberg/transform.h"
+#include "iceberg/type.h"
+#include "iceberg/util/bucket_util.h"
+#include "iceberg/util/checked_cast.h"
+#include "iceberg/util/temporal_util.h"
+
+namespace iceberg {
+
+namespace {
+
+std::string SanitizeDate(int32_t days, int32_t today) {
+  std::string is_past = today > days ? "ago" : "from-now";
+  uint32_t diff = today > days ? 
static_cast<uint32_t>(static_cast<uint32_t>(today) -
+                                                       
static_cast<uint32_t>(days))
+                               : 
static_cast<uint32_t>(static_cast<uint32_t>(days) -
+                                                       
static_cast<uint32_t>(today));
+  if (diff == 0) {
+    return "(date-today)";
+  } else if (diff < 90) {
+    return "(date-" + std::to_string(diff) + "-days-" + is_past + ")";
+  }
+
+  return "(date)";
+}
+
+std::string SanitizeTimestamp(int64_t micros, int64_t now) {
+  constexpr int64_t kMicrosPerHour = 60LL * 60LL * 1'000'000LL;
+  constexpr int64_t kFiveMinutesInMicros = 5LL * 60LL * 1'000'000LL;
+  constexpr int64_t kThreeDaysInHours = 3LL * 24LL;
+  constexpr int64_t kNinetyDaysInHours = 90LL * 24LL;
+
+  std::string is_past = now > micros ? "ago" : "from-now";
+  uint64_t diff = now > micros ? 
static_cast<uint64_t>(static_cast<uint64_t>(now) -
+                                                       
static_cast<uint64_t>(micros))
+                               : 
static_cast<uint64_t>(static_cast<uint64_t>(micros) -
+                                                       
static_cast<uint64_t>(now));
+  if (diff < kFiveMinutesInMicros) {
+    return "(timestamp-about-now)";
+  }
+
+  int64_t hours = diff / kMicrosPerHour;
+  if (hours <= kThreeDaysInHours) {
+    return "(timestamp-" + std::to_string(hours) + "-hours-" + is_past + ")";
+  } else if (hours < kNinetyDaysInHours) {
+    int64_t days = hours / 24;
+    return "(timestamp-" + std::to_string(days) + "-days-" + is_past + ")";
+  }
+
+  return "(timestamp)";
+}
+
+std::string SanitizeNumber(double value, std::string_view type) {
+  int32_t num_digits =
+      value == 0 ? 1 : static_cast<int32_t>(std::log10(std::abs(value))) + 1;
+  return std::format("({}-digit-{})", num_digits, type);
+}
+
+Result<std::string> SanitizeSimpleString(std::string_view value) {
+  ICEBERG_ASSIGN_OR_RAISE(auto hash,
+                          
BucketUtils::BucketIndex(Literal::String(std::string(value)),
+                                                   
std::numeric_limits<int32_t>::max()));
+  return std::format("(hash-{:08x})", hash);
+}
+
+Result<std::string> SanitizeString(std::string_view value, int64_t now, 
int32_t today) {
+  static const std::regex kDate(R"(\d{4}-\d{2}-\d{2})");
+  static const std::regex kTime(R"(\d{2}:\d{2}(:\d{2}(.\d{1,9})?)?)");
+  static const std::regex kTimestamp(
+      R"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(.\d{1,9})?)?)");
+  static const std::regex kTimestampTz(
+      
R"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(.\d{1,9})?)?([-+]\d{2}:\d{2}|Z))");
+  static const std::regex kTimestampNs(
+      R"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(.\d{7,9})?)?)");
+  static const std::regex kTimestampTzNs(
+      
R"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(.\d{7,9})?)?([-+]\d{2}:\d{2}|Z))");

Review Comment:
   The time/timestamp regexes use an unescaped '.' inside the 
fractional-seconds group (e.g. '(.d{1,9})'), which matches any character 
rather than a literal dot. This makes the classification overly permissive and 
can mis-sanitize strings that aren't actually timestamps. Escape '.' as '\\.' 
in these patterns.



##########
src/iceberg/test/rest_metrics_reporter_test.cc:
##########
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "iceberg/catalog/rest/rest_metrics_reporter.h"
+
+#include <memory>
+#include <string>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <nlohmann/json.hpp>
+
+#include "iceberg/catalog/rest/auth/auth_session.h"
+#include "iceberg/catalog/rest/error_handlers.h"
+#include "iceberg/catalog/rest/http_client.h"
+#include "iceberg/metrics/commit_report.h"
+#include "iceberg/metrics/metrics_reporter.h"
+#include "iceberg/metrics/scan_report.h"
+#include "iceberg/result.h"
+#include "iceberg/test/matchers.h"
+
+namespace iceberg::rest {
+
+namespace {
+
+// A minimal HttpClientBase test double: RestMetricsReporter only ever calls 
Post(), so
+// only that method needs to be mockable (see the migration comment on 
HttpClientBase).
+class MockHttpClient : public HttpClientBase {
+ public:
+  MOCK_METHOD(Result<HttpResponse>, Post,
+              (const std::string& path, const std::string& body,
+               (const std::unordered_map<std::string, std::string>& headers),
+               const ErrorHandler& error_handler, auth::AuthSession& session),
+              (override));
+};
+
+}  // namespace
+
+class RestMetricsReporterTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    client_ = std::make_shared<HttpClient>();
+    session_ = auth::AuthSession::MakeDefault({});
+  }
+
+  std::shared_ptr<HttpClient> client_;
+  std::shared_ptr<auth::AuthSession> session_;
+};
+
+namespace {
+
+// A Scan/Commit report test case: the report to send plus what a correct 
request for
+// it must contain. Parameterizing over this collapses what would otherwise be 
3
+// near-identical Scan/Commit test-body pairs into 3 bodies total.
+struct ReportTestCase {
+  std::string name;  // instantiation test-name suffix, e.g. "ScanReport"
+  MetricsReport report;
+  std::string expected_report_type;
+  std::vector<std::pair<std::string, nlohmann::json>> expected_fields;
+};
+
+ReportTestCase MakeScanReportCase() {
+  ScanReport report;
+  report.table_name = "ns.tbl";
+  report.snapshot_id = 42;
+  report.schema_id = 0;
+  return {"ScanReport",
+          report,
+          "scan-report",
+          {{"table-name", "ns.tbl"}, {"snapshot-id", 42}}};
+}
+
+ReportTestCase MakeCommitReportCase() {
+  CommitReport report;
+  report.table_name = "ns.tbl";
+  report.snapshot_id = 99;
+  report.sequence_number = 1;
+  report.operation = "append";
+  return {"CommitReport",
+          report,
+          "commit-report",
+          {{"table-name", "ns.tbl"},
+           {"snapshot-id", 99},
+           {"sequence-number", 1},
+           {"operation", "append"}}};
+}
+
+}  // namespace
+
+class RestMetricsReporterPayloadTest
+    : public RestMetricsReporterTest,
+      public ::testing::WithParamInterface<ReportTestCase> {};
+
+// Report() must return OK even when the HTTP call fails (connection refused).
+// This validates the fire-and-forget error-suppression contract matching Java 
behavior.
+TEST_P(RestMetricsReporterPayloadTest, ReportSuppressesHttpErrors) {
+  RestMetricsReporter reporter(client_, 
"http://localhost:0/v1/ns/tables/tbl/metrics";,
+                               session_);
+  EXPECT_THAT(reporter.Report(GetParam().report), IsOk());
+}

Review Comment:
   This test uses a real HttpClient and an endpoint that will attempt a real 
network connection ("localhost:0"). That can be flaky/slow in CI and isn't 
needed to validate the fire-and-forget error suppression contract. Prefer a 
MockHttpClient that returns an error Result from Post().



##########
src/iceberg/catalog/rest/rest_metrics_reporter.cc:
##########
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "iceberg/catalog/rest/rest_metrics_reporter.h"
+
+#include <cstdio>
+#include <utility>
+
+#include <nlohmann/json.hpp>
+
+#include "iceberg/catalog/rest/auth/auth_session.h"
+#include "iceberg/catalog/rest/error_handlers.h"
+#include "iceberg/catalog/rest/http_client.h"
+#include "iceberg/metrics/json_serde_internal.h"
+#include "iceberg/metrics/metrics_reporter.h"
+
+namespace iceberg::rest {
+
+namespace {
+
+constexpr std::string_view kReportType = "report-type";
+constexpr std::string_view kScanReportType = "scan-report";
+constexpr std::string_view kCommitReportType = "commit-report";
+
+}  // namespace
+
+RestMetricsReporter::RestMetricsReporter(std::shared_ptr<HttpClientBase> 
client,
+                                         std::string metrics_endpoint,
+                                         std::shared_ptr<auth::AuthSession> 
session)
+    : client_(std::move(client)),
+      metrics_endpoint_(std::move(metrics_endpoint)),
+      session_(std::move(session)) {}
+
+Result<std::string> RestMetricsReporter::BuildRequestBody(const MetricsReport& 
report) {
+  ICEBERG_ASSIGN_OR_RAISE(
+      auto json,
+      std::visit([](const auto& r) -> Result<nlohmann::json> { return 
ToJson(r); },
+                 report));
+
+  // Inject "report-type" required by the REST spec (not included in core 
ToJson).
+  json[kReportType] =
+      std::holds_alternative<ScanReport>(report) ? kScanReportType : 
kCommitReportType;
+  return json.dump();
+}
+
+Status RestMetricsReporter::Report(const MetricsReport& report) {
+  try {
+    auto body_result = BuildRequestBody(report);
+    if (!body_result) {
+      return {};
+    }

Review Comment:
   RestMetricsReporter::Report() unconditionally dereferences client_ and 
session_. If either is null (misconfiguration, moved-from reporter, etc.), this 
will crash; since the contract is fire-and-forget with suppressed errors, a 
null-guard should return OK instead.



##########
src/iceberg/table_scan.cc:
##########
@@ -569,11 +598,53 @@ Result<std::vector<std::shared_ptr<FileScanTask>>> 
DataTableScan::PlanFiles() co
       .FilterData(filter())
       .IgnoreDeleted()
       .ColumnsToKeepStats(context_.columns_to_keep_stats)
-      .PlanWith(context_.plan_executor);
+      .PlanWith(context_.plan_executor)
+      .ScanMetrics(scan_metrics);
   if (context_.ignore_residuals) {
     manifest_group->IgnoreResiduals();
   }
-  return manifest_group->PlanFiles();
+  ICEBERG_ASSIGN_OR_RAISE(auto tasks, manifest_group->PlanFiles());
+
+  timed.Stop();
+
+  if (context_.metrics_reporter) {
+    ICEBERG_ASSIGN_OR_RAISE(auto projected_schema, ResolveProjectedSchema());
+    const auto& schema_ptr = projected_schema.get();

Review Comment:
   When metrics_reporter is set, ScanReport.table_name is populated from 
context_.table_name but there is no guard that it was actually set. This can 
produce reports with an empty table_name, despite ScanReport documenting it as 
a fully-qualified table name. Consider enforcing TableName() when 
MetricsReporter() is configured.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to