JingsongLi commented on code in PR #9540:
URL: https://github.com/apache/paimon/pull/9540#discussion_r3914482020


##########
paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala:
##########
@@ -241,101 +266,194 @@ case class PaimonFormatTable(table: FormatTable)
       rows: Array[InternalRow],
       maps: Array[JMap[String, String]],
       ignoreIfExists: Boolean): Unit = {
-    if 
(maps.exists(_.keySet().asScala.exists(_.equalsIgnoreCase("location")))) {
-      throw new UnsupportedOperationException(
-        s"ADD PARTITION with LOCATION is not supported for Format Table 
${table.fullName()}.")
-    }
     val onlyValueInPath =
       
CoreOptions.fromMap(table.options()).formatTablePartitionOnlyValueInPath()
     val partitionKeys = table.partitionKeys().asScala.toSeq
     rows.foreach(row => requireNameablePartitionValues("ADD PARTITION", row, 
partitionKeys))
-    val specs = rows.map(row => toPaimonPartition(row, 
partitionKeys.take(row.numFields))).toSeq
-    // Resolve (and path-safety validate) every directory before mutating 
anything.
+    val partitions = rows
+      .zip(maps)
+      .map {
+        case (row, properties) =>
+          val spec = toPaimonPartition(row, partitionKeys.take(row.numFields))
+          val location = properties.asScala.collectFirst {
+            case (key, value) if key.equalsIgnoreCase("location") =>
+              normalizeExplicitPartitionLocation(value, spec, onlyValueInPath)
+          }
+          spec -> location
+      }
+      .toSeq
+    val specs = partitions.map(_._1)
+    // Resolve (and path-safety validate) every default directory before 
mutating anything.
     val partitionPaths =
-      specs.map(spec => resolvePartitionPathWithinTable(orderedSpec(spec), 
onlyValueInPath))
-    requirePartitionManager().createPartitions(specs.asJava, ignoreIfExists)
+      partitions.collect {
+        case (spec, None) =>
+          resolvePartitionPathWithinTable(orderedSpec(spec), onlyValueInPath)
+      }
+    val locations = partitions.collect {
+      case (spec, Some(location)) => new PartitionLocation(spec, location)
+    }
+    if (locations.isEmpty) {
+      requirePartitionManager().createPartitions(specs.asJava, ignoreIfExists)
+    } else {
+      requirePartitionManager()
+        .createPartitions(specs.asJava, ignoreIfExists, null, false, 
locations.asJava)

Review Comment:
   **[P1] Validate the prospective registry atomically before registering 
locations.**
   
   This path validates each explicit location only against the table root and 
the default directory of that same spec, then immediately mutates the catalog. 
A batch can therefore assign the same or nested `LOCATION` values to different 
specs; MSCK can similarly add a default spec whose directory is already owned 
by another explicit spec. Registration succeeds, but scans, writes, and DROP 
later reject the full registry as overlapping, so normal SQL cannot even 
unregister the bad entry. Please make the catalog/REST mutation atomically 
validate the complete post-update ownership set for both ordinary create and 
`/with-locations`; a client-side listing alone would still race concurrent ADDs.



##########
paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionPathResolver.java:
##########
@@ -0,0 +1,386 @@
+/*
+ * 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.paimon.table.format;
+
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.utils.PartitionPathUtils;
+
+import javax.annotation.Nullable;
+
+import java.io.ByteArrayOutputStream;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+/** Resolves catalog-managed Format Table partition paths and rejects 
ambiguous catalog metadata. */
+public final class FormatTablePartitionPathResolver {
+
+    private final Path tablePath;
+    private final String tableName;
+    private final boolean onlyValueInPath;
+    private final Map<Map<String, String>, ResolvedPath> pathsBySpec = new 
LinkedHashMap<>();
+    private final Map<FileSystemKey, OwnershipNode> ownershipRoots = new 
HashMap<>();
+
+    FormatTablePartitionPathResolver(Path tablePath, String tableName, boolean 
onlyValueInPath) {
+        this.tablePath = tablePath;
+        this.tableName = tableName;
+        this.onlyValueInPath = onlyValueInPath;
+    }
+
+    Path resolve(LinkedHashMap<String, String> spec, @Nullable String 
explicitLocation) {
+        Path defaultPath =
+                new Path(
+                        tablePath,
+                        PartitionPathUtils.generatePartitionPathUtil(spec, 
onlyValueInPath));
+        if (explicitLocation == null) {
+            return defaultPath;
+        }
+
+        try {
+            return resolveExplicitLocation(tablePath, spec, onlyValueInPath, 
explicitLocation);
+        } catch (IllegalArgumentException e) {
+            throw invalidLocation(spec);
+        }
+    }
+
+    /**
+     * Canonicalizes an explicit partition location and verifies that it 
cannot name the table, its
+     * ancestor, or the partition's default directory.
+     */
+    public static Path resolveExplicitLocation(
+            Path tablePath,
+            LinkedHashMap<String, String> spec,
+            boolean onlyValueInPath,
+            String explicitLocation) {
+        PartitionPathUtils.validatePartitionSpecForPath(spec, onlyValueInPath);
+        Path defaultPath =
+                new Path(
+                        tablePath,
+                        PartitionPathUtils.generatePartitionPathUtil(spec, 
onlyValueInPath));
+        Path explicitPath = canonicalizeExplicitLocation(explicitLocation);
+        if (sameLocation(explicitPath, defaultPath)
+                || sameLocation(explicitPath, tablePath)
+                || isAncestor(explicitPath, tablePath)) {
+            throw new IllegalArgumentException("Explicit partition location 
overlaps table data.");
+        }
+        return explicitPath;
+    }
+
+    /**
+     * Records a resolved path. Returns false only for an identical duplicate 
entry for the same
+     * spec, which must not duplicate every row in that partition.
+     */
+    boolean validateAndRecord(LinkedHashMap<String, String> spec, Path path) {
+        ResolvedPath resolved = ResolvedPath.of(path);
+        ResolvedPath previousForSpec = pathsBySpec.get(spec);
+        if (previousForSpec != null) {
+            if (previousForSpec.equals(resolved)) {
+                return false;
+            }
+            throw overlappingLocations();
+        }
+
+        if (overlapsOwnedPath(resolved)) {
+            throw overlappingLocations();
+        }
+        pathsBySpec.put(new LinkedHashMap<>(spec), resolved);
+        return true;
+    }
+
+    private boolean overlapsOwnedPath(ResolvedPath path) {
+        OwnershipNode node =
+                ownershipRoots.computeIfAbsent(path.fileSystem(), ignored -> 
new OwnershipNode());
+        String[] segments = path.pathSegments();
+        for (String segment : segments) {
+            // A terminal node reached before the candidate ends is an 
existing ancestor.
+            if (node.owned) {
+                return true;
+            }
+            node = node.children.computeIfAbsent(segment, ignored -> new 
OwnershipNode());
+        }
+        // A terminal final node is equality. Children below it make the 
candidate an ancestor.
+        if (node.owned || !node.children.isEmpty()) {
+            return true;
+        }
+        node.owned = true;
+        return false;
+    }
+
+    /** Returns the canonical URI representation used on the 
partition-location wire contract. */
+    public static Path canonicalizeExplicitLocation(String location) {
+        String decoded = location;
+        try {
+            while (true) {
+                validateDecodedLocation(decoded);
+                String next = decodePercentOnce(decoded);
+                if (next.equals(decoded)) {
+                    break;
+                }
+                decoded = next;
+            }
+
+            Path path = new Path(decoded);
+            URI uri = path.toUri();
+            String scheme = uri.getScheme();
+            String authority = uri.getAuthority();
+            String uriPath = uri.getPath();
+            if (scheme == null
+                    || scheme.isEmpty()
+                    || uri.getUserInfo() != null
+                    || uriPath == null
+                    || !uriPath.startsWith(Path.SEPARATOR)
+                    || uriPath.equals(Path.SEPARATOR)) {
+                throw new IllegalArgumentException("Invalid explicit partition 
location.");
+            }
+
+            scheme = scheme.toLowerCase(Locale.ROOT);
+            if (!scheme.equals("file") && (authority == null || 
authority.isEmpty())) {
+                throw new IllegalArgumentException("Invalid explicit partition 
location.");
+            }
+            authority =
+                    authority == null || authority.isEmpty()
+                            ? null
+                            : authority.toLowerCase(Locale.ROOT);
+            return new Path(scheme, authority, uriPath);
+        } catch (IllegalArgumentException e) {
+            throw e;
+        } catch (RuntimeException e) {
+            throw new IllegalArgumentException("Invalid explicit partition 
location.", e);
+        }
+    }
+
+    private static void validateDecodedLocation(String location) {
+        if (location == null
+                || location.isEmpty()
+                || isBoundaryWhitespace(location)
+                || location.contains("?")
+                || location.contains("#")
+                || location.contains("\\")) {
+            throw new IllegalArgumentException("Invalid explicit partition 
location.");
+        }
+
+        for (int offset = 0; offset < location.length(); ) {
+            int codePoint = location.codePointAt(offset);
+            if (Character.isISOControl(codePoint)) {
+                throw new IllegalArgumentException("Invalid explicit partition 
location.");
+            }
+            offset += Character.charCount(codePoint);
+        }
+
+        for (String segment : location.split(Path.SEPARATOR, -1)) {
+            if (segment.equals(Path.CUR_DIR) || segment.equals("..")) {
+                throw new IllegalArgumentException("Invalid explicit partition 
location.");
+            }
+        }
+    }
+
+    private static boolean isBoundaryWhitespace(String value) {
+        int first = value.codePointAt(0);
+        int last = value.codePointBefore(value.length());
+        return isWhitespace(first) || isWhitespace(last);
+    }
+
+    private static boolean isWhitespace(int codePoint) {
+        return Character.isWhitespace(codePoint) || 
Character.isSpaceChar(codePoint);
+    }
+
+    private static String decodePercentOnce(String value) {
+        StringBuilder decoded = new StringBuilder(value.length());
+        for (int offset = 0; offset < value.length(); ) {
+            char current = value.charAt(offset);
+            if (current != '%') {
+                decoded.append(current);
+                offset++;
+                continue;
+            }
+
+            ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+            while (offset < value.length() && value.charAt(offset) == '%') {
+                if (offset + 2 >= value.length()) {
+                    throw new IllegalArgumentException("Invalid percent 
encoding in location.");
+                }
+                int high = Character.digit(value.charAt(offset + 1), 16);
+                int low = Character.digit(value.charAt(offset + 2), 16);
+                if (high < 0 || low < 0) {
+                    throw new IllegalArgumentException("Invalid percent 
encoding in location.");
+                }
+                bytes.write((high << 4) + low);
+                offset += 3;
+            }
+            try {
+                decoded.append(
+                        StandardCharsets.UTF_8
+                                .newDecoder()
+                                .onMalformedInput(CodingErrorAction.REPORT)
+                                
.onUnmappableCharacter(CodingErrorAction.REPORT)
+                                .decode(ByteBuffer.wrap(bytes.toByteArray())));
+            } catch (CharacterCodingException e) {
+                throw new IllegalArgumentException("Invalid percent encoding 
in location.", e);
+            }
+        }
+        return decoded.toString();
+    }
+
+    static boolean isWithin(Path candidate, Path root) {
+        ResolvedPath candidatePath = ResolvedPath.of(candidate);
+        ResolvedPath rootPath = ResolvedPath.of(root);
+        return rootPath.equals(candidatePath) || 
rootPath.isAncestorOf(candidatePath);
+    }
+
+    private static boolean sameLocation(Path left, Path right) {
+        return ResolvedPath.of(left).equals(ResolvedPath.of(right));
+    }
+
+    private static boolean isAncestor(Path candidateAncestor, Path 
candidateChild) {
+        return 
ResolvedPath.of(candidateAncestor).isAncestorOf(ResolvedPath.of(candidateChild));
+    }
+
+    private IllegalStateException invalidLocation(Map<String, String> spec) {
+        return new IllegalStateException(
+                String.format(
+                        "Catalog returned an invalid explicit location for 
partition %s of Format Table %s.",
+                        spec, tableName));
+    }
+
+    private IllegalStateException overlappingLocations() {
+        return new IllegalStateException(
+                String.format(
+                        "Catalog returned overlapping locations for different 
partitions of Format Table %s.",
+                        tableName));
+    }
+
+    /**
+     * One trie is maintained per filesystem. Visiting each path segment once 
is sufficient:
+     * ancestors are terminal nodes on the route, equality is the terminal 
node at the route's end,
+     * and descendants are children below that node.
+     */
+    private static final class OwnershipNode {
+
+        private final Map<String, OwnershipNode> children = new HashMap<>();
+        private boolean owned;
+    }
+
+    private static final class FileSystemKey {
+
+        private final String scheme;
+        private final String authority;
+
+        private FileSystemKey(String scheme, String authority) {
+            this.scheme = scheme;
+            this.authority = authority;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (o == null || getClass() != o.getClass()) {
+                return false;
+            }
+            FileSystemKey that = (FileSystemKey) o;
+            return scheme.equals(that.scheme) && 
authority.equals(that.authority);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(scheme, authority);
+        }
+    }
+
+    private static final class ResolvedPath {
+
+        private final String scheme;
+        private final String authority;
+        private final String path;
+
+        private ResolvedPath(String scheme, String authority, String path) {
+            this.scheme = scheme;
+            this.authority = authority;
+            this.path = path;
+        }
+
+        private static ResolvedPath of(Path path) {
+            URI uri = path.toUri().normalize();
+            String scheme = uri.getScheme();
+            // An absolute path without a scheme and file:/ name the same 
local filesystem.
+            scheme = scheme == null ? "file" : scheme.toLowerCase(Locale.ROOT);
+            String authority = uri.getAuthority();
+            authority = authority == null ? "" : 
authority.toLowerCase(Locale.ROOT);

Review Comment:
   **[P1] Canonicalize the filesystem identity before comparing ownership.**
   
   The ownership key uses the raw lower-cased scheme and authority, although 
different URI spellings can resolve to the same filesystem. For example, Hadoop 
can resolve `hdfs://nn` and `hdfs://nn:8020` to the same NameNode, but this 
code treats them as separate roots. A table at `hdfs://nn:8020/warehouse/t` can 
therefore accept an explicit location such as `hdfs://nn/warehouse/t`, 
bypassing the table-root check and potentially reading the whole table as one 
partition or writing through a default sibling into explicitly owned data. 
Please derive the key from the resolved/canonical FileSystem URI, including 
default ports and configured aliases, and use that identity consistently for 
overlap checks and FileIO routing.



##########
paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionPathResolver.java:
##########
@@ -0,0 +1,386 @@
+/*
+ * 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.paimon.table.format;
+
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.utils.PartitionPathUtils;
+
+import javax.annotation.Nullable;
+
+import java.io.ByteArrayOutputStream;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+/** Resolves catalog-managed Format Table partition paths and rejects 
ambiguous catalog metadata. */
+public final class FormatTablePartitionPathResolver {
+
+    private final Path tablePath;
+    private final String tableName;
+    private final boolean onlyValueInPath;
+    private final Map<Map<String, String>, ResolvedPath> pathsBySpec = new 
LinkedHashMap<>();
+    private final Map<FileSystemKey, OwnershipNode> ownershipRoots = new 
HashMap<>();
+
+    FormatTablePartitionPathResolver(Path tablePath, String tableName, boolean 
onlyValueInPath) {
+        this.tablePath = tablePath;
+        this.tableName = tableName;
+        this.onlyValueInPath = onlyValueInPath;
+    }
+
+    Path resolve(LinkedHashMap<String, String> spec, @Nullable String 
explicitLocation) {
+        Path defaultPath =
+                new Path(
+                        tablePath,
+                        PartitionPathUtils.generatePartitionPathUtil(spec, 
onlyValueInPath));
+        if (explicitLocation == null) {
+            return defaultPath;
+        }
+
+        try {
+            return resolveExplicitLocation(tablePath, spec, onlyValueInPath, 
explicitLocation);
+        } catch (IllegalArgumentException e) {
+            throw invalidLocation(spec);
+        }
+    }
+
+    /**
+     * Canonicalizes an explicit partition location and verifies that it 
cannot name the table, its
+     * ancestor, or the partition's default directory.
+     */
+    public static Path resolveExplicitLocation(
+            Path tablePath,
+            LinkedHashMap<String, String> spec,
+            boolean onlyValueInPath,
+            String explicitLocation) {
+        PartitionPathUtils.validatePartitionSpecForPath(spec, onlyValueInPath);
+        Path defaultPath =
+                new Path(
+                        tablePath,
+                        PartitionPathUtils.generatePartitionPathUtil(spec, 
onlyValueInPath));
+        Path explicitPath = canonicalizeExplicitLocation(explicitLocation);
+        if (sameLocation(explicitPath, defaultPath)
+                || sameLocation(explicitPath, tablePath)
+                || isAncestor(explicitPath, tablePath)) {
+            throw new IllegalArgumentException("Explicit partition location 
overlaps table data.");
+        }
+        return explicitPath;
+    }
+
+    /**
+     * Records a resolved path. Returns false only for an identical duplicate 
entry for the same
+     * spec, which must not duplicate every row in that partition.
+     */
+    boolean validateAndRecord(LinkedHashMap<String, String> spec, Path path) {
+        ResolvedPath resolved = ResolvedPath.of(path);
+        ResolvedPath previousForSpec = pathsBySpec.get(spec);
+        if (previousForSpec != null) {
+            if (previousForSpec.equals(resolved)) {
+                return false;
+            }
+            throw overlappingLocations();
+        }
+
+        if (overlapsOwnedPath(resolved)) {
+            throw overlappingLocations();
+        }
+        pathsBySpec.put(new LinkedHashMap<>(spec), resolved);
+        return true;
+    }
+
+    private boolean overlapsOwnedPath(ResolvedPath path) {
+        OwnershipNode node =
+                ownershipRoots.computeIfAbsent(path.fileSystem(), ignored -> 
new OwnershipNode());
+        String[] segments = path.pathSegments();
+        for (String segment : segments) {
+            // A terminal node reached before the candidate ends is an 
existing ancestor.
+            if (node.owned) {
+                return true;
+            }
+            node = node.children.computeIfAbsent(segment, ignored -> new 
OwnershipNode());
+        }
+        // A terminal final node is equality. Children below it make the 
candidate an ancestor.
+        if (node.owned || !node.children.isEmpty()) {
+            return true;
+        }
+        node.owned = true;
+        return false;
+    }
+
+    /** Returns the canonical URI representation used on the 
partition-location wire contract. */
+    public static Path canonicalizeExplicitLocation(String location) {
+        String decoded = location;
+        try {
+            while (true) {
+                validateDecodedLocation(decoded);
+                String next = decodePercentOnce(decoded);
+                if (next.equals(decoded)) {
+                    break;
+                }
+                decoded = next;
+            }
+
+            Path path = new Path(decoded);
+            URI uri = path.toUri();
+            String scheme = uri.getScheme();
+            String authority = uri.getAuthority();
+            String uriPath = uri.getPath();
+            if (scheme == null
+                    || scheme.isEmpty()
+                    || uri.getUserInfo() != null

Review Comment:
   **[P1] Do not reject URI forms used by supported filesystems.**
   
   These checks reject `hdfs:///path` because it has no authority, even though 
Paimon documents that form and HadoopFileIO can resolve it through the catalog 
Hadoop configuration. They also reject the standard ABFS form 
`abfs://[email protected]/path`, because `java.net.URI` 
parses `filesystem` as user info even though it is the Azure 
filesystem/container name rather than a credential. As a result, explicit 
partition locations are unusable for common HDFS deployments and standard ADLS 
Gen2 addresses. Please make validation scheme/FileIO-aware: qualify 
authorityless HDFS through `CatalogContext`, recognize the structural ABFS 
authority, and continue rejecting actual embedded credentials.



##########
paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonAnalyzeFormatTablePartitionsCommand.scala:
##########
@@ -62,11 +62,17 @@ case class PaimonAnalyzeFormatTablePartitionsCommand(
 
   override def run(sparkSession: SparkSession): Seq[Row] = {
     val prefix = leadingPrefix(sparkSession)
-    val partitions = v2Table.partitionManager
+    val registeredPartitions = v2Table.partitionManager

Review Comment:
   **[P2] Validate the full registry before scoped ANALYZE.**
   
   This query is already prefix-pruned, so checking `location` only on the 
returned partitions does not validate global path ownership or the 
authoritative explicit-location count. If selected default partition A has a 
directory that is also owned by an unselected explicit partition B, this guard 
passes and `FormatTablePartitionStatsCollector` reads that directory as A, 
probing explicitly owned data and replacing A statistics with B data. That 
state is reachable through the current ADD/MSCK registration gap or an 
inconsistent provider. Please run the same count-aware full-registry preflight 
used by scans and commits, then apply the ANALYZE prefix and reject selected 
explicit partitions.



##########
paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java:
##########
@@ -832,6 +952,41 @@ public List<Partition> listPartitions(Identifier 
identifier) throws TableNotExis
         }
     }
 
+    @Override
+    public OptionalLong getExplicitPartitionLocationCount(Identifier 
identifier)
+            throws TableNotExistException {
+        try {
+            GetTableResponse response = api.getTable(identifier);
+            validateExplicitPartitionLocationCount(response);
+            Long count = response.getExplicitPartitionLocationCount();
+            return count == null || count < 0 ? OptionalLong.empty() : 
OptionalLong.of(count);
+        } catch (NoSuchResourceException e) {
+            throw new TableNotExistException(identifier);
+        } catch (ForbiddenException e) {
+            throw new TableNoPermissionException(identifier, e);
+        }
+    }
+
+    private void validateExplicitPartitionLocationCount(GetTableResponse 
response) {
+        Long count = response.getExplicitPartitionLocationCount();
+        if (RESTApi.containsCapability(
+                        
api.options().get(RESTCatalogInternalOptions.SERVER_CAPABILITIES),
+                        RESTApi.FORMAT_TABLE_PARTITION_LOCATION_CAPABILITY)
+                && (count == null || count < 0)) {

Review Comment:
   **[P1] Preserve the documented unknown-count fallback.**
   
   The OpenAPI description explicitly allows `explicitPartitionLocationCount` 
to be omitted, and `Catalog#getExplicitPartitionLocationCount` defines an empty 
result as a signal to load and validate the full registry. Requiring the count 
whenever the server advertises the location capability contradicts both 
contracts: a location-capable provider that cannot supply this optional 
optimization will make every `getTable` and table-detail conversion fail before 
any fallback can run. Please accept `null` as `OptionalLong.empty()` and use 
the existing full-registry path, or advertise count support through a separate 
capability that makes the field mandatory.



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

Reply via email to