From 35fad5f08b734383afd07cfb35e03dc4e5440a9b Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 23 Jul 2026 19:35:29 +0300 Subject: [PATCH 1/4] refactor(engine-java): extract JavaLoader.applyGeneration() from rebuild() Behavior-preserving split so the generation-install path (diff vs current -> notify consumers unload/load -> swap client loader -> rebuild bean container -> record) is source-agnostic: it takes a pre-built FQN->LoadedClass map + the loader. rebuild() still owns compile + effective-bytecode + class-file write + JavaCompiledEvent and calls applyGeneration() for the install. Foundation for AOT-packaged `compiled` modules, where a second producer discovers already-compiled classes on the classpath and drives the identical install path (no runtime javac). No functional change; 66 engine-java tests green. Co-Authored-By: Claude Opus 4.8 --- .../engine/java/runtime/JavaLoader.java | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/JavaLoader.java b/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/JavaLoader.java index ad9da36bd8f..aa42a745537 100644 --- a/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/JavaLoader.java +++ b/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/JavaLoader.java @@ -160,6 +160,41 @@ public synchronized RebuildResult rebuild(List sources) { } } + // Install the new generation: diff vs the current one, notify consumers, swap the client + // loader, rebuild the client bean container, and record it. Extracted so a SECOND producer - + // classpath-discovered compiled modules (AOT packaging) - can drive the exact same install + // path with a pre-built generation; see applyGeneration. + Set removed = applyGeneration(nextGeneration, nextLoader); + + currentBytecode.clear(); + currentBytecode.putAll(effectiveBytecode); + + RebuildResult result = new RebuildResult(Collections.unmodifiableSet(succeeded), Collections.unmodifiableMap(failures), + Collections.unmodifiableSet(removed), Collections.unmodifiableMap(batch.diagnostics()), componentContainer.wiringErrors()); + + // Writes this cycle's fresh bytecode and deletes only source-removed FQNs. Carried-over + // (failed-to-recompile) classes keep their existing .class files untouched. + writeClassFiles(batch, result.unloadedFqns()); + eventPublisher.publishEvent(new JavaCompiledEvent(this, result.succeededFqns(), result.unloadedFqns())); + + return result; + } + + /** + * Install a freshly-built generation into the running state: diff it against the current + * generation, notify every {@link JavaClassConsumer} ({@code onClassUnloaded} for removed/replaced + * FQNs, then {@code onClassLoaded} for the new set), swap the client {@link ClassLoader}, and + * rebuild the client bean container so behaviour consumers wire over ready instances. + * + *

+ * Source-agnostic by design. The generation is just a {@code FQN -> LoadedClass} map plus + * the loader to install; it may originate from compiled registry sources (the {@link #rebuild} + * path) or, for AOT-packaged {@code compiled} modules, from classes discovered on the application + * classpath. Both drive the identical consumer/container dispatch here. + * + * @return the FQNs removed from the generation (present before, absent now) + */ + private Set applyGeneration(Map nextGeneration, ClientClassLoader nextLoader) { // Diff against the previous generation: notify consumers of removals first, then additions. Set previousFqns = new HashSet<>(currentGeneration.keySet()); Set nextFqns = new HashSet<>(nextGeneration.keySet()); @@ -195,18 +230,7 @@ public synchronized RebuildResult rebuild(List sources) { currentGeneration.clear(); currentGeneration.putAll(nextGeneration); - currentBytecode.clear(); - currentBytecode.putAll(effectiveBytecode); - - RebuildResult result = new RebuildResult(Collections.unmodifiableSet(succeeded), Collections.unmodifiableMap(failures), - Collections.unmodifiableSet(removed), Collections.unmodifiableMap(batch.diagnostics()), componentContainer.wiringErrors()); - - // Writes this cycle's fresh bytecode and deletes only source-removed FQNs. Carried-over - // (failed-to-recompile) classes keep their existing .class files untouched. - writeClassFiles(batch, result.unloadedFqns()); - eventPublisher.publishEvent(new JavaCompiledEvent(this, result.succeededFqns(), result.unloadedFqns())); - - return result; + return removed; } /** From 3e75746c436f87a81cef8f4574a59743129de2d3 Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 23 Jul 2026 22:12:12 +0300 Subject: [PATCH 2/4] =?UTF-8?q?feat(engine-java):=20JavaLoader.installComp?= =?UTF-8?q?iledModules()=20=E2=80=94=20register=20AOT=20compiled-module=20?= =?UTF-8?q?classes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second producer for the install path (AOT packaging): compiled-module classes already loaded by the application classloader are recorded as the `compiledGeneration` and surfaced to the same JavaClassConsumers via applyGeneration - NO runtime javac. The installed generation is now the union of the registry-compiled (rebuild) and classpath-compiled sub-generations, so a registry rebuild does not unload compiled modules and vice-versa. FQN clash between the two logs a warning (a mis-assembled image; the "exactly one provider" assembly check catches it earlier). Unit test: installCompiledModules registers a compiled class through the consumer path, and it survives a registry rebuild that drops all authored sources (not reported in unloadedFqns). engine- java 67/67 green. Co-Authored-By: Claude Opus 4.8 --- .../engine/java/runtime/JavaLoader.java | 67 +++++++++++++++++-- .../engine/java/runtime/JavaLoaderTest.java | 28 ++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/JavaLoader.java b/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/JavaLoader.java index aa42a745537..6b6ef01d9d5 100644 --- a/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/JavaLoader.java +++ b/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/JavaLoader.java @@ -65,9 +65,19 @@ public class JavaLoader { private final JavaCompiledOutputDirectory outputDirectory; private final ApplicationEventPublisher eventPublisher; - /** FQN → {@link LoadedClass} record for the currently-installed generation. */ + /** FQN → {@link LoadedClass} record for the currently-installed generation (the union below). */ private final Map currentGeneration = new HashMap<>(); + /** Registry-compiled sub-generation (produced by {@link #rebuild}). */ + private final Map registryGeneration = new HashMap<>(); + + /** + * Classpath-discovered sub-generation for AOT-packaged {@code compiled} modules (produced by + * {@link #installCompiledModules}). These classes are already loaded by the application classloader + * - never compiled at runtime. The installed generation is the union of the two. + */ + private final Map compiledGeneration = new HashMap<>(); + /** * Binary class name (top-level + nested) → bytecode for the currently-installed generation. * Retained so a class that fails to recompile this cycle can fall back to its last-good bytecode @@ -160,11 +170,12 @@ public synchronized RebuildResult rebuild(List sources) { } } - // Install the new generation: diff vs the current one, notify consumers, swap the client - // loader, rebuild the client bean container, and record it. Extracted so a SECOND producer - - // classpath-discovered compiled modules (AOT packaging) - can drive the exact same install - // path with a pre-built generation; see applyGeneration. - Set removed = applyGeneration(nextGeneration, nextLoader); + // Install the new generation as the union of the registry-compiled classes (this rebuild) and + // any classpath-discovered compiled modules. Extracted (applyGeneration) so both producers + // drive the identical consumer/container dispatch. + registryGeneration.clear(); + registryGeneration.putAll(nextGeneration); + Set removed = applyGeneration(mergedGeneration(), nextLoader); currentBytecode.clear(); currentBytecode.putAll(effectiveBytecode); @@ -233,6 +244,50 @@ private Set applyGeneration(Map nextGeneration, Cli return removed; } + /** + * Install the classes of AOT-packaged {@code compiled} modules discovered on the application + * classpath. Unlike {@link #rebuild}, these are not compiled at runtime - they are already + * loaded by the application classloader; this only records them as the compiled sub-generation and + * installs the union with the current registry-compiled generation through {@link #applyGeneration} + * (the same consumer/container dispatch). Idempotent: re-invoking replaces the compiled set. + * + * @param classes the compiled modules' top-level classes to surface to consumers + * @return the FQNs removed from the installed generation as a result (compiled classes dropped + * since the previous compiled install; never registry-source classes) + */ + public synchronized Set installCompiledModules(List classes) { + compiledGeneration.clear(); + for (LoadedClass info : classes) { + if (info != null) { + compiledGeneration.put(info.fqn(), info); + } + } + // No runtime-compiled client code yet → give the holder an empty ClientClassLoader whose parent + // (the platform / application classloader) already resolves the compiled-module classes. + ClientClassLoader loader = loaderHolder.current(); + if (loader == null) { + loader = new ClientClassLoader(JavaHandler.class.getClassLoader(), Map.of()); + } + return applyGeneration(mergedGeneration(), loader); + } + + /** + * The installed generation: the union of the registry-compiled and classpath-compiled + * sub-generations. On an FQN clash (a capability provided by both a registry source and a compiled + * module - a mis-assembled image) the registry source wins and the clash is logged; a clean + * "exactly one provider" assembly check is expected to catch this earlier. + */ + private Map mergedGeneration() { + Map union = new LinkedHashMap<>(compiledGeneration); + for (Map.Entry entry : registryGeneration.entrySet()) { + if (union.put(entry.getKey(), entry.getValue()) != null) { + LOGGER.warn("FQN [{}] is provided by BOTH a compiled module and a registry source; using the registry source", + entry.getKey()); + } + } + return union; + } + /** * Writes newly compiled {@code .class} files to the output directory and deletes files for FQNs * that were removed. Each file operation is guarded independently so a single failure does not diff --git a/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/runtime/JavaLoaderTest.java b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/runtime/JavaLoaderTest.java index 9aaafe799d1..2cf2f578b8b 100644 --- a/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/runtime/JavaLoaderTest.java +++ b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/runtime/JavaLoaderTest.java @@ -341,6 +341,34 @@ private Path classFile(String fqn) { return tempDir.resolve(fqn.replace('.', '/') + ".class"); } + @Test + void install_compiled_modules_registers_via_consumers_and_survives_a_registry_rebuild() { + // An AOT-packaged compiled-module class already loaded by the application classloader — no + // runtime compile. installCompiledModules must surface it through the same consumer path. + Class type = CompiledSample.class; + LoadedClass compiled = new LoadedClass("acme-employee", type.getName(), type, type.getClassLoader()); + + loader.installCompiledModules(List.of(compiled)); + + assertTrue(recording.loaded.stream() + .anyMatch(c -> c.fqn() + .equals(type.getName())), + "compiled-module class is registered through the consumer path"); + + // A registry rebuild that drops every authored source must NOT report the compiled module as + // removed — it survives via the union generation (registry + compiled). + JavaLoader.RebuildResult result = loader.rebuild(List.of()); + assertFalse(result.unloadedFqns() + .contains(type.getName()), + "compiled module survives a registry rebuild that removes all authored sources"); + } + + /** + * Stand-in for an AOT-packaged compiled-module class already present on the application classpath. + */ + public static class CompiledSample { + } + private static JavaLoader.ClientSource handlerSource(String fqn, String body) { int dot = fqn.lastIndexOf('.'); String pkg = dot >= 0 ? fqn.substring(0, dot) : ""; From 5c8b8ac8352ab4becbacb87539a8876d287dc9ce Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 23 Jul 2026 23:38:49 +0300 Subject: [PATCH 3/4] =?UTF-8?q?feat(engine-java):=20CompiledModuleClassPro?= =?UTF-8?q?vider=20=E2=80=94=20discover=20+=20register=20AOT=20compiled=20?= =?UTF-8?q?modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The producer that drives installCompiledModules: on ApplicationReadyEvent it scans the classpath for META-INF/dirigible//.compiled markers (a UTF-8 list of the module's top-level class binary names, # comments allowed), derives the project from the path, Class.forName's each through the application classloader, and installs them - no runtime javac. Reads markers straight from the classpath, so it does not depend on the ClasspathExpander laying the registry payload first; a later registry rebuild unions the compiled classes back in. Unit test + fixture (AotFixtureClass + a test .compiled marker): discover() finds the marker, derives project "aot-test-mod", and loads the listed class via the app classloader. engine-java 68/68 green. Co-Authored-By: Claude Opus 4.8 --- .../runtime/CompiledModuleClassProvider.java | 145 ++++++++++++++++++ .../engine/java/runtime/AotFixtureClass.java | 18 +++ .../CompiledModuleClassProviderTest.java | 46 ++++++ .../META-INF/dirigible/aot-test-mod/.compiled | 2 + 4 files changed, 211 insertions(+) create mode 100644 components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/CompiledModuleClassProvider.java create mode 100644 components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/runtime/AotFixtureClass.java create mode 100644 components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/runtime/CompiledModuleClassProviderTest.java create mode 100644 components/engine/engine-java/src/test/resources/META-INF/dirigible/aot-test-mod/.compiled diff --git a/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/CompiledModuleClassProvider.java b/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/CompiledModuleClassProvider.java new file mode 100644 index 00000000000..cb65de48401 --- /dev/null +++ b/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/CompiledModuleClassProvider.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.engine.java.runtime; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.dirigible.engine.java.spi.LoadedClass; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.stereotype.Component; + +/** + * Discovers AOT-packaged {@code compiled} modules on the application classpath and registers their + * already-compiled classes through {@link JavaLoader#installCompiledModules(List)} - without + * any runtime compilation. + * + *

+ * A compiled module jar carries a marker at {@code META-INF/dirigible//.compiled} - a + * UTF-8 text file listing the module's top-level class binary names (its controllers / repositories + * / delegates / listeners / …), one per line (blank lines and {@code #} comments ignored). The + * build emits it; it names exactly the classes the engine should surface to the + * {@code JavaClassConsumer}s. + * + *

+ * Discovery reads the markers directly from the classpath (so it does not depend on the + * {@code ClasspathExpander} having laid the module's registry payload down first) and loads each + * listed class through the application classloader - the same one that already holds the + * compiled module jar. It runs once, on {@link ApplicationReadyEvent}; a later registry rebuild + * unions its classes back in (see {@link JavaLoader}). + */ +@Component +public class CompiledModuleClassProvider { + + private static final Logger LOGGER = LoggerFactory.getLogger(CompiledModuleClassProvider.class); + + /** + * Marker location pattern: one path segment ({@code }) under {@code META-INF/dirigible}. + */ + private static final String MARKER_PATTERN = "classpath*:META-INF/dirigible/*/.compiled"; + + private static final String DIRIGIBLE_ROOT = "META-INF/dirigible/"; + + private final JavaLoader javaLoader; + + @Autowired + public CompiledModuleClassProvider(JavaLoader javaLoader) { + this.javaLoader = javaLoader; + } + + /** Discover and register the compiled modules once the application context is ready. */ + @EventListener(ApplicationReadyEvent.class) + public void registerCompiledModules() { + List classes = discover(); + if (classes.isEmpty()) { + return; + } + javaLoader.installCompiledModules(classes); + LOGGER.info("Registered [{}] class(es) from AOT compiled module(s) on the classpath", classes.size()); + } + + /** + * Scan the classpath for {@code .compiled} markers and load every listed class through the + * application classloader. Package-visible for testing. Never throws: an unreadable marker or an + * unloadable class is logged and skipped so one bad module cannot block the rest. + */ + List discover() { + List result = new ArrayList<>(); + ClassLoader classLoader = getClass().getClassLoader(); + Resource[] markers; + try { + markers = new PathMatchingResourcePatternResolver(classLoader).getResources(MARKER_PATTERN); + } catch (IOException e) { + LOGGER.error("Failed to scan the classpath for AOT compiled-module markers", e); + return result; + } + for (Resource marker : markers) { + String project = projectOf(marker); + for (String fqn : readClassNames(marker)) { + try { + Class type = Class.forName(fqn, true, classLoader); + result.add(new LoadedClass(project, fqn, type, classLoader)); + } catch (ClassNotFoundException | LinkageError e) { + LOGGER.error("Compiled-module class [{}] (project [{}]) could not be loaded: {}", fqn, project, e.getMessage(), e); + } + } + } + return result; + } + + /** + * The {@code } path segment immediately under {@code META-INF/dirigible/} in the marker + * URL. + */ + private static String projectOf(Resource marker) { + try { + String url = marker.getURL() + .toString(); + int at = url.indexOf(DIRIGIBLE_ROOT); + if (at < 0) { + return ""; + } + String rest = url.substring(at + DIRIGIBLE_ROOT.length()); + int slash = rest.indexOf('/'); + return slash < 0 ? rest : rest.substring(0, slash); + } catch (IOException e) { + LOGGER.error("Failed to resolve the project of compiled-module marker [{}]: {}", marker, e.getMessage(), e); + return ""; + } + } + + /** Non-blank, non-comment lines of a marker, trimmed. */ + private static List readClassNames(Resource marker) { + List names = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(marker.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { + names.add(trimmed); + } + } + } catch (IOException e) { + LOGGER.error("Failed to read compiled-module marker [{}]: {}", marker, e.getMessage(), e); + } + return names; + } + +} diff --git a/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/runtime/AotFixtureClass.java b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/runtime/AotFixtureClass.java new file mode 100644 index 00000000000..fbaf5688636 --- /dev/null +++ b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/runtime/AotFixtureClass.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.engine.java.runtime; + +/** + * A top-level class on the test classpath standing in for an AOT compiled-module class. Listed by + * the {@code META-INF/dirigible/aot-test-mod/.compiled} marker so + * {@link CompiledModuleClassProvider} discovers and loads it. + */ +public class AotFixtureClass { +} diff --git a/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/runtime/CompiledModuleClassProviderTest.java b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/runtime/CompiledModuleClassProviderTest.java new file mode 100644 index 00000000000..73ea86a70e8 --- /dev/null +++ b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/runtime/CompiledModuleClassProviderTest.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.engine.java.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.util.List; + +import org.eclipse.dirigible.engine.java.spi.LoadedClass; +import org.junit.jupiter.api.Test; + +/** + * Verifies {@link CompiledModuleClassProvider} discovers a + * {@code META-INF/dirigible//.compiled} marker on the classpath, derives the project from + * the path, and loads the listed class through the application classloader - no runtime + * compilation. + */ +class CompiledModuleClassProviderTest { + + @Test + void discovers_marker_and_loads_the_listed_class() { + // javaLoader is unused by discover(); the classpath carries the test fixture marker at + // src/test/resources/META-INF/dirigible/aot-test-mod/.compiled. + CompiledModuleClassProvider provider = new CompiledModuleClassProvider(null); + + List discovered = provider.discover(); + + LoadedClass fixture = discovered.stream() + .filter(c -> c.fqn() + .equals(AotFixtureClass.class.getName())) + .findFirst() + .orElse(null); + assertNotNull(fixture, "the class listed in the .compiled marker must be discovered"); + assertEquals("aot-test-mod", fixture.project(), "project is the path segment under META-INF/dirigible"); + assertSame(AotFixtureClass.class, fixture.type(), "the class is loaded via the application classloader (no compile)"); + } +} diff --git a/components/engine/engine-java/src/test/resources/META-INF/dirigible/aot-test-mod/.compiled b/components/engine/engine-java/src/test/resources/META-INF/dirigible/aot-test-mod/.compiled new file mode 100644 index 00000000000..4e0d137c35f --- /dev/null +++ b/components/engine/engine-java/src/test/resources/META-INF/dirigible/aot-test-mod/.compiled @@ -0,0 +1,2 @@ +# AOT compiled-module marker (test fixture): top-level class binary names, one per line. +org.eclipse.dirigible.engine.java.runtime.AotFixtureClass From 36c82b978039ad950dd6d7e80a762d76f5dafa11 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 24 Jul 2026 00:24:28 +0300 Subject: [PATCH 4/4] test(engine-java): end-to-end compiled-module controller registration (no javac) Integration test closing the loop for AOT classpath loading: a @Controller shipped by a compiled module - installed via JavaLoader.installCompiledModules rather than compiled from registry source - is instantiated by the ComponentContainer, registered by the standard ControllerClassConsumer, and its GET route resolves through ControllerRouter. Proves the outermost engine-reachable layer (served route) without any runtime javac. engine-java 69/69 green. Co-Authored-By: Claude Opus 4.8 --- ...piledModuleControllerRegistrationTest.java | 86 +++++++++++++++++++ .../controller/CompiledSampleController.java | 27 ++++++ 2 files changed, 113 insertions(+) create mode 100644 components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/controller/CompiledModuleControllerRegistrationTest.java create mode 100644 components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/controller/CompiledSampleController.java diff --git a/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/controller/CompiledModuleControllerRegistrationTest.java b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/controller/CompiledModuleControllerRegistrationTest.java new file mode 100644 index 00000000000..4e743738222 --- /dev/null +++ b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/controller/CompiledModuleControllerRegistrationTest.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.engine.java.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import org.eclipse.dirigible.engine.java.component.ComponentContainer; +import org.eclipse.dirigible.engine.java.runtime.ClientBeansHolder; +import org.eclipse.dirigible.engine.java.runtime.ClientClassLoaderHolder; +import org.eclipse.dirigible.engine.java.runtime.JavaCompiledOutputDirectory; +import org.eclipse.dirigible.engine.java.runtime.JavaLoader; +import org.eclipse.dirigible.engine.java.runtime.JavaSourceCompiler; +import org.eclipse.dirigible.engine.java.spi.LoadedClass; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpMethod; + +/** + * End-to-end (within engine-java): a controller shipped by an AOT-packaged {@code compiled} module + * - installed through {@code JavaLoader.installCompiledModules} rather than compiled from registry + * sources - registers a real route in the {@link ControllerRouter} via the standard + * {@link ControllerClassConsumer}. No runtime {@code javac} is involved. + */ +class CompiledModuleControllerRegistrationTest { + + private static final ApplicationEventPublisher NOOP_PUBLISHER = new ApplicationEventPublisher() { + @Override + public void publishEvent(ApplicationEvent event) { + // intentionally empty + } + + @Override + public void publishEvent(Object event) { + // intentionally empty + } + }; + + @TempDir + Path tempDir; + + @Test + void compiled_module_controller_registers_a_route_end_to_end() { + ControllerRouter router = new ControllerRouter(); + ComponentContainer container = new ComponentContainer(new ClientBeansHolder()); + ControllerClassConsumer controllerConsumer = new ControllerClassConsumer(router, Optional.empty(), container); + + JavaCompiledOutputDirectory outputDirectory = mock(JavaCompiledOutputDirectory.class); + when(outputDirectory.get()).thenReturn(tempDir); + JavaLoader loader = new JavaLoader(new JavaSourceCompiler(), new ClientClassLoaderHolder(), container, List.of(controllerConsumer), + outputDirectory, NOOP_PUBLISHER); + + // A compiled-module controller already on the classpath (no registry .java, no javac). + Class type = CompiledSampleController.class; + String project = "acme-mod"; + loader.installCompiledModules(List.of(new LoadedClass(project, type.getName(), type, type.getClassLoader()))); + + // The route is resolvable through the router - base path is /, suffix /ping. + String base = "/" + type.getName() + .replace('.', '/'); + RouteMatch match = router.match(HttpMethod.GET, project, base + "/ping") + .orElseThrow(() -> new AssertionError("compiled-module controller route not registered")); + assertEquals(type.getName(), match.entry() + .fqn()); + assertEquals(HttpMethod.GET, match.route() + .httpMethod()); + assertTrue(router.match(HttpMethod.POST, project, base + "/ping") + .isEmpty(), + "only the declared GET route matches"); + } +} diff --git a/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/controller/CompiledSampleController.java b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/controller/CompiledSampleController.java new file mode 100644 index 00000000000..0ede5d7659a --- /dev/null +++ b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/controller/CompiledSampleController.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.engine.java.controller; + +import org.eclipse.dirigible.sdk.http.Controller; +import org.eclipse.dirigible.sdk.http.Get; + +/** + * Stand-in for a controller shipped by an AOT-packaged {@code compiled} module (already on the + * classpath). Registered end-to-end via {@code JavaLoader.installCompiledModules} in + * {@link CompiledModuleControllerRegistrationTest}. + */ +@Controller +public class CompiledSampleController { + + @Get("/ping") + public String ping() { + return "pong"; + } +}