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

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new a943a2ed30 Issue #8794 : Write Iceberg PATH tables at their path on 
the Spark engine (#8795)
a943a2ed30 is described below

commit a943a2ed3058389e6554655c1db4841f744a67a3
Author: Ankur Partap Kotwal <[email protected]>
AuthorDate: Sat Oct 10 01:18:20 2026 -0700

    Issue #8794 : Write Iceberg PATH tables at their path on the Spark engine 
(#8795)
    
    * Issue #8794 : Write Iceberg PATH tables at their path on the Spark engine
    
    An Iceberg PATH table was addressed as hop_iceberg.`<uri>` in a single
    Hadoop catalog whose warehouse is in java.io.tmpdir. Iceberg only treats
    an identifier as a location when Spark builds it from load(path), so the
    catalog took the whole URI as a table name and stored the table under
    its own warehouse instead of at the path.
    
    Each folder that holds PATH tables now gets its own Hadoop catalog,
    hop_iceberg_<hash of the folder>, with that folder as warehouse, and a
    table is addressed as hop_iceberg_<hash>.`<name>`. The Hadoop catalog
    keeps such a table at <warehouse>/<name>, which is the table path.
    Reads, writes, merges, time travel and maintenance procedures all go
    through it.
    
    Generated-by: Claude Opus 5.5
    
    * Issue #8794 : Update the remaining hop_iceberg references for PATH tables
    
    Address review: put the Javadoc of both ensureIcebergPathCatalog methods
    back on the right method, describe PATH identifiers as
    hop_iceberg_<hash>.`table` in the class and resolver Javadoc, drop the
    hop_iceberg hint from the PATH read and write errors, and update the
    troubleshooting table in the lakehouse guide.
    
    Generated-by: Claude Opus 5.5
    
    * Issue #8794 : One catalog per folder, whatever the spelling of the path
    
    Address review: the path of an Iceberg PATH table is canonicalized
    before it is split into warehouse and table name, and before the
    catalog name is hashed: the scheme is lowercased, file:/x,
    file://localhost/x and file:///x become file:///x, empty segments are
    collapsed and . and .. are resolved. Two spellings of one folder now
    share one catalog, so a write through one isn't hidden by the other's
    cached snapshot.
    
    Only a parent that is a scheme alone (file:, file://, s3a:/) is
    rejected now, so a table in a Windows drive root (file:///C:/orders)
    and paths with an empty segment (s3a://bucket/lake//orders) work. An
    invalid path is reported as a HopException that names the transform.
    
    Generated-by: Claude Opus 5.5
    
    * Issue #8794 : Decode escapes and uppercase drive letters in PATH table 
locations
    
    Address second review. The canonical location is used as a Hadoop path,
    and Hadoop doesn't treat % as an escape, so a scheme-less path like
    /data/my lake/orders, which reaches us as file:///data/my%20lake/orders,
    was written to a folder literally named my%20lake. Percent-escapes in
    the path and authority are now decoded as UTF-8 (+ stays +, a segment
    with a broken escape is kept as is), and for file URIs a Windows drive
    letter is uppercased, so file:///c:/x and file:///C:/x are one catalog.
    
    A Spark round trip into a folder with spaces checks the table lands
    there; it failed before this change.
    
    Generated-by: Claude Opus 5.5
---
 .../ROOT/pages/metadata-types/spark-catalog.adoc   |   2 +-
 .../ROOT/pages/pipeline/spark/lakehouse.adoc       |  12 +-
 .../transforms/spark-lake-table-input.adoc         |   2 +-
 .../transforms/spark-lake-table-maintenance.adoc   |   2 +-
 .../transforms/spark-lake-table-merge.adoc         |   2 +-
 .../transforms/spark-lake-table-output.adoc        |   2 +-
 .../apache/hop/spark/table/SparkLakeFormats.java   |   6 +-
 .../hop/spark/table/SparkLakeTableSupport.java     | 260 ++++++++++++++++++---
 .../spark/table/SparkLakeTableIcebergPathTest.java |  25 +-
 .../hop/spark/table/SparkLakeTableSupportTest.java | 130 ++++++++++-
 10 files changed, 396 insertions(+), 47 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/spark-catalog.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/spark-catalog.adoc
index 278f6ac221..96ecd26dc2 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/spark-catalog.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/spark-catalog.adoc
@@ -27,7 +27,7 @@ under the License.
 
 Lake Table transforms in **TABLE** mode can reference a Spark Catalog by name. 
Hop expands the entry into `spark.sql.catalog.<catalogName>.*` configuration 
when building or preparing the session (`LakeSessionPlan` / 
`SparkCatalogApplier`).
 
-PATH-mode Iceberg tables do **not** require this metadata type — the engine 
auto-registers a built-in Hadoop catalog named `hop_iceberg`. Use Spark Catalog 
for production Iceberg warehouses, REST catalogs, and other named catalogs.
+PATH-mode Iceberg tables do **not** require this metadata type — the engine 
registers a Hadoop catalog for the folder that holds each table 
(`hop_iceberg_<hash>`). Use Spark Catalog for production Iceberg warehouses, 
REST catalogs, and other named catalogs.
 
 Connector packaging and session matrix: 
xref:pipeline/spark/lakehouse.adoc[Lakehouse tables on the native Spark engine].
 
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/spark/lakehouse.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/spark/lakehouse.adoc
index 50588960a6..e44db08e66 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/spark/lakehouse.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/spark/lakehouse.adoc
@@ -160,13 +160,15 @@ Hop’s `LakeSessionPlan` scans Lake Table transforms 
before the session is used
 |`spark.sql.extensions` includes `io.delta.sql.DeltaSparkSessionExtension` 
**and** 
`spark.sql.catalog.spark_catalog=org.apache.spark.sql.delta.catalog.DeltaCatalog`
 
 |Any Iceberg PATH use
-|Iceberg extensions + built-in Hadoop catalog **`hop_iceberg`** 
(`type=hadoop`, warehouse under tmp)
+|Iceberg extensions + a Hadoop catalog per table folder, 
**`hop_iceberg_<hash>`** (`type=hadoop`, warehouse = the folder that holds the 
table)
 
 |Iceberg TABLE
 |`spark.sql.catalog.<name>=org.apache.iceberg.spark.SparkCatalog` (+ `type`, 
`warehouse` / `uri`, …) from xref:metadata-types/spark-catalog.adoc[Spark 
Catalog] metadata
 |===
 
-IMPORTANT: Delta PATH I/O on Spark 4.1 + Delta 4.3 **requires DeltaCatalog**, 
not only the extension. Iceberg bare `format("iceberg").load(path)` is **not** 
used — it defaults toward HiveCatalog and fails without HMS. PATH mode uses 
`hop_iceberg.\`file:///…\`` (or equivalent URI) instead.
+IMPORTANT: Delta PATH I/O on Spark 4.1 + Delta 4.3 **requires DeltaCatalog**, 
not only the extension. Iceberg bare `format("iceberg").load(path)` is **not** 
used — it defaults toward HiveCatalog and fails without HMS. PATH mode uses 
`hop_iceberg_<hash>.\`<table>\``, where the catalog's warehouse is the table's 
parent folder, so the table is read and written at its path.
+
+NOTE: Before Hop 2.20, Iceberg PATH tables written by the Spark engine were 
stored under `<java.io.tmpdir>/hop-iceberg-path-catalog-warehouse/` instead of 
at the table path. To keep such a table, copy its folder from there (it is in a 
sub-folder named after the full table URI) to the table path before running the 
pipeline with Hop 2.20 or later.
 
 [[path-vs-table]]
 == PATH vs TABLE
@@ -198,8 +200,8 @@ IMPORTANT: Delta PATH I/O on Spark 4.1 + Delta 4.3 
**requires DeltaCatalog**, no
 | `format("delta").mode(…).save(path)`
 
 | **Iceberg**
-| SQL over `hop_iceberg.uri`
-| `writeTo(hop_iceberg.uri).using("iceberg")`
+| SQL over `hop_iceberg_<hash>.table`
+| `writeTo(hop_iceberg_<hash>.table).using("iceberg")`
 
 |===
 
@@ -290,7 +292,7 @@ Standalone *Spark lake table maintenance*:
 |`spark_catalog` must be `DeltaCatalog` when using Delta (hop-run sets this 
when Delta transforms are present)
 
 |Iceberg PATH fails with HiveCatalog / HMS
-|Do not use bare `format("iceberg")` outside Hop; ensure Iceberg transforms 
run under hop-run so `hop_iceberg` is registered, or pass equivalent conf on 
submit
+|Do not use bare `format("iceberg")` outside Hop; ensure Iceberg transforms 
run under hop-run so the per-folder `hop_iceberg_<hash>` catalogs are registered
 
 |TABLE unresolved
 |Spark Catalog metadata exported? Catalog name matches the first segment of 
`catalog.ns.table`? Warehouse / URI reachable from driver and executors?
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-input.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-input.adoc
index 5e6901bf56..cffc18c66d 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-input.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-input.adoc
@@ -104,7 +104,7 @@ Typical pattern: template pipeline with Lake Table Input; 
injecting pipeline sup
 
 == Notes
 
-* Prefer Lake Table Input over *Spark file input* for `delta` / `iceberg` so 
session extensions, `hop_iceberg` PATH catalog, and probes stay consistent.
+* Prefer Lake Table Input over *Spark file input* for `delta` / `iceberg` so 
session extensions, Iceberg PATH catalogs, and probes stay consistent.
 * See the xref:pipeline/spark/lakehouse.adoc[lakehouse guide] for packaging, 
PATH vs TABLE, and troubleshooting.
 
 == Related pages
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-maintenance.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-maintenance.adoc
index ee7d966fab..97029fc01f 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-maintenance.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-maintenance.adoc
@@ -100,7 +100,7 @@ Aliases accepted for operation: `COMPACT` / 
`REWRITE_DATA_FILES` → OPTIMIZE; `
 == Behaviour
 
 * SQL is executed as a Spark action during graph materialisation; an empty 
leaf Dataset is registered afterward.
-* Iceberg CALL procedures use the catalog name from TABLE mode (`lake` in 
`lake.db.t`) or `hop_iceberg` for PATH mode.
+* Iceberg CALL procedures use the catalog name from TABLE mode (`lake` in 
`lake.db.t`) or, for PATH mode, the `hop_iceberg_<hash>` catalog of the folder 
that holds the table.
 * Metrics are **log-only**; expect little or no GUI row traffic on this sink.
 
 == Metadata injection
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-merge.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-merge.adoc
index ab116c83d6..a654871ab5 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-merge.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-merge.adoc
@@ -78,7 +78,7 @@ ON t.id = s.id
 WHEN MATCHED THEN UPDATE SET *
 WHEN NOT MATCHED THEN INSERT *
 ----
-* Target SQL ids: Delta PATH → `delta.\`path\``; Iceberg PATH → 
`hop_iceberg.\`uri\``; TABLE → multi-part identifier.
+* Target SQL ids: Delta PATH → `delta.\`path\``; Iceberg PATH → 
`hop_iceberg_<hash>.\`table\`` (catalog warehouse = the table's parent folder); 
TABLE → multi-part identifier.
 * At least one action (matched and/or not-matched) is required.
 * Metrics are **log-only** (merge completion); GUI in/out row counts for the 
sink may stay at zero.
 
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-output.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-output.adoc
index b6fffd7355..683fdd6789 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-output.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/spark-lake-table-output.adoc
@@ -72,7 +72,7 @@ This transform is **native Spark only**. Connectors ship with 
the native Spark p
 * The write runs as a Spark **action** when the engine materialises the graph.
 * An empty leaf Dataset is registered so a later engine-wide `count()` does 
not re-write the table.
 * **Delta PATH:** `format("delta").mode(…).save(path)` (requires Delta 
extension + `DeltaCatalog` on the session — hop-run applies these when Delta 
transforms are present).
-* **Iceberg PATH:** `writeTo(hop_iceberg.\`uri\`).using("iceberg")` with the 
built-in `hop_iceberg` Hadoop catalog.
+* **Iceberg PATH:** `writeTo(hop_iceberg_<hash>.\`table\`).using("iceberg")`, 
with a Hadoop catalog whose warehouse is the table's parent folder, so the 
table is written at its path.
 * **Iceberg TABLE:** `writeTo(catalog.ns.table).using("iceberg")`.
 * **Delta TABLE (advanced):** `format("delta").saveAsTable(id)`.
 
diff --git 
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/table/SparkLakeFormats.java
 
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/table/SparkLakeFormats.java
index d5a80b151e..871658ce2f 100644
--- 
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/table/SparkLakeFormats.java
+++ 
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/table/SparkLakeFormats.java
@@ -48,8 +48,10 @@ public final class SparkLakeFormats {
   public static final String ICEBERG_CATALOG = LakeFormats.ICEBERG_CATALOG;
 
   /**
-   * Built-in Hadoop catalog name used for Iceberg PATH mode ({@code 
hop_iceberg.`file:///…`}).
-   * Distinct from {@code spark_catalog} so Delta can keep DeltaCatalog when 
both formats co-exist.
+   * Name prefix of the Hadoop catalogs used for Iceberg PATH mode: one 
catalog per table folder,
+   * for example {@code hop_iceberg_1a2b3c4d5e6f.`orders`} (see {@link
+   * SparkLakeTableSupport#icebergPathTable(String)}). Distinct from {@code 
spark_catalog} so Delta
+   * can keep DeltaCatalog when both formats co-exist.
    */
   public static final String ICEBERG_PATH_CATALOG_NAME = "hop_iceberg";
 
diff --git 
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/table/SparkLakeTableSupport.java
 
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/table/SparkLakeTableSupport.java
index 7cc0bf747b..ce3d3afa58 100644
--- 
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/table/SparkLakeTableSupport.java
+++ 
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/table/SparkLakeTableSupport.java
@@ -23,6 +23,8 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.exception.HopException;
@@ -46,8 +48,9 @@ import org.apache.spark.sql.SparkSession;
  *
  * <ul>
  *   <li>Delta PATH: {@code format("delta").load/save(path)} (requires 
DeltaCatalog on session)
- *   <li>Iceberg PATH: path identifier under built-in Hadoop catalog {@code 
hop_iceberg.`uri`} —
- *       bare {@code format("iceberg").load(path)} defaults to HiveCatalog and 
is not used
+ *   <li>Iceberg PATH: {@code hop_iceberg_<hash>.`table`}, in a Hadoop catalog 
whose warehouse is
+ *       the table's parent folder (see {@link IcebergPathTable}) — bare {@code
+ *       format("iceberg").load(path)} defaults to HiveCatalog and is not used
  * </ul>
  */
 public final class SparkLakeTableSupport {
@@ -107,15 +110,193 @@ public final class SparkLakeTableSupport {
   }
 
   /**
-   * Spark SQL multi-part identifier for an Iceberg path-based table under the 
hop path catalog:
-   * {@code hop_iceberg.`file:///path/to/table`}.
+   * An Iceberg table at a path, seen through the Hadoop catalog whose 
warehouse is the table's
+   * parent folder. A Hadoop catalog keeps a table of the default namespace in 
{@code
+   * <warehouse>/<table>}, so the table is read and written exactly at its 
path.
+   *
+   * <p>A quoted path in a single shared catalog, like {@code 
hop_iceberg.`file:///data/orders`},
+   * doesn't work: Iceberg only treats an identifier as a location when Spark 
creates it from {@code
+   * DataFrameReader.load(path)}. Otherwise the Hadoop catalog takes the whole 
URI as a table name
+   * and stores the table under its own warehouse.
+   */
+  public record IcebergPathTable(String catalogName, String warehouse, String 
tableName) {
+
+    /** Spark SQL identifier of the table, e.g. {@code 
hop_iceberg_1a2b3c4d5e6f.`orders`}. */
+    public String sqlIdentifier() {
+      return catalogName + "." + procedureTableRef();
+    }
+
+    /** The table as named in an Iceberg procedure call of {@link 
#catalogName()}. */
+    public String procedureTableRef() {
+      return "`" + tableName.replace("`", "``") + "`";
+    }
+  }
+
+  /**
+   * The path catalog and table name for an Iceberg table at {@code path}. 
Tables in the same folder
+   * share a catalog.
+   */
+  public static IcebergPathTable icebergPathTable(String path) {
+    String uri = canonicalLocation(toTableLocationUri(path));
+    int slash = uri == null ? -1 : uri.lastIndexOf('/');
+    String parent = slash < 0 ? "" : uri.substring(0, slash);
+    String name = slash < 0 ? "" : uri.substring(slash + 1);
+    if (name.isEmpty() || isSchemeOnly(parent)) {
+      throw new IllegalArgumentException(
+          "Iceberg table path '" + path + "' needs a parent folder, e.g. 
file:///data/orders");
+    }
+    return new IcebergPathTable(
+        SparkLakeFormats.ICEBERG_PATH_CATALOG_NAME + "_" + shortHash(parent), 
parent, name);
+  }
+
+  /**
+   * Like {@link #icebergPathTable(String)}, but reports an invalid path as a 
{@link HopException}
+   * that names the transform.
+   */
+  public static IcebergPathTable icebergPathTable(String path, String 
transformName)
+      throws HopException {
+    try {
+      return icebergPathTable(path);
+    } catch (IllegalArgumentException e) {
+      throw new HopException("Spark Lake Table '" + transformName + "': " + 
e.getMessage(), e);
+    }
+  }
+
+  private static final Pattern URI_SCHEME = 
Pattern.compile("^([A-Za-z][A-Za-z0-9+.-]*):(.*)$");
+
+  /**
+   * One spelling per location, so that one folder is always one catalog: the 
scheme is lowercased,
+   * {@code file:/x}, {@code file://localhost/x} and {@code file:///x} all 
become {@code file:///x},
+   * empty path segments are collapsed, {@code .} and {@code ..} are resolved, 
and a trailing slash
+   * is removed. Percent-escapes in the path and the authority are decoded, 
because the result is
+   * used as a Hadoop path, and Hadoop doesn't treat {@code %} as an escape: 
{@code
+   * file:///data/my%20lake} would otherwise be the folder {@code my%20lake}. 
A segment with a
+   * broken escape is kept as it is. For {@code file} URIs, a Windows drive 
letter is uppercased.
+   */
+  static String canonicalLocation(String uri) {
+    if (StringUtils.isEmpty(uri)) {
+      return uri;
+    }
+    Matcher matcher = URI_SCHEME.matcher(uri.trim());
+    if (!matcher.matches() || matcher.group(1).length() == 1) {
+      // No scheme (or a Windows drive letter): leave it as it is.
+      return StringUtils.removeEnd(uri.trim(), "/");
+    }
+    String scheme = matcher.group(1).toLowerCase(java.util.Locale.ROOT);
+    String rest = matcher.group(2);
+    String authority = "";
+    String pathPart = rest;
+    if (rest.startsWith("//")) {
+      int end = rest.indexOf('/', 2);
+      authority = end < 0 ? rest.substring(2) : rest.substring(2, end);
+      pathPart = end < 0 ? "" : rest.substring(end);
+    }
+    authority = percentDecode(authority);
+    if ("file".equals(scheme) && "localhost".equalsIgnoreCase(authority)) {
+      authority = "";
+    }
+    java.util.Deque<String> segments = new java.util.ArrayDeque<>();
+    for (String raw : pathPart.split("/")) {
+      String segment = percentDecode(raw);
+      if (segment.isEmpty() || ".".equals(segment)) {
+        continue;
+      }
+      if ("..".equals(segment)) {
+        segments.pollLast();
+      } else {
+        segments.addLast(segment);
+      }
+    }
+    if ("file".equals(scheme)
+        && !segments.isEmpty()
+        && WINDOWS_DRIVE.matcher(segments.peekFirst()).matches()) {
+      
segments.addFirst(segments.pollFirst().toUpperCase(java.util.Locale.ROOT));
+    }
+    String normalizedPath = segments.isEmpty() ? "" : "/" + String.join("/", 
segments);
+    return scheme + "://" + authority + normalizedPath;
+  }
+
+  private static final Pattern WINDOWS_DRIVE = Pattern.compile("[A-Za-z]:");
+
+  /**
+   * Decodes {@code %XX} escapes as UTF-8, leaving {@code +} alone. A value 
with a malformed escape
+   * is returned unchanged.
    */
-  @SuppressWarnings("javabugs:S2259") // toTableLocationUri() never returns 
null for a resolved path
+  static String percentDecode(String value) {
+    if (value.indexOf('%') < 0) {
+      return value;
+    }
+    java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
+    for (int i = 0; i < value.length(); ) {
+      char c = value.charAt(i);
+      if (c == '%') {
+        if (i + 2 >= value.length()) {
+          return value;
+        }
+        int hi = Character.digit(value.charAt(i + 1), 16);
+        int lo = Character.digit(value.charAt(i + 2), 16);
+        if (hi < 0 || lo < 0) {
+          return value;
+        }
+        bytes.write(hi * 16 + lo);
+        i += 3;
+      } else {
+        int end = i + Character.charCount(value.codePointAt(i));
+        byte[] encoded = value.substring(i, 
end).getBytes(java.nio.charset.StandardCharsets.UTF_8);
+        bytes.write(encoded, 0, encoded.length);
+        i = end;
+      }
+    }
+    java.nio.charset.CharsetDecoder decoder =
+        java.nio.charset.StandardCharsets.UTF_8
+            .newDecoder()
+            .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
+            .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT);
+    try {
+      return 
decoder.decode(java.nio.ByteBuffer.wrap(bytes.toByteArray())).toString();
+    } catch (java.nio.charset.CharacterCodingException e) {
+      return value;
+    }
+  }
+
+  /**
+   * True for {@code file:}, {@code file://}, {@code s3a:/} and the like: a 
scheme and no folder.
+   */
+  private static boolean isSchemeOnly(String parent) {
+    if (parent.isEmpty()) {
+      return true;
+    }
+    Matcher matcher = URI_SCHEME.matcher(parent);
+    if (!matcher.matches() || matcher.group(1).length() == 1) {
+      return false;
+    }
+    String rest = matcher.group(2);
+    if (!rest.startsWith("//")) {
+      return StringUtils.strip(rest, "/").isEmpty();
+    }
+    // scheme://authority/path: a warehouse needs a bucket or host, or for 
file: a path.
+    String afterSlashes = rest.substring(2);
+    return StringUtils.strip(afterSlashes, "/").isEmpty();
+  }
+
+  private static String shortHash(String value) {
+    try {
+      byte[] digest =
+          java.security.MessageDigest.getInstance("SHA-256")
+              .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+      StringBuilder hex = new StringBuilder();
+      for (int i = 0; i < 6; i++) {
+        hex.append(String.format("%02x", digest[i]));
+      }
+      return hex.toString();
+    } catch (java.security.NoSuchAlgorithmException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  /** Spark SQL identifier for an Iceberg table at a path; see {@link 
IcebergPathTable}. */
   public static String icebergPathSqlIdentifier(String path) {
-    String uri = toTableLocationUri(path);
-    // Escape any backticks in the URI (unlikely) by doubling them for Spark 
SQL quoting
-    String escaped = uri.replace("`", "``");
-    return SparkLakeFormats.ICEBERG_PATH_CATALOG_NAME + ".`" + escaped + "`";
+    return icebergPathTable(path).sqlIdentifier();
   }
 
   public static String normalizeTimeTravelType(String type) throws 
HopException {
@@ -187,7 +368,7 @@ public final class SparkLakeTableSupport {
    *
    * <ul>
    *   <li>Delta PATH → {@code delta.`path`}
-   *   <li>Iceberg PATH → {@code hop_iceberg.`uri`}
+   *   <li>Iceberg PATH → {@code hop_iceberg_<hash>.`table`} (see {@link 
IcebergPathTable})
    *   <li>TABLE → resolved multi-part table id
    * </ul>
    */
@@ -256,10 +437,12 @@ public final class SparkLakeTableSupport {
     String tableRefForCall = null;
     if (SparkLakeFormats.FORMAT_ICEBERG.equals(format)) {
       if (LakeTableInputMeta.MODE_PATH.equals(mode)) {
-        procedureCatalog = SparkLakeFormats.ICEBERG_PATH_CATALOG_NAME;
-        tableRefForCall =
-            toTableLocationUri(
-                
SparkPathDialect.toSparkUri(variables.resolve(meta.getTablePath()), 
pathSchemeMap));
+        IcebergPathTable pathTable =
+            icebergPathTable(
+                
SparkPathDialect.toSparkUri(variables.resolve(meta.getTablePath()), 
pathSchemeMap),
+                transformName);
+        procedureCatalog = pathTable.catalogName();
+        tableRefForCall = pathTable.procedureTableRef();
       } else {
         String tableId = resolveTableIdentifier(meta.getTableIdentifier(), 
null, variables);
         String[] parts = tableId.split("\\.");
@@ -337,10 +520,11 @@ public final class SparkLakeTableSupport {
       return "delta.`" + loc + "`";
     }
 
+    IcebergPathTable pathTable = icebergPathTable(path, transformName);
     if (spark != null) {
-      ensureIcebergPathCatalog(spark);
+      ensureIcebergPathCatalog(spark, pathTable);
     }
-    return icebergPathSqlIdentifier(path);
+    return pathTable.sqlIdentifier();
   }
 
   /** Maintenance target resolution result. */
@@ -680,8 +864,9 @@ public final class SparkLakeTableSupport {
       String timestamp,
       String transformName)
       throws HopException {
-    ensureIcebergPathCatalog(spark);
-    String sqlId = icebergPathSqlIdentifier(path);
+    IcebergPathTable pathTable = icebergPathTable(path, transformName);
+    ensureIcebergPathCatalog(spark, pathTable);
+    String sqlId = pathTable.sqlIdentifier();
     String sql = buildIcebergTimeTravelSql(sqlId, timeTravelType, version, 
timestamp);
     try {
       return spark.sql(sql);
@@ -694,10 +879,8 @@ public final class SparkLakeTableSupport {
                   + sql
                   + ") in transform '"
                   + transformName
-                  + "'. Ensure the Iceberg runtime is on the engine classpath 
and session has"
-                  + " IcebergSparkSessionExtensions + Hadoop catalog '"
-                  + SparkLakeFormats.ICEBERG_PATH_CATALOG_NAME
-                  + "' (see plugins/engines/spark/README.md).",
+                  + "'. Ensure the Iceberg runtime is on the engine classpath 
and the session"
+                  + " has IcebergSparkSessionExtensions (see 
plugins/engines/spark/README.md).",
               path),
           e);
     }
@@ -741,8 +924,9 @@ public final class SparkLakeTableSupport {
       String[] partitionColumns,
       String transformName)
       throws HopException {
-    ensureIcebergPathCatalog(spark);
-    String sqlId = icebergPathSqlIdentifier(path);
+    IcebergPathTable pathTable = icebergPathTable(path, transformName);
+    ensureIcebergPathCatalog(spark, pathTable);
+    String sqlId = pathTable.sqlIdentifier();
 
     try {
       switch (saveMode) {
@@ -807,8 +991,8 @@ public final class SparkLakeTableSupport {
                   + sqlId
                   + ") in transform '"
                   + transformName
-                  + "'. Ensure Iceberg is on the classpath and hop_iceberg 
Hadoop catalog is"
-                  + " configured (see plugins/engines/spark/README.md).",
+                  + "'. Ensure Iceberg is on the classpath (see"
+                  + " plugins/engines/spark/README.md).",
               path),
           e);
     }
@@ -832,8 +1016,28 @@ public final class SparkLakeTableSupport {
   }
 
   /**
-   * Ensure the built-in Hadoop catalog for path identifiers is registered on 
this session. Safe to
-   * call multiple times; no-ops when already present.
+   * Registers the Hadoop catalog that serves the Iceberg table at {@code 
path} (see {@link
+   * IcebergPathTable}) on this session. Safe to call multiple times.
+   */
+  public static void ensureIcebergPathCatalog(SparkSession spark, String path) 
{
+    ensureIcebergPathCatalog(spark, icebergPathTable(path));
+  }
+
+  /** Registers the Hadoop catalog that serves {@code table} on this session. 
*/
+  public static void ensureIcebergPathCatalog(SparkSession spark, 
IcebergPathTable table) {
+    String key = "spark.sql.catalog." + table.catalogName();
+    if (StringUtils.isNotEmpty(spark.conf().get(key, ""))) {
+      return;
+    }
+    spark.conf().set(key, SparkLakeFormats.ICEBERG_CATALOG);
+    spark.conf().set(key + ".type", "hadoop");
+    spark.conf().set(key + ".warehouse", table.warehouse());
+  }
+
+  /**
+   * Ensure the built-in {@code hop_iceberg} Hadoop catalog is registered on 
this session. It is
+   * used for two-part TABLE identifiers; PATH tables use {@link 
#ensureIcebergPathCatalog(
+   * SparkSession, String)}. Safe to call multiple times; no-ops when already 
present.
    */
   public static void ensureIcebergPathCatalog(SparkSession spark) {
     String existing = 
spark.conf().get(SparkLakeFormats.SPARK_CONF_ICEBERG_PATH_CATALOG, "");
diff --git 
a/plugins/engines/spark/src/test/java/org/apache/hop/spark/table/SparkLakeTableIcebergPathTest.java
 
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/table/SparkLakeTableIcebergPathTest.java
index d4b5e53247..bcd5a5d777 100644
--- 
a/plugins/engines/spark/src/test/java/org/apache/hop/spark/table/SparkLakeTableIcebergPathTest.java
+++ 
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/table/SparkLakeTableIcebergPathTest.java
@@ -18,8 +18,11 @@
 package org.apache.hop.spark.table;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assumptions.assumeTrue;
 
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.HashMap;
 import java.util.List;
@@ -80,11 +83,26 @@ class SparkLakeTableIcebergPathTest {
 
   @Test
   void icebergPathOutputThenInputRoundTrip() throws Exception {
+    roundTrip(tempDir.resolve("orders_iceberg"));
+  }
+
+  /**
+   * A folder with spaces: the path reaches Spark escaped ({@code my%20lake}), 
and Hadoop doesn't
+   * treat {@code %} as an escape, so without decoding the table would land in 
a folder literally
+   * named {@code my%20lake}.
+   */
+  @Test
+  void icebergPathWithSpacesIsWrittenThere() throws Exception {
+    Path tablePath = tempDir.resolve("my lake").resolve("my orders");
+    roundTrip(tablePath);
+    assertFalse(Files.exists(tempDir.resolve("my%20lake")), "no folder with an 
escaped name");
+  }
+
+  private void roundTrip(Path tablePath) throws Exception {
     assumeTrue(
         
SparkLakeConnectorProbe.isIcebergPresent(SparkLakeConnectorProbe.class.getClassLoader()),
         "Iceberg connector not on classpath; connectors missing from test 
classpath");
 
-    Path tablePath = tempDir.resolve("orders_iceberg");
     Path warehouse = tempDir.resolve("iceberg_wh");
     spark =
         SparkSession.builder()
@@ -135,6 +153,11 @@ class SparkLakeTableIcebergPathTest {
             source);
 
     assertEquals(0, map.get("ice_out").count());
+    // The table is written at its path, not under a catalog warehouse 
elsewhere.
+    assertTrue(
+        Files.exists(tablePath.resolve("metadata/version-hint.text")),
+        "Iceberg metadata is written under the table path");
+    assertFalse(Files.exists(warehouse), "nothing is written to the 
hop_iceberg warehouse");
 
     LakeTableInputMeta inMeta = new LakeTableInputMeta();
     inMeta.setFormat(SparkLakeFormats.FORMAT_ICEBERG);
diff --git 
a/plugins/engines/spark/src/test/java/org/apache/hop/spark/table/SparkLakeTableSupportTest.java
 
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/table/SparkLakeTableSupportTest.java
index 2470c825a3..28898995be 100644
--- 
a/plugins/engines/spark/src/test/java/org/apache/hop/spark/table/SparkLakeTableSupportTest.java
+++ 
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/table/SparkLakeTableSupportTest.java
@@ -18,6 +18,7 @@
 package org.apache.hop.spark.table;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -59,12 +60,129 @@ class SparkLakeTableSupportTest {
   }
 
   @Test
-  void icebergPathSqlIdentifierQuotesUri() {
-    String id = SparkLakeTableSupport.icebergPathSqlIdentifier("/tmp/orders");
-    assertTrue(id.startsWith(SparkLakeFormats.ICEBERG_PATH_CATALOG_NAME + 
".`"));
-    assertTrue(id.endsWith("`"));
-    assertTrue(id.contains("file:"));
-    assertTrue(id.contains("orders"));
+  void icebergPathTableUsesTheParentFolderAsWarehouse() {
+    SparkLakeTableSupport.IcebergPathTable table =
+        SparkLakeTableSupport.icebergPathTable("s3a://bucket/lake/orders/");
+
+    assertEquals("s3a://bucket/lake", table.warehouse());
+    assertEquals("orders", table.tableName());
+    
assertTrue(table.catalogName().startsWith(SparkLakeFormats.ICEBERG_PATH_CATALOG_NAME
 + "_"));
+    assertEquals(table.catalogName() + ".`orders`", table.sqlIdentifier());
+    assertEquals("`orders`", table.procedureTableRef());
+  }
+
+  @Test
+  void icebergPathTablesShareACatalogPerFolder() {
+    String orders =
+        
SparkLakeTableSupport.icebergPathTable("file:///data/lake/orders").catalogName();
+    String items = 
SparkLakeTableSupport.icebergPathTable("file:///data/lake/items").catalogName();
+    String other =
+        
SparkLakeTableSupport.icebergPathTable("file:///data/other/orders").catalogName();
+
+    assertEquals(orders, items);
+    assertNotEquals(orders, other);
+  }
+
+  @Test
+  void icebergPathSqlIdentifierQuotesTheTableName() {
+    String id = 
SparkLakeTableSupport.icebergPathSqlIdentifier("/tmp/my-orders");
+    assertTrue(id.startsWith(SparkLakeFormats.ICEBERG_PATH_CATALOG_NAME + 
"_"));
+    assertTrue(id.endsWith(".`my-orders`"));
+  }
+
+  @Test
+  void icebergPathTableNeedsAParentFolder() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> SparkLakeTableSupport.icebergPathTable("s3a://bucket"));
+    assertThrows(
+        IllegalArgumentException.class, () -> 
SparkLakeTableSupport.icebergPathTable("file:///t"));
+  }
+
+  @Test
+  void icebergPathTableAcceptsAWindowsDriveRoot() {
+    SparkLakeTableSupport.IcebergPathTable table =
+        SparkLakeTableSupport.icebergPathTable("file:///C:/orders");
+
+    assertEquals("file:///C:", table.warehouse());
+    assertEquals("orders", table.tableName());
+  }
+
+  @Test
+  void icebergPathTableCollapsesEmptySegments() {
+    SparkLakeTableSupport.IcebergPathTable doubled =
+        SparkLakeTableSupport.icebergPathTable("s3a://bucket/lake//orders");
+    SparkLakeTableSupport.IcebergPathTable single =
+        SparkLakeTableSupport.icebergPathTable("s3a://bucket/lake/orders");
+
+    assertEquals("s3a://bucket/lake", doubled.warehouse());
+    assertEquals(single, doubled);
+    assertEquals(
+        single, 
SparkLakeTableSupport.icebergPathTable("s3a://bucket/lake/./tmp/../orders"));
+  }
+
+  @Test
+  void oneFolderIsOneCatalogWhateverTheSpelling() {
+    SparkLakeTableSupport.IcebergPathTable canonical =
+        SparkLakeTableSupport.icebergPathTable("file:///data/lake/orders");
+
+    // File.toURI() and Path.toUri() spell the same folder differently.
+    for (String spelling :
+        new String[] {
+          "file:/data/lake/orders",
+          "file://localhost/data/lake/orders",
+          "FILE:///data/lake/orders/",
+          "file:///data//lake/orders"
+        }) {
+      assertEquals(canonical, 
SparkLakeTableSupport.icebergPathTable(spelling), spelling);
+    }
+    assertEquals("file:///data/lake", canonical.warehouse());
+  }
+
+  @Test
+  void escapedAndUnescapedSpellingsAreOneCatalog() {
+    SparkLakeTableSupport.IcebergPathTable expected =
+        SparkLakeTableSupport.icebergPathTable("file:///data/my lake/my 
orders");
+
+    assertEquals("file:///data/my lake", expected.warehouse());
+    assertEquals("my orders", expected.tableName());
+    assertEquals(
+        expected, 
SparkLakeTableSupport.icebergPathTable("file:///data/my%20lake/my%20orders"));
+    // A scheme-less path goes through Path.toUri(), which escapes the spaces.
+    assertEquals(
+        expected.tableName(),
+        SparkLakeTableSupport.icebergPathTable("/data/my lake/my 
orders").tableName());
+    assertTrue(
+        SparkLakeTableSupport.icebergPathTable("/data/my lake/my orders")
+            .warehouse()
+            .endsWith("/data/my lake"));
+  }
+
+  @Test
+  void plusAndBrokenEscapesAreKept() {
+    assertEquals("a+b", SparkLakeTableSupport.percentDecode("a+b"));
+    assertEquals("100%", SparkLakeTableSupport.percentDecode("100%"));
+    assertEquals("x%zzy", SparkLakeTableSupport.percentDecode("x%zzy"));
+    assertEquals("caf\u00e9", 
SparkLakeTableSupport.percentDecode("caf%C3%A9"));
+  }
+
+  @Test
+  void driveLetterCaseIsOneCatalog() {
+    assertEquals(
+        SparkLakeTableSupport.icebergPathTable("file:///C:/data/orders"),
+        SparkLakeTableSupport.icebergPathTable("file:///c:/data/orders"));
+    assertEquals(
+        "file:///C:/data",
+        
SparkLakeTableSupport.icebergPathTable("file:///c:/data/orders").warehouse());
+  }
+
+  @Test
+  void invalidPathNamesTheTransform() {
+    HopException e =
+        assertThrows(
+            HopException.class,
+            () -> SparkLakeTableSupport.icebergPathTable("file:///t", "write 
orders"));
+    assertTrue(e.getMessage().contains("'write orders'"), e.getMessage());
   }
 
   @Test

Reply via email to