diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6e1625f9e3c9..8133b71a9f14e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -233,6 +233,9 @@ If you have not done so on this machine, you need to: * macOS: Use the `Disk Utility.app` to check. It also allows you to create a case-sensitive volume to store your code projects. See this [blog entry](https://karnsonline.com/case-sensitive-apfs/) for more. * Windows: [Enable case sensitive file names per directory](https://learn.microsoft.com/en-us/windows/wsl/case-sensitivity) * Install Git and configure your GitHub access + * Windows: + * enable longpaths: `git config --global core.longpaths true` + * avoid CRLF breaks: `git config --global core.autocrlf false` * Install Java SDK 17+ (OpenJDK recommended) * Install [GraalVM](https://quarkus.io/guides/building-native-image) * Install platform C developer tools: diff --git a/build-parent/pom.xml b/build-parent/pom.xml index 371a946a9aea2..053deff0e84c4 100644 --- a/build-parent/pom.xml +++ b/build-parent/pom.xml @@ -101,7 +101,7 @@ 7.0.1 - 3.26.0 + 3.26.3 3.8.0 7.3.0 diff --git a/core/deployment/src/main/java/io/quarkus/deployment/builditem/BytecodeTransformerBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/builditem/BytecodeTransformerBuildItem.java index ec968f494b8b0..ea79ca227b8bf 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/builditem/BytecodeTransformerBuildItem.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/builditem/BytecodeTransformerBuildItem.java @@ -8,6 +8,15 @@ import io.quarkus.builder.item.MultiBuildItem; +/** + * Transform a class using ASM {@link ClassVisitor}. Note that the transformation is performed after assembling the + * index and thus the changes won't be visible to any processor steps relying on the index. + *

+ * You may consider using {@code io.quarkus.arc.deployment.AnnotationsTransformerBuildItem} if your transformation + * should be visible for Arc. See also + * I Need To + * Transform Annotation Metadata section of Quarkus CDI integration guide. + */ public final class BytecodeTransformerBuildItem extends MultiBuildItem { final String classToTransform; diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/DevModeMain.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/DevModeMain.java index 02bfdcd3405e5..6377b9c3b908a 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/DevModeMain.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/DevModeMain.java @@ -40,7 +40,7 @@ public class DevModeMain implements Closeable { private final DevModeContext context; - private static volatile CuratedApplication curatedApplication; + private volatile CuratedApplication curatedApplication; private Closeable realCloseable; public DevModeMain(DevModeContext context) { diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/IsolatedDevModeMain.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/IsolatedDevModeMain.java index b68f3b9bbc676..7fb810f807708 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/IsolatedDevModeMain.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/IsolatedDevModeMain.java @@ -15,6 +15,7 @@ import java.util.ServiceLoader; import java.util.Set; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Consumer; @@ -61,16 +62,16 @@ public class IsolatedDevModeMain implements BiConsumer hotReplacementSetups = new ArrayList<>(); - private static volatile RunningQuarkusApplication runner; - static volatile Throwable deploymentProblem; - private static volatile CuratedApplication curatedApplication; - private static volatile AugmentAction augmentAction; - private static volatile boolean restarting; - private static volatile boolean firstStartCompleted; - private static final CountDownLatch shutdownLatch = new CountDownLatch(1); + private volatile RunningQuarkusApplication runner; + final AtomicReference deploymentProblem = new AtomicReference<>(); + private volatile CuratedApplication curatedApplication; + private volatile AugmentAction augmentAction; + private volatile boolean restarting; + private volatile boolean firstStartCompleted; + private final CountDownLatch shutdownLatch = new CountDownLatch(1); private Thread shutdownThread; private CodeGenWatcher codeGenWatcher; - private static volatile ConsoleStateManager.ConsoleContext consoleContext; + private volatile ConsoleStateManager.ConsoleContext consoleContext; private final List listeners = new ArrayList<>(); private synchronized void firstStart() { @@ -85,30 +86,7 @@ private synchronized void firstStart() { //TODO: look at implementing a common core classloader, that removes the need for this sort of crappy hack curatedApplication.getOrCreateBaseRuntimeClassLoader().loadClass(ApplicationLifecycleManager.class.getName()) .getMethod("setDefaultExitCodeHandler", Consumer.class) - .invoke(null, new Consumer() { - @Override - public void accept(Integer integer) { - if (restarting || ApplicationLifecycleManager.isVmShuttingDown() - || context.isAbortOnFailedStart() || - context.isTest()) { - return; - } - if (consoleContext == null) { - consoleContext = ConsoleStateManager.INSTANCE - .createContext("Completed Application"); - } - //this sucks, but when we get here logging is gone - //so we just setup basic console logging - InitialConfigurator.DELAYED_HANDLER.addHandler(new ConsoleHandler( - ConsoleHandler.Target.SYSTEM_OUT, - new ColorPatternFormatter("%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{3.}] (%t) %s%e%n"))); - consoleContext.reset(new ConsoleCommand(' ', "Restarts the application", "to restart", 0, null, - () -> { - consoleContext.reset(); - RuntimeUpdatesProcessor.INSTANCE.doScan(true, true); - })); - } - }); + .invoke(null, getExitCodeHandler()); StartupAction start = augmentAction.createInitialRuntimeApplication(); @@ -127,7 +105,7 @@ public void accept(Integer integer) { rootCause = rootCause.getCause(); } if (!(rootCause instanceof BindException)) { - deploymentProblem = t; + deploymentProblem.set(t); if (!context.isAbortOnFailedStart()) { //we need to set this here, while we still have the correct TCCL //this is so the config is still valid, and we can read HTTP config from application.properties @@ -174,6 +152,35 @@ public void accept(Integer integer) { } } + private Consumer getExitCodeHandler() { + if (context.isTest() || context.isAbortOnFailedStart()) { + return TestExitCodeHandler.INSTANCE; + } + + return new Consumer() { + @Override + public void accept(Integer integer) { + if (restarting || ApplicationLifecycleManager.isVmShuttingDown()) { + return; + } + if (consoleContext == null) { + consoleContext = ConsoleStateManager.INSTANCE + .createContext("Completed Application"); + } + //this sucks, but when we get here logging is gone + //so we just setup basic console logging + InitialConfigurator.DELAYED_HANDLER.addHandler(new ConsoleHandler( + ConsoleHandler.Target.SYSTEM_OUT, + new ColorPatternFormatter("%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{3.}] (%t) %s%e%n"))); + consoleContext.reset(new ConsoleCommand(' ', "Restarts the application", "to restart", 0, null, + () -> { + consoleContext.reset(); + RuntimeUpdatesProcessor.INSTANCE.doScan(true, true); + })); + } + }; + } + public void restartCallback(Set changedResources, ClassScanResult result) { restartApp(changedResources, new ClassChangeInformation(result.changedClassNames, result.deletedClassNames, result.addedClassNames)); @@ -186,7 +193,7 @@ public synchronized void restartApp(Set changedResources, ClassChangeInf } stop(); Timing.restart(curatedApplication.getOrCreateAugmentClassLoader()); - deploymentProblem = null; + deploymentProblem.set(null); ClassLoader old = Thread.currentThread().getContextClassLoader(); try { @@ -200,7 +207,7 @@ public synchronized void restartApp(Set changedResources, ClassChangeInf firstStartCompleted = true; } } catch (Throwable t) { - deploymentProblem = t; + deploymentProblem.set(t); Throwable rootCause = t; while (rootCause.getCause() != null) { rootCause = rootCause.getCause(); @@ -253,7 +260,7 @@ private RuntimeUpdatesProcessor setupRuntimeCompilation(DevModeContext context, public byte[] apply(String s, byte[] bytes) { return ClassTransformingBuildStep.transform(s, bytes); } - }, testSupport); + }, testSupport, deploymentProblem); for (HotReplacementSetup service : ServiceLoader.load(HotReplacementSetup.class, curatedApplication.getOrCreateBaseRuntimeClassLoader())) { @@ -350,6 +357,7 @@ public void close() { curatedApplication.close(); curatedApplication = null; augmentAction = null; + deploymentProblem.set(null); } finally { if (shutdownThread != null) { try { @@ -422,10 +430,11 @@ public void run() { firstStart(); // doStart(false, Collections.emptySet()); - if (deploymentProblem != null || RuntimeUpdatesProcessor.INSTANCE.getCompileProblem() != null) { + if (deploymentProblem.get() != null || RuntimeUpdatesProcessor.INSTANCE.getCompileProblem() != null) { if (context.isAbortOnFailedStart()) { - Throwable throwable = deploymentProblem == null ? RuntimeUpdatesProcessor.INSTANCE.getCompileProblem() - : deploymentProblem; + Throwable throwable = deploymentProblem.get() == null + ? RuntimeUpdatesProcessor.INSTANCE.getCompileProblem() + : deploymentProblem.get(); throw (throwable instanceof RuntimeException ? (RuntimeException) throwable : new RuntimeException(throwable)); @@ -483,4 +492,14 @@ public boolean test(String s) { }).produces(ApplicationClassPredicateBuildItem.class).build(); } } + + private static class TestExitCodeHandler implements Consumer { + + private static final TestExitCodeHandler INSTANCE = new TestExitCodeHandler(); + + @Override + public void accept(Integer exitCode) { + // do nothing + } + } } diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/IsolatedRemoteDevModeMain.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/IsolatedRemoteDevModeMain.java index a1e691f0748ed..17d4b5e547fb8 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/IsolatedRemoteDevModeMain.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/IsolatedRemoteDevModeMain.java @@ -21,6 +21,7 @@ import java.util.Optional; import java.util.ServiceLoader; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Function; @@ -57,7 +58,7 @@ public class IsolatedRemoteDevModeMain implements BiConsumer hotReplacementSetups = new ArrayList<>(); - static volatile Throwable deploymentProblem; + private AtomicReference deploymentProblem = new AtomicReference<>(); static volatile RemoteDevClient remoteDevClient; static volatile Closeable remoteDevClientSession; private static volatile CuratedApplication curatedApplication; @@ -99,7 +100,7 @@ private synchronized JarResult generateApplication() { curatedApplication.getApplicationModel(), null); return start.getJar(); } catch (Throwable t) { - deploymentProblem = t; + deploymentProblem.set(t); log.error("Failed to generate Quarkus application", t); return null; } @@ -137,7 +138,7 @@ public void accept(DevModeContext.ModuleInfo moduleInfo, String s) { public byte[] apply(String s, byte[] bytes) { return ClassTransformingBuildStep.transform(s, bytes); } - }, null); + }, null, deploymentProblem); for (HotReplacementSetup service : ServiceLoader.load(HotReplacementSetup.class, curatedApplication.getOrCreateBaseRuntimeClassLoader())) { @@ -189,6 +190,7 @@ public void close() { } } } finally { + deploymentProblem.set(null); curatedApplication.close(); } @@ -248,7 +250,7 @@ public void run() { } private Closeable doConnect() { - return remoteDevClient.sendConnectRequest(new RemoteDevState(currentHashes, deploymentProblem), + return remoteDevClient.sendConnectRequest(new RemoteDevState(currentHashes, deploymentProblem.get()), new Function, Map>() { @Override public Map apply(Set fileNames) { @@ -283,6 +285,7 @@ private RemoteDevClient.SyncResult runSync() { Set removed = new HashSet<>(); Map changed = new HashMap<>(); try { + deploymentProblem.set(null); boolean scanResult = RuntimeUpdatesProcessor.INSTANCE.doScan(true); if (!scanResult && !copiedStaticResources.isEmpty()) { scanResult = true; @@ -305,7 +308,7 @@ private RemoteDevClient.SyncResult runSync() { currentHashes = newHashes; } } catch (IOException e) { - deploymentProblem = e; + deploymentProblem.set(e); } return new RemoteDevClient.SyncResult() { @Override diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/IsolatedTestModeMain.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/IsolatedTestModeMain.java index 1f5b37aa311f0..9fa94f1ff6aec 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/IsolatedTestModeMain.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/IsolatedTestModeMain.java @@ -36,7 +36,6 @@ public class IsolatedTestModeMain extends IsolatedDevModeMain { private volatile DevModeContext context; private final List hotReplacementSetups = new ArrayList<>(); - static volatile Throwable deploymentProblem; private static volatile CuratedApplication curatedApplication; private static volatile AugmentAction augmentAction; @@ -68,7 +67,7 @@ public void accept(DevModeContext.ModuleInfo moduleInfo, String s) { public byte[] apply(String s, byte[] bytes) { return ClassTransformingBuildStep.transform(s, bytes); } - }, testSupport); + }, testSupport, deploymentProblem); for (HotReplacementSetup service : ServiceLoader.load(HotReplacementSetup.class, curatedApplication.getOrCreateBaseRuntimeClassLoader())) { diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java index f32d052a25aec..ca8f49634d88a 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java @@ -40,6 +40,7 @@ import java.util.concurrent.ConcurrentSkipListSet; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; @@ -91,6 +92,7 @@ public class RuntimeUpdatesProcessor implements HotReplacementContext, Closeable volatile Throwable compileProblem; volatile Throwable testCompileProblem; volatile Throwable hotReloadProblem; + private final AtomicReference deploymentProblem; private volatile Predicate disableInstrumentationForClassPredicate = new AlwaysFalsePredicate<>(); private volatile Predicate disableInstrumentationForIndexPredicate = new AlwaysFalsePredicate<>(); @@ -141,7 +143,7 @@ public RuntimeUpdatesProcessor(Path applicationRoot, DevModeContext context, Qua DevModeType devModeType, BiConsumer, ClassScanResult> restartCallback, BiConsumer copyResourceNotification, BiFunction classTransformers, - TestSupport testSupport) { + TestSupport testSupport, AtomicReference deploymentProblem) { this.applicationRoot = applicationRoot; this.context = context; this.compiler = compiler; @@ -180,6 +182,7 @@ public void testsDisabled() { } }); } + this.deploymentProblem = deploymentProblem; } public TestSupport getTestSupport() { @@ -392,7 +395,7 @@ public List getResourcesDir() { public Throwable getDeploymentProblem() { //we differentiate between these internally, however for the error reporting they are the same return compileProblem != null ? compileProblem - : IsolatedDevModeMain.deploymentProblem != null ? IsolatedDevModeMain.deploymentProblem + : deploymentProblem.get() != null ? deploymentProblem.get() : hotReloadProblem; } @@ -535,7 +538,7 @@ public boolean doScan(boolean userInitiated, boolean forceRestart) { //all broken we just assume the reason that they have refreshed is because they have fixed something //trying to watch all resource files is complex and this is likely a good enough solution for what is already an edge case boolean restartNeeded = !instrumentationChange && (changedClassResults.isChanged() - || (IsolatedDevModeMain.deploymentProblem != null && userInitiated) || fileRestartNeeded); + || (deploymentProblem.get() != null && userInitiated) || fileRestartNeeded); if (restartNeeded) { String changeString = changedFilesForRestart.stream().map(Path::getFileName).map(Object::toString) .collect(Collectors.joining(", ")); diff --git a/core/runtime/src/main/java/io/quarkus/runtime/ApplicationLifecycleManager.java b/core/runtime/src/main/java/io/quarkus/runtime/ApplicationLifecycleManager.java index fa2d8b32c4255..48c18f868b7b2 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/ApplicationLifecycleManager.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/ApplicationLifecycleManager.java @@ -225,6 +225,7 @@ public static void run(Application application, Class quarkus.datasource.username = hibernate quarkus.datasource.password = hibernate quarkus.datasource.jdbc.url = jdbc:postgresql://localhost:5432/hibernate_db -# drop and create the database at startup (use `update` to only update the schema) -quarkus.hibernate-orm.database.generation=drop-and-create +quarkus.hibernate-orm.database.generation=drop-and-create <2> ---- +<1> xref:datasource.adoc[Configure the datasource]. +<2> Drop and create the database at startup (use `update` to only update the schema). Note that these configuration properties are not the same ones as in your typical Hibernate ORM configuration file. They will often map to Hibernate ORM configuration properties but could have different names and don't necessarily map 1:1 to each other. @@ -227,9 +227,16 @@ quarkus.datasource.username = hibernate quarkus.datasource.password = hibernate quarkus.datasource.jdbc.url = jdbc:postgresql://localhost:26257/hibernate_db -quarkus.hibernate-orm.dialect=org.hibernate.dialect.CockroachDialect <1> +quarkus.hibernate-orm.dialect=Cockroach <1> ---- <1> Set the Hibernate ORM dialect. ++ +For built-in dialects, the expected value is one of the names +in the link:{hibernate-orm-dialect-docs-url}[official list of dialects], *without* the `Dialect` suffix, +for example `Cockroach` for `CockroachDialect`. ++ +For third-party dialects, the expected value is the fully-qualified class name, +for example `com.acme.hibernate.AcmeDbDialect`. [WARNING] ==== @@ -249,12 +256,29 @@ quarkus.datasource.username = hibernate quarkus.datasource.password = hibernate quarkus.datasource.jdbc.url = jdbc:postgresql://localhost:26257/hibernate_db -quarkus.hibernate-orm.dialect=org.hibernate.dialect.CockroachDialect <2> +quarkus.hibernate-orm.dialect=Cockroach <2> ---- <1> Set the database version. The Hibernate ORM dialect will target that version. Since we're targeting CockroachDB here, we're passing the CockroachDB version, not the PostgreSQL version. <2> Set the Hibernate ORM dialect. +[[hibernate-dialect-varying-database]] +== Varying database + +When enabling <>, +Hibernate ORM will use multiple datasources at runtime for the same persistence unit, +and by default Quarkus cannot tell which datasource is going to be used, +so it will not be able to detect a dialect to use in Hibernate ORM. + +For that reason, when enabling <>, +it is recommended to explicitly point the Hibernate ORM configuration to one datasource +among those that will be used at runtime, e.g. with `quarkus.hibernate-orm.datasource=base` +(`base` being the name of a datasource). + +When doing so, Quarkus will infer the database version and (if possible) dialect from that datasource. +For unsupported databases, you may still need to set the Hibernate ORM dialect explicitly, +as explained in <>. + [[hibernate-configuration-properties]] === Hibernate ORM configuration properties @@ -1120,32 +1144,37 @@ In general, it is not possible to use the Hibernate ORM database generation feat Therefore, you have to disable it, and you need to make sure that the tables are created per schema. The following setup will use the xref:flyway.adoc[Flyway] extension to achieve this goal. +[[schema-approach]] ==== SCHEMA approach The same data source will be used for all tenants and a schema has to be created for every tenant inside that data source. -CAUTION: Some databases like MariaDB/MySQL do not support database schemas. In these cases you have to use the DATABASE approach below. + +CAUTION: Some databases like MariaDB/MySQL do not support database schemas. In these cases you have to use the <>. [source,properties] ---- -# Disable generation -quarkus.hibernate-orm.database.generation=none +quarkus.hibernate-orm.database.generation=none <1> -# Enable SCHEMA approach and use default datasource -quarkus.hibernate-orm.multitenant=SCHEMA -# You could use a non-default datasource by using the following setting -# quarkus.hibernate-orm.datasource=other +quarkus.hibernate-orm.multitenant=SCHEMA <2> -# The default data source used for all tenant schemas -quarkus.datasource.db-kind=postgresql +quarkus.datasource.db-kind=postgresql <3> quarkus.datasource.username=quarkus_test quarkus.datasource.password=quarkus_test quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/quarkus_test -# Enable Flyway configuration to create schemas -quarkus.flyway.schemas=base,mycompany +quarkus.flyway.schemas=base,mycompany <4> quarkus.flyway.locations=classpath:schema quarkus.flyway.migrate-at-start=true ---- +<1> Disable schema generation, because it is not supported by Hibernate ORM for schema multi-tenancy. +We'll use Flyway instead, see further down. +<2> Enable schema multi-tenancy. ++ +We use the default datasource here, but could use a named datasource if we wanted to, +by following instructions <>. +<3> xref:datasource.adoc[Configure the datasource]. +<4> Configure xref:flyway.adoc[Flyway] for database initialization, +because schema generation by Hibernate ORM is not supported in this case. Here is an example of the Flyway SQL (`V1.0.0__create_fruits.sql`) to be created in the configured folder `src/main/resources/schema`. @@ -1174,44 +1203,62 @@ INSERT INTO mycompany.known_fruits(id, name) VALUES (2, 'Apricots'); INSERT INTO mycompany.known_fruits(id, name) VALUES (3, 'Blackberries'); ---- - - +[[database-approach]] ==== DATABASE approach For every tenant you need to create a named data source with the same identifier that is returned by the `TenantResolver`. +[CAUTION] +==== +// Related to https://github.com/quarkusio/quarkus/issues/11861 +With this approach, all datasources used by the same persistence unit +are assumed to point to a database of the same vendor (same `db-kind`) and version. + +Mismatches will not be detected, and may result in unpredictable behavior. +==== + [source,properties] ---- -# Disable generation -quarkus.hibernate-orm.database.generation=none +quarkus.hibernate-orm.database.generation=none <1> -# Enable DATABASE approach -quarkus.hibernate-orm.multitenant=DATABASE +quarkus.hibernate-orm.multitenant=DATABASE <2> +quarkus.hibernate-orm.datasource=base <3> # Default tenant 'base' -quarkus.datasource.base.db-kind=postgresql +quarkus.datasource.base.db-kind=postgresql <4> quarkus.datasource.base.username=quarkus_test quarkus.datasource.base.password=quarkus_test quarkus.datasource.base.jdbc.url=jdbc:postgresql://localhost:5432/quarkus_test +quarkus.flyway.base.locations=classpath:database/base <5> +quarkus.flyway.base.migrate-at-start=true # Tenant 'mycompany' -quarkus.datasource.mycompany.db-kind=postgresql +quarkus.datasource.mycompany.db-kind=postgresql <6> quarkus.datasource.mycompany.username=mycompany quarkus.datasource.mycompany.password=mycompany quarkus.datasource.mycompany.jdbc.url=jdbc:postgresql://localhost:5433/mycompany - -# Flyway configuration for the default datasource -quarkus.flyway.locations=classpath:database/default -quarkus.flyway.migrate-at-start=true - -# Flyway configuration for the mycompany datasource -quarkus.flyway.mycompany.locations=classpath:database/mycompany +quarkus.flyway.mycompany.locations=classpath:database/mycompany <7> quarkus.flyway.mycompany.migrate-at-start=true ---- +<1> Disable schema generation, because it is not supported by Hibernate ORM for database multi-tenancy. +We'll use Flyway instead, see further down. +<2> Enable database multi-tenancy. +<3> Select a datasource for the persistence unit. ++ +This is only to allow Quarkus to determine the Hibernate ORM dialect to use; +see <> for details. +<4> xref:datasource.adoc[Configure the datasource] for one tenant, `base`. +<5> Configure xref:flyway.adoc[Flyway] for database initialization for tenant `base`, +because schema generation by Hibernate ORM is not supported in this case. +<6> xref:datasource.adoc[Configure the datasource] for another tenant. ++ +There could be more tenants, but here we're stopping at two. +<7> Configure xref:flyway.adoc[Flyway] for database initialization for tenant `mycompany`, +because schema generation by Hibernate ORM is not supported in this case. Following are examples of the Flyway SQL files to be created in the configured folder `src/main/resources/database`. -Default schema (`src/main/resources/database/default/V1.0.0__create_fruits.sql`): +Schema for tenant `base` (`src/main/resources/database/base/V1.0.0__create_fruits.sql`): [source,sql] ---- @@ -1227,7 +1274,7 @@ INSERT INTO known_fruits(id, name) VALUES (2, 'Apple'); INSERT INTO known_fruits(id, name) VALUES (3, 'Banana'); ---- -Mycompany schema (`src/main/resources/database/mycompany/V1.0.0__create_fruits.sql`): +Schema for tenant `mycompany` (`src/main/resources/database/mycompany/V1.0.0__create_fruits.sql`): [source,sql] ---- @@ -1243,8 +1290,7 @@ INSERT INTO known_fruits(id, name) VALUES (2, 'Apricots'); INSERT INTO known_fruits(id, name) VALUES (3, 'Blackberries'); ---- - - +[[discriminator-approach]] ==== DISCRIMINATOR approach The default data source will be used for all tenants. All entities defining a field annotated with `@TenantId` will have that field populated automatically, and will get filtered automatically in queries. @@ -1252,16 +1298,15 @@ The default data source will be used for all tenants. All entities defining a fi [source,properties] ---- -# Enable DISCRIMINATOR approach -quarkus.hibernate-orm.multitenant=DISCRIMINATOR +quarkus.hibernate-orm.multitenant=DISCRIMINATOR <1> -# The default data source used for all tenant schemas -quarkus.datasource.db-kind=postgresql +quarkus.datasource.db-kind=postgresql <2> quarkus.datasource.username=quarkus_test quarkus.datasource.password=quarkus_test quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/quarkus_test ---- - +<1> Enable discriminator multi-tenancy. +<2> xref:datasource.adoc[Configure the datasource]. === Programmatically Resolving Tenants Connections diff --git a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfigPersistenceUnit.java b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfigPersistenceUnit.java index 94fe4d3b64c94..dd91316bffb0e 100644 --- a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfigPersistenceUnit.java +++ b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfigPersistenceUnit.java @@ -276,26 +276,28 @@ default boolean isAnyPropertySet() { interface HibernateOrmConfigPersistenceUnitDialect { /** - * Class name of the Hibernate ORM dialect. + * Name of the Hibernate ORM dialect. * - * The complete list of bundled dialects is available in the - * https://docs.jboss.org/hibernate/stable/orm/javadocs/org/hibernate/dialect/package-summary.html[Hibernate ORM - * JavaDoc]. - * - * Setting the dialect directly is only recommended as a last resort: - * most popular databases have a corresponding Quarkus extension, - * allowing Quarkus to select the dialect automatically, - * in which case you do not need to set the dialect at all, - * though you may want to set - * xref:datasource.adoc#quarkus-datasource_quarkus.datasource.db-version[`quarkus.datasource.db-version`] as - * high as possible + * For xref:datasource.adoc#extensions-and-database-drivers-reference[supported databases], + * this property does not need to be set explicitly: + * it is selected automatically based on the datasource, + * and configured using the xref:datasource.adoc#quarkus-datasource_quarkus.datasource.db-version[DB version set on the + * datasource] * to benefit from the best performance and latest features. * * If your database does not have a corresponding Quarkus extension, - * you will need to set the dialect directly. + * you *will* need to set this property explicitly. * In that case, keep in mind that the JDBC driver and Hibernate ORM dialect * may not work properly in GraalVM native executables. * + * For built-in dialects, the expected value is one of the names + * in the link:{hibernate-orm-dialect-docs-url}[official list of dialects], + * *without* the `Dialect` suffix, + * for example `Cockroach` for `CockroachDialect`. + * + * For third-party dialects, the expected value is the fully-qualified class name, + * for example `com.acme.hibernate.AcmeDbDialect`. + * * @asciidoclet */ @WithParentName diff --git a/extensions/hibernate-search-standalone-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/standalone/elasticsearch/runtime/HibernateSearchStandaloneBuildTimeConfig.java b/extensions/hibernate-search-standalone-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/standalone/elasticsearch/runtime/HibernateSearchStandaloneBuildTimeConfig.java index 8ef6845e7aa04..fc3de18ff9b91 100644 --- a/extensions/hibernate-search-standalone-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/standalone/elasticsearch/runtime/HibernateSearchStandaloneBuildTimeConfig.java +++ b/extensions/hibernate-search-standalone-elasticsearch/runtime/src/main/java/io/quarkus/hibernate/search/standalone/elasticsearch/runtime/HibernateSearchStandaloneBuildTimeConfig.java @@ -269,7 +269,7 @@ interface MappingConfig { * Associations between entities must be bi-directional: * specifying the inverse side of associations through `@AssociationInverseSide` *is required*, * unless reindexing is disabled for that association through `@IndexingDependency(reindexOnUpdate = ...)`. - * `tree`:: + * `document`:: * Entities indexed through Hibernate Search are the root of a document, * i.e. an indexed entity "owns" other entities it references through associations, * which *cannot* be updated independently of the indexed entity. diff --git a/extensions/quartz/deployment/src/test/java/io/quarkus/quartz/test/ScheduledMethodTimeZoneTest.java b/extensions/quartz/deployment/src/test/java/io/quarkus/quartz/test/timezone/ScheduledMethodTimeZoneTest.java similarity index 98% rename from extensions/quartz/deployment/src/test/java/io/quarkus/quartz/test/ScheduledMethodTimeZoneTest.java rename to extensions/quartz/deployment/src/test/java/io/quarkus/quartz/test/timezone/ScheduledMethodTimeZoneTest.java index 95d4d47724c53..38b42d218c2a6 100644 --- a/extensions/quartz/deployment/src/test/java/io/quarkus/quartz/test/ScheduledMethodTimeZoneTest.java +++ b/extensions/quartz/deployment/src/test/java/io/quarkus/quartz/test/timezone/ScheduledMethodTimeZoneTest.java @@ -1,4 +1,4 @@ -package io.quarkus.quartz.test; +package io.quarkus.quartz.test.timezone; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; diff --git a/extensions/quartz/deployment/src/test/java/io/quarkus/quartz/test/timezone/TriggerNextFireTimeZoneTest.java b/extensions/quartz/deployment/src/test/java/io/quarkus/quartz/test/timezone/TriggerNextFireTimeZoneTest.java new file mode 100644 index 0000000000000..4e588223254a3 --- /dev/null +++ b/extensions/quartz/deployment/src/test/java/io/quarkus/quartz/test/timezone/TriggerNextFireTimeZoneTest.java @@ -0,0 +1,74 @@ +package io.quarkus.quartz.test.timezone; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.scheduler.Scheduled; +import io.quarkus.scheduler.ScheduledExecution; +import io.quarkus.scheduler.Scheduler; +import io.quarkus.scheduler.Trigger; +import io.quarkus.test.QuarkusUnitTest; + +public class TriggerNextFireTimeZoneTest { + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(Jobs.class); + }); + + @Inject + Scheduler scheduler; + + @Test + public void testScheduledJobs() throws InterruptedException { + Trigger prague = scheduler.getScheduledJob("prague"); + Trigger boston = scheduler.getScheduledJob("boston"); + Trigger ulaanbaatar = scheduler.getScheduledJob("ulaanbaatar"); + assertNotNull(prague); + assertNotNull(boston); + assertNotNull(ulaanbaatar); + Instant pragueNext = prague.getNextFireTime(); + Instant bostonNext = boston.getNextFireTime(); + Instant ulaanbaatarNext = ulaanbaatar.getNextFireTime(); + assertTime(pragueNext.atZone(ZoneId.of("Europe/Prague"))); + assertTime(bostonNext.atZone(ZoneId.of("America/New_York"))); + assertTime(ulaanbaatarNext.atZone(ZoneId.of("Asia/Ulaanbaatar"))); + } + + private static void assertTime(ZonedDateTime time) { + assertEquals(20, time.getHour()); + assertEquals(30, time.getMinute()); + assertEquals(0, time.getSecond()); + } + + static class Jobs { + + @Scheduled(identity = "prague", cron = "0 30 20 * * ?", timeZone = "Europe/Prague") + void withPragueTimezone(ScheduledExecution execution) { + assertNotEquals(execution.getFireTime(), execution.getScheduledFireTime()); + assertTime(execution.getScheduledFireTime().atZone(ZoneId.of("Europe/Prague"))); + } + + @Scheduled(identity = "boston", cron = "0 30 20 * * ?", timeZone = "America/New_York") + void withBostonTimezone() { + } + + @Scheduled(identity = "ulaanbaatar", cron = "0 30 20 * * ?", timeZone = "Asia/Ulaanbaatar") + void withIstanbulTimezone(ScheduledExecution execution) { + assertTime(execution.getScheduledFireTime().atZone(ZoneId.of("Asia/Ulaanbaatar"))); + } + + } + +} diff --git a/extensions/quartz/deployment/src/test/java/io/quarkus/quartz/test/timezone/TriggerPrevFireTimeZoneTest.java b/extensions/quartz/deployment/src/test/java/io/quarkus/quartz/test/timezone/TriggerPrevFireTimeZoneTest.java new file mode 100644 index 0000000000000..4da6a548d7e43 --- /dev/null +++ b/extensions/quartz/deployment/src/test/java/io/quarkus/quartz/test/timezone/TriggerPrevFireTimeZoneTest.java @@ -0,0 +1,93 @@ +package io.quarkus.quartz.test.timezone; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.scheduler.Scheduled; +import io.quarkus.scheduler.Scheduler; +import io.quarkus.scheduler.Trigger; +import io.quarkus.test.QuarkusUnitTest; + +public class TriggerPrevFireTimeZoneTest { + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime prague = now.withZoneSameInstant(ZoneId.of("Europe/Prague")); + ZonedDateTime istanbul = now.withZoneSameInstant(ZoneId.of("Europe/Istanbul")); + // For example, the current date-time is 2024-07-09 10:08:00; + // the default time zone is Europe/London + // then the config should look like: + // simpleJobs1.cron=0/1 * 11 * * ? + // simpleJobs2.cron=0/1 * 12 * * ? + String properties = String.format( + "simpleJobs1.cron=0/1 * %s * * ?\n" + + "simpleJobs1.hour=%s\n" + + "simpleJobs2.cron=0/1 * %s * * ?\n" + + "simpleJobs2.hour=%s", + prague.getHour(), prague.getHour(), istanbul.getHour(), istanbul.getHour()); + root.addClasses(Jobs.class) + .addAsResource( + new StringAsset(properties), + "application.properties"); + }); + + @ConfigProperty(name = "simpleJobs1.hour") + int pragueHour; + + @ConfigProperty(name = "simpleJobs2.hour") + int istanbulHour; + + @Inject + Scheduler scheduler; + + @Test + public void testScheduledJobs() throws InterruptedException { + assertTrue(Jobs.PRAGUE_LATCH.await(5, TimeUnit.SECONDS)); + assertTrue(Jobs.ISTANBUL_LATCH.await(5, TimeUnit.SECONDS)); + Trigger prague = scheduler.getScheduledJob("prague"); + Trigger istanbul = scheduler.getScheduledJob("istanbul"); + assertNotNull(prague); + assertNotNull(istanbul); + Instant praguePrev = prague.getPreviousFireTime(); + Instant istanbulPrev = istanbul.getPreviousFireTime(); + assertNotNull(praguePrev); + assertNotNull(istanbulPrev); + assertEquals(praguePrev, istanbulPrev); + assertEquals(pragueHour, praguePrev.atZone(ZoneId.of("Europe/Prague")).getHour()); + assertEquals(istanbulHour, istanbulPrev.atZone(ZoneId.of("Europe/Istanbul")).getHour()); + } + + static class Jobs { + + static final CountDownLatch PRAGUE_LATCH = new CountDownLatch(1); + static final CountDownLatch ISTANBUL_LATCH = new CountDownLatch(1); + + @Scheduled(identity = "prague", cron = "{simpleJobs1.cron}", timeZone = "Europe/Prague") + void withPragueTimezone() { + PRAGUE_LATCH.countDown(); + } + + @Scheduled(identity = "istanbul", cron = "{simpleJobs2.cron}", timeZone = "Europe/Istanbul") + void withIstanbulTimezone() { + ISTANBUL_LATCH.countDown(); + } + + } + +} diff --git a/extensions/scheduler/api/src/main/java/io/quarkus/scheduler/ScheduledExecution.java b/extensions/scheduler/api/src/main/java/io/quarkus/scheduler/ScheduledExecution.java index 5f69240af667c..fda1ac26fec88 100644 --- a/extensions/scheduler/api/src/main/java/io/quarkus/scheduler/ScheduledExecution.java +++ b/extensions/scheduler/api/src/main/java/io/quarkus/scheduler/ScheduledExecution.java @@ -14,6 +14,9 @@ public interface ScheduledExecution { Trigger getTrigger(); /** + * The returned {@code Instant} is converted from the date-time in the default timezone. A timezone of a cron-based job + * is not taken into account. + *

* Unlike {@link Trigger#getPreviousFireTime()} this method always returns the same value. * * @return the time the associated trigger was fired @@ -21,6 +24,12 @@ public interface ScheduledExecution { Instant getFireTime(); /** + * If the trigger represents a cron-based job with a timezone, then the returned {@code Instant} takes the timezone into + * account. + *

+ * For example, if there is a job with cron expression {@code 0 30 20 ? * * *} with timezone {@code Europe/Berlin}, + * then the return value looks like {@code 2024-07-08T18:30:00Z}. And {@link Instant#atZone(java.time.ZoneId)} for + * {@code Europe/Berlin} would yield {@code 2024-07-08T20:30+02:00[Europe/Berlin]}. * * @return the time the action was scheduled for */ diff --git a/extensions/scheduler/api/src/main/java/io/quarkus/scheduler/Trigger.java b/extensions/scheduler/api/src/main/java/io/quarkus/scheduler/Trigger.java index c076e5712bc0e..0a5f94d48ffb3 100644 --- a/extensions/scheduler/api/src/main/java/io/quarkus/scheduler/Trigger.java +++ b/extensions/scheduler/api/src/main/java/io/quarkus/scheduler/Trigger.java @@ -21,12 +21,24 @@ public interface Trigger { String getId(); /** + * If the trigger represents a cron-based job with a timezone, then the returned {@code Instant} takes the timezone into + * account. + *

+ * For example, if there is a job with cron expression {@code 0 30 20 ? * * *} with timezone {@code Europe/Berlin}, then the + * return value looks like {@code 2024-07-08T18:30:00Z}. And {@link Instant#atZone(java.time.ZoneId)} for + * {@code Europe/Berlin} would yield {@code 2024-07-08T20:30+02:00[Europe/Berlin]}. * * @return the next time at which the trigger is scheduled to fire, or {@code null} if it will not fire again */ Instant getNextFireTime(); /** + * If the trigger represents a cron-based job with a timezone, then the returned {@code Instant} takes the timezone into + * account. + *

+ * For example, if there is a job with cron expression {@code 0 30 20 ? * * *} with timezone {@code Europe/Berlin}, then the + * return value looks like {@code 2024-07-08T18:30:00Z}. And {@link Instant#atZone(java.time.ZoneId)} for + * {@code Europe/Berlin} would yield {@code 2024-07-08T20:30+02:00[Europe/Berlin]}. * * @return the previous time at which the trigger fired, or {@code null} if it has not fired yet */ diff --git a/extensions/scheduler/deployment/src/test/java/io/quarkus/scheduler/test/ScheduledMethodTimeZoneTest.java b/extensions/scheduler/deployment/src/test/java/io/quarkus/scheduler/test/timezone/ScheduledMethodTimeZoneTest.java similarity index 97% rename from extensions/scheduler/deployment/src/test/java/io/quarkus/scheduler/test/ScheduledMethodTimeZoneTest.java rename to extensions/scheduler/deployment/src/test/java/io/quarkus/scheduler/test/timezone/ScheduledMethodTimeZoneTest.java index aba645216812a..9db547b46e7a0 100644 --- a/extensions/scheduler/deployment/src/test/java/io/quarkus/scheduler/test/ScheduledMethodTimeZoneTest.java +++ b/extensions/scheduler/deployment/src/test/java/io/quarkus/scheduler/test/timezone/ScheduledMethodTimeZoneTest.java @@ -1,4 +1,4 @@ -package io.quarkus.scheduler.test; +package io.quarkus.scheduler.test.timezone; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -41,7 +41,6 @@ public class ScheduledMethodTimeZoneTest { + "simpleJobs2.cron=0/1 * %s * * ?\n" + "simpleJobs2.timeZone=%s", now.getHour(), timeZone, job2Hour, timeZone); - // System.out.println(properties); jar.addClasses(Jobs.class) .addAsResource( new StringAsset(properties), diff --git a/extensions/scheduler/deployment/src/test/java/io/quarkus/scheduler/test/timezone/TriggerNextFireTimeZoneTest.java b/extensions/scheduler/deployment/src/test/java/io/quarkus/scheduler/test/timezone/TriggerNextFireTimeZoneTest.java new file mode 100644 index 0000000000000..d301a0e1cff5d --- /dev/null +++ b/extensions/scheduler/deployment/src/test/java/io/quarkus/scheduler/test/timezone/TriggerNextFireTimeZoneTest.java @@ -0,0 +1,74 @@ +package io.quarkus.scheduler.test.timezone; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.scheduler.Scheduled; +import io.quarkus.scheduler.ScheduledExecution; +import io.quarkus.scheduler.Scheduler; +import io.quarkus.scheduler.Trigger; +import io.quarkus.test.QuarkusUnitTest; + +public class TriggerNextFireTimeZoneTest { + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + root.addClasses(Jobs.class); + }); + + @Inject + Scheduler scheduler; + + @Test + public void testScheduledJobs() throws InterruptedException { + Trigger prague = scheduler.getScheduledJob("prague"); + Trigger boston = scheduler.getScheduledJob("boston"); + Trigger ulaanbaatar = scheduler.getScheduledJob("ulaanbaatar"); + assertNotNull(prague); + assertNotNull(boston); + assertNotNull(ulaanbaatar); + Instant pragueNext = prague.getNextFireTime(); + Instant bostonNext = boston.getNextFireTime(); + Instant ulaanbaatarNext = ulaanbaatar.getNextFireTime(); + assertTime(pragueNext.atZone(ZoneId.of("Europe/Prague"))); + assertTime(bostonNext.atZone(ZoneId.of("America/New_York"))); + assertTime(ulaanbaatarNext.atZone(ZoneId.of("Asia/Ulaanbaatar"))); + } + + private static void assertTime(ZonedDateTime time) { + assertEquals(20, time.getHour()); + assertEquals(30, time.getMinute()); + assertEquals(0, time.getSecond()); + } + + static class Jobs { + + @Scheduled(identity = "prague", cron = "0 30 20 * * ?", timeZone = "Europe/Prague") + void withPragueTimezone(ScheduledExecution execution) { + assertNotEquals(execution.getFireTime(), execution.getScheduledFireTime()); + assertTime(execution.getScheduledFireTime().atZone(ZoneId.of("Europe/Prague"))); + } + + @Scheduled(identity = "boston", cron = "0 30 20 * * ?", timeZone = "America/New_York") + void withBostonTimezone() { + } + + @Scheduled(identity = "ulaanbaatar", cron = "0 30 20 * * ?", timeZone = "Asia/Ulaanbaatar") + void withIstanbulTimezone(ScheduledExecution execution) { + assertTime(execution.getScheduledFireTime().atZone(ZoneId.of("Asia/Ulaanbaatar"))); + } + + } + +} diff --git a/extensions/scheduler/deployment/src/test/java/io/quarkus/scheduler/test/timezone/TriggerPrevFireTimeZoneTest.java b/extensions/scheduler/deployment/src/test/java/io/quarkus/scheduler/test/timezone/TriggerPrevFireTimeZoneTest.java new file mode 100644 index 0000000000000..ed1ef873b77b4 --- /dev/null +++ b/extensions/scheduler/deployment/src/test/java/io/quarkus/scheduler/test/timezone/TriggerPrevFireTimeZoneTest.java @@ -0,0 +1,93 @@ +package io.quarkus.scheduler.test.timezone; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.scheduler.Scheduled; +import io.quarkus.scheduler.Scheduler; +import io.quarkus.scheduler.Trigger; +import io.quarkus.test.QuarkusUnitTest; + +public class TriggerPrevFireTimeZoneTest { + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot(root -> { + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime prague = now.withZoneSameInstant(ZoneId.of("Europe/Prague")); + ZonedDateTime istanbul = now.withZoneSameInstant(ZoneId.of("Europe/Istanbul")); + // For example, the current date-time is 2024-07-09 10:08:00; + // the default time zone is Europe/London + // then the config should look like: + // simpleJobs1.cron=0/1 * 11 * * ? + // simpleJobs2.cron=0/1 * 12 * * ? + String properties = String.format( + "simpleJobs1.cron=0/1 * %s * * ?\n" + + "simpleJobs1.hour=%s\n" + + "simpleJobs2.cron=0/1 * %s * * ?\n" + + "simpleJobs2.hour=%s", + prague.getHour(), prague.getHour(), istanbul.getHour(), istanbul.getHour()); + root.addClasses(Jobs.class) + .addAsResource( + new StringAsset(properties), + "application.properties"); + }); + + @ConfigProperty(name = "simpleJobs1.hour") + int pragueHour; + + @ConfigProperty(name = "simpleJobs2.hour") + int istanbulHour; + + @Inject + Scheduler scheduler; + + @Test + public void testScheduledJobs() throws InterruptedException { + assertTrue(Jobs.PRAGUE_LATCH.await(5, TimeUnit.SECONDS)); + assertTrue(Jobs.ISTANBUL_LATCH.await(5, TimeUnit.SECONDS)); + Trigger prague = scheduler.getScheduledJob("prague"); + Trigger istanbul = scheduler.getScheduledJob("istanbul"); + assertNotNull(prague); + assertNotNull(istanbul); + Instant praguePrev = prague.getPreviousFireTime(); + Instant istanbulPrev = istanbul.getPreviousFireTime(); + assertNotNull(praguePrev); + assertNotNull(istanbulPrev); + assertEquals(praguePrev, istanbulPrev); + assertEquals(pragueHour, praguePrev.atZone(ZoneId.of("Europe/Prague")).getHour()); + assertEquals(istanbulHour, istanbulPrev.atZone(ZoneId.of("Europe/Istanbul")).getHour()); + } + + static class Jobs { + + static final CountDownLatch PRAGUE_LATCH = new CountDownLatch(1); + static final CountDownLatch ISTANBUL_LATCH = new CountDownLatch(1); + + @Scheduled(identity = "prague", cron = "{simpleJobs1.cron}", timeZone = "Europe/Prague") + void withPragueTimezone() { + PRAGUE_LATCH.countDown(); + } + + @Scheduled(identity = "istanbul", cron = "{simpleJobs2.cron}", timeZone = "Europe/Istanbul") + void withIstanbulTimezone() { + ISTANBUL_LATCH.countDown(); + } + + } + +} diff --git a/extensions/scheduler/runtime/src/main/java/io/quarkus/scheduler/runtime/SimpleScheduler.java b/extensions/scheduler/runtime/src/main/java/io/quarkus/scheduler/runtime/SimpleScheduler.java index 02e2b46e25b14..8a3d77797146e 100644 --- a/extensions/scheduler/runtime/src/main/java/io/quarkus/scheduler/runtime/SimpleScheduler.java +++ b/extensions/scheduler/runtime/src/main/java/io/quarkus/scheduler/runtime/SimpleScheduler.java @@ -589,28 +589,29 @@ static class CronTrigger extends SimpleTrigger { super(id, start, description); this.cron = cron; this.executionTime = ExecutionTime.forCron(cron); - this.lastFireTime = start; this.gracePeriod = gracePeriod; this.timeZone = timeZone; + // The last fire time stores the zoned time + this.lastFireTime = zoned(start); } @Override public Instant getNextFireTime() { - Optional nextFireTime = executionTime.nextExecution(lastFireTime); - return nextFireTime.isPresent() ? nextFireTime.get().toInstant() : null; + return executionTime.nextExecution(lastFireTime).map(ZonedDateTime::toInstant).orElse(null); } + @Override ZonedDateTime evaluate(ZonedDateTime now) { if (now.isBefore(start)) { return null; } - ZonedDateTime zonedNow = timeZone == null ? now : now.withZoneSameInstant(timeZone); - Optional lastExecution = executionTime.lastExecution(zonedNow); + now = zoned(now); + Optional lastExecution = executionTime.lastExecution(now); if (lastExecution.isPresent()) { ZonedDateTime lastTruncated = lastExecution.get().truncatedTo(ChronoUnit.SECONDS); - if (zonedNow.isAfter(lastTruncated) && lastFireTime.isBefore(lastTruncated)) { + if (now.isAfter(lastTruncated) && lastFireTime.isBefore(lastTruncated)) { LOG.tracef("%s fired, last=%s", this, lastTruncated); - lastFireTime = zonedNow; + lastFireTime = now; return lastTruncated; } } @@ -623,9 +624,9 @@ public boolean isOverdue() { if (now.isBefore(start)) { return false; } - ZonedDateTime zonedNow = timeZone == null ? now : now.withZoneSameInstant(timeZone); + now = zoned(now); Optional nextFireTime = executionTime.nextExecution(lastFireTime); - return nextFireTime.isEmpty() || nextFireTime.get().plus(gracePeriod).isBefore(zonedNow); + return nextFireTime.isEmpty() || nextFireTime.get().plus(gracePeriod).isBefore(now); } @Override @@ -634,6 +635,10 @@ public String toString() { + timeZone + "]"; } + private ZonedDateTime zoned(ZonedDateTime time) { + return timeZone == null ? time : time.withZoneSameInstant(timeZone); + } + } static class SimpleScheduledExecution implements ScheduledExecution { diff --git a/independent-projects/arc/pom.xml b/independent-projects/arc/pom.xml index dc4ae1b543f15..f305342687fef 100644 --- a/independent-projects/arc/pom.xml +++ b/independent-projects/arc/pom.xml @@ -50,7 +50,7 @@ 2.6.1 1.6.Final - 3.26.0 + 3.26.3 5.10.3 2.0.0 1.8.1 diff --git a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/util/IoUtils.java b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/util/IoUtils.java index 0c4006fc3dc74..18513222b7384 100644 --- a/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/util/IoUtils.java +++ b/independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/util/IoUtils.java @@ -1,21 +1,23 @@ package io.quarkus.bootstrap.util; -import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.io.StringWriter; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.FileAlreadyExistsException; import java.nio.file.FileVisitOption; import java.nio.file.FileVisitResult; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.SecureDirectoryStream; import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributeView; import java.nio.file.attribute.BasicFileAttributes; import java.util.EnumSet; import java.util.Objects; @@ -29,8 +31,6 @@ */ public class IoUtils { - private static final int DEFAULT_BUFFER_SIZE = 1024 * 4; - private static final Path TMP_DIR = Paths.get(PropertyUtils.getProperty("java.io.tmpdir")); private static final Logger log = Logger.getLogger(IoUtils.class); @@ -60,40 +60,36 @@ public static Path mkdirs(Path dir) { return dir; } + /** + * Recursively delete the file or directory given by {@code root}. + * The implementation will attempt to do so in a secure manner. + * Any problems encountered will be logged at {@code DEBUG} level. + * + * @param root the root path (must not be {@code null}) + */ public static void recursiveDelete(Path root) { - log.debugf("Recursively delete directory %s", root); + log.debugf("Recursively delete path %s", root); if (root == null || !Files.exists(root)) { return; } try { - Files.walkFileTree(root, new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) - throws IOException { - try { - Files.delete(file); - } catch (IOException ex) { - log.debugf(ex, "Unable to delete file " + file); - } - return FileVisitResult.CONTINUE; + if (Files.isDirectory(root)) { + try (DirectoryStream ds = Files.newDirectoryStream(root)) { + recursiveDelete(ds); } - - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException e) - throws IOException { - if (e == null) { - try { - Files.delete(dir); - } catch (IOException ex) { - log.debugf(ex, "Unable to delete directory " + dir); - } - return FileVisitResult.CONTINUE; - } else { - // directory iteration failed - throw e; - } + try { + Files.delete(root); + } catch (IOException e) { + log.debugf(e, "Unable to delete directory %s", root); } - }); + } else { + log.debugf("Delete file %s", root); + try { + Files.delete(root); + } catch (IOException e) { + log.debugf(e, "Unable to delete file %s", root); + } + } } catch (IOException e) { log.debugf(e, "Error recursively deleting directory"); } @@ -101,9 +97,10 @@ public FileVisitResult postVisitDirectory(Path dir, IOException e) /** * Creates a new empty directory or empties an existing one. + * Any problems encountered while emptying the directory will be logged at {@code DEBUG} level. * * @param dir directory - * @throws IOException in case of a failure + * @throws IOException if creating or accessing the directory itself fails */ public static void createOrEmptyDir(Path dir) throws IOException { log.debugf("Create or empty directory %s", dir); @@ -113,17 +110,51 @@ public static void createOrEmptyDir(Path dir) throws IOException { Files.createDirectories(dir); return; } - if (!Files.isDirectory(dir)) { - throw new IllegalArgumentException(dir + " is not a directory"); + // recursively delete the *contents* of the directory, if any (keep the directory itself) + try (DirectoryStream ds = Files.newDirectoryStream(dir)) { + recursiveDelete(ds); + } + } + + private static void recursiveDelete(DirectoryStream ds) { + if (ds instanceof SecureDirectoryStream sds) { + // best, fastest, and most likely path for most OSes + recursiveDeleteSecure(sds); + } else { + // this may not work well on e.g. NFS, so we avoid this path if possible + for (Path p : ds) { + recursiveDelete(p); + } } - log.debugf("Iterate over contents of %s to delete its contents", dir); - try (DirectoryStream stream = Files.newDirectoryStream(dir)) { - for (Path p : stream) { - if (Files.isDirectory(p)) { - recursiveDelete(p); - } else { - log.debugf("Delete file %s", p); - Files.delete(p); + } + + private static void recursiveDeleteSecure(SecureDirectoryStream sds) { + for (Path p : sds) { + Path file = p.getFileName(); + BasicFileAttributes attrs; + try { + attrs = sds.getFileAttributeView(file, BasicFileAttributeView.class, LinkOption.NOFOLLOW_LINKS) + .readAttributes(); + } catch (IOException e) { + log.debugf(e, "Unable to query file type of %s", p); + continue; + } + if (attrs.isDirectory()) { + try { + try (SecureDirectoryStream nested = sds.newDirectoryStream(file)) { + recursiveDeleteSecure(nested); + } + sds.deleteDirectory(file); + } catch (IOException e) { + log.debugf(e, "Unable to delete directory %s", p); + } + } else { + // log the whole path, not the file name + log.debugf("Delete file %s", p); + try { + sds.deleteFile(file); + } catch (IOException e) { + log.debugf(e, "Unable to delete file %s", p); } } } @@ -163,24 +194,43 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) return target; } + /** + * Read the contents of a file as a string. + * + * @param file the file to read (must not be {@code null}) + * @return the file content, as a string (not {@code null}) + * @throws IOException if an error occurs when reading the file + * @deprecated Use {@link Files#readString(Path, Charset)} instead. + */ + @Deprecated(forRemoval = true) public static String readFile(Path file) throws IOException { - final char[] charBuffer = new char[DEFAULT_BUFFER_SIZE]; - int n = 0; - final StringWriter output = new StringWriter(); - try (BufferedReader input = Files.newBufferedReader(file)) { - while ((n = input.read(charBuffer)) != -1) { - output.write(charBuffer, 0, n); - } - } - return output.getBuffer().toString(); + return Files.readString(file, StandardCharsets.UTF_8); } + /** + * Copy the input stream to the given output stream. + * Calling this method is identical to calling {@code in.transferTo(out)}. + * + * @param out the output stream (must not be {@code null}) + * @param in the input stream (must not be {@code null}) + * @throws IOException if an error occurs during the copy + * @see InputStream#transferTo(OutputStream) + */ public static void copy(OutputStream out, InputStream in) throws IOException { in.transferTo(out); } + /** + * Write a string to a file using UTF-8 encoding. + * The file will be created if it does not exist, and truncated if it is not empty. + * + * @param file the file to write (must not be {@code null}) + * @param content the string to write to the file (must not be {@code null}) + * @throws IOException if an error occurs when writing the file + */ public static void writeFile(Path file, String content) throws IOException { - Files.write(file, content.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE); + Files.writeString(file, content, StandardCharsets.UTF_8, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); } } diff --git a/independent-projects/bootstrap/pom.xml b/independent-projects/bootstrap/pom.xml index 9947110925a77..56cf070bcae3f 100644 --- a/independent-projects/bootstrap/pom.xml +++ b/independent-projects/bootstrap/pom.xml @@ -38,7 +38,7 @@ 1.37 - 3.26.0 + 3.26.3 0.9.5 3.6.0.Final 5.10.3 diff --git a/independent-projects/junit5-virtual-threads/pom.xml b/independent-projects/junit5-virtual-threads/pom.xml index d3c57b6cd6c79..875e37d03ee88 100644 --- a/independent-projects/junit5-virtual-threads/pom.xml +++ b/independent-projects/junit5-virtual-threads/pom.xml @@ -46,7 +46,7 @@ 1.11.0 5.10.3 - 3.26.0 + 3.26.3 diff --git a/independent-projects/qute/pom.xml b/independent-projects/qute/pom.xml index e5bc2a45674c3..dbb58c57a40ee 100644 --- a/independent-projects/qute/pom.xml +++ b/independent-projects/qute/pom.xml @@ -39,7 +39,7 @@ UTF-8 5.10.3 - 3.26.0 + 3.26.3 3.2.0 1.8.0 3.6.0.Final diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientSetResponseEntityRestHandler.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientSetResponseEntityRestHandler.java index cb811043af91b..8e0232603448e 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientSetResponseEntityRestHandler.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/handlers/ClientSetResponseEntityRestHandler.java @@ -24,7 +24,7 @@ public class ClientSetResponseEntityRestHandler implements ClientRestHandler { @Override public void handle(RestClientRequestContext context) throws Exception { - ClientRequestContextImpl requestContext = context.getClientRequestContext(); + ClientRequestContextImpl requestContext = context.getOrCreateClientRequestContext(); if (context.isCheckSuccessfulFamily()) { StatusType effectiveResponseStatus = determineEffectiveResponseStatus(context, requestContext); if (Response.Status.Family.familyOf(effectiveResponseStatus.getStatusCode()) != Response.Status.Family.SUCCESSFUL) { diff --git a/independent-projects/resteasy-reactive/pom.xml b/independent-projects/resteasy-reactive/pom.xml index 4f2d4c0744c21..9f47d79ec5775 100644 --- a/independent-projects/resteasy-reactive/pom.xml +++ b/independent-projects/resteasy-reactive/pom.xml @@ -49,7 +49,7 @@ 1.14.11 5.10.3 3.9.8 - 3.26.0 + 3.26.3 3.6.0.Final 3.0.6.Final 3.0.0 diff --git a/independent-projects/tools/pom.xml b/independent-projects/tools/pom.xml index 28abaed4649c6..6fd63c9de1735 100644 --- a/independent-projects/tools/pom.xml +++ b/independent-projects/tools/pom.xml @@ -48,7 +48,7 @@ 4.4.0 - 3.26.0 + 3.26.3 2.17.2 4.1.0 5.10.3 diff --git a/integration-tests/hibernate-orm-tenancy/connection-resolver-legacy-qualifiers/src/main/resources/application.properties b/integration-tests/hibernate-orm-tenancy/connection-resolver-legacy-qualifiers/src/main/resources/application.properties index 86da8eec68667..b943eb6cc10f0 100644 --- a/integration-tests/hibernate-orm-tenancy/connection-resolver-legacy-qualifiers/src/main/resources/application.properties +++ b/integration-tests/hibernate-orm-tenancy/connection-resolver-legacy-qualifiers/src/main/resources/application.properties @@ -3,7 +3,7 @@ quarkus.hibernate-orm.database.generation=none quarkus.hibernate-orm.multitenant=database # Necessary because we're creating datasources dynamically, # which means the extension can't rely on a static datasource to guess the dialect -quarkus.hibernate-orm.dialect=org.hibernate.dialect.MariaDBDialect +quarkus.hibernate-orm.dialect=MariaDB quarkus.hibernate-orm.packages=io.quarkus.it.hibernate.multitenancy.fruit # We create datasources manually, so a lack of configuration doesn't mean Quarkus should step in with defaults. @@ -12,7 +12,7 @@ quarkus.datasource.devservices.enabled=false # Inventory persistence unit quarkus.hibernate-orm."inventory".database.generation=none quarkus.hibernate-orm."inventory".multitenant=database -quarkus.hibernate-orm."inventory".dialect=org.hibernate.dialect.MariaDBDialect +quarkus.hibernate-orm."inventory".dialect=MariaDB quarkus.hibernate-orm."inventory".packages=io.quarkus.it.hibernate.multitenancy.inventory #mariadb.base_url is set through Maven config diff --git a/integration-tests/hibernate-orm-tenancy/connection-resolver/src/main/resources/application.properties b/integration-tests/hibernate-orm-tenancy/connection-resolver/src/main/resources/application.properties index 86da8eec68667..b943eb6cc10f0 100644 --- a/integration-tests/hibernate-orm-tenancy/connection-resolver/src/main/resources/application.properties +++ b/integration-tests/hibernate-orm-tenancy/connection-resolver/src/main/resources/application.properties @@ -3,7 +3,7 @@ quarkus.hibernate-orm.database.generation=none quarkus.hibernate-orm.multitenant=database # Necessary because we're creating datasources dynamically, # which means the extension can't rely on a static datasource to guess the dialect -quarkus.hibernate-orm.dialect=org.hibernate.dialect.MariaDBDialect +quarkus.hibernate-orm.dialect=MariaDB quarkus.hibernate-orm.packages=io.quarkus.it.hibernate.multitenancy.fruit # We create datasources manually, so a lack of configuration doesn't mean Quarkus should step in with defaults. @@ -12,7 +12,7 @@ quarkus.datasource.devservices.enabled=false # Inventory persistence unit quarkus.hibernate-orm."inventory".database.generation=none quarkus.hibernate-orm."inventory".multitenant=database -quarkus.hibernate-orm."inventory".dialect=org.hibernate.dialect.MariaDBDialect +quarkus.hibernate-orm."inventory".dialect=MariaDB quarkus.hibernate-orm."inventory".packages=io.quarkus.it.hibernate.multitenancy.inventory #mariadb.base_url is set through Maven config diff --git a/integration-tests/spring-data-jpa/src/test/resources/property-warnings-test.properties b/integration-tests/spring-data-jpa/src/test/resources/property-warnings-test.properties index e73c7a6fe1469..318540bf44426 100644 --- a/integration-tests/spring-data-jpa/src/test/resources/property-warnings-test.properties +++ b/integration-tests/spring-data-jpa/src/test/resources/property-warnings-test.properties @@ -1,5 +1,5 @@ spring.jpa.show-sql=true -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect +spring.jpa.properties.hibernate.dialect=MySQL5 spring.jpa.open-in-view=false spring.jpa.hibernate.naming.physical-strategy=org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy spring.jpa.hibernate.naming.implicit-strategy=org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy diff --git a/test-framework/common/src/main/java/io/quarkus/test/common/DefaultDockerContainerLauncher.java b/test-framework/common/src/main/java/io/quarkus/test/common/DefaultDockerContainerLauncher.java index 2d01ef727fd4a..533048d795934 100644 --- a/test-framework/common/src/main/java/io/quarkus/test/common/DefaultDockerContainerLauncher.java +++ b/test-framework/common/src/main/java/io/quarkus/test/common/DefaultDockerContainerLauncher.java @@ -118,7 +118,9 @@ public void start() throws IOException { args.add("-i"); // Interactive, write logs to stdout args.add("--rm"); - args.addAll(NativeImageBuildLocalContainerRunner.getVolumeAccessArguments(containerRuntime)); + if (!volumeMounts.isEmpty()) { + args.addAll(NativeImageBuildLocalContainerRunner.getVolumeAccessArguments(containerRuntime)); + } args.add("-p"); args.add(httpPort + ":" + httpPort); diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTest.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTest.java index 3d66a0e200756..3af12981b584e 100644 --- a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTest.java +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTest.java @@ -6,6 +6,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.Target; +import org.eclipse.microprofile.config.spi.Converter; import org.junit.jupiter.api.extension.ExtendWith; import io.quarkus.arc.processor.AnnotationsTransformer; @@ -72,4 +73,11 @@ */ Class[] annotationsTransformers() default {}; + /** + * The additional config converters. By default, the Quarkus-specific converters are registered. + * + * @see QuarkusComponentTestExtensionBuilder#addConverter(Converter) + */ + Class>[] configConverters() default {}; + } diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestConfiguration.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestConfiguration.java index 8279bbf99c84b..1d6f226a713db 100644 --- a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestConfiguration.java +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestConfiguration.java @@ -9,6 +9,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import jakarta.enterprise.event.Event; import jakarta.enterprise.inject.Instance; @@ -17,16 +18,43 @@ import jakarta.inject.Inject; import jakarta.inject.Provider; +import org.eclipse.microprofile.config.spi.Converter; import org.jboss.logging.Logger; import io.quarkus.arc.InjectableInstance; import io.quarkus.arc.processor.AnnotationsTransformer; +import io.quarkus.runtime.configuration.CharsetConverter; +import io.quarkus.runtime.configuration.CidrAddressConverter; +import io.quarkus.runtime.configuration.DurationConverter; +import io.quarkus.runtime.configuration.InetAddressConverter; +import io.quarkus.runtime.configuration.InetSocketAddressConverter; +import io.quarkus.runtime.configuration.LocaleConverter; +import io.quarkus.runtime.configuration.MemorySizeConverter; +import io.quarkus.runtime.configuration.PathConverter; +import io.quarkus.runtime.configuration.RegexConverter; +import io.quarkus.runtime.configuration.ZoneIdConverter; +import io.quarkus.runtime.logging.LevelConverter; import io.quarkus.test.InjectMock; +import io.smallrye.config.SmallRyeConfigBuilder; class QuarkusComponentTestConfiguration { + // As defined in /quarkus/core/runtime/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.Converter + static final List> DEFAULT_CONVERTERS = List.of(new InetSocketAddressConverter(), + new CharsetConverter(), + new CidrAddressConverter(), + new InetAddressConverter(), + new RegexConverter(), + new PathConverter(), + new DurationConverter(), + new MemorySizeConverter(), + new LocaleConverter(), + new ZoneIdConverter(), + new LevelConverter()); + static final QuarkusComponentTestConfiguration DEFAULT = new QuarkusComponentTestConfiguration(Map.of(), List.of(), - List.of(), false, true, QuarkusComponentTestExtensionBuilder.DEFAULT_CONFIG_SOURCE_ORDINAL, List.of()); + List.of(), false, true, QuarkusComponentTestExtensionBuilder.DEFAULT_CONFIG_SOURCE_ORDINAL, List.of(), + DEFAULT_CONVERTERS, null); private static final Logger LOG = Logger.getLogger(QuarkusComponentTestConfiguration.class); @@ -37,11 +65,14 @@ class QuarkusComponentTestConfiguration { final boolean addNestedClassesAsComponents; final int configSourceOrdinal; final List annotationsTransformers; + final List> configConverters; + final Consumer configBuilderCustomizer; QuarkusComponentTestConfiguration(Map configProperties, List> componentClasses, List> mockConfigurators, boolean useDefaultConfigProperties, boolean addNestedClassesAsComponents, int configSourceOrdinal, - List annotationsTransformers) { + List annotationsTransformers, List> configConverters, + Consumer configBuilderCustomizer) { this.configProperties = configProperties; this.componentClasses = componentClasses; this.mockConfigurators = mockConfigurators; @@ -49,6 +80,8 @@ class QuarkusComponentTestConfiguration { this.addNestedClassesAsComponents = addNestedClassesAsComponents; this.configSourceOrdinal = configSourceOrdinal; this.annotationsTransformers = annotationsTransformers; + this.configConverters = configConverters; + this.configBuilderCustomizer = configBuilderCustomizer; } QuarkusComponentTestConfiguration update(Class testClass) { @@ -58,6 +91,7 @@ QuarkusComponentTestConfiguration update(Class testClass) { boolean addNestedClassesAsComponents = this.addNestedClassesAsComponents; int configSourceOrdinal = this.configSourceOrdinal; List annotationsTransformers = new ArrayList<>(this.annotationsTransformers); + List> configConverters = new ArrayList<>(this.configConverters); QuarkusComponentTest testAnnotation = testClass.getAnnotation(QuarkusComponentTest.class); if (testAnnotation != null) { @@ -71,7 +105,17 @@ QuarkusComponentTestConfiguration update(Class testClass) { try { annotationsTransformers.add(transformerClass.getDeclaredConstructor().newInstance()); } catch (Exception e) { - LOG.errorf("Unable to instantiate %s", transformerClass); + LOG.errorf(e, "Unable to instantiate %s", transformerClass); + } + } + } + Class>[] converters = testAnnotation.configConverters(); + if (converters.length > 0) { + for (Class> converterClass : converters) { + try { + configConverters.add(converterClass.getDeclaredConstructor().newInstance()); + } catch (Exception e) { + LOG.errorf(e, "Unable to instantiate %s", converterClass); } } } @@ -120,7 +164,7 @@ QuarkusComponentTestConfiguration update(Class testClass) { return new QuarkusComponentTestConfiguration(Map.copyOf(configProperties), List.copyOf(componentClasses), this.mockConfigurators, useDefaultConfigProperties, addNestedClassesAsComponents, configSourceOrdinal, - List.copyOf(annotationsTransformers)); + List.copyOf(annotationsTransformers), List.copyOf(configConverters), configBuilderCustomizer); } QuarkusComponentTestConfiguration update(Method testMethod) { @@ -132,7 +176,7 @@ QuarkusComponentTestConfiguration update(Method testMethod) { } return new QuarkusComponentTestConfiguration(configProperties, componentClasses, mockConfigurators, useDefaultConfigProperties, addNestedClassesAsComponents, configSourceOrdinal, - annotationsTransformers); + annotationsTransformers, configConverters, configBuilderCustomizer); } private static boolean resolvesToBuiltinBean(Class rawType) { diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtension.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtension.java index 2656adaa4fcb1..5896ee4f04115 100644 --- a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtension.java +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtension.java @@ -57,6 +57,7 @@ import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.spi.ConfigProviderResolver; +import org.eclipse.microprofile.config.spi.Converter; import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationValue; import org.jboss.jandex.ClassInfo; @@ -78,6 +79,7 @@ import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Store; import org.junit.jupiter.api.extension.ParameterContext; import org.junit.jupiter.api.extension.ParameterResolutionException; import org.junit.jupiter.api.extension.ParameterResolver; @@ -203,7 +205,7 @@ public QuarkusComponentTestExtension() { public QuarkusComponentTestExtension(Class... additionalComponentClasses) { this(new QuarkusComponentTestConfiguration(Map.of(), List.of(additionalComponentClasses), List.of(), false, true, QuarkusComponentTestExtensionBuilder.DEFAULT_CONFIG_SOURCE_ORDINAL, - List.of())); + List.of(), List.of(), null)); } QuarkusComponentTestExtension(QuarkusComponentTestConfiguration baseConfiguration) { @@ -251,7 +253,7 @@ public void afterEach(ExtensionContext context) throws Exception { @Override public void postProcessTestInstance(Object testInstance, ExtensionContext context) throws Exception { long start = System.nanoTime(); - context.getRoot().getStore(NAMESPACE).put(KEY_TEST_INSTANCE, testInstance); + store(context).put(KEY_TEST_INSTANCE, testInstance); LOG.debugf("postProcessTestInstance: %s ms", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)); } @@ -321,7 +323,7 @@ && isTestMethod(parameterContext.getDeclaringExecutable()) public Object resolveParameter(ParameterContext parameterContext, ExtensionContext context) throws ParameterResolutionException { @SuppressWarnings("unchecked") - List> injectedParams = context.getRoot().getStore(NAMESPACE).get(KEY_INJECTED_PARAMS, List.class); + List> injectedParams = store(context).get(KEY_INJECTED_PARAMS, List.class); ArcContainer container = Arc.container(); BeanManager beanManager = container.beanManager(); java.lang.reflect.Type requiredType = parameterContext.getParameter().getParameterizedType(); @@ -338,7 +340,7 @@ public Object resolveParameter(ParameterContext parameterContext, ExtensionConte private void destroyDependentTestMethodParams(ExtensionContext context) { @SuppressWarnings("unchecked") - List> injectedParams = context.getRoot().getStore(NAMESPACE).get(KEY_INJECTED_PARAMS, List.class); + List> injectedParams = store(context).get(KEY_INJECTED_PARAMS, List.class); for (InstanceHandle handle : injectedParams) { if (handle.getBean() != null && handle.getBean().getScope().equals(Dependent.class)) { try { @@ -354,17 +356,17 @@ private void destroyDependentTestMethodParams(ExtensionContext context) { private void buildContainer(ExtensionContext context) { QuarkusComponentTestConfiguration testClassConfiguration = baseConfiguration .update(context.getRequiredTestClass()); - context.getRoot().getStore(NAMESPACE).put(KEY_TEST_CLASS_CONFIG, testClassConfiguration); + store(context).put(KEY_TEST_CLASS_CONFIG, testClassConfiguration); ClassLoader oldTccl = initArcContainer(context, testClassConfiguration); - context.getRoot().getStore(NAMESPACE).put(KEY_OLD_TCCL, oldTccl); + store(context).put(KEY_OLD_TCCL, oldTccl); } @SuppressWarnings("unchecked") private void cleanup(ExtensionContext context) { - ClassLoader oldTccl = context.getRoot().getStore(NAMESPACE).get(KEY_OLD_TCCL, ClassLoader.class); + ClassLoader oldTccl = store(context).get(KEY_OLD_TCCL, ClassLoader.class); Thread.currentThread().setContextClassLoader(oldTccl); - context.getRoot().getStore(NAMESPACE).remove(KEY_CONFIG_MAPPINGS); - Set generatedResources = context.getRoot().getStore(NAMESPACE).get(KEY_GENERATED_RESOURCES, Set.class); + store(context).remove(KEY_CONFIG_MAPPINGS); + Set generatedResources = store(context).get(KEY_GENERATED_RESOURCES, Set.class); for (Path path : generatedResources) { try { LOG.debugf("Delete generated %s", path); @@ -378,7 +380,7 @@ private void cleanup(ExtensionContext context) { @SuppressWarnings("unchecked") private void stopContainer(ExtensionContext context, Lifecycle testInstanceLifecycle) throws Exception { if (testInstanceLifecycle.equals(context.getTestInstanceLifecycle().orElse(Lifecycle.PER_METHOD))) { - for (FieldInjector fieldInjector : (List) context.getRoot().getStore(NAMESPACE) + for (FieldInjector fieldInjector : (List) store(context) .get(KEY_INJECTED_FIELDS, List.class)) { fieldInjector.unset(context.getRequiredTestInstance()); } @@ -391,10 +393,10 @@ private void stopContainer(ExtensionContext context, Lifecycle testInstanceLifec ConfigBeanCreator.clear(); InterceptorMethodCreator.clear(); - SmallRyeConfig config = context.getRoot().getStore(NAMESPACE).get(KEY_CONFIG, SmallRyeConfig.class); + SmallRyeConfig config = store(context).get(KEY_CONFIG, SmallRyeConfig.class); ConfigProviderResolver.instance().releaseConfig(config); ConfigProviderResolver - .setInstance(context.getRoot().getStore(NAMESPACE).get(KEY_OLD_CONFIG_PROVIDER_RESOLVER, + .setInstance(store(context).get(KEY_OLD_CONFIG_PROVIDER_RESOLVER, ConfigProviderResolver.class)); } } @@ -404,15 +406,15 @@ private void startContainer(ExtensionContext context, Lifecycle testInstanceLife // Init ArC Arc.initialize(); - QuarkusComponentTestConfiguration configuration = context.getRoot().getStore(NAMESPACE) - .get(KEY_TEST_CLASS_CONFIG, QuarkusComponentTestConfiguration.class); + QuarkusComponentTestConfiguration configuration = store(context).get(KEY_TEST_CLASS_CONFIG, + QuarkusComponentTestConfiguration.class); Optional testMethod = context.getTestMethod(); if (testMethod.isPresent()) { configuration = configuration.update(testMethod.get()); } ConfigProviderResolver oldConfigProviderResolver = ConfigProviderResolver.instance(); - context.getRoot().getStore(NAMESPACE).put(KEY_OLD_CONFIG_PROVIDER_RESOLVER, oldConfigProviderResolver); + store(context).put(KEY_OLD_CONFIG_PROVIDER_RESOLVER, oldConfigProviderResolver); SmallRyeConfigProviderResolver smallRyeConfigProviderResolver = new SmallRyeConfigProviderResolver(); ConfigProviderResolver.setInstance(smallRyeConfigProviderResolver); @@ -421,6 +423,7 @@ private void startContainer(ExtensionContext context, Lifecycle testInstanceLife ClassLoader tccl = Thread.currentThread().getContextClassLoader(); SmallRyeConfigBuilder configBuilder = new SmallRyeConfigBuilder().forClassLoader(tccl) .addDefaultInterceptors() + .withConverters(configuration.configConverters.toArray(new Converter[] {})) .addDefaultSources() .withSources(new ApplicationPropertiesConfigSourceLoader.InFileSystem()) .withSources(new ApplicationPropertiesConfigSourceLoader.InClassPath()) @@ -428,28 +431,33 @@ private void startContainer(ExtensionContext context, Lifecycle testInstanceLife new QuarkusComponentTestConfigSource(configuration.configProperties, configuration.configSourceOrdinal)); @SuppressWarnings("unchecked") - Set configMappings = context.getRoot().getStore(NAMESPACE).get(KEY_CONFIG_MAPPINGS, - Set.class); + Set configMappings = store(context).get(KEY_CONFIG_MAPPINGS, Set.class); if (configMappings != null) { // Register the mappings found during bean discovery for (ConfigClassWithPrefix mapping : configMappings) { configBuilder.withMapping(mapping.getKlass(), mapping.getPrefix()); } } + if (configuration.configBuilderCustomizer != null) { + configuration.configBuilderCustomizer.accept(configBuilder); + } SmallRyeConfig config = configBuilder.build(); smallRyeConfigProviderResolver.registerConfig(config, tccl); - context.getRoot().getStore(NAMESPACE).put(KEY_CONFIG, config); + store(context).put(KEY_CONFIG, config); ConfigBeanCreator.setClassLoader(tccl); // Inject fields declated on the test class Object testInstance = context.getRequiredTestInstance(); - context.getRoot().getStore(NAMESPACE).put(KEY_INJECTED_FIELDS, - injectFields(context.getRequiredTestClass(), testInstance)); + store(context).put(KEY_INJECTED_FIELDS, injectFields(context.getRequiredTestClass(), testInstance)); // Injected test method parameters - context.getRoot().getStore(NAMESPACE).put(KEY_INJECTED_PARAMS, new CopyOnWriteArrayList<>()); + store(context).put(KEY_INJECTED_PARAMS, new CopyOnWriteArrayList<>()); } } + private Store store(ExtensionContext context) { + return context.getRoot().getStore(NAMESPACE); + } + private BeanRegistrar registrarForMock(MockBeanConfiguratorImpl mock) { return new BeanRegistrar() { @@ -631,7 +639,7 @@ public void writeResource(Resource resource) throws IOException { }); } - extensionContext.getRoot().getStore(NAMESPACE).put(KEY_GENERATED_RESOURCES, generatedResources); + store(extensionContext).put(KEY_GENERATED_RESOURCES, generatedResources); builder.addAnnotationTransformation(AnnotationsTransformer.appliedToField().whenContainsAny(qualifiers) .whenContainsNone(DotName.createSimple(Inject.class)).thenTransform(t -> t.add(Inject.class))); @@ -781,7 +789,7 @@ public void register(RegistrationContext registrationContext) { .configClassWithPrefix(ConfigMappingBeanCreator.tryLoad(mapping), e.getKey())); } } - extensionContext.getRoot().getStore(NAMESPACE).put(KEY_CONFIG_MAPPINGS, configMappings); + store(extensionContext).put(KEY_CONFIG_MAPPINGS, configMappings); } LOG.debugf("Test injection points analyzed in %s ms [found: %s, mocked: %s]", @@ -848,7 +856,7 @@ public void accept(BytecodeTransformer transformer) { return oldTccl; } - private void processTestInterceptorMethods(Class testClass, ExtensionContext extensionContext, + private void processTestInterceptorMethods(Class testClass, ExtensionContext context, BeanRegistrar.RegistrationContext registrationContext, Set interceptorBindings) { List> annotations = List.of(AroundInvoke.class, PostConstruct.class, PreDestroy.class, AroundConstruct.class); @@ -871,7 +879,7 @@ private void processTestInterceptorMethods(Class testClass, ExtensionContext return ic -> { Object instance = null; if (!Modifier.isStatic(method.getModifiers())) { - Object testInstance = extensionContext.getRoot().getStore(NAMESPACE).get(KEY_TEST_INSTANCE); + Object testInstance = store(context).get(KEY_TEST_INSTANCE); if (testInstance == null) { throw new IllegalStateException("Test instance not available"); } diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtensionBuilder.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtensionBuilder.java index e0c267e507065..b1fdccb0d1aeb 100644 --- a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtensionBuilder.java +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtensionBuilder.java @@ -5,9 +5,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import java.util.function.Function; +import org.eclipse.microprofile.config.spi.Converter; + import io.quarkus.arc.processor.AnnotationsTransformer; +import io.smallrye.config.SmallRyeConfigBuilder; /** * Convenient builder for {@link QuarkusComponentTestExtension}. @@ -26,9 +30,11 @@ public class QuarkusComponentTestExtensionBuilder { private final List> componentClasses = new ArrayList<>(); private final List> mockConfigurators = new ArrayList<>(); private final List annotationsTransformers = new ArrayList<>(); + private final List> configConverters = new ArrayList<>(); private boolean useDefaultConfigProperties = false; private boolean addNestedClassesAsComponents = true; private int configSourceOrdinal = QuarkusComponentTestExtensionBuilder.DEFAULT_CONFIG_SOURCE_ORDINAL; + private Consumer configBuilderCustomizer; /** * The initial set of components under test is derived from the test class. The types of all fields annotated with @@ -105,6 +111,31 @@ public QuarkusComponentTestExtensionBuilder addAnnotationsTransformer(Annotation return this; } + /** + * Add an additional {@link Converter}. By default, the Quarkus-specific converters are registered. + * + * @param converter + * @return self + * @see #setConfigBuilderCustomizer(Consumer) + */ + public QuarkusComponentTestExtensionBuilder addConverter(Converter converter) { + configConverters.add(converter); + return this; + } + + /** + * Set the {@link SmallRyeConfigBuilder} customizer. + *

+ * The customizer can affect the configuration of a test method and should be used with caution. + * + * @param customizer + * @return self + */ + public QuarkusComponentTestExtensionBuilder setConfigBuilderCustomizer(Consumer customizer) { + this.configBuilderCustomizer = customizer; + return this; + } + /** * Configure a new mock of a bean. *

@@ -124,10 +155,18 @@ public MockBeanConfigurator mock(Class beanClass) { * @return a new extension instance */ public QuarkusComponentTestExtension build() { + List> converters; + if (configConverters.isEmpty()) { + converters = QuarkusComponentTestConfiguration.DEFAULT_CONVERTERS; + } else { + converters = new ArrayList<>(QuarkusComponentTestConfiguration.DEFAULT_CONVERTERS); + converters.addAll(configConverters); + converters = List.copyOf(converters); + } return new QuarkusComponentTestExtension(new QuarkusComponentTestConfiguration(Map.copyOf(configProperties), - List.copyOf(componentClasses), - List.copyOf(mockConfigurators), useDefaultConfigProperties, addNestedClassesAsComponents, configSourceOrdinal, - List.copyOf(annotationsTransformers))); + List.copyOf(componentClasses), List.copyOf(mockConfigurators), useDefaultConfigProperties, + addNestedClassesAsComponents, configSourceOrdinal, + List.copyOf(annotationsTransformers), converters, configBuilderCustomizer)); } void registerMockBean(MockBeanConfiguratorImpl mock) { diff --git a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/config/ConfigBuilderCustomizerTest.java b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/config/ConfigBuilderCustomizerTest.java new file mode 100644 index 0000000000000..6150ea7aff01a --- /dev/null +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/config/ConfigBuilderCustomizerTest.java @@ -0,0 +1,52 @@ +package io.quarkus.test.component.config; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.function.Consumer; + +import jakarta.inject.Singleton; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.config.spi.Converter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.component.QuarkusComponentTestExtension; +import io.quarkus.test.component.TestConfigProperty; +import io.smallrye.config.SmallRyeConfigBuilder; + +public class ConfigBuilderCustomizerTest { + + @RegisterExtension + static final QuarkusComponentTestExtension extension = QuarkusComponentTestExtension.builder() + .setConfigBuilderCustomizer(new Consumer() { + @Override + public void accept(SmallRyeConfigBuilder builder) { + builder.withConverter(Boolean.class, 300, new CustomBooleanConverter()); + } + }).build(); + + @TestConfigProperty(key = "my.boolean", value = "jo") + @Test + public void testBuilderCustomizer(Foo foo) { + assertTrue(foo.boolVal); + } + + @Singleton + public static class Foo { + + @ConfigProperty(name = "my.boolean") + boolean boolVal; + + } + + @SuppressWarnings("serial") + public static class CustomBooleanConverter implements Converter { + + @Override + public Boolean convert(String value) throws IllegalArgumentException, NullPointerException { + return "jo".equals(value) ? true : false; + } + + } +} diff --git a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/config/ConfigConverterExtensionTest.java b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/config/ConfigConverterExtensionTest.java new file mode 100644 index 0000000000000..3b4eb785f45f5 --- /dev/null +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/config/ConfigConverterExtensionTest.java @@ -0,0 +1,55 @@ +package io.quarkus.test.component.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import jakarta.annotation.Priority; +import jakarta.inject.Singleton; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.config.spi.Converter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.component.QuarkusComponentTestExtension; +import io.quarkus.test.component.TestConfigProperty; + +public class ConfigConverterExtensionTest { + + @RegisterExtension + static final QuarkusComponentTestExtension extension = QuarkusComponentTestExtension.builder() + .addConverter(new CustomBooleanConverter()).build(); + + @TestConfigProperty(key = "my.boolean", value = "jo") + @TestConfigProperty(key = "my.duration", value = "5s") + @Test + public void testConverters(Foo foo) { + assertEquals(TimeUnit.SECONDS.toMillis(5), foo.durationVal.toMillis()); + assertTrue(foo.boolVal); + } + + @Singleton + public static class Foo { + + @ConfigProperty(name = "my.duration", defaultValue = "60s") + Duration durationVal; + + @ConfigProperty(name = "my.boolean") + boolean boolVal; + + } + + @SuppressWarnings("serial") + @Priority(300) + public static class CustomBooleanConverter implements Converter { + + @Override + public Boolean convert(String value) throws IllegalArgumentException, NullPointerException { + return "jo".equals(value) ? true : false; + } + + } +} diff --git a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/config/ConfigConverterTest.java b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/config/ConfigConverterTest.java new file mode 100644 index 0000000000000..423df0e2e41ea --- /dev/null +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/config/ConfigConverterTest.java @@ -0,0 +1,52 @@ +package io.quarkus.test.component.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import jakarta.annotation.Priority; +import jakarta.inject.Singleton; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.config.spi.Converter; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.component.QuarkusComponentTest; +import io.quarkus.test.component.TestConfigProperty; +import io.quarkus.test.component.config.ConfigConverterTest.CustomBooleanConverter; + +@QuarkusComponentTest(configConverters = CustomBooleanConverter.class) +public class ConfigConverterTest { + + @TestConfigProperty(key = "my.boolean", value = "jo") + @TestConfigProperty(key = "my.duration", value = "5s") + @Test + public void testConverters(Foo foo) { + assertEquals(TimeUnit.SECONDS.toMillis(5), foo.durationVal.toMillis()); + assertTrue(foo.boolVal); + } + + @Singleton + public static class Foo { + + @ConfigProperty(name = "my.duration", defaultValue = "60s") + Duration durationVal; + + @ConfigProperty(name = "my.boolean") + boolean boolVal; + + } + + @SuppressWarnings("serial") + @Priority(300) + public static class CustomBooleanConverter implements Converter { + + @Override + public Boolean convert(String value) throws IllegalArgumentException, NullPointerException { + return "jo".equals(value) ? true : false; + } + + } +}