This is an automated email from the ASF dual-hosted git repository.

wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new ad221b77 feat: support catalog labels on tables loaded from REST 
catalogs (#960)
ad221b77 is described below

commit ad221b772ccbdeebfe6f01748186d1ff6faef944
Author: Nihal Jain <[email protected]>
AuthorDate: Wed Oct 14 20:01:27 2026 +0530

    feat: support catalog labels on tables loaded from REST catalogs (#960)
    
    Parse the optional `labels` field of the REST load-table response and
    expose it through Table::labels(), mirroring apache/iceberg#18045 and
    apache/iceberg#18046. Labels are catalog-provided rather than table
    state, so they live on the Table instance instead of TableMetadata.
    
    Part of #938.
---
 src/iceberg/catalog/rest/json_serde.cc         |  66 ++++++++
 src/iceberg/catalog/rest/json_serde_internal.h |   2 +
 src/iceberg/catalog/rest/rest_catalog.cc       |  17 +-
 src/iceberg/catalog/rest/rest_catalog.h        |   4 +-
 src/iceberg/catalog/rest/rest_table.cc         |  17 +-
 src/iceberg/catalog/rest/rest_table.h          |   6 +-
 src/iceberg/catalog/rest/types.cc              |   2 +-
 src/iceberg/catalog/rest/types.h               |   3 +
 src/iceberg/cpp20_compatibility_internal.h     |   1 +
 src/iceberg/labels.h                           |  56 +++++++
 src/iceberg/table.cc                           |  47 +++---
 src/iceberg/table.h                            |  15 +-
 src/iceberg/test/rest_catalog_unit_test.cc     |  72 +++++++++
 src/iceberg/test/rest_json_serde_test.cc       | 209 ++++++++++++++++++++++++-
 src/iceberg/test/table_test.cc                 |  71 +++++++++
 src/iceberg/type_fwd.h                         |   2 +
 16 files changed, 552 insertions(+), 38 deletions(-)

diff --git a/src/iceberg/catalog/rest/json_serde.cc 
b/src/iceberg/catalog/rest/json_serde.cc
index c7d4662d..a54042c2 100644
--- a/src/iceberg/catalog/rest/json_serde.cc
+++ b/src/iceberg/catalog/rest/json_serde.cc
@@ -33,6 +33,7 @@
 #include "iceberg/expression/json_serde_internal.h"
 #include "iceberg/file_format.h"
 #include "iceberg/json_serde_internal.h"
+#include "iceberg/labels.h"
 #include "iceberg/manifest/manifest_entry.h"
 #include "iceberg/partition_spec.h"
 #include "iceberg/schema.h"
@@ -72,6 +73,10 @@ constexpr std::string_view kDestination = "destination";
 constexpr std::string_view kMetadata = "metadata";
 constexpr std::string_view kConfig = "config";
 constexpr std::string_view kStorageCredentials = "storage-credentials";
+constexpr std::string_view kLabels = "labels";
+constexpr std::string_view kObjectLabels = "object-labels";
+constexpr std::string_view kFields = "fields";
+constexpr std::string_view kFieldId = "field-id";
 constexpr std::string_view kPrefix = "prefix";
 constexpr std::string_view kIdentifiers = "identifiers";
 constexpr std::string_view kOverrides = "overrides";
@@ -488,6 +493,59 @@ Result<RenameTableRequest> 
RenameTableRequestFromJson(const nlohmann::json& json
   return request;
 }
 
+nlohmann::json ToJson(const FieldLabel& field_label) {
+  nlohmann::json json;
+  json[kFieldId] = field_label.field_id;
+  json[kLabels] = field_label.labels;
+  return json;
+}
+
+Result<FieldLabel> FieldLabelFromJson(const nlohmann::json& json) {
+  if (!json.is_object()) {
+    return JsonParseError("Cannot parse field label from non-object: {}",
+                          SafeDumpJson(json));
+  }
+  FieldLabel field_label;
+  ICEBERG_ASSIGN_OR_RAISE(field_label.field_id, GetJsonInteger<int32_t>(json, 
kFieldId));
+  ICEBERG_ASSIGN_OR_RAISE(field_label.labels,
+                          GetJsonValue<decltype(field_label.labels)>(json, 
kLabels));
+  return field_label;
+}
+
+nlohmann::json ToJson(const Labels& labels) {
+  nlohmann::json json = nlohmann::json::object();
+  if (!labels.object_labels.empty()) {
+    json[kObjectLabels] = labels.object_labels;
+  }
+  if (!labels.fields.empty()) {
+    nlohmann::json fields = nlohmann::json::array();
+    for (const auto& field_label : labels.fields) {
+      fields.push_back(ToJson(field_label));
+    }
+    json[kFields] = std::move(fields);
+  }
+  return json;
+}
+
+// A non-object value is treated as absent labels rather than failing the load.
+Result<Labels> LabelsFromJson(const nlohmann::json& json) {
+  Labels labels;
+  ICEBERG_ASSIGN_OR_RAISE(
+      labels.object_labels,
+      GetJsonValueOrDefault<decltype(labels.object_labels)>(json, 
kObjectLabels));
+  if (auto it = json.find(kFields); it != json.end() && !it->is_null()) {
+    if (!it->is_array()) {
+      return JsonParseError("Cannot parse '{}' from non-array: {}", kFields,
+                            SafeDumpJson(*it));
+    }
+    for (const auto& entry : *it) {
+      ICEBERG_ASSIGN_OR_RAISE(auto field_label, FieldLabelFromJson(entry));
+      labels.fields.push_back(std::move(field_label));
+    }
+  }
+  return labels;
+}
+
 // LoadTableResult (used by CreateTableResponse, LoadTableResponse)
 Result<nlohmann::json> ToJson(const LoadTableResult& result) {
   nlohmann::json json;
@@ -502,6 +560,9 @@ Result<nlohmann::json> ToJson(const LoadTableResult& 
result) {
     }
     json[kStorageCredentials] = std::move(creds);
   }
+  if (!result.labels.empty()) {
+    json[kLabels] = ToJson(result.labels);
+  }
   return json;
 }
 
@@ -515,6 +576,9 @@ Result<LoadTableResult> LoadTableResultFromJson(const 
nlohmann::json& json) {
   ICEBERG_ASSIGN_OR_RAISE(result.config,
                           GetJsonValueOrDefault<decltype(result.config)>(json, 
kConfig));
   ICEBERG_ASSIGN_OR_RAISE(result.storage_credentials, 
StorageCredentialsFromJson(json));
+  if (auto it = json.find(kLabels); it != json.end() && !it->is_null()) {
+    ICEBERG_ASSIGN_OR_RAISE(result.labels, LabelsFromJson(*it));
+  }
   ICEBERG_RETURN_UNEXPECTED(result.Validate());
   return result;
 }
@@ -1003,5 +1067,7 @@ ICEBERG_DEFINE_FROM_JSON(CommitTableResponse)
 ICEBERG_DEFINE_FROM_JSON(OAuthTokenResponse)
 ICEBERG_DEFINE_FROM_JSON(PlanTableScanRequest)
 ICEBERG_DEFINE_FROM_JSON(FetchScanTasksRequest)
+ICEBERG_DEFINE_FROM_JSON(FieldLabel)
+ICEBERG_DEFINE_FROM_JSON(Labels)
 
 }  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/json_serde_internal.h 
b/src/iceberg/catalog/rest/json_serde_internal.h
index 1bba932a..9db6f78e 100644
--- a/src/iceberg/catalog/rest/json_serde_internal.h
+++ b/src/iceberg/catalog/rest/json_serde_internal.h
@@ -60,6 +60,8 @@ ICEBERG_DECLARE_JSON_SERDE(ListTablesResponse)
 ICEBERG_DECLARE_JSON_SERDE(RegisterTableRequest)
 ICEBERG_DECLARE_JSON_SERDE(RenameTableRequest)
 ICEBERG_DECLARE_JSON_SERDE(OAuthTokenResponse)
+ICEBERG_DECLARE_JSON_SERDE(FieldLabel)
+ICEBERG_DECLARE_JSON_SERDE(Labels)
 
 #undef ICEBERG_DECLARE_JSON_SERDE
 
diff --git a/src/iceberg/catalog/rest/rest_catalog.cc 
b/src/iceberg/catalog/rest/rest_catalog.cc
index 767b6eef..c4506731 100644
--- a/src/iceberg/catalog/rest/rest_catalog.cc
+++ b/src/iceberg/catalog/rest/rest_catalog.cc
@@ -47,6 +47,7 @@
 #include "iceberg/catalog/rest/rest_util.h"
 #include "iceberg/catalog/rest/types.h"
 #include "iceberg/json_serde_internal.h"
+#include "iceberg/labels.h"
 #include "iceberg/logging/log_level.h"
 #include "iceberg/logging/log_macros.h"
 #include "iceberg/logging/logger.h"
@@ -984,6 +985,11 @@ Result<std::shared_ptr<Table>> 
RestCatalog::MakeTableFromLoadResult(
     std::shared_ptr<auth::AuthSession> contextual_session) {
   auto table_config = std::move(result.config);
   auto storage_credentials = std::move(result.storage_credentials);
+  // Labels are usually absent; keep the table free of an empty Labels 
allocation.
+  std::unique_ptr<Labels> labels;
+  if (!result.labels.empty()) {
+    labels = std::make_unique<Labels>(std::move(result.labels));
+  }
   // Before the FileIO: refreshing its credentials reuses the table session.
   ICEBERG_ASSIGN_OR_RAISE(
       auto table_session,
@@ -997,7 +1003,7 @@ Result<std::shared_ptr<Table>> 
RestCatalog::MakeTableFromLoadResult(
   return MakeTable(identifier, std::move(result.metadata),
                    std::move(result.metadata_location), std::move(table_io),
                    std::move(table_catalog), std::move(reporter), table_config,
-                   std::move(table_session));
+                   std::move(table_session), std::move(labels));
 }
 
 Result<std::shared_ptr<Table>> RestCatalog::MakeTableFromCommitResponse(
@@ -1012,7 +1018,7 @@ Result<std::shared_ptr<Table>> 
RestCatalog::MakeTableFromCommitResponse(
   return MakeTable(identifier, std::move(response.metadata),
                    std::move(response.metadata_location), std::move(table_io),
                    std::move(table_catalog), std::move(reporter), table_config,
-                   std::move(table_session));
+                   std::move(table_session), /*labels=*/nullptr);
 }
 
 Result<std::shared_ptr<Table>> RestCatalog::MakeTable(
@@ -1020,7 +1026,7 @@ Result<std::shared_ptr<Table>> RestCatalog::MakeTable(
     std::string metadata_location, std::shared_ptr<FileIO> table_io,
     std::shared_ptr<Catalog> table_catalog, std::shared_ptr<MetricsReporter> 
reporter,
     const std::unordered_map<std::string, std::string>& table_config,
-    std::shared_ptr<auth::AuthSession> table_session) {
+    std::shared_ptr<auth::AuthSession> table_session, std::unique_ptr<Labels> 
labels) {
   // Table config overrides client config.
   ICEBERG_ASSIGN_OR_RAISE(auto server_mode,
                           
RestCatalogProperties::ScanPlanningModeFrom(table_config));
@@ -1068,12 +1074,13 @@ Result<std::shared_ptr<Table>> RestCatalog::MakeTable(
     return RestTable::Make(identifier, std::move(metadata), 
std::move(metadata_location),
                            std::move(table_io), std::move(table_catalog),
                            RestTableName(name_, identifier), 
std::move(reporter),
-                           std::move(rest_ctx));
+                           std::move(rest_ctx), std::move(labels));
   }
 
   return Table::Make(identifier, std::move(metadata), 
std::move(metadata_location),
                      std::move(table_io), std::move(table_catalog),
-                     RestTableName(name_, identifier), std::move(reporter));
+                     RestTableName(name_, identifier), std::move(reporter),
+                     std::move(labels));
 }
 
 }  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/rest_catalog.h 
b/src/iceberg/catalog/rest/rest_catalog.h
index 967e7cc9..1e713a50 100644
--- a/src/iceberg/catalog/rest/rest_catalog.h
+++ b/src/iceberg/catalog/rest/rest_catalog.h
@@ -32,6 +32,7 @@
 #include "iceberg/catalog/rest/type_fwd.h"
 #include "iceberg/catalog/session_catalog.h"
 #include "iceberg/catalog/session_context.h"
+#include "iceberg/labels.h"
 #include "iceberg/result.h"
 #include "iceberg/storage_credential.h"
 #include "iceberg/type_fwd.h"
@@ -214,7 +215,8 @@ class ICEBERG_REST_EXPORT RestCatalog final
       std::string metadata_location, std::shared_ptr<FileIO> table_io,
       std::shared_ptr<Catalog> table_catalog, std::shared_ptr<MetricsReporter> 
reporter,
       const std::unordered_map<std::string, std::string>& table_config,
-      std::shared_ptr<auth::AuthSession> table_session);
+      std::shared_ptr<auth::AuthSession> table_session,
+      std::unique_ptr<Labels> labels = nullptr);
 
   RestCatalogProperties config_;
   std::shared_ptr<FileIO> file_io_;
diff --git a/src/iceberg/catalog/rest/rest_table.cc 
b/src/iceberg/catalog/rest/rest_table.cc
index dda33316..f70bd9e9 100644
--- a/src/iceberg/catalog/rest/rest_table.cc
+++ b/src/iceberg/catalog/rest/rest_table.cc
@@ -23,6 +23,7 @@
 #include <utility>
 
 #include "iceberg/catalog/rest/rest_table_scan.h"
+#include "iceberg/labels.h"
 #include "iceberg/result.h"
 #include "iceberg/table_metadata.h"
 #include "iceberg/util/macros.h"
@@ -33,9 +34,10 @@ RestTable::RestTable(TableIdentifier identifier, 
std::shared_ptr<TableMetadata>
                      std::string metadata_location, std::shared_ptr<FileIO> io,
                      std::shared_ptr<Catalog> catalog, std::string full_name,
                      std::shared_ptr<MetricsReporter> reporter,
-                     RestScanContext rest_context)
+                     RestScanContext rest_context, std::unique_ptr<Labels> 
labels)
     : Table(std::move(identifier), std::move(metadata), 
std::move(metadata_location),
-            std::move(io), std::move(catalog), std::move(full_name), 
std::move(reporter)),
+            std::move(io), std::move(catalog), std::move(full_name), 
std::move(reporter),
+            std::move(labels)),
       rest_context_(std::make_shared<const 
RestScanContext>(std::move(rest_context))) {}
 
 RestTable::~RestTable() = default;
@@ -44,14 +46,15 @@ Result<std::shared_ptr<RestTable>> RestTable::Make(
     TableIdentifier identifier, std::shared_ptr<TableMetadata> metadata,
     std::string metadata_location, std::shared_ptr<FileIO> io,
     std::shared_ptr<Catalog> catalog, std::string full_name,
-    std::shared_ptr<MetricsReporter> reporter, RestScanContext rest_context) {
+    std::shared_ptr<MetricsReporter> reporter, RestScanContext rest_context,
+    std::unique_ptr<Labels> labels) {
   if (metadata == nullptr) {
     return InvalidArgument("Metadata cannot be null");
   }
-  return std::shared_ptr<RestTable>(
-      new RestTable(std::move(identifier), std::move(metadata),
-                    std::move(metadata_location), std::move(io), 
std::move(catalog),
-                    std::move(full_name), std::move(reporter), 
std::move(rest_context)));
+  return std::shared_ptr<RestTable>(new RestTable(
+      std::move(identifier), std::move(metadata), std::move(metadata_location),
+      std::move(io), std::move(catalog), std::move(full_name), 
std::move(reporter),
+      std::move(rest_context), std::move(labels)));
 }
 
 Result<std::unique_ptr<DataTableScanBuilder>> RestTable::NewScan() const {
diff --git a/src/iceberg/catalog/rest/rest_table.h 
b/src/iceberg/catalog/rest/rest_table.h
index fdfa7d95..57b06f7b 100644
--- a/src/iceberg/catalog/rest/rest_table.h
+++ b/src/iceberg/catalog/rest/rest_table.h
@@ -41,7 +41,8 @@ class ICEBERG_REST_EXPORT RestTable final : public Table {
       TableIdentifier identifier, std::shared_ptr<TableMetadata> metadata,
       std::string metadata_location, std::shared_ptr<FileIO> io,
       std::shared_ptr<Catalog> catalog, std::string full_name,
-      std::shared_ptr<MetricsReporter> reporter, RestScanContext rest_context);
+      std::shared_ptr<MetricsReporter> reporter, RestScanContext rest_context,
+      std::unique_ptr<Labels> labels = nullptr);
 
   ~RestTable() override;
 
@@ -57,7 +58,8 @@ class ICEBERG_REST_EXPORT RestTable final : public Table {
   RestTable(TableIdentifier identifier, std::shared_ptr<TableMetadata> 
metadata,
             std::string metadata_location, std::shared_ptr<FileIO> io,
             std::shared_ptr<Catalog> catalog, std::string full_name,
-            std::shared_ptr<MetricsReporter> reporter, RestScanContext 
rest_context);
+            std::shared_ptr<MetricsReporter> reporter, RestScanContext 
rest_context,
+            std::unique_ptr<Labels> labels);
 
   std::shared_ptr<const RestScanContext> rest_context_;
 };
diff --git a/src/iceberg/catalog/rest/types.cc 
b/src/iceberg/catalog/rest/types.cc
index 27cd7b17..156df17e 100644
--- a/src/iceberg/catalog/rest/types.cc
+++ b/src/iceberg/catalog/rest/types.cc
@@ -87,7 +87,7 @@ bool CreateTableRequest::operator==(const CreateTableRequest& 
other) const {
 
 bool LoadTableResult::operator==(const LoadTableResult& other) const {
   if (metadata_location != other.metadata_location || config != other.config ||
-      storage_credentials != other.storage_credentials) {
+      storage_credentials != other.storage_credentials || labels != 
other.labels) {
     return false;
   }
 
diff --git a/src/iceberg/catalog/rest/types.h b/src/iceberg/catalog/rest/types.h
index f048d6e7..f6f80902 100644
--- a/src/iceberg/catalog/rest/types.h
+++ b/src/iceberg/catalog/rest/types.h
@@ -29,6 +29,7 @@
 
 #include "iceberg/catalog/rest/endpoint.h"
 #include "iceberg/catalog/rest/iceberg_rest_export.h"
+#include "iceberg/labels.h"
 #include "iceberg/result.h"
 #include "iceberg/storage_credential.h"
 #include "iceberg/table_identifier.h"
@@ -188,6 +189,8 @@ struct ICEBERG_REST_EXPORT LoadTableResult {
   std::unordered_map<std::string, std::string> config;
   /// \brief Vended storage credentials, one per URI prefix; empty if none.
   std::vector<StorageCredential> storage_credentials;
+  /// \brief Catalog-provided labels; empty if none.
+  Labels labels;
 
   /// \brief Validates the LoadTableResult.
   Status Validate() const {
diff --git a/src/iceberg/cpp20_compatibility_internal.h 
b/src/iceberg/cpp20_compatibility_internal.h
index 3f9b5b57..1b719f1b 100644
--- a/src/iceberg/cpp20_compatibility_internal.h
+++ b/src/iceberg/cpp20_compatibility_internal.h
@@ -81,6 +81,7 @@
 #include "iceberg/inspect/history_table.h"
 #include "iceberg/inspect/metadata_table.h"
 #include "iceberg/inspect/snapshots_table.h"
+#include "iceberg/labels.h"
 #include "iceberg/location_provider.h"
 #include "iceberg/logging/cerr_logger.h"
 #include "iceberg/logging/log_level.h"
diff --git a/src/iceberg/labels.h b/src/iceberg/labels.h
new file mode 100644
index 00000000..f97975b3
--- /dev/null
+++ b/src/iceberg/labels.h
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+/// \file iceberg/labels.h
+/// \brief Catalog-provided labels for tables and their fields.
+
+#include <cstdint>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "iceberg/iceberg_export.h"
+
+namespace iceberg {
+
+/// \brief Catalog-provided labels for a single field, identified by its field 
ID.
+struct ICEBERG_EXPORT FieldLabel {
+  int32_t field_id = 0;
+  std::unordered_map<std::string, std::string> labels;
+
+  bool operator==(const FieldLabel&) const = default;
+};
+
+/// \brief Optional catalog-provided labels returned when a table is loaded.
+///
+/// Labels are advisory enrichment, not table state: they are not persisted 
with the
+/// table and may be absent.
+struct ICEBERG_EXPORT Labels {
+  std::unordered_map<std::string, std::string> object_labels;
+  std::vector<FieldLabel> fields;
+
+  /// \brief Returns true when there are neither object-level nor field-level 
labels.
+  bool empty() const { return object_labels.empty() && fields.empty(); }
+
+  bool operator==(const Labels&) const = default;
+};
+
+}  // namespace iceberg
diff --git a/src/iceberg/table.cc b/src/iceberg/table.cc
index ca02c623..e56746e3 100644
--- a/src/iceberg/table.cc
+++ b/src/iceberg/table.cc
@@ -25,6 +25,7 @@
 #include "iceberg/data_table_scan.h"
 #include "iceberg/incremental_append_scan.h"
 #include "iceberg/incremental_changelog_scan.h"
+#include "iceberg/labels.h"
 #include "iceberg/location_provider.h"
 #include "iceberg/partition_spec.h"
 #include "iceberg/result.h"
@@ -56,13 +57,11 @@
 
 namespace iceberg {
 
-Result<std::shared_ptr<Table>> Table::Make(TableIdentifier identifier,
-                                           std::shared_ptr<TableMetadata> 
metadata,
-                                           std::string metadata_location,
-                                           std::shared_ptr<FileIO> io,
-                                           std::shared_ptr<Catalog> catalog,
-                                           std::string full_name,
-                                           std::shared_ptr<MetricsReporter> 
reporter) {
+Result<std::shared_ptr<Table>> Table::Make(
+    TableIdentifier identifier, std::shared_ptr<TableMetadata> metadata,
+    std::string metadata_location, std::shared_ptr<FileIO> io,
+    std::shared_ptr<Catalog> catalog, std::string full_name,
+    std::shared_ptr<MetricsReporter> reporter, std::unique_ptr<Labels> labels) 
{
   if (metadata == nullptr) [[unlikely]] {
     return InvalidArgument("Metadata cannot be null");
   }
@@ -75,9 +74,10 @@ Result<std::shared_ptr<Table>> Table::Make(TableIdentifier 
identifier,
   if (catalog == nullptr) [[unlikely]] {
     return InvalidArgument("Catalog cannot be null");
   }
-  return std::shared_ptr<Table>(new Table(
-      std::move(identifier), std::move(metadata), std::move(metadata_location),
-      std::move(io), std::move(catalog), std::move(full_name), 
std::move(reporter)));
+  return std::shared_ptr<Table>(new Table(std::move(identifier), 
std::move(metadata),
+                                          std::move(metadata_location), 
std::move(io),
+                                          std::move(catalog), 
std::move(full_name),
+                                          std::move(reporter), 
std::move(labels)));
 }
 
 Table::~Table() = default;
@@ -85,7 +85,7 @@ Table::~Table() = default;
 Table::Table(TableIdentifier identifier, std::shared_ptr<TableMetadata> 
metadata,
              std::string metadata_location, std::shared_ptr<FileIO> io,
              std::shared_ptr<Catalog> catalog, std::string full_name,
-             std::shared_ptr<MetricsReporter> reporter)
+             std::shared_ptr<MetricsReporter> reporter, 
std::unique_ptr<Labels> labels)
     : identifier_(std::move(identifier)),
       full_name_(full_name.empty() ? identifier_.ToString() : 
std::move(full_name)),
       metadata_(std::move(metadata)),
@@ -93,7 +93,8 @@ Table::Table(TableIdentifier identifier, 
std::shared_ptr<TableMetadata> metadata
       io_(std::move(io)),
       catalog_(std::move(catalog)),
       reporter_(std::move(reporter)),
-      metadata_cache_(std::make_unique<TableMetadataCache>(metadata_.get())) {}
+      metadata_cache_(std::make_unique<TableMetadataCache>(metadata_.get())),
+      labels_(labels && !labels->empty() ? std::move(labels) : nullptr) {}
 
 const std::string& Table::uuid() const { return metadata_->table_uuid; }
 
@@ -167,6 +168,14 @@ const std::shared_ptr<Catalog>& Table::catalog() const { 
return catalog_; }
 
 const std::shared_ptr<MetricsReporter>& Table::reporter() const { return 
reporter_; }
 
+const Labels& Table::labels() const {
+  if (labels_) {
+    return *labels_;
+  }
+  static const Labels kEmpty;
+  return kEmpty;
+}
+
 Result<std::unique_ptr<LocationProvider>> Table::location_provider() const {
   return LocationProvider::Make(metadata_->location, metadata_->properties);
 }
@@ -299,9 +308,10 @@ Result<std::shared_ptr<StagedTable>> StagedTable::Make(
   if (catalog == nullptr) [[unlikely]] {
     return InvalidArgument("Catalog cannot be null");
   }
-  return std::shared_ptr<StagedTable>(new StagedTable(
-      std::move(identifier), std::move(metadata), std::move(metadata_location),
-      std::move(io), std::move(catalog), std::move(full_name), 
std::move(reporter)));
+  return std::shared_ptr<StagedTable>(
+      new StagedTable(std::move(identifier), std::move(metadata),
+                      std::move(metadata_location), std::move(io), 
std::move(catalog),
+                      std::move(full_name), std::move(reporter), 
/*labels=*/nullptr));
 }
 
 StagedTable::~StagedTable() = default;
@@ -319,9 +329,10 @@ Result<std::shared_ptr<StaticTable>> StaticTable::Make(
   if (io == nullptr) [[unlikely]] {
     return InvalidArgument("FileIO cannot be null");
   }
-  return std::shared_ptr<StaticTable>(new StaticTable(
-      std::move(identifier), std::move(metadata), std::move(metadata_location),
-      std::move(io), /*catalog=*/nullptr, std::move(full_name)));
+  return std::shared_ptr<StaticTable>(
+      new StaticTable(std::move(identifier), std::move(metadata),
+                      std::move(metadata_location), std::move(io), 
/*catalog=*/nullptr,
+                      std::move(full_name), /*reporter=*/nullptr, 
/*labels=*/nullptr));
 }
 
 StaticTable::~StaticTable() = default;
diff --git a/src/iceberg/table.h b/src/iceberg/table.h
index 4c0470cb..97b1a43a 100644
--- a/src/iceberg/table.h
+++ b/src/iceberg/table.h
@@ -29,6 +29,7 @@
 #include <vector>
 
 #include "iceberg/iceberg_export.h"
+#include "iceberg/labels.h"
 #include "iceberg/snapshot.h"
 #include "iceberg/table_identifier.h"
 #include "iceberg/type_fwd.h"
@@ -50,11 +51,13 @@ class ICEBERG_EXPORT Table : public 
std::enable_shared_from_this<Table> {
   ///            string representation of identifier when empty.
   /// \param[in] reporter Optional metrics reporter for this table. Defaults 
to nullptr
   ///            (noop).
+  /// \param[in] labels Labels for this table, or nullptr when it has none.
   static Result<std::shared_ptr<Table>> Make(
       TableIdentifier identifier, std::shared_ptr<TableMetadata> metadata,
       std::string metadata_location, std::shared_ptr<FileIO> io,
       std::shared_ptr<Catalog> catalog, std::string full_name = "",
-      std::shared_ptr<MetricsReporter> reporter = nullptr);
+      std::shared_ptr<MetricsReporter> reporter = nullptr,
+      std::unique_ptr<Labels> labels = nullptr);
 
   virtual ~Table();
 
@@ -130,6 +133,12 @@ class ICEBERG_EXPORT Table : public 
std::enable_shared_from_this<Table> {
   /// \brief Returns the metrics reporter for this table.
   const std::shared_ptr<MetricsReporter>& reporter() const;
 
+  /// \brief Returns the catalog-provided labels for this table.
+  ///
+  /// Labels are supplied at construction and are not updated by Refresh().
+  /// Reload the table to obtain current catalog labels.
+  const Labels& labels() const;
+
   /// \brief Returns a LocationProvider for this table
   Result<std::unique_ptr<LocationProvider>> location_provider() const;
 
@@ -216,7 +225,8 @@ class ICEBERG_EXPORT Table : public 
std::enable_shared_from_this<Table> {
   Table(TableIdentifier identifier, std::shared_ptr<TableMetadata> metadata,
         std::string metadata_location, std::shared_ptr<FileIO> io,
         std::shared_ptr<Catalog> catalog, std::string full_name,
-        std::shared_ptr<MetricsReporter> reporter = nullptr);
+        std::shared_ptr<MetricsReporter> reporter = nullptr,
+        std::unique_ptr<Labels> labels = nullptr);
 
   const TableIdentifier identifier_;
   const std::string full_name_;
@@ -226,6 +236,7 @@ class ICEBERG_EXPORT Table : public 
std::enable_shared_from_this<Table> {
   std::shared_ptr<Catalog> catalog_;
   std::shared_ptr<MetricsReporter> reporter_;
   std::unique_ptr<class TableMetadataCache> metadata_cache_;
+  const std::unique_ptr<const Labels> labels_;
 };
 
 /// \brief A table created by stage-create and not yet committed.
diff --git a/src/iceberg/test/rest_catalog_unit_test.cc 
b/src/iceberg/test/rest_catalog_unit_test.cc
index bb76a59a..97c9c365 100644
--- a/src/iceberg/test/rest_catalog_unit_test.cc
+++ b/src/iceberg/test/rest_catalog_unit_test.cc
@@ -39,6 +39,8 @@
 #include "iceberg/catalog/rest/types.h"
 #include "iceberg/file_io.h"
 #include "iceberg/file_io_registry.h"
+#include "iceberg/labels.h"
+#include "iceberg/schema.h"
 #include "iceberg/storage_credential.h"
 #include "iceberg/table.h"
 #include "iceberg/table_identifier.h"
@@ -214,6 +216,76 @@ class RestCatalogLoadTableTest : public ::testing::Test {
   std::unordered_set<Endpoint> no_plan_endpoint_set_;
 };
 
+class RestCatalogLabelsTest : public RestCatalogLoadTableTest,
+                              public ::testing::WithParamInterface<bool> {
+ protected:
+  std::string LabelsResponse() const {
+    auto response = nlohmann::json::parse(kLoadTableDefaultResponse);
+    response["config"]["scan-planning-mode"] = GetParam() ? "server" : 
"client";
+    response["labels"] = nlohmann::json::parse(
+        
R"({"object-labels":{"owner":"team-a"},"fields":[{"field-id":1,"labels":{"classification":"pii"}}]})");
+    return response.dump();
+  }
+
+  Result<std::shared_ptr<Catalog>> MakeLabelsCatalog() {
+    auto endpoints = all_plan_endpoints_;
+    endpoints.insert(Endpoint::CreateTable());
+    endpoints.insert(Endpoint::RegisterTable());
+    ICEBERG_ASSIGN_OR_RAISE(auto catalog, MakeCatalog(endpoints));
+    return catalog->AsCatalog();
+  }
+
+  void CheckLabels(const std::shared_ptr<Table>& table) const {
+    ASSERT_NE(table, nullptr);
+    EXPECT_EQ(table->labels(), labels_);
+    EXPECT_EQ(std::dynamic_pointer_cast<RestTable>(table) != nullptr, 
GetParam());
+  }
+
+  const Labels labels_{
+      .object_labels = {{"owner", "team-a"}},
+      .fields = {{.field_id = 1, .labels = {{"classification", "pii"}}}}};
+};
+
+TEST_P(RestCatalogLabelsTest, LoadExposesLabels) {
+  EXPECT_CALL(*mock_client_, Get(::testing::EndsWith("/tables/my_table"), _, 
_, _, _))
+      .WillOnce(Return(HttpResponse(200, LabelsResponse())));
+  ICEBERG_UNWRAP_OR_FAIL(auto catalog, MakeLabelsCatalog());
+  ICEBERG_UNWRAP_OR_FAIL(auto table, catalog->LoadTable(identifier_));
+
+  CheckLabels(table);
+}
+
+TEST_P(RestCatalogLabelsTest, CreateExposesLabels) {
+  EXPECT_CALL(*mock_client_,
+              Post(::testing::EndsWith("/namespaces/default/tables"), _, _, _, 
_))
+      .WillOnce(Return(HttpResponse(200, LabelsResponse())));
+  ICEBERG_UNWRAP_OR_FAIL(auto catalog, MakeLabelsCatalog());
+  auto schema = std::make_shared<Schema>(
+      std::vector<SchemaField>{SchemaField::MakeRequired(1, "id", int32())}, 
1);
+  ICEBERG_UNWRAP_OR_FAIL(auto table,
+                         catalog->CreateTable(identifier_, schema, 
/*spec=*/nullptr,
+                                              /*order=*/nullptr, 
/*location=*/"",
+                                              /*properties=*/{}));
+
+  CheckLabels(table);
+}
+
+TEST_P(RestCatalogLabelsTest, RegisterExposesLabels) {
+  EXPECT_CALL(*mock_client_,
+              Post(::testing::EndsWith("/namespaces/default/register"), _, _, 
_, _))
+      .WillOnce(Return(HttpResponse(200, LabelsResponse())));
+  ICEBERG_UNWRAP_OR_FAIL(auto catalog, MakeLabelsCatalog());
+  ICEBERG_UNWRAP_OR_FAIL(
+      auto table, catalog->RegisterTable(identifier_, 
"s3://bucket/metadata/v1.json"));
+
+  CheckLabels(table);
+}
+
+INSTANTIATE_TEST_SUITE_P(PlanningModes, RestCatalogLabelsTest, 
::testing::Bool(),
+                         [](const ::testing::TestParamInfo<bool>& info) {
+                           return info.param ? "Server" : "Client";
+                         });
+
 TEST_F(RestCatalogLoadTableTest, InitializationPropagatesClientFactoryErrors) {
   auto config = RestCatalogProperties::FromMap(
       {{"uri", "http://test-server"}, {"io-impl", 
"rest-catalog-unit-test-noop"}});
diff --git a/src/iceberg/test/rest_json_serde_test.cc 
b/src/iceberg/test/rest_json_serde_test.cc
index 34ca4a45..db19860a 100644
--- a/src/iceberg/test/rest_json_serde_test.cc
+++ b/src/iceberg/test/rest_json_serde_test.cc
@@ -33,6 +33,7 @@
 #include "iceberg/expression/expressions.h"
 #include "iceberg/file_format.h"
 #include "iceberg/json_serde_internal.h"
+#include "iceberg/labels.h"
 #include "iceberg/manifest/manifest_entry.h"
 #include "iceberg/partition_spec.h"
 #include "iceberg/result.h"
@@ -85,6 +86,11 @@ static std::shared_ptr<TableMetadata> 
MakeSimpleTableMetadata() {
   });
 }
 
+std::string LoadTableJsonWithLabels(std::string_view labels) {
+  return std::string(R"({"labels":)") + std::string(labels) +
+         
R"(,"metadata":{"format-version":2,"table-uuid":"test-uuid-1234","location":"s3://bucket/test","last-sequence-number":0,"last-updated-ms":0,"last-column-id":1,"schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],"current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[]}],"default-spec-id":0,"last-partition-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"default-sort-order-id":0,"properties":{}}})";
+}
+
 std::string LoadTableJsonWithCredentials(std::string_view storage_credentials) 
{
   return std::string(R"({"storage-credentials":)") + 
std::string(storage_credentials) +
          
R"(,"metadata":{"format-version":2,"table-uuid":"test","location":"s3://test","last-sequence-number":0,"last-column-id":1,"last-updated-ms":0,"schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],"current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[]}],"default-spec-id":0,"last-partition-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"default-sort-order-id":0}})";
@@ -1136,7 +1142,15 @@ INSTANTIATE_TEST_SUITE_P(
                                           .config = {{"s3.access-key-id", 
"AKIAtest"},
                                                      {"s3.secret-access-key", 
"secret"},
                                                      {"s3.session-token", 
"token"},
-                                                     {"client.region", 
"us-east-1"}}}}}}),
+                                                     {"client.region", 
"us-east-1"}}}}}},
+        LoadTableResultParam{
+            .test_name = "WithLabels",
+            .expected_json_str =
+                
R"({"labels":{"fields":[{"field-id":1,"labels":{"classification":"pii"}}],"object-labels":{"owner":"team-a"}},"metadata":{"current-schema-id":1,"current-snapshot-id":null,"default-sort-order-id":0,"default-spec-id":0,"format-version":2,"last-column-id":1,"last-partition-id":0,"last-sequence-number":0,"last-updated-ms":0,"location":"s3://bucket/test","metadata-log":[],"partition-specs":[{"fields":[],"spec-id":0}],"partition-statistics":[],"properties":{},"refs":{},"schemas
 [...]
+            .model = {.metadata = MakeSimpleTableMetadata(),
+                      .labels = {.object_labels = {{"owner", "team-a"}},
+                                 .fields = {{.field_id = 1,
+                                             .labels = {{"classification", 
"pii"}}}}}}}),
     [](const ::testing::TestParamInfo<LoadTableResultParam>& info) {
       return info.param.test_name;
     });
@@ -1165,7 +1179,33 @@ INSTANTIATE_TEST_SUITE_P(
             .json_str =
                 
R"({"metadata":{"format-version":2,"table-uuid":"test-uuid-1234","location":"s3://bucket/test","last-sequence-number":0,"last-updated-ms":0,"last-column-id":1,"schemas":[{"type":"struct","schema-id":1,"fields":[{"id":1,"name":"id","type":"int","required":true}]}],"current-schema-id":1,"partition-specs":[{"spec-id":0,"fields":[]}],"default-spec-id":0,"last-partition-id":0,"sort-orders":[{"order-id":0,"fields":[]}],"default-sort-order-id":0,"properties":{}},"config":{"wareh
 [...]
             .expected_model = {.metadata = MakeSimpleTableMetadata(),
-                               .config = {{"warehouse", 
"s3://bucket/warehouse"}}}}),
+                               .config = {{"warehouse", 
"s3://bucket/warehouse"}}}},
+        LoadTableResultDeserializeParam{
+            .test_name = "WithLabels",
+            .json_str = LoadTableJsonWithLabels(
+                
R"({"object-labels":{"owner":"team-a"},"fields":[{"field-id":1,"labels":{"classification":"pii"}}]})"),
+            .expected_model = {.metadata = MakeSimpleTableMetadata(),
+                               .labels = {.object_labels = {{"owner", 
"team-a"}},
+                                          .fields = {{.field_id = 1,
+                                                      .labels = 
{{"classification",
+                                                                  "pii"}}}}}}},
+        LoadTableResultDeserializeParam{
+            .test_name = "NullLabels",
+            .json_str = LoadTableJsonWithLabels("null"),
+            .expected_model = {.metadata = MakeSimpleTableMetadata()}},
+        LoadTableResultDeserializeParam{
+            .test_name = "EmptyLabels",
+            .json_str = LoadTableJsonWithLabels("{}"),
+            .expected_model = {.metadata = MakeSimpleTableMetadata()}},
+        // Non-object labels are ignored rather than rejected
+        LoadTableResultDeserializeParam{
+            .test_name = "StringLabels",
+            .json_str = LoadTableJsonWithLabels(R"("oops")"),
+            .expected_model = {.metadata = MakeSimpleTableMetadata()}},
+        LoadTableResultDeserializeParam{
+            .test_name = "ArrayLabels",
+            .json_str = 
LoadTableJsonWithLabels(R"([{"object-labels":{"k":"v"}}])"),
+            .expected_model = {.metadata = MakeSimpleTableMetadata()}}),
     [](const ::testing::TestParamInfo<LoadTableResultDeserializeParam>& info) {
       return info.param.test_name;
     });
@@ -1205,6 +1245,10 @@ INSTANTIATE_TEST_SUITE_P(
             .test_name = "InvalidMetadataContent",
             .invalid_json_str = R"({"metadata":{"format-version":"invalid"}})",
             .expected_error_message = "type must be number, but is string"},
+        LoadTableResultInvalidParam{
+            .test_name = "LabelsFieldsNotArray",
+            .invalid_json_str = 
LoadTableJsonWithLabels(R"({"fields":"oops"})"),
+            .expected_error_message = "Cannot parse 'fields' from non-array"},
         LoadTableResultInvalidParam{
             .test_name = "CredentialsNotArray",
             .invalid_json_str = LoadTableJsonWithCredentials(R"("oops")"),
@@ -2580,4 +2624,165 @@ TEST(FetchPlanningResultResponseRoundtripTest, 
FailedWithError) {
   EXPECT_EQ(*result, *result2);
 }
 
+DECLARE_ROUNDTRIP_TEST(FieldLabel)
+
+INSTANTIATE_TEST_SUITE_P(
+    FieldLabelCases, FieldLabelTest,
+    ::testing::Values(
+        FieldLabelParam{
+            .test_name = "WithLabels",
+            .expected_json_str =
+                
R"({"field-id":1,"labels":{"classification":"pii","owner":"team-a"}})",
+            .model = {.field_id = 1,
+                      .labels = {{"classification", "pii"}, {"owner", 
"team-a"}}}},
+        // A field label always writes "labels", even when empty
+        FieldLabelParam{.test_name = "EmptyLabels",
+                        .expected_json_str = R"({"field-id":1,"labels":{}})",
+                        .model = {.field_id = 1}},
+        FieldLabelParam{.test_name = "MaxInt32FieldId",
+                        .expected_json_str = 
R"({"field-id":2147483647,"labels":{}})",
+                        .model = {.field_id = 2147483647}},
+        FieldLabelParam{.test_name = "NegativeFieldId",
+                        .expected_json_str = 
R"({"field-id":-1,"labels":{"k":"v"}})",
+                        .model = {.field_id = -1, .labels = {{"k", "v"}}}}),
+    [](const ::testing::TestParamInfo<FieldLabelParam>& info) {
+      return info.param.test_name;
+    });
+
+DECLARE_INVALID_TEST(FieldLabel)
+
+INSTANTIATE_TEST_SUITE_P(
+    FieldLabelInvalidCases, FieldLabelInvalidTest,
+    ::testing::Values(
+        FieldLabelInvalidParam{
+            .test_name = "NotObject",
+            .invalid_json_str = R"([])",
+            .expected_error_message = "Cannot parse field label from 
non-object"},
+        FieldLabelInvalidParam{.test_name = "MissingFieldId",
+                               .invalid_json_str = R"({"labels":{}})",
+                               .expected_error_message = "Missing 'field-id'"},
+        FieldLabelInvalidParam{.test_name = "NullFieldId",
+                               .invalid_json_str = 
R"({"field-id":null,"labels":{}})",
+                               .expected_error_message = "Missing 'field-id'"},
+        FieldLabelInvalidParam{.test_name = "FloatFieldId",
+                               .invalid_json_str = 
R"({"field-id":1.5,"labels":{}})",
+                               .expected_error_message = "'field-id' must be 
an integer"},
+        FieldLabelInvalidParam{.test_name = "BoolFieldId",
+                               .invalid_json_str = 
R"({"field-id":true,"labels":{}})",
+                               .expected_error_message = "'field-id' must be 
an integer"},
+        FieldLabelInvalidParam{.test_name = "StringFieldId",
+                               .invalid_json_str = 
R"({"field-id":"1","labels":{}})",
+                               .expected_error_message = "'field-id' must be 
an integer"},
+        FieldLabelInvalidParam{
+            .test_name = "FieldIdAboveInt32",
+            .invalid_json_str = R"({"field-id":2147483648,"labels":{}})",
+            .expected_error_message = "'field-id' integer is out of range"},
+        FieldLabelInvalidParam{
+            .test_name = "FieldIdBelowInt32",
+            .invalid_json_str = R"({"field-id":-2147483649,"labels":{}})",
+            .expected_error_message = "'field-id' integer is out of range"},
+        FieldLabelInvalidParam{.test_name = "MissingLabels",
+                               .invalid_json_str = R"({"field-id":1})",
+                               .expected_error_message = "Missing 'labels'"},
+        FieldLabelInvalidParam{.test_name = "NullLabels",
+                               .invalid_json_str = 
R"({"field-id":1,"labels":null})",
+                               .expected_error_message = "Missing 'labels'"},
+        FieldLabelInvalidParam{.test_name = "LabelsNotObject",
+                               .invalid_json_str = 
R"({"field-id":1,"labels":"x"})",
+                               .expected_error_message = "type must be 
object"},
+        FieldLabelInvalidParam{.test_name = "NonStringLabelValue",
+                               .invalid_json_str = 
R"({"field-id":1,"labels":{"k":1}})",
+                               .expected_error_message = "type must be 
string"}),
+    [](const ::testing::TestParamInfo<FieldLabelInvalidParam>& info) {
+      return info.param.test_name;
+    });
+
+DECLARE_ROUNDTRIP_TEST(Labels)
+
+INSTANTIATE_TEST_SUITE_P(
+    LabelsCases, LabelsTest,
+    ::testing::Values(
+        LabelsParam{.test_name = "Empty", .expected_json_str = R"({})", .model 
= {}},
+        LabelsParam{.test_name = "ObjectLabelsOnly",
+                    .expected_json_str = 
R"({"object-labels":{"owner":"team-a"}})",
+                    .model = {.object_labels = {{"owner", "team-a"}}}},
+        LabelsParam{
+            .test_name = "FieldsOnly",
+            .expected_json_str =
+                
R"({"fields":[{"field-id":1,"labels":{"classification":"pii"}},{"field-id":2,"labels":{"unit":"usd"}}]})",
+            .model = {.fields = {{.field_id = 1, .labels = {{"classification", 
"pii"}}},
+                                 {.field_id = 2, .labels = {{"unit", 
"usd"}}}}}},
+        LabelsParam{
+            .test_name = "ObjectAndFields",
+            .expected_json_str =
+                
R"({"object-labels":{"owner":"team-a"},"fields":[{"field-id":1,"labels":{"classification":"pii"}}]})",
+            .model = {.object_labels = {{"owner", "team-a"}},
+                      .fields = {{.field_id = 1,
+                                  .labels = {{"classification", "pii"}}}}}},
+        // Duplicate field IDs are kept verbatim and in order; no resolution
+        LabelsParam{
+            .test_name = "DuplicateFieldIds",
+            .expected_json_str =
+                
R"({"fields":[{"field-id":1,"labels":{"a":"1"}},{"field-id":1,"labels":{"b":"2"}}]})",
+            .model = {.fields = {{.field_id = 1, .labels = {{"a", "1"}}},
+                                 {.field_id = 1, .labels = {{"b", "2"}}}}}}),
+    [](const ::testing::TestParamInfo<LabelsParam>& info) {
+      return info.param.test_name;
+    });
+
+DECLARE_DESERIALIZE_TEST(Labels)
+
+INSTANTIATE_TEST_SUITE_P(
+    LabelsDeserializeCases, LabelsDeserializeTest,
+    ::testing::Values(
+        LabelsDeserializeParam{.test_name = "NullObjectLabels",
+                               .json_str = R"({"object-labels":null})",
+                               .expected_model = {}},
+        LabelsDeserializeParam{.test_name = "NullFields",
+                               .json_str = R"({"fields":null})",
+                               .expected_model = {}},
+        LabelsDeserializeParam{
+            .test_name = "UnknownKeysIgnored",
+            .json_str =
+                
R"({"object-labels":{"a":"b"},"future":1,"fields":[{"field-id":2,"labels":{},"extra":true}]})",
+            .expected_model = {.object_labels = {{"a", "b"}},
+                               .fields = {{.field_id = 2}}}}),
+    [](const ::testing::TestParamInfo<LabelsDeserializeParam>& info) {
+      return info.param.test_name;
+    });
+
+DECLARE_INVALID_TEST(Labels)
+
+INSTANTIATE_TEST_SUITE_P(
+    LabelsInvalidCases, LabelsInvalidTest,
+    ::testing::Values(
+        LabelsInvalidParam{.test_name = "ObjectLabelsNotObject",
+                           .invalid_json_str = R"({"object-labels":"x"})",
+                           .expected_error_message = "type must be object"},
+        LabelsInvalidParam{.test_name = "ObjectLabelsNonStringValue",
+                           .invalid_json_str = R"({"object-labels":{"k":1}})",
+                           .expected_error_message = "type must be string"},
+        LabelsInvalidParam{.test_name = "ObjectLabelsNullValue",
+                           .invalid_json_str = 
R"({"object-labels":{"k":null}})",
+                           .expected_error_message = "type must be string"},
+        LabelsInvalidParam{
+            .test_name = "FieldsNotArray",
+            .invalid_json_str = R"({"fields":{}})",
+            .expected_error_message = "Cannot parse 'fields' from non-array"},
+        LabelsInvalidParam{
+            .test_name = "FieldEntryNotObject",
+            .invalid_json_str = R"({"fields":[1]})",
+            .expected_error_message = "Cannot parse field label from 
non-object"},
+        LabelsInvalidParam{
+            .test_name = "FieldEntryNull",
+            .invalid_json_str = R"({"fields":[null]})",
+            .expected_error_message = "Cannot parse field label from 
non-object"},
+        LabelsInvalidParam{
+            .test_name = "FieldEntryInvalidFieldId",
+            .invalid_json_str = R"({"fields":[{"field-id":1.5,"labels":{}}]})",
+            .expected_error_message = "'field-id' must be an integer"}),
+    [](const ::testing::TestParamInfo<LabelsInvalidParam>& info) {
+      return info.param.test_name;
+    });
+
 }  // namespace iceberg::rest
diff --git a/src/iceberg/test/table_test.cc b/src/iceberg/test/table_test.cc
index a85dc301..00c77371 100644
--- a/src/iceberg/test/table_test.cc
+++ b/src/iceberg/test/table_test.cc
@@ -22,12 +22,14 @@
 #include <gmock/gmock.h>
 #include <gtest/gtest.h>
 
+#include "iceberg/labels.h"
 #include "iceberg/schema.h"
 #include "iceberg/schema_field.h"
 #include "iceberg/table_metadata.h"
 #include "iceberg/test/matchers.h"
 #include "iceberg/test/mock_catalog.h"
 #include "iceberg/test/mock_io.h"
+#include "iceberg/transaction.h"
 
 namespace iceberg {
 
@@ -148,6 +150,75 @@ TYPED_TEST(TypedTableTest, NewTransaction) {
   }
 }
 
+class TableLabelsTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    io_ = std::make_shared<MockFileIO>();
+    catalog_ = std::make_shared<MockCatalog>();
+    auto schema = std::make_shared<Schema>(
+        std::vector<SchemaField>{SchemaField::MakeRequired(1, "id", int64())}, 
1);
+    metadata_ = std::make_shared<TableMetadata>(
+        TableMetadata{.format_version = 2, .schemas = {schema}, 
.current_schema_id = 1});
+  }
+
+  const TableIdentifier ident_{.ns = Namespace{.levels = {"db"}}, .name = "t"};
+  const Labels labels_{
+      .object_labels = {{"owner", "team-a"}},
+      .fields = {{.field_id = 1, .labels = {{"classification", "pii"}}}}};
+  std::shared_ptr<MockFileIO> io_;
+  std::shared_ptr<MockCatalog> catalog_;
+  std::shared_ptr<TableMetadata> metadata_;
+};
+
+class TableLabelsRefreshTest : public TableLabelsTest,
+                               public ::testing::WithParamInterface<bool> {};
+
+TEST_P(TableLabelsRefreshTest, RefreshKeepsLabels) {
+  const std::string metadata_location =
+      GetParam() ? "s3://bucket/meta2.json" : "s3://bucket/meta.json";
+  const Labels changed_labels{
+      .object_labels = {{"owner", "team-b"}},
+      .fields = {{.field_id = 1, .labels = {{"classification", "public"}}}}};
+  for (const auto& refreshed_labels : {Labels{}, changed_labels}) {
+    SCOPED_TRACE(refreshed_labels.empty() ? "Absent labels" : "Changed 
labels");
+    ICEBERG_UNWRAP_OR_FAIL(
+        auto table, Table::Make(ident_, metadata_, "s3://bucket/meta.json", 
io_, catalog_,
+                                /*full_name=*/"", /*reporter=*/nullptr,
+                                std::make_unique<Labels>(labels_)));
+    ICEBERG_UNWRAP_OR_FAIL(
+        auto refreshed, Table::Make(ident_, metadata_, metadata_location, io_, 
catalog_,
+                                    /*full_name=*/"", /*reporter=*/nullptr,
+                                    refreshed_labels.empty()
+                                        ? nullptr
+                                        : 
std::make_unique<Labels>(refreshed_labels)));
+    EXPECT_CALL(*catalog_, LoadTable(::testing::_))
+        .WillOnce(::testing::Return(refreshed));
+
+    ASSERT_THAT(table->Refresh(), IsOk());
+
+    EXPECT_EQ(table->metadata_file_location(), metadata_location);
+    EXPECT_EQ(table->labels(), labels_);
+  }
+}
+
+INSTANTIATE_TEST_SUITE_P(MetadataLocation, TableLabelsRefreshTest, 
::testing::Bool(),
+                         [](const ::testing::TestParamInfo<bool>& info) {
+                           return info.param ? "Changed" : "Unchanged";
+                         });
+
+TEST_F(TableLabelsTest, EmptyTransactionKeepsLabels) {
+  ICEBERG_UNWRAP_OR_FAIL(
+      auto table, Table::Make(ident_, metadata_, "s3://bucket/meta.json", io_, 
catalog_,
+                              /*full_name=*/"", /*reporter=*/nullptr,
+                              std::make_unique<Labels>(labels_)));
+  EXPECT_CALL(*catalog_, UpdateTable(::testing::_, ::testing::_, 
::testing::_)).Times(0);
+  ICEBERG_UNWRAP_OR_FAIL(auto transaction, table->NewTransaction());
+  ICEBERG_UNWRAP_OR_FAIL(auto committed, transaction->Commit());
+
+  ASSERT_EQ(committed, table);
+  EXPECT_EQ(committed->labels(), labels_);
+}
+
 TEST(StaticTableTest, NewMutatingOperationsAreNotSupported) {
   auto io = std::make_shared<MockFileIO>();
   auto schema = std::make_shared<Schema>(
diff --git a/src/iceberg/type_fwd.h b/src/iceberg/type_fwd.h
index 511b14eb..477774a1 100644
--- a/src/iceberg/type_fwd.h
+++ b/src/iceberg/type_fwd.h
@@ -241,6 +241,8 @@ class CommitMetrics;
 /// \brief Table.
 class Table;
 class TableProperties;
+struct FieldLabel;
+struct Labels;
 
 /// \brief Table update.
 class TableMetadataBuilder;

Reply via email to