huaxingao commented on code in PR #18015:
URL: https://github.com/apache/iceberg/pull/18015#discussion_r4020682538


##########
core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java:
##########
@@ -478,6 +495,7 @@ public Table loadTable(SessionContext context, 
TableIdentifier identifier) {
               context,
               identifier,
               snapshotMode,
+              loadContext,

Review Comment:
   The cache key here is `(sessionId, identifier)` and doesn't include the view 
chain, so a load through `view_B` finds the entry saved by an earlier load 
through `view_A`. On a `200` that's invisible, since the client rebuilds with 
the current context, but on a `304` it returns the cached entry and its 
`FileIO` still carries `view_A`. Would the credentials then be scoped to the 
wrong view? 



##########
core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java:
##########
@@ -590,7 +617,8 @@ private Supplier<BaseTable> createTableSupplier(
               paths.table(identifier),
               Map::of,
               mutationHeaders,
-              tableFileIO(identifier, context, tableConf, credentials, 
remoteSigningConfig),
+              tableFileIO(
+                  identifier, context, tableConf, loadContext, credentials, 
remoteSigningConfig),

Review Comment:
   `loadContext` is used in `tableFileIO` here, but it isn't passed to 
`newTableOps`. So the table operations object never gets it, and its 
`refresh()` sends no query parameters at all, including no `referenced-by`.
   
   `refresh()` runs on its own, not only when someone calls `loadTable`: on 
stale reads and on commit retries. So the server sees the view chain on the 
first load, but not on any refresh after that.
   



##########
core/src/main/java/org/apache/iceberg/rest/RESTUtil.java:
##########
@@ -436,4 +441,45 @@ public static Map<String, String> 
configHeaders(Map<String, String> properties)
   public static Map<String, String> idempotencyHeaders() {
     return ImmutableMap.of(IDEMPOTENCY_KEY_HEADER, 
UUIDUtil.generateUuidV7().toString());
   }
+
+  /** Query parameters for the loadCredentials endpoint, from client-side 
request context. */
+  public static Map<String, String> credentialsQueryParams(Map<String, String> 
properties) {
+    ImmutableMap.Builder<String, String> queryParams = ImmutableMap.builder();
+    String planId = properties.get(RESTCatalogProperties.REST_SCAN_PLAN_ID);
+    if (planId != null) {
+      queryParams.put(RESTCatalogProperties.PLAN_ID_QUERY_PARAMETER, planId);
+    }
+
+    String referencedBy = 
properties.get(RESTCatalogProperties.REST_REFERENCED_BY);
+    if (referencedBy != null) {
+      queryParams.put(RESTCatalogProperties.REFERENCED_BY_QUERY_PARAMETER, 
referencedBy);
+    }
+
+    return queryParams.build();
+  }
+
+  /**
+   * Encodes a view chain (outermost first) as the {@code referenced-by} value.
+   *
+   * <p>Within an entry, the namespace levels and the view name are encoded 
like the {@code parent}
+   * query parameter, as the spec requires, and joined by the namespace 
separator as-is; entries are
+   * joined by a literal comma. The result is already percent-encoded and must 
reach the wire
+   * verbatim, see {@link HTTPRequest#requestUri()}.
+   */
+  static String encodeReferencedBy(List<TableIdentifier> referencedBy, String 
namespaceSeparator) {
+    if (referencedBy == null || referencedBy.isEmpty()) {
+      return null;
+    }
+
+    Preconditions.checkArgument(
+        !Strings.isNullOrEmpty(namespaceSeparator), "Invalid separator: null 
or empty");
+
+    return referencedBy.stream()
+        .map(
+            ident ->
+                Stream.concat(Arrays.stream(ident.namespace().levels()), 
Stream.of(ident.name()))
+                    .map(level -> PercentCodec.encode(level, 
StandardCharsets.UTF_8))
+                    .collect(Collectors.joining(namespaceSeparator)))

Review Comment:
   The configured separator is pasted into the URL unescaped, the levels and 
view name go through `PercentCodec`, but the separator is just the string 
handed to `joining()`, and this value skips `URIBuilder`, so nothing escapes 
it. Fine for the default `%1F` since that's already encoded, but `#` makes the 
view name become the URI fragment (never sent, so the server gets a truncated 
chain) and a raw `\u001F` makes `requestUri()` throw. Could this normalize the 
separator like `namespaceToQueryParam` does and then encode it like the levels 
above?



##########
core/src/test/java/org/apache/iceberg/rest/TestReferencedByQueryParam.java:
##########
@@ -0,0 +1,253 @@
+/*
+ * 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.
+ */
+package org.apache.iceberg.rest;
+
+import static org.apache.iceberg.rest.RequestMatcher.matches;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+
+import java.util.Map;
+import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.LoadContext;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.SessionCatalog;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.catalog.ViewCatalog;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.rest.HTTPRequest.HTTPMethod;
+import org.apache.iceberg.rest.responses.LoadTableResponse;
+import org.apache.iceberg.rest.responses.LoadViewResponse;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+public class TestReferencedByQueryParam {
+
+  private static final Schema SCHEMA =
+      new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get()));
+
+  private static final Namespace NS = Namespace.of("ns");
+  private static final TableIdentifier TABLE_IDENT = TableIdentifier.of(NS, 
"test_table");
+
+  private RESTCatalogAdapter adapter;
+  private RESTCatalog restCatalog;
+
+  @BeforeEach
+  public void before() {
+    InMemoryCatalog backendCatalog = new InMemoryCatalog();
+    backendCatalog.initialize("test", ImmutableMap.of());
+
+    adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog));
+    restCatalog = new RESTCatalog(SessionCatalog.SessionContext.createEmpty(), 
(config) -> adapter);
+    restCatalog.initialize(
+        "test",
+        ImmutableMap.of(
+            CatalogProperties.FILE_IO_IMPL, 
"org.apache.iceberg.inmemory.InMemoryFileIO"));
+
+    restCatalog.createNamespace(NS);
+    restCatalog.buildTable(TABLE_IDENT, SCHEMA).create();
+    Mockito.clearInvocations(adapter);
+  }
+
+  @AfterEach
+  public void after() throws Exception {
+    if (restCatalog != null) {
+      restCatalog.close();
+    }
+  }
+
+  @Test
+  public void loadTableSendsReferencedBy() {
+    restCatalog.loadTable(TABLE_IDENT, referencedBy("outer_view"));
+
+    // the test adapter uses %2E as the namespace separator
+    Mockito.verify(adapter)
+        .execute(
+            matches(
+                HTTPMethod.GET,
+                "v1/namespaces/ns/tables/test_table",
+                Map.of(),
+                ImmutableMap.of(
+                    "snapshots",
+                    "all",
+                    RESTCatalogProperties.REFERENCED_BY_QUERY_PARAMETER,
+                    "ns%2Eouter_view")),
+            eq(LoadTableResponse.class),
+            any(),
+            any());
+  }
+
+  @Test
+  public void loadTableWithoutContextHasNoReferencedByParam() {
+    restCatalog.loadTable(TABLE_IDENT);
+
+    Mockito.verify(adapter)
+        .execute(
+            matches(
+                HTTPMethod.GET,
+                "v1/namespaces/ns/tables/test_table",
+                Map.of(),
+                ImmutableMap.of("snapshots", "all")),
+            eq(LoadTableResponse.class),
+            any(),
+            any());
+  }
+
+  @Test
+  public void loadViewSendsReferencedBy() {
+    TableIdentifier viewIdent = createView();
+
+    restCatalog.loadView(viewIdent, referencedBy("outer_view"));
+
+    Mockito.verify(adapter)
+        .execute(
+            matches(
+                HTTPMethod.GET,
+                "v1/namespaces/ns/views/test_view",
+                Map.of(),
+                ImmutableMap.of(
+                    RESTCatalogProperties.REFERENCED_BY_QUERY_PARAMETER, 
"ns%2Eouter_view")),
+            eq(LoadViewResponse.class),
+            any(),
+            any());
+  }
+
+  @Test
+  public void loadViewWithoutContextHasNoReferencedByParam() {
+    TableIdentifier viewIdent = createView();
+
+    restCatalog.loadView(viewIdent);
+
+    Mockito.verify(adapter)
+        .execute(
+            matches(HTTPMethod.GET, "v1/namespaces/ns/views/test_view", 
Map.of(), Map.of()),
+            eq(LoadViewResponse.class),
+            any(),
+            any());
+  }
+
+  @Test
+  public void loadViewThroughViewCatalogBridgeSendsReferencedBy() {
+    TableIdentifier viewIdent = createView();
+
+    // the ViewCatalog returned by asViewCatalog must forward the load 
context, otherwise
+    // ViewCatalog's default implementation silently drops the view chain
+    SessionCatalog.SessionContext session = 
SessionCatalog.SessionContext.createEmpty();
+    ViewCatalog viewCatalog = 
restCatalog.sessionCatalog().asViewCatalog(session);
+
+    viewCatalog.loadView(viewIdent, referencedBy("outer_view"));
+
+    Mockito.verify(adapter)
+        .execute(
+            matches(
+                HTTPMethod.GET,
+                "v1/namespaces/ns/views/test_view",
+                Map.of(),
+                ImmutableMap.of(
+                    RESTCatalogProperties.REFERENCED_BY_QUERY_PARAMETER, 
"ns%2Eouter_view")),
+            eq(LoadViewResponse.class),
+            any(),
+            any());
+  }
+
+  @Test
+  public void referencedByReachesTableFileIOProperties() {
+    // a non-empty table config forces a table-level FileIO; that FileIO's 
properties are what
+    // credential providers read the chain back out of
+    Mockito.doAnswer(
+            invocation -> {
+              LoadTableResponse response = (LoadTableResponse) 
invocation.callRealMethod();
+              return LoadTableResponse.builder()
+                  .withTableMetadata(response.tableMetadata())
+                  .addAllConfig(response.config())
+                  .addAllConfig(ImmutableMap.of("table-scoped", "config"))
+                  .build();
+            })
+        .when(adapter)
+        .execute(
+            matches(HTTPMethod.GET, "v1/namespaces/ns/tables/test_table"),
+            eq(LoadTableResponse.class),
+            any(),
+            any());
+
+    Table table = restCatalog.loadTable(TABLE_IDENT, 
referencedBy("outer_view"));
+
+    assertThat(table.io().properties())
+        .containsEntry(RESTCatalogProperties.REST_REFERENCED_BY, 
"ns%2Eouter_view");
+  }
+
+  @Test
+  public void referencedByLoadStillReusesCatalogFileIO() {
+    // with no table-scoped config from the server there are no per-table 
credentials to scope, so
+    // the chain must not force a new FileIO per load
+    Table plain = restCatalog.loadTable(TABLE_IDENT);
+    Table viaView = restCatalog.loadTable(TABLE_IDENT, 
referencedBy("outer_view"));
+
+    assertThat(viaView.io()).isSameAs(plain.io());

Review Comment:
   This builds the expected value with the same separator it passes in
   (String.format("ns%sviewName", namespaceSeparator)), so it holds for any 
separator and can only
   fail if the function ignores the argument. I added "&", "=", and "?" to this 
list and all eight
   cases still passed.
   
   That's why `#` is asserted as correct here even though it breaks once the 
value is appended to a
   query string (see the comment on encodeReferencedBy).
   
   Could one case run the result through HTTPRequest.requestUri() and assert on 
getRawQuery()?
   That's where the encoding actually has to hold, and it's the only thing that 
would catch a
   separator that isn't URL-safe.



-- 
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