voonhous commented on code in PR #19624:
URL: https://github.com/apache/hudi/pull/19624#discussion_r3967273344


##########
hudi-io/src/main/java/org/apache/hudi/common/util/ReflectionUtils.java:
##########
@@ -130,19 +135,71 @@ public static Object loadClass(String clazz, Object... 
constructorArgs) {
    */
   public static Stream<String> getTopLevelClassesInClasspath(Class<?> clazz) {
     ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
-    String packageName = clazz.getPackage().getName();
+    // Arrays and primitives have no package, and Class#getPackage is also 
null when the class was
+    // loaded by a loader that defines no package for it.
+    Package pkg = clazz.getPackage();
+    if (pkg == null) {
+      return Stream.empty();
+    }
+    String packageName = pkg.getName();
     String path = packageName.replace('.', '/');
     try {
       return Collections.list(classLoader.getResources(path)).stream()
-          .map(ReflectionUtils::toDirectory)
-          .filter(Objects::nonNull)
-          .flatMap(directory -> findClasses(directory, packageName).stream());
+          .flatMap(resource -> classNamesIn(resource, packageName));
     } catch (IOException e) {
       log.error("Unable to fetch Resources in package {}", packageName, e);
       return Stream.empty();
     }
   }
 
+  /**
+   * Class names under a single classpath entry for the package, whether that 
entry is an exploded
+   * directory or a jar.
+   *
+   * <p>A jar entry cannot go through {@link #toDirectory}: a {@code jar:} URL 
is non-hierarchical,
+   * so {@code new File(uri)} throws and the entry would be dropped. Every 
bundle {@code Main} class
+   * runs from inside a shaded jar, so that path has to be read through the 
jar connection instead.
+   *
+   * @param resource    a classpath entry holding the package
+   * @param packageName the package being scanned
+   * @return class names found under that entry, empty if it cannot be read
+   */
+  private static Stream<String> classNamesIn(URL resource, String packageName) 
{
+    if ("jar".equals(resource.getProtocol())) {
+      return classNamesInJar(resource, packageName);
+    }
+    File directory = toDirectory(resource);
+    return directory == null ? Stream.empty() : findClasses(directory, 
packageName).stream();
+  }
+
+  /**
+   * Class names under the package inside a jar, read through the jar 
connection.
+   *
+   * @param resource    a {@code jar:} classpath entry holding the package
+   * @param packageName the package being scanned
+   * @return class names found in that jar, empty if the jar cannot be read
+   */
+  private static Stream<String> classNamesInJar(URL resource, String 
packageName) {
+    String entryPrefix = packageName.replace('.', '/') + '/';
+    try {
+      JarURLConnection connection = (JarURLConnection) 
resource.openConnection();
+      // Without this the JarFile is cached and shared, and closing it below 
would break other readers.
+      connection.setUseCaches(false);

Review Comment:
   **minor:** Nothing pins this line: deleting it leaves all 8 tests green 
(checked by mutation), yet without it the scan closes the JVM-wide cached 
`JarFile`, and any reader that opened the same jar first then gets 
`IllegalStateException: zip file closed` (reproduced on JDK 8/11/17/25). A 
second scan does not catch it either, since the closed entry is evicted from 
the cache. Not blocking, but could `testGetTopLevelClassesInClasspathFromJar` 
open a `JarURLConnection` on the jar before the scan and assert 
`shared.getEntry(dir + "Alpha.class")` is still readable afterwards?



##########
hudi-common/src/test/java/org/apache/hudi/common/util/TestReflectionUtils.java:
##########
@@ -101,4 +113,96 @@ public Enumeration<URL> getResources(String name) throws 
IOException {
       Thread.currentThread().setContextClassLoader(original);
     }
   }
+
+  /**
+   * An exploded directory on the classpath, reached over the "file" protocol. 
Built explicitly
+   * rather than relying on the test classpath, because under Maven the 
modules are jars and this
+   * branch would never be entered.
+   */
+  @Test
+  void testGetTopLevelClassesInClasspathFromDirectory(@TempDir Path tempDir) 
throws Exception {
+    String scanned = ReflectionUtils.class.getPackage().getName();
+    Path root = tempDir.resolve("classes");
+    Path pkgDir = root.resolve(scanned.replace('.', File.separatorChar));
+    Files.createDirectories(pkgDir.resolve("nested"));
+    Files.write(pkgDir.resolve("Alpha.class"), new byte[] {1});
+    Files.write(pkgDir.resolve("Beta.class"), new byte[] {1});
+    Files.write(pkgDir.resolve("nested").resolve("Gamma.class"), new byte[] 
{1});
+    Files.write(pkgDir.resolve("notaclass.txt"), new byte[] {1});
+
+    List<String> classes = withContextClassLoaderOver(root, () ->
+        
getTopLevelClassesInClasspath(ReflectionUtils.class).collect(Collectors.toList()));
+
+    assertEquals(
+        Arrays.asList(scanned + ".Alpha", scanned + ".Beta", scanned + 
".nested.Gamma"),
+        classes.stream().sorted().collect(Collectors.toList()),
+        "a directory entry must yield the classes of the package and its 
subpackages, and nothing else");
+  }
+
+  /**
+   * Classes packaged in a jar are reached over the "jar" protocol, whose URLs 
are not hierarchical
+   * and so cannot be turned into a {@link File}. Every caller of this method 
is a bundle
+   * Main class, which is exactly the packaged case.
+   * <p>
+   * The jar is built under the scanned class's own package, and the loader is 
given no parent, so
+   * the only resource found for that package is the one written here.
+   */
+  @Test
+  void testGetTopLevelClassesInClasspathFromJar(@TempDir Path tempDir) throws 
Exception {

Review Comment:
   **minor:** Small coverage regression: the `"jar:file:/unused.jar!/..."` 
parameter of `testGetTopLevelClassesInClasspathSkipsInvalidResources` (:82) 
used to land in `toDirectory`'s `IllegalArgumentException` arm and now routes 
to `classNamesInJar`, so nothing in the suite reaches that arm any more. Not 
blocking: could a third `@ValueSource` entry such as 
`"http://example.invalid/org/apache/hudi/common/util"` be added? `new 
File(URI)` rejects it with `URI scheme is not "file"`, which restores the arm.



##########
hudi-common/src/test/java/org/apache/hudi/common/util/TestReflectionUtils.java:
##########
@@ -101,4 +113,96 @@ public Enumeration<URL> getResources(String name) throws 
IOException {
       Thread.currentThread().setContextClassLoader(original);
     }
   }
+
+  /**
+   * An exploded directory on the classpath, reached over the "file" protocol. 
Built explicitly
+   * rather than relying on the test classpath, because under Maven the 
modules are jars and this
+   * branch would never be entered.
+   */
+  @Test
+  void testGetTopLevelClassesInClasspathFromDirectory(@TempDir Path tempDir) 
throws Exception {
+    String scanned = ReflectionUtils.class.getPackage().getName();
+    Path root = tempDir.resolve("classes");
+    Path pkgDir = root.resolve(scanned.replace('.', File.separatorChar));
+    Files.createDirectories(pkgDir.resolve("nested"));
+    Files.write(pkgDir.resolve("Alpha.class"), new byte[] {1});
+    Files.write(pkgDir.resolve("Beta.class"), new byte[] {1});
+    Files.write(pkgDir.resolve("nested").resolve("Gamma.class"), new byte[] 
{1});
+    Files.write(pkgDir.resolve("notaclass.txt"), new byte[] {1});
+
+    List<String> classes = withContextClassLoaderOver(root, () ->
+        
getTopLevelClassesInClasspath(ReflectionUtils.class).collect(Collectors.toList()));
+
+    assertEquals(
+        Arrays.asList(scanned + ".Alpha", scanned + ".Beta", scanned + 
".nested.Gamma"),
+        classes.stream().sorted().collect(Collectors.toList()),
+        "a directory entry must yield the classes of the package and its 
subpackages, and nothing else");
+  }
+
+  /**
+   * Classes packaged in a jar are reached over the "jar" protocol, whose URLs 
are not hierarchical
+   * and so cannot be turned into a {@link File}. Every caller of this method 
is a bundle
+   * Main class, which is exactly the packaged case.
+   * <p>
+   * The jar is built under the scanned class's own package, and the loader is 
given no parent, so
+   * the only resource found for that package is the one written here.
+   */
+  @Test
+  void testGetTopLevelClassesInClasspathFromJar(@TempDir Path tempDir) throws 
Exception {
+    String scanned = ReflectionUtils.class.getPackage().getName();
+    String dir = scanned.replace('.', '/') + "/";
+    Path jar = tempDir.resolve("classes.jar");
+    writeJar(jar,
+        dir,
+        dir + "Alpha.class",
+        dir + "Beta.class",
+        dir + "nested/",
+        dir + "nested/Gamma.class",
+        dir + "notaclass.txt",
+        "org/example/other/Delta.class");
+
+    List<String> classes = withContextClassLoaderOver(jar, () ->
+        
getTopLevelClassesInClasspath(ReflectionUtils.class).collect(Collectors.toList()));
+
+    assertEquals(
+        Arrays.asList(scanned + ".Alpha", scanned + ".Beta", scanned + 
".nested.Gamma"),
+        classes.stream().sorted().collect(Collectors.toList()),
+        "a jar entry must yield the classes of the package and its 
subpackages, and nothing else");
+  }
+
+  @Test
+  void testGetTopLevelClassesInClasspathForClassesWithoutAPackage() {
+    // Arrays have no package, which used to dereference null.
+    assertEquals(0, getTopLevelClassesInClasspath(String[].class).count());
+    assertEquals(0, getTopLevelClassesInClasspath(int[].class).count());
+  }
+
+  /** Writes a jar holding the given entry names; names ending in "/" become 
directory entries. */
+  private static void writeJar(Path jar, String... entryNames) throws 
IOException {
+    try (JarOutputStream out = new 
JarOutputStream(Files.newOutputStream(jar))) {
+      for (String entryName : entryNames) {
+        out.putNextEntry(new JarEntry(entryName));
+        if (!entryName.endsWith("/")) {
+          // Content is irrelevant: the scan reads entry names, never the 
bytecode.
+          out.write(new byte[] {1, 2, 3});
+        }
+        out.closeEntry();
+      }
+    }
+  }
+
+  /**
+   * Runs the supplier with the thread context class loader reading only from 
the given classpath
+   * root, which may be a jar or an exploded directory. The loader is given no 
parent so the scan
+   * sees nothing else.
+   */
+  private static <T> T withContextClassLoaderOver(Path root, Supplier<T> 
supplier) throws IOException {

Review Comment:
   **nit:** Every new test uses a single classpath root, so the `flatMap` union 
over several `getResources` hits (the surefire shape: 
`org.apache.hudi.common.util` is in the hudi-io jar and in 
`hudi-common/target/classes` at once) works but is never asserted. Feel free to 
ignore: could this helper take varargs roots so one test asserts that `{jar, 
directory}` yields both sets?



##########
hudi-io/src/main/java/org/apache/hudi/common/util/ReflectionUtils.java:
##########
@@ -130,19 +135,71 @@ public static Object loadClass(String clazz, Object... 
constructorArgs) {
    */
   public static Stream<String> getTopLevelClassesInClasspath(Class<?> clazz) {
     ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
-    String packageName = clazz.getPackage().getName();
+    // Arrays and primitives have no package, and Class#getPackage is also 
null when the class was
+    // loaded by a loader that defines no package for it.
+    Package pkg = clazz.getPackage();
+    if (pkg == null) {
+      return Stream.empty();
+    }
+    String packageName = pkg.getName();
     String path = packageName.replace('.', '/');
     try {
       return Collections.list(classLoader.getResources(path)).stream()
-          .map(ReflectionUtils::toDirectory)
-          .filter(Objects::nonNull)
-          .flatMap(directory -> findClasses(directory, packageName).stream());
+          .flatMap(resource -> classNamesIn(resource, packageName));
     } catch (IOException e) {
       log.error("Unable to fetch Resources in package {}", packageName, e);
       return Stream.empty();
     }
   }
 
+  /**
+   * Class names under a single classpath entry for the package, whether that 
entry is an exploded
+   * directory or a jar.
+   *
+   * <p>A jar entry cannot go through {@link #toDirectory}: a {@code jar:} URL 
is non-hierarchical,
+   * so {@code new File(uri)} throws and the entry would be dropped. Every 
bundle {@code Main} class
+   * runs from inside a shaded jar, so that path has to be read through the 
jar connection instead.
+   *
+   * @param resource    a classpath entry holding the package
+   * @param packageName the package being scanned
+   * @return class names found under that entry, empty if it cannot be read
+   */
+  private static Stream<String> classNamesIn(URL resource, String packageName) 
{
+    if ("jar".equals(resource.getProtocol())) {
+      return classNamesInJar(resource, packageName);
+    }
+    File directory = toDirectory(resource);
+    return directory == null ? Stream.empty() : findClasses(directory, 
packageName).stream();
+  }
+
+  /**
+   * Class names under the package inside a jar, read through the jar 
connection.
+   *
+   * @param resource    a {@code jar:} classpath entry holding the package
+   * @param packageName the package being scanned
+   * @return class names found in that jar, empty if the jar cannot be read
+   */
+  private static Stream<String> classNamesInJar(URL resource, String 
packageName) {
+    String entryPrefix = packageName.replace('.', '/') + '/';

Review Comment:
   **nit:** On a multi-release jar the class loader resolves the package URL to 
`META-INF/versions/N/<pkg>/`, so a prefix derived from `packageName` never 
matches the versioned entries and classes that exist only there are invisible. 
No Hudi bundle is multi-release today, so feel free to ignore: could the prefix 
come from `connection.getEntryName()` instead, with names built as `packageName 
+ '.' + relativePath.replace('/', '.')` the way `findClasses` anchors them?



##########
hudi-io/src/main/java/org/apache/hudi/common/util/ReflectionUtils.java:
##########
@@ -130,19 +135,71 @@ public static Object loadClass(String clazz, Object... 
constructorArgs) {
    */
   public static Stream<String> getTopLevelClassesInClasspath(Class<?> clazz) {
     ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
-    String packageName = clazz.getPackage().getName();
+    // Arrays and primitives have no package, and Class#getPackage is also 
null when the class was
+    // loaded by a loader that defines no package for it.
+    Package pkg = clazz.getPackage();
+    if (pkg == null) {
+      return Stream.empty();
+    }
+    String packageName = pkg.getName();
     String path = packageName.replace('.', '/');
     try {
       return Collections.list(classLoader.getResources(path)).stream()
-          .map(ReflectionUtils::toDirectory)
-          .filter(Objects::nonNull)
-          .flatMap(directory -> findClasses(directory, packageName).stream());
+          .flatMap(resource -> classNamesIn(resource, packageName));
     } catch (IOException e) {
       log.error("Unable to fetch Resources in package {}", packageName, e);
       return Stream.empty();
     }
   }
 
+  /**
+   * Class names under a single classpath entry for the package, whether that 
entry is an exploded
+   * directory or a jar.
+   *
+   * <p>A jar entry cannot go through {@link #toDirectory}: a {@code jar:} URL 
is non-hierarchical,
+   * so {@code new File(uri)} throws and the entry would be dropped. Every 
bundle {@code Main} class
+   * runs from inside a shaded jar, so that path has to be read through the 
jar connection instead.
+   *
+   * @param resource    a classpath entry holding the package
+   * @param packageName the package being scanned
+   * @return class names found under that entry, empty if it cannot be read
+   */
+  private static Stream<String> classNamesIn(URL resource, String packageName) 
{
+    if ("jar".equals(resource.getProtocol())) {
+      return classNamesInJar(resource, packageName);
+    }
+    File directory = toDirectory(resource);
+    return directory == null ? Stream.empty() : findClasses(directory, 
packageName).stream();
+  }
+
+  /**
+   * Class names under the package inside a jar, read through the jar 
connection.
+   *
+   * @param resource    a {@code jar:} classpath entry holding the package
+   * @param packageName the package being scanned
+   * @return class names found in that jar, empty if the jar cannot be read
+   */
+  private static Stream<String> classNamesInJar(URL resource, String 
packageName) {
+    String entryPrefix = packageName.replace('.', '/') + '/';
+    try {
+      JarURLConnection connection = (JarURLConnection) 
resource.openConnection();

Review Comment:
   **nit:** A `jar:` URL served by a non-JDK handler makes this cast throw 
`ClassCastException`, which the `IOException` catch below does not cover, so an 
entry master logged and skipped now throws. Only reachable with a custom 
`URLStreamHandlerFactory`, so feel free to ignore: could this be `URLConnection 
conn = resource.openConnection()` plus an `instanceof JarURLConnection` check 
that falls through to log-and-skip?



##########
hudi-io/src/main/java/org/apache/hudi/common/util/ReflectionUtils.java:
##########
@@ -44,6 +48,7 @@
 public class ReflectionUtils {
 
   private static final Map<String, Class<?>> CLAZZ_CACHE = new 
ConcurrentHashMap<>();
+  private static final String CLASS_FILE_SUFFIX = ".class";

Review Comment:
   **nit:** This constant is only used by the jar branch; `findClasses` 
(:234-235) still carries the `".class"` literal and the magic `6`. Feel free to 
ignore, but could both branches read alike?
   
   ```java
   } else if (file.getName().endsWith(CLASS_FILE_SUFFIX)) {
     classes.add(packageName + '.' + file.getName().substring(0, 
file.getName().length() - CLASS_FILE_SUFFIX.length()));
   }
   ```



##########
hudi-io/src/main/java/org/apache/hudi/common/util/ReflectionUtils.java:
##########
@@ -130,19 +135,71 @@ public static Object loadClass(String clazz, Object... 
constructorArgs) {
    */
   public static Stream<String> getTopLevelClassesInClasspath(Class<?> clazz) {
     ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
-    String packageName = clazz.getPackage().getName();
+    // Arrays and primitives have no package, and Class#getPackage is also 
null when the class was
+    // loaded by a loader that defines no package for it.
+    Package pkg = clazz.getPackage();
+    if (pkg == null) {
+      return Stream.empty();
+    }
+    String packageName = pkg.getName();
     String path = packageName.replace('.', '/');
     try {
       return Collections.list(classLoader.getResources(path)).stream()
-          .map(ReflectionUtils::toDirectory)
-          .filter(Objects::nonNull)
-          .flatMap(directory -> findClasses(directory, packageName).stream());
+          .flatMap(resource -> classNamesIn(resource, packageName));
     } catch (IOException e) {
       log.error("Unable to fetch Resources in package {}", packageName, e);
       return Stream.empty();
     }
   }
 
+  /**
+   * Class names under a single classpath entry for the package, whether that 
entry is an exploded
+   * directory or a jar.
+   *
+   * <p>A jar entry cannot go through {@link #toDirectory}: a {@code jar:} URL 
is non-hierarchical,
+   * so {@code new File(uri)} throws and the entry would be dropped. Every 
bundle {@code Main} class
+   * runs from inside a shaded jar, so that path has to be read through the 
jar connection instead.
+   *
+   * @param resource    a classpath entry holding the package
+   * @param packageName the package being scanned
+   * @return class names found under that entry, empty if it cannot be read
+   */
+  private static Stream<String> classNamesIn(URL resource, String packageName) 
{
+    if ("jar".equals(resource.getProtocol())) {
+      return classNamesInJar(resource, packageName);
+    }
+    File directory = toDirectory(resource);

Review Comment:
   **minor:** `findClasses` still has one throw path: `File#listFiles()` 
returns null for an unreadable package directory or for a package path that is 
a regular file, and `Objects.requireNonNull(files)` at :231 lets that NPE 
escape `getTopLevelClassesInClasspath`. Pre-existing, but it is the same class 
of throw this PR removes elsewhere. Not blocking: could `findClasses` return an 
empty list when `listFiles()` is null, with a "package path is a regular file" 
fixture as the test (deterministic, no chmod needed)?



##########
hudi-common/src/test/java/org/apache/hudi/common/util/TestReflectionUtils.java:
##########
@@ -101,4 +113,96 @@ public Enumeration<URL> getResources(String name) throws 
IOException {
       Thread.currentThread().setContextClassLoader(original);
     }
   }
+
+  /**
+   * An exploded directory on the classpath, reached over the "file" protocol. 
Built explicitly
+   * rather than relying on the test classpath, because under Maven the 
modules are jars and this
+   * branch would never be entered.

Review Comment:
   **nit:** The stated reason is not quite right: surefire puts 
`hudi-common/target/classes` on the test classpath as a directory (that is what 
makes the pre-existing `SkipsInvalidResources` test pass on master), so a 
real-classpath scan does enter this branch. The fixture still earns its keep 
because it pins an exact set. Feel free to ignore:
   
   ```suggestion
      * An exploded directory on the classpath, reached over the "file" 
protocol. Built explicitly
      * rather than scanning the test classpath, so the expected set is exact 
and independent of
      * whatever else surefire puts on the classpath.
   ```



##########
hudi-common/src/test/java/org/apache/hudi/common/util/TestReflectionUtils.java:
##########
@@ -101,4 +113,96 @@ public Enumeration<URL> getResources(String name) throws 
IOException {
       Thread.currentThread().setContextClassLoader(original);
     }
   }
+
+  /**
+   * An exploded directory on the classpath, reached over the "file" protocol. 
Built explicitly
+   * rather than relying on the test classpath, because under Maven the 
modules are jars and this
+   * branch would never be entered.
+   */
+  @Test

Review Comment:
   **nit:** These three tests need nothing from hudi-common, but the class 
under test lives in hudi-io, and 
`hudi-io/src/test/java/org/apache/hudi/common/util/` already holds 
`TestStringUtils` and `TestValidationUtils`. Feel free to ignore since #19784 
set the precedent here: would it be worth putting the three new tests next to 
those, so hudi-io's own suite covers the branch being fixed?



##########
hudi-common/src/test/java/org/apache/hudi/common/util/TestReflectionUtils.java:
##########
@@ -101,4 +113,96 @@ public Enumeration<URL> getResources(String name) throws 
IOException {
       Thread.currentThread().setContextClassLoader(original);
     }
   }
+
+  /**
+   * An exploded directory on the classpath, reached over the "file" protocol. 
Built explicitly
+   * rather than relying on the test classpath, because under Maven the 
modules are jars and this
+   * branch would never be entered.
+   */
+  @Test
+  void testGetTopLevelClassesInClasspathFromDirectory(@TempDir Path tempDir) 
throws Exception {
+    String scanned = ReflectionUtils.class.getPackage().getName();
+    Path root = tempDir.resolve("classes");
+    Path pkgDir = root.resolve(scanned.replace('.', File.separatorChar));
+    Files.createDirectories(pkgDir.resolve("nested"));
+    Files.write(pkgDir.resolve("Alpha.class"), new byte[] {1});
+    Files.write(pkgDir.resolve("Beta.class"), new byte[] {1});
+    Files.write(pkgDir.resolve("nested").resolve("Gamma.class"), new byte[] 
{1});
+    Files.write(pkgDir.resolve("notaclass.txt"), new byte[] {1});
+
+    List<String> classes = withContextClassLoaderOver(root, () ->
+        
getTopLevelClassesInClasspath(ReflectionUtils.class).collect(Collectors.toList()));
+
+    assertEquals(
+        Arrays.asList(scanned + ".Alpha", scanned + ".Beta", scanned + 
".nested.Gamma"),
+        classes.stream().sorted().collect(Collectors.toList()),
+        "a directory entry must yield the classes of the package and its 
subpackages, and nothing else");
+  }
+
+  /**
+   * Classes packaged in a jar are reached over the "jar" protocol, whose URLs 
are not hierarchical
+   * and so cannot be turned into a {@link File}. Every caller of this method 
is a bundle
+   * Main class, which is exactly the packaged case.
+   * <p>
+   * The jar is built under the scanned class's own package, and the loader is 
given no parent, so
+   * the only resource found for that package is the one written here.
+   */
+  @Test
+  void testGetTopLevelClassesInClasspathFromJar(@TempDir Path tempDir) throws 
Exception {
+    String scanned = ReflectionUtils.class.getPackage().getName();
+    String dir = scanned.replace('.', '/') + "/";
+    Path jar = tempDir.resolve("classes.jar");
+    writeJar(jar,
+        dir,
+        dir + "Alpha.class",
+        dir + "Beta.class",
+        dir + "nested/",
+        dir + "nested/Gamma.class",
+        dir + "notaclass.txt",
+        "org/example/other/Delta.class");
+
+    List<String> classes = withContextClassLoaderOver(jar, () ->
+        
getTopLevelClassesInClasspath(ReflectionUtils.class).collect(Collectors.toList()));
+
+    assertEquals(
+        Arrays.asList(scanned + ".Alpha", scanned + ".Beta", scanned + 
".nested.Gamma"),
+        classes.stream().sorted().collect(Collectors.toList()),
+        "a jar entry must yield the classes of the package and its 
subpackages, and nothing else");
+  }
+
+  @Test
+  void testGetTopLevelClassesInClasspathForClassesWithoutAPackage() {
+    // Arrays have no package, which used to dereference null.
+    assertEquals(0, getTopLevelClassesInClasspath(String[].class).count());
+    assertEquals(0, getTopLevelClassesInClasspath(int[].class).count());
+  }
+
+  /** Writes a jar holding the given entry names; names ending in "/" become 
directory entries. */
+  private static void writeJar(Path jar, String... entryNames) throws 
IOException {
+    try (JarOutputStream out = new 
JarOutputStream(Files.newOutputStream(jar))) {
+      for (String entryName : entryNames) {
+        out.putNextEntry(new JarEntry(entryName));
+        if (!entryName.endsWith("/")) {
+          // Content is irrelevant: the scan reads entry names, never the 
bytecode.
+          out.write(new byte[] {1, 2, 3});
+        }
+        out.closeEntry();
+      }
+    }
+  }
+
+  /**
+   * Runs the supplier with the thread context class loader reading only from 
the given classpath
+   * root, which may be a jar or an exploded directory. The loader is given no 
parent so the scan
+   * sees nothing else.
+   */
+  private static <T> T withContextClassLoaderOver(Path root, Supplier<T> 
supplier) throws IOException {
+    ClassLoader original = Thread.currentThread().getContextClassLoader();

Review Comment:
   **nit:** This is the third copy of the TCCL save/restore in the file (:84 
and :102 open-code the same `original` / `finally` pair). Feel free to ignore: 
could it be split into `withContextClassLoader(ClassLoader, Supplier<T>)` plus 
this `Path` overload, so the two pre-existing tests can use it too?



##########
hudi-common/src/test/java/org/apache/hudi/common/util/TestReflectionUtils.java:
##########
@@ -101,4 +113,96 @@ public Enumeration<URL> getResources(String name) throws 
IOException {
       Thread.currentThread().setContextClassLoader(original);
     }
   }
+
+  /**
+   * An exploded directory on the classpath, reached over the "file" protocol. 
Built explicitly
+   * rather than relying on the test classpath, because under Maven the 
modules are jars and this
+   * branch would never be entered.
+   */
+  @Test
+  void testGetTopLevelClassesInClasspathFromDirectory(@TempDir Path tempDir) 
throws Exception {
+    String scanned = ReflectionUtils.class.getPackage().getName();
+    Path root = tempDir.resolve("classes");
+    Path pkgDir = root.resolve(scanned.replace('.', File.separatorChar));
+    Files.createDirectories(pkgDir.resolve("nested"));
+    Files.write(pkgDir.resolve("Alpha.class"), new byte[] {1});
+    Files.write(pkgDir.resolve("Beta.class"), new byte[] {1});
+    Files.write(pkgDir.resolve("nested").resolve("Gamma.class"), new byte[] 
{1});
+    Files.write(pkgDir.resolve("notaclass.txt"), new byte[] {1});
+
+    List<String> classes = withContextClassLoaderOver(root, () ->
+        
getTopLevelClassesInClasspath(ReflectionUtils.class).collect(Collectors.toList()));
+
+    assertEquals(
+        Arrays.asList(scanned + ".Alpha", scanned + ".Beta", scanned + 
".nested.Gamma"),
+        classes.stream().sorted().collect(Collectors.toList()),
+        "a directory entry must yield the classes of the package and its 
subpackages, and nothing else");
+  }
+
+  /**
+   * Classes packaged in a jar are reached over the "jar" protocol, whose URLs 
are not hierarchical
+   * and so cannot be turned into a {@link File}. Every caller of this method 
is a bundle
+   * Main class, which is exactly the packaged case.
+   * <p>
+   * The jar is built under the scanned class's own package, and the loader is 
given no parent, so
+   * the only resource found for that package is the one written here.
+   */
+  @Test
+  void testGetTopLevelClassesInClasspathFromJar(@TempDir Path tempDir) throws 
Exception {
+    String scanned = ReflectionUtils.class.getPackage().getName();
+    String dir = scanned.replace('.', '/') + "/";
+    Path jar = tempDir.resolve("classes.jar");
+    writeJar(jar,
+        dir,
+        dir + "Alpha.class",
+        dir + "Beta.class",
+        dir + "nested/",
+        dir + "nested/Gamma.class",
+        dir + "notaclass.txt",
+        "org/example/other/Delta.class");
+
+    List<String> classes = withContextClassLoaderOver(jar, () ->
+        
getTopLevelClassesInClasspath(ReflectionUtils.class).collect(Collectors.toList()));
+
+    assertEquals(
+        Arrays.asList(scanned + ".Alpha", scanned + ".Beta", scanned + 
".nested.Gamma"),
+        classes.stream().sorted().collect(Collectors.toList()),
+        "a jar entry must yield the classes of the package and its 
subpackages, and nothing else");
+  }
+
+  @Test
+  void testGetTopLevelClassesInClasspathForClassesWithoutAPackage() {
+    // Arrays have no package, which used to dereference null.
+    assertEquals(0, getTopLevelClassesInClasspath(String[].class).count());
+    assertEquals(0, getTopLevelClassesInClasspath(int[].class).count());

Review Comment:
   **nit:** `String[]` and `int[]` are both arrays, so the second assertion 
hits the identical branch and the "primitives" half of the test name (and of 
the PR body) is untested. `int.class.getPackage()` and 
`void.class.getPackage()` are also null. Feel free to ignore:
   
   ```suggestion
       assertEquals(0, getTopLevelClassesInClasspath(int.class).count());
   ```



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