diff --git a/docs/src/main/asciidoc/hibernate-orm.adoc b/docs/src/main/asciidoc/hibernate-orm.adoc index aafdc84a18cd9..b026d765d6f37 100644 --- a/docs/src/main/asciidoc/hibernate-orm.adoc +++ b/docs/src/main/asciidoc/hibernate-orm.adoc @@ -542,3 +542,216 @@ Datasource configuration is extremely simple, but is covered in a different guid it's implemented by the Agroal connection pool extension for Quarkus. Jump over to link:datasource[Quarkus - Datasources] for all details. + +== Multitenancy + +"The term multitenancy, in general, is applied to software development to indicate an architecture in which a single running instance of an application simultaneously serves multiple clients (tenants). This is highly common in SaaS solutions. Isolating information (data, customizations, etc.) pertaining to the various tenants is a particular challenge in these systems. This includes the data owned by each tenant stored in the database" (link:https://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#multitenacy[Hibernate User Guide]). + +Quarkus currently supports the link:https://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#multitenacy-separate-database[separate database] and the link:https://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#multitenacy-separate-schema[separate schema] approach. + +=== Writing the application + +Let's start by implementing the `/{tenant}` endpoint. As you can see from the source code below it is just a regular JAX-RS resource: + +[source,java] +---- +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.persistence.EntityManager; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; + +@ApplicationScoped +@Produces("application/json") +@Consumes("application/json") +@Path("/{tenant}") +public class FruitResource { + + @Inject + EntityManager entityManager; + + @GET + @Path("fruits") + public Fruit[] getFruits() { + return entityManager.createNamedQuery("Fruits.findAll", Fruit.class) + .getResultList().toArray(new Fruit[0]); + } + +} +---- + +In order to resolve the tenant from incoming requests and map it to a specific tenant configuration, you need to create an implementation for the `io.quarkus.hibernate.orm.runtime.tenant.TenantResolver` interface. + +[source,java] +---- +import javax.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.Unremovable; +import io.quarkus.hibernate.orm.runtime.tenant.TenantResolver; +import io.vertx.ext.web.RoutingContext; + +@@RequestScoped +@Unremovable +public class CustomTenantResolver implements TenantResolver { + + @Inject + RoutingContext context; + + @Override + public String getDefaultTenantId() { + return "base"; + } + + @Override + public String resolveTenantId() { + String path = context.request().path(); + String[] parts = path.split("/"); + + if (parts.length == 0) { + // resolve to default tenant config + return getDefaultTenantId(); + } + + return parts[1]; + } + +} +---- + +From the implementation above, tenants are resolved from the request path so that in case no tenant could be inferred, the default tenant identifier is returned. + +=== Configuring the application + +In general it is not possible to use the Hibernate ORM database generation feature in conjunction with a multitenancy setup. +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 link:https://quarkus.io/guides/flyway[Flyway] extension to achieve this goal. + +==== 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. + +[source,properties] +---- +# Disable generation +quarkus.hibernate-orm.database.generation=none + +# Enable SCHEMA approach and use default schema +quarkus.hibernate-orm.multitenant=SCHEMA +# You could use a non-default schema by using the following setting +# quarkus.hibernate-orm.multitenant-schema-datasource=other + +# The default data source used for all tenant schemas +quarkus.datasource.db-kind=postgresql +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.locations=classpath:schema +quarkus.flyway.migrate-at-start=true +---- + +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`. + +[source,sql] +---- +CREATE SEQUENCE base.known_fruits_id_seq; +SELECT setval('base."known_fruits_id_seq"', 3); +CREATE TABLE base.known_fruits +( + id INT, + name VARCHAR(40) +); +INSERT INTO base.known_fruits(id, name) VALUES (1, 'Cherry'); +INSERT INTO base.known_fruits(id, name) VALUES (2, 'Apple'); +INSERT INTO base.known_fruits(id, name) VALUES (3, 'Banana'); + +CREATE SEQUENCE mycompany.known_fruits_id_seq; +SELECT setval('mycompany."known_fruits_id_seq"', 3); +CREATE TABLE mycompany.known_fruits +( + id INT, + name VARCHAR(40) +); +INSERT INTO mycompany.known_fruits(id, name) VALUES (1, 'Avocado'); +INSERT INTO mycompany.known_fruits(id, name) VALUES (2, 'Apricots'); +INSERT INTO mycompany.known_fruits(id, name) VALUES (3, 'Blackberries'); +---- + + + +==== DATABASE approach + +For every tenant you need to create a named data source with the same identifier that is returned by the `TenantResolver`. + +[source,properties] +---- +# Disable generation +quarkus.hibernate-orm.database.generation=none + +# Enable DATABASE approach +quarkus.hibernate-orm.multitenant=DATABASE + +# Default tenant 'base' +quarkus.datasource.base.db-kind=postgresql +quarkus.datasource.base.username=quarkus_test +quarkus.datasource.base.password=quarkus_test +quarkus.datasource.base.jdbc.url=jdbc:postgresql://localhost:5432/quarkus_test + +# Tenant 'mycompany' +quarkus.datasource.mycompany.db-kind=postgresql +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.migrate-at-start=true +---- + +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`): + +[source,sql] +---- +CREATE SEQUENCE known_fruits_id_seq; +SELECT setval('known_fruits_id_seq', 3); +CREATE TABLE known_fruits +( + id INT, + name VARCHAR(40) +); +INSERT INTO known_fruits(id, name) VALUES (1, 'Cherry'); +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`): + +[source,sql] +---- +CREATE SEQUENCE known_fruits_id_seq; +SELECT setval('known_fruits_id_seq', 3); +CREATE TABLE known_fruits +( + id INT, + name VARCHAR(40) +); +INSERT INTO known_fruits(id, name) VALUES (1, 'Avocado'); +INSERT INTO known_fruits(id, name) VALUES (2, 'Apricots'); +INSERT INTO known_fruits(id, name) VALUES (3, 'Blackberries'); +---- + +=== Programmatically Resolving Tenants Connections + +If you need a more dynamic configuration for the different tenants you want to support and don't want to end up with multiple entries in your configuration file, you can use the `io.quarkus.hibernate.orm.runtime.tenant.TenantConnectionResolver` interface to implement your own logic for retrieving a connection. Creating an application scoped bean that implements this interface will replace the current Quarkus default implementation `io.quarkus.hibernate.orm.runtime.tenant.DataSourceTenantConnectionResolver`. Your custom connection resolver would allow for example to read tenant information from a database and create a connection per tenant at runtime based on it. diff --git a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfig.java b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfig.java index 32bdd39f0036e..d3e3106ee4d7e 100644 --- a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfig.java +++ b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfig.java @@ -101,6 +101,24 @@ public class HibernateOrmConfig { @ConfigItem Optional implicitNamingStrategy; + /** + * Defines the method for multi-tenancy (DATABASE, NONE, SCHEMA). The complete list of allowed values is available in the + * https://docs.jboss.org/hibernate/stable/orm/javadocs/org/hibernate/MultiTenancyStrategy.html[Hibernate ORM JavaDoc]. + * The type DISCRIMINATOR is currently not supported. The default value is NONE (no multi-tenancy). + * + * @asciidoclet + */ + @ConfigItem + public Optional multitenant; + + /** + * Defines the name of the data source to use in case of SCHEMA approach. The default data source will be used if not set. + * + * @asciidoclet + */ + @ConfigItem + public Optional multitenantSchemaDatasource; + /** * Query related configuration. */ @@ -169,6 +187,8 @@ public boolean isAnyPropertySet() { database.isAnyPropertySet() || jdbc.isAnyPropertySet() || log.isAnyPropertySet() || + multitenant.isPresent() || + multitenantSchemaDatasource.isPresent() || !cache.isEmpty(); } diff --git a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessor.java b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessor.java index 69eada880790b..7c0b6fafa18e6 100644 --- a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessor.java +++ b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessor.java @@ -35,6 +35,7 @@ import org.eclipse.microprofile.metrics.Metadata; import org.eclipse.microprofile.metrics.MetricType; +import org.hibernate.MultiTenancyStrategy; import org.hibernate.annotations.Proxy; import org.hibernate.boot.archive.scan.spi.ClassDescriptor; import org.hibernate.bytecode.internal.bytebuddy.BytecodeProviderImpl; @@ -105,6 +106,7 @@ import io.quarkus.hibernate.orm.runtime.dialect.QuarkusPostgreSQL10Dialect; import io.quarkus.hibernate.orm.runtime.metrics.HibernateCounter; import io.quarkus.hibernate.orm.runtime.proxies.PreGeneratedProxies; +import io.quarkus.hibernate.orm.runtime.tenant.DataSourceTenantConnectionResolver; import io.quarkus.runtime.LaunchMode; import io.quarkus.smallrye.metrics.deployment.spi.MetricBuildItem; import net.bytebuddy.description.type.TypeDescription; @@ -281,10 +283,23 @@ public void build(RecorderContext recorderContext, HibernateOrmRecorder recorder } PreGeneratedProxies proxyDefinitions = generatedProxies(entitiesToGenerateProxiesFor, compositeIndex, generatedClassBuildItemBuildProducer); + + // Multi tenancy mode (DATABASE, DISCRIMINATOR, NONE, SCHEMA) + MultiTenancyStrategy strategy = getMultiTenancyStrategy(); + if (strategy == MultiTenancyStrategy.DISCRIMINATOR) { + // See https://hibernate.atlassian.net/browse/HHH-6054 + throw new ConfigurationError("The Hibernate ORM multi tenancy strategy " + + MultiTenancyStrategy.DISCRIMINATOR + " is currently not supported"); + } + beanContainerListener .produce(new BeanContainerListenerBuildItem( recorder.initMetadata(allDescriptors, scanner, integratorClasses, serviceContributorClasses, - proxyDefinitions))); + proxyDefinitions, strategy))); + } + + private MultiTenancyStrategy getMultiTenancyStrategy() { + return MultiTenancyStrategy.valueOf(hibernateConfig.multitenant.orElse(MultiTenancyStrategy.NONE.name())); } private PreGeneratedProxies generatedProxies(Set entityClassNames, IndexView combinedIndex, @@ -407,9 +422,16 @@ void registerBeans(BuildProducer additionalBeans, Combi return; } + List> unremovableClasses = new ArrayList<>(); + unremovableClasses.add(JPAConfig.class); + unremovableClasses.add(TransactionEntityManagers.class); + unremovableClasses.add(RequestScopedEntityManagerHolder.class); + if (getMultiTenancyStrategy() != MultiTenancyStrategy.NONE) { + unremovableClasses.add(DataSourceTenantConnectionResolver.class); + } + additionalBeans.produce(AdditionalBeanBuildItem.builder().setUnremovable() - .addBeanClasses(JPAConfig.class, TransactionEntityManagers.class, - RequestScopedEntityManagerHolder.class) + .addBeanClasses(unremovableClasses.toArray(new Class[unremovableClasses.size()])) .build()); if (descriptors.size() == 1) { @@ -444,9 +466,12 @@ public void build(HibernateOrmRecorder recorder, if (!hasEntities(jpaEntities, nonJpaModels)) { return; } - + MultiTenancyStrategy strategy = MultiTenancyStrategy + .valueOf(hibernateConfig.multitenant.orElse(MultiTenancyStrategy.NONE.name())); buildProducer.produce(new BeanContainerListenerBuildItem( - recorder.initializeJpa(capabilities.isCapabilityPresent(Capabilities.TRANSACTIONS)))); + recorder.initializeJpa(capabilities.isCapabilityPresent(Capabilities.TRANSACTIONS), strategy, + hibernateConfig.multitenantSchemaDatasource.orElse(null)))); + // Bootstrap all persistence units for (PersistenceUnitDescriptorBuildItem persistenceUnitDescriptor : descriptors) { buildProducer.produce(new BeanContainerListenerBuildItem( diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/FastBootHibernatePersistenceProvider.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/FastBootHibernatePersistenceProvider.java index b5a6ae8639921..b98ed2f2eb4e2 100644 --- a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/FastBootHibernatePersistenceProvider.java +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/FastBootHibernatePersistenceProvider.java @@ -169,7 +169,7 @@ private EntityManagerFactoryBuilder getEntityManagerFactoryBuilderOrNull(String persistenceUnitName, standardServiceRegistry /* Mostly ignored! (yet needs to match) */, runtimeSettings, - validatorFactory, cdiBeanManager); + validatorFactory, cdiBeanManager, recordedState.getMultiTenancyStrategy()); } log.debug("Found no matching persistence units"); diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/HibernateOrmRecorder.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/HibernateOrmRecorder.java index 15be7e0bf4ef0..ed975de332275 100644 --- a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/HibernateOrmRecorder.java +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/HibernateOrmRecorder.java @@ -4,6 +4,7 @@ import java.util.Collection; import java.util.List; +import org.hibernate.MultiTenancyStrategy; import org.hibernate.boot.archive.scan.spi.Scanner; import org.hibernate.integrator.spi.Integrator; import org.hibernate.jpa.boot.internal.ParsedPersistenceXmlDescriptor; @@ -40,11 +41,25 @@ public void callHibernateFeatureInit(boolean enabled) { Hibernate.featureInit(enabled); } - public BeanContainerListener initializeJpa(boolean jtaEnabled) { + /** + * Initializes the JPA configuration to be used at runtime. + * + * @param jtaEnabled Should JTA be enabled? + * @param strategy Multitenancy strategy to use. + * @param multiTenancySchemaDataSource Data source to use in case of {@link MultiTenancyStrategy#SCHEMA} approach or + * {@link null} in case the default data source. + * + * @return + */ + public BeanContainerListener initializeJpa(boolean jtaEnabled, MultiTenancyStrategy strategy, + String multiTenancySchemaDataSource) { return new BeanContainerListener() { @Override public void created(BeanContainer beanContainer) { - beanContainer.instance(JPAConfig.class).setJtaEnabled(jtaEnabled); + JPAConfig instance = beanContainer.instance(JPAConfig.class); + instance.setJtaEnabled(jtaEnabled); + instance.setMultiTenancyStrategy(strategy); + instance.setMultiTenancySchemaDataSource(multiTenancySchemaDataSource); } }; } @@ -70,12 +85,12 @@ public void created(BeanContainer beanContainer) { public BeanContainerListener initMetadata(List parsedPersistenceXmlDescriptors, Scanner scanner, Collection> additionalIntegrators, Collection> additionalServiceContributors, - PreGeneratedProxies proxyDefinitions) { + PreGeneratedProxies proxyDefinitions, MultiTenancyStrategy strategy) { return new BeanContainerListener() { @Override public void created(BeanContainer beanContainer) { PersistenceUnitsHolder.initializeJpa(parsedPersistenceXmlDescriptors, scanner, additionalIntegrators, - additionalServiceContributors, proxyDefinitions); + additionalServiceContributors, proxyDefinitions, strategy); } }; } diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/JPAConfig.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/JPAConfig.java index aad2ca171460b..625307ce67db6 100644 --- a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/JPAConfig.java +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/JPAConfig.java @@ -13,6 +13,7 @@ import javax.persistence.EntityManagerFactory; import javax.persistence.Persistence; +import org.hibernate.MultiTenancyStrategy; import org.jboss.logging.Logger; @Singleton @@ -22,12 +23,18 @@ public class JPAConfig { private final AtomicBoolean jtaEnabled; + private final AtomicReference multiTenancyStrategy; + + private final AtomicReference multiTenancySchemaDataSource; + private final Map persistenceUnits; private final AtomicReference defaultPersistenceUnitName; public JPAConfig() { this.jtaEnabled = new AtomicBoolean(); + this.multiTenancyStrategy = new AtomicReference(); + this.multiTenancySchemaDataSource = new AtomicReference(); this.persistenceUnits = new ConcurrentHashMap<>(); this.defaultPersistenceUnitName = new AtomicReference(); } @@ -36,6 +43,24 @@ void setJtaEnabled(boolean value) { jtaEnabled.set(value); } + /** + * Sets the strategy for multitenancy. + * + * @param strategy Strategy to use. + */ + void setMultiTenancyStrategy(MultiTenancyStrategy strategy) { + multiTenancyStrategy.set(strategy); + } + + /** + * Sets the name of the data source that should be used in case of {@link MultiTenancyStrategy#SCHEMA} approach. + * + * @param dataSourceName Name to use or {@literal null} for the default data source. + */ + void setMultiTenancySchemaDataSource(String dataSourceName) { + multiTenancySchemaDataSource.set(dataSourceName); + } + public EntityManagerFactory getEntityManagerFactory(String unitName) { if (unitName == null || unitName.isEmpty()) { if (persistenceUnits.size() == 1) { @@ -69,6 +94,24 @@ boolean isJtaEnabled() { return jtaEnabled.get(); } + /** + * Returns the selected multitenancy strategy. + * + * @return Strategy to use. + */ + public MultiTenancyStrategy getMultiTenancyStrategy() { + return multiTenancyStrategy.get(); + } + + /** + * Determines which data source should be used in case of {@link MultiTenancyStrategy#SCHEMA} approach. + * + * @return Data source name or {@link null} in case the default data source should be used. + */ + public String getMultiTenancySchemaDataSource() { + return multiTenancySchemaDataSource.get(); + } + /** * Need to shutdown all instances of Hibernate ORM before the actual destroy event, * as it might need to use the datasources during shutdown. diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/PersistenceUnitsHolder.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/PersistenceUnitsHolder.java index 0307c9543e4af..91cd7f8010cc3 100644 --- a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/PersistenceUnitsHolder.java +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/PersistenceUnitsHolder.java @@ -8,6 +8,7 @@ import javax.persistence.PersistenceException; +import org.hibernate.MultiTenancyStrategy; import org.hibernate.boot.archive.scan.spi.Scanner; import org.hibernate.integrator.spi.Integrator; import org.hibernate.jpa.boot.internal.ParsedPersistenceXmlDescriptor; @@ -44,10 +45,10 @@ public final class PersistenceUnitsHolder { static void initializeJpa(List parsedPersistenceXmlDescriptors, Scanner scanner, Collection> additionalIntegrators, Collection> additionalServiceContributors, - PreGeneratedProxies preGeneratedProxies) { + PreGeneratedProxies preGeneratedProxies, MultiTenancyStrategy strategy) { final List units = convertPersistenceUnits(parsedPersistenceXmlDescriptors); final Map metadata = constructMetadataAdvance(units, scanner, additionalIntegrators, - preGeneratedProxies); + preGeneratedProxies, strategy); persistenceUnits = new PersistenceUnits(units, metadata); } @@ -79,11 +80,12 @@ private static List convertPersistenceUnits( private static Map constructMetadataAdvance( final List parsedPersistenceXmlDescriptors, Scanner scanner, Collection> additionalIntegrators, - PreGeneratedProxies proxyClassDefinitions) { + PreGeneratedProxies proxyClassDefinitions, + MultiTenancyStrategy strategy) { Map recordedStates = new HashMap<>(); for (PersistenceUnitDescriptor unit : parsedPersistenceXmlDescriptors) { - RecordedState m = createMetadata(unit, scanner, additionalIntegrators, proxyClassDefinitions); + RecordedState m = createMetadata(unit, scanner, additionalIntegrators, proxyClassDefinitions, strategy); Object previous = recordedStates.put(unitName(unit), m); if (previous != null) { throw new IllegalStateException("Duplicate persistence unit name: " + unit.getName()); @@ -108,9 +110,10 @@ private static String unitName(PersistenceUnitDescriptor unit) { } public static RecordedState createMetadata(PersistenceUnitDescriptor unit, Scanner scanner, - Collection> additionalIntegrators, PreGeneratedProxies proxyDefinitions) { + Collection> additionalIntegrators, PreGeneratedProxies proxyDefinitions, + MultiTenancyStrategy strategy) { FastBootMetadataBuilder fastBootMetadataBuilder = new FastBootMetadataBuilder(unit, scanner, additionalIntegrators, - proxyDefinitions); + proxyDefinitions, strategy); return fastBootMetadataBuilder.build(); } diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/boot/FastBootEntityManagerFactoryBuilder.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/boot/FastBootEntityManagerFactoryBuilder.java index 5274e7dbc308e..93dc0ee5c5f2f 100644 --- a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/boot/FastBootEntityManagerFactoryBuilder.java +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/boot/FastBootEntityManagerFactoryBuilder.java @@ -8,6 +8,7 @@ import javax.persistence.PersistenceException; import javax.sql.DataSource; +import org.hibernate.MultiTenancyStrategy; import org.hibernate.SessionFactory; import org.hibernate.SessionFactoryObserver; import org.hibernate.boot.internal.SessionFactoryOptionsBuilder; @@ -28,6 +29,7 @@ import io.quarkus.hibernate.orm.runtime.RuntimeSettings; import io.quarkus.hibernate.orm.runtime.recording.PrevalidatedQuarkusMetadata; +import io.quarkus.hibernate.orm.runtime.tenant.HibernateCurrentTenantIdentifierResolver; public final class FastBootEntityManagerFactoryBuilder implements EntityManagerFactoryBuilder { @@ -37,17 +39,19 @@ public final class FastBootEntityManagerFactoryBuilder implements EntityManagerF private final RuntimeSettings runtimeSettings; private final Object validatorFactory; private final Object cdiBeanManager; + private final MultiTenancyStrategy multiTenancyStrategy; public FastBootEntityManagerFactoryBuilder( PrevalidatedQuarkusMetadata metadata, String persistenceUnitName, StandardServiceRegistry standardServiceRegistry, RuntimeSettings runtimeSettings, Object validatorFactory, - Object cdiBeanManager) { + Object cdiBeanManager, MultiTenancyStrategy strategy) { this.metadata = metadata; this.persistenceUnitName = persistenceUnitName; this.standardServiceRegistry = standardServiceRegistry; this.runtimeSettings = runtimeSettings; this.validatorFactory = validatorFactory; this.cdiBeanManager = cdiBeanManager; + this.multiTenancyStrategy = strategy; } @Override @@ -64,7 +68,7 @@ public EntityManagerFactoryBuilder withDataSource(DataSource dataSource) { public EntityManagerFactory build() { try { final SessionFactoryOptionsBuilder optionsBuilder = metadata.buildSessionFactoryOptionsBuilder(); - populate(optionsBuilder, standardServiceRegistry); + populate(optionsBuilder, standardServiceRegistry, multiTenancyStrategy); return new SessionFactoryImpl(metadata.getOriginalMetadata(), optionsBuilder.buildOptions()); } catch (Exception e) { throw persistenceException("Unable to build Hibernate SessionFactory", e); @@ -114,7 +118,7 @@ private String getExceptionHeader() { return "[PersistenceUnit: " + persistenceUnitName + "] "; } - protected void populate(SessionFactoryOptionsBuilder options, StandardServiceRegistry ssr) { + protected void populate(SessionFactoryOptionsBuilder options, StandardServiceRegistry ssr, MultiTenancyStrategy strategy) { // will use user override value or default to false if not supplied to follow // JPA spec. @@ -156,6 +160,12 @@ protected void populate(SessionFactoryOptionsBuilder options, StandardServiceReg //(On start is useful especially in Quarkus as we won't do any more enhancement after this point) BytecodeProvider bytecodeProvider = ssr.getService(BytecodeProvider.class); options.addSessionFactoryObservers(new SessionFactoryObserverForBytecodeEnhancer(bytecodeProvider)); + + if (strategy != null && strategy != MultiTenancyStrategy.NONE) { + options.applyMultiTenancyStrategy(strategy); + options.applyCurrentTenantIdentifierResolver(new HibernateCurrentTenantIdentifierResolver()); + } + } private static class ServiceRegistryCloser implements SessionFactoryObserver { diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/boot/FastBootMetadataBuilder.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/boot/FastBootMetadataBuilder.java index a399e52c864d1..59aa5ace03be2 100644 --- a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/boot/FastBootMetadataBuilder.java +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/boot/FastBootMetadataBuilder.java @@ -31,6 +31,7 @@ import javax.persistence.PersistenceException; import javax.persistence.spi.PersistenceUnitTransactionType; +import org.hibernate.MultiTenancyStrategy; import org.hibernate.boot.CacheRegionDefinition; import org.hibernate.boot.MetadataBuilder; import org.hibernate.boot.MetadataSources; @@ -41,7 +42,6 @@ import org.hibernate.boot.model.process.spi.MetadataBuildingProcess; import org.hibernate.boot.registry.BootstrapServiceRegistry; import org.hibernate.boot.registry.StandardServiceRegistry; -import org.hibernate.boot.registry.StandardServiceRegistryBuilder; import org.hibernate.boot.registry.classloading.spi.ClassLoaderService; import org.hibernate.boot.registry.internal.BootstrapServiceRegistryImpl; import org.hibernate.boot.registry.selector.spi.StrategySelector; @@ -52,6 +52,7 @@ import org.hibernate.cfg.AvailableSettings; import org.hibernate.cfg.beanvalidation.BeanValidationIntegrator; import org.hibernate.dialect.Dialect; +import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider; import org.hibernate.engine.jdbc.dialect.spi.DialectFactory; import org.hibernate.engine.transaction.jta.platform.spi.JtaPlatform; import org.hibernate.id.factory.spi.MutableIdentifierGeneratorFactory; @@ -86,6 +87,7 @@ import io.quarkus.hibernate.orm.runtime.recording.RecordedState; import io.quarkus.hibernate.orm.runtime.recording.RecordingDialectFactory; import io.quarkus.hibernate.orm.runtime.service.FlatClassLoaderService; +import io.quarkus.hibernate.orm.runtime.tenant.HibernateMultiTenantConnectionProvider; /** * Alternative to EntityManagerFactoryBuilderImpl so to have full control of how MetadataBuilderImplementor @@ -104,10 +106,12 @@ public class FastBootMetadataBuilder { private final Collection> additionalIntegrators; private final Collection providedServices; private final PreGeneratedProxies preGeneratedProxies; + private final MultiTenancyStrategy multiTenancyStrategy; @SuppressWarnings("unchecked") public FastBootMetadataBuilder(final PersistenceUnitDescriptor persistenceUnit, Scanner scanner, - Collection> additionalIntegrators, PreGeneratedProxies preGeneratedProxies) { + Collection> additionalIntegrators, PreGeneratedProxies preGeneratedProxies, + MultiTenancyStrategy strategy) { this.persistenceUnit = persistenceUnit; this.additionalIntegrators = additionalIntegrators; this.preGeneratedProxies = preGeneratedProxies; @@ -186,6 +190,12 @@ public FastBootMetadataBuilder(final PersistenceUnitDescriptor persistenceUnit, // for the time being we want to revoke access to the temp ClassLoader if one // was passed metamodelBuilder.applyTempClassLoader(null); + + if (strategy != null && strategy != MultiTenancyStrategy.NONE) { + ssrBuilder.addService(MultiTenantConnectionProvider.class, new HibernateMultiTenantConnectionProvider()); + } + this.multiTenancyStrategy = strategy; + } private void addPUManagedClassNamesToMetadataSources(PersistenceUnitDescriptor persistenceUnit, @@ -348,7 +358,7 @@ public RecordedState build() { destroyServiceRegistry(fullMeta); ProxyDefinitions proxyClassDefinitions = ProxyDefinitions.createFromMetadata(storeableMetadata, preGeneratedProxies); return new RecordedState(dialect, jtaPlatform, storeableMetadata, buildTimeSettings, getIntegrators(), - providedServices, integrationSettingsBuilder.build(), proxyClassDefinitions); + providedServices, integrationSettingsBuilder.build(), proxyClassDefinitions, multiTenancyStrategy); } private void destroyServiceRegistry(MetadataImplementor fullMeta) { diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/recording/RecordedState.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/recording/RecordedState.java index 41aa6ca7e6e06..8bb4c387f07c3 100644 --- a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/recording/RecordedState.java +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/recording/RecordedState.java @@ -2,6 +2,7 @@ import java.util.Collection; +import org.hibernate.MultiTenancyStrategy; import org.hibernate.dialect.Dialect; import org.hibernate.engine.transaction.jta.platform.spi.JtaPlatform; import org.hibernate.integrator.spi.Integrator; @@ -21,11 +22,12 @@ public final class RecordedState { private final Collection providedServices; private final IntegrationSettings integrationSettings; private final ProxyDefinitions proxyClassDefinitions; + private final MultiTenancyStrategy multiTenancyStrategy; public RecordedState(Dialect dialect, JtaPlatform jtaPlatform, PrevalidatedQuarkusMetadata metadata, BuildTimeSettings settings, Collection integrators, Collection providedServices, IntegrationSettings integrationSettings, - ProxyDefinitions classDefinitions) { + ProxyDefinitions classDefinitions, MultiTenancyStrategy strategy) { this.dialect = dialect; this.jtaPlatform = jtaPlatform; this.metadata = metadata; @@ -34,6 +36,7 @@ public RecordedState(Dialect dialect, JtaPlatform jtaPlatform, PrevalidatedQuark this.providedServices = providedServices; this.integrationSettings = integrationSettings; this.proxyClassDefinitions = classDefinitions; + this.multiTenancyStrategy = strategy; } public Dialect getDialect() { @@ -67,4 +70,8 @@ public JtaPlatform getJtaPlatform() { public ProxyDefinitions getProxyClassDefinitions() { return proxyClassDefinitions; } + + public MultiTenancyStrategy getMultiTenancyStrategy() { + return multiTenancyStrategy; + } } diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/DataSourceTenantConnectionResolver.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/DataSourceTenantConnectionResolver.java new file mode 100644 index 0000000000000..58a5053c536fe --- /dev/null +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/DataSourceTenantConnectionResolver.java @@ -0,0 +1,111 @@ +package io.quarkus.hibernate.orm.runtime.tenant; + +import java.sql.Connection; +import java.sql.SQLException; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +import org.hibernate.MultiTenancyStrategy; +import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider; +import org.jboss.logging.Logger; + +import io.agroal.api.AgroalDataSource; +import io.agroal.api.configuration.AgroalDataSourceConfiguration; +import io.quarkus.agroal.DataSource; +import io.quarkus.arc.Arc; +import io.quarkus.arc.DefaultBean; +import io.quarkus.hibernate.orm.runtime.JPAConfig; +import io.quarkus.hibernate.orm.runtime.customized.QuarkusConnectionProvider; + +/** + * Creates a database connection based on the data sources in the configuration file. + * The tenant identifier is used as the data source name. + * + * @author Michael Schnell + * + */ +@DefaultBean +@ApplicationScoped +public class DataSourceTenantConnectionResolver implements TenantConnectionResolver { + + private static final Logger LOG = Logger.getLogger(DataSourceTenantConnectionResolver.class); + + @Inject + JPAConfig jpaConfig; + + @Override + public ConnectionProvider resolve(String tenantId) { + + LOG.debugv("resolve({0})", tenantId); + + final MultiTenancyStrategy strategy = jpaConfig.getMultiTenancyStrategy(); + LOG.debugv("multitenancy strategy: {0}", strategy); + AgroalDataSource dataSource = tenantDataSource(jpaConfig, tenantId, strategy); + if (dataSource == null) { + throw new IllegalStateException("No instance of datasource found for tenant: " + tenantId); + } + if (strategy == MultiTenancyStrategy.SCHEMA) { + return new TenantConnectionProvider(tenantId, dataSource); + } + return new QuarkusConnectionProvider(dataSource); + } + + /** + * Create a new data source from the given configuration. + * + * @param config Configuration to use. + * + * @return New data source instance. + */ + private static AgroalDataSource createFrom(AgroalDataSourceConfiguration config) { + try { + return AgroalDataSource.from(config); + } catch (SQLException ex) { + throw new IllegalStateException("Failed to create a new data source based on the default config", ex); + } + } + + /** + * Returns either the default data source or the tenant specific one. + * + * @param tenantId Tenant identifier. The value is required (non-{@literal null}) in case of + * {@link MultiTenancyStrategy#DATABASE}. + * @param strategy Current multitenancy strategy Required value that cannot be {@literal null}. + * + * @return Data source. + */ + private static AgroalDataSource tenantDataSource(JPAConfig jpaConfig, String tenantId, MultiTenancyStrategy strategy) { + if (strategy != MultiTenancyStrategy.SCHEMA) { + return Arc.container().instance(AgroalDataSource.class, new DataSource.DataSourceLiteral(tenantId)).get(); + } + String dataSourceName = jpaConfig.getMultiTenancySchemaDataSource(); + if (dataSourceName == null) { + AgroalDataSource dataSource = Arc.container().instance(AgroalDataSource.class).get(); + return createFrom(dataSource.getConfiguration()); + } + return Arc.container().instance(AgroalDataSource.class, new DataSource.DataSourceLiteral(dataSourceName)).get(); + } + + private static class TenantConnectionProvider extends QuarkusConnectionProvider { + + private static final long serialVersionUID = 1L; + + private final String tenantId; + + public TenantConnectionProvider(String tenantId, AgroalDataSource dataSource) { + super(dataSource); + this.tenantId = tenantId; + } + + @Override + public Connection getConnection() throws SQLException { + Connection conn = super.getConnection(); + conn.setSchema(tenantId); + LOG.debugv("Set tenant {0} for connection: {1}", tenantId, conn); + return conn; + } + + } + +} diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/HibernateCurrentTenantIdentifierResolver.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/HibernateCurrentTenantIdentifierResolver.java new file mode 100644 index 0000000000000..1b8924c3a9086 --- /dev/null +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/HibernateCurrentTenantIdentifierResolver.java @@ -0,0 +1,58 @@ +package io.quarkus.hibernate.orm.runtime.tenant; + +import org.hibernate.context.spi.CurrentTenantIdentifierResolver; +import org.jboss.logging.Logger; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.InstanceHandle; + +/** + * Maps from the Quarkus {@link TenantResolver} to the Hibernate {@link CurrentTenantIdentifierResolver} model. + * + * @author Michael Schnell + * + */ +public class HibernateCurrentTenantIdentifierResolver implements CurrentTenantIdentifierResolver { + + private static final Logger LOG = Logger.getLogger(HibernateCurrentTenantIdentifierResolver.class); + + @Override + public String resolveCurrentTenantIdentifier() { + + // Make sure that we're in a request + if (!Arc.container().requestContext().isActive()) { + return null; + } + + TenantResolver resolver = tenantResolver(); + String tenantId = resolver.resolveTenantId(); + if (tenantId == null) { + throw new IllegalStateException("Method 'TenantResolver.resolveTenantId()' returned a null value. " + + "Unfortunately Hibernate ORM does not allow null for tenant identifiers. " + + "Please use a non-null value!"); + } + LOG.debugv("resolveCurrentTenantIdentifier(): {0}", tenantId); + return tenantId; + + } + + @Override + public boolean validateExistingCurrentSessions() { + return false; + } + + /** + * Retrieves the tenant resolver or fails if it is not available. + * + * @return Current tenant resolver. + */ + private static TenantResolver tenantResolver() { + InstanceHandle resolverInstance = Arc.container().instance(TenantResolver.class); + if (!resolverInstance.isAvailable()) { + throw new IllegalStateException("No instance of " + TenantResolver.class.getName() + " was found. " + + "You need to create an implementation for this interface to allow resolving the current tenant identifier."); + } + return resolverInstance.get(); + } + +} diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/HibernateMultiTenantConnectionProvider.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/HibernateMultiTenantConnectionProvider.java new file mode 100644 index 0000000000000..325465fd838f7 --- /dev/null +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/HibernateMultiTenantConnectionProvider.java @@ -0,0 +1,78 @@ +package io.quarkus.hibernate.orm.runtime.tenant; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.hibernate.engine.jdbc.connections.spi.AbstractMultiTenantConnectionProvider; +import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider; +import org.jboss.logging.Logger; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.InstanceHandle; + +/** + * Maps from the Quarkus {@link TenantConnectionResolver} to the {@link HibernateMultiTenantConnectionProvider} model. + * + * @author Michael Schnell + * + */ +public class HibernateMultiTenantConnectionProvider extends AbstractMultiTenantConnectionProvider { + + private static final Logger LOG = Logger.getLogger(HibernateMultiTenantConnectionProvider.class); + + private final Map providerMap = new ConcurrentHashMap<>(); + + @Override + protected ConnectionProvider getAnyConnectionProvider() { + String tenantId = tenantResolver().getDefaultTenantId(); + if (tenantId == null) { + throw new IllegalStateException("Method 'TenantResolver.getDefaultTenantId()' returned a null value. " + + "This violates the contract of the interface!"); + } + return selectConnectionProvider(tenantId); + } + + @Override + protected ConnectionProvider selectConnectionProvider(String tenantIdentifier) { + LOG.debugv("selectConnectionProvider({0})", tenantIdentifier); + + ConnectionProvider provider = providerMap.get(tenantIdentifier); + if (provider == null) { + return providerMap.computeIfAbsent(tenantIdentifier, tid -> resolveConnectionProvider(tid)); + } + return provider; + + } + + private static ConnectionProvider resolveConnectionProvider(String tenantIdentifier) { + LOG.debugv("resolveConnectionProvider({0})", tenantIdentifier); + InstanceHandle instance = Arc.container().instance(TenantConnectionResolver.class); + if (!instance.isAvailable()) { + throw new IllegalStateException( + "No instance of " + TenantConnectionResolver.class.getSimpleName() + " was found. " + + "You need to create an implementation for this interface to allow resolving the current tenant connection."); + } + TenantConnectionResolver resolver = instance.get(); + ConnectionProvider cp = resolver.resolve(tenantIdentifier); + if (cp == null) { + throw new IllegalStateException("Method 'TenantConnectionResolver." + + "resolve(String)' returned a null value. This violates the contract of the interface!"); + } + return cp; + } + + /** + * Retrieves the tenant resolver or fails if it is not available. + * + * @return Current tenant resolver. + */ + private static TenantResolver tenantResolver() { + InstanceHandle resolverInstance = Arc.container().instance(TenantResolver.class); + if (!resolverInstance.isAvailable()) { + throw new IllegalStateException("No instance of " + TenantResolver.class.getName() + " was found. " + + "You need to create an implementation for this interface to allow resolving the current tenant identifier."); + } + return resolverInstance.get(); + } + +} diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/TenantConnectionResolver.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/TenantConnectionResolver.java new file mode 100644 index 0000000000000..46ebc0126a490 --- /dev/null +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/TenantConnectionResolver.java @@ -0,0 +1,21 @@ +package io.quarkus.hibernate.orm.runtime.tenant; + +import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider; + +/** + * Resolves the {@link ConnectionProvider} for tenants dynamically. + * + * @author Michael Schnell + * + */ +public interface TenantConnectionResolver { + + /** + * Returns a connection provider for the current tenant based on the context. + * + * @param tenantId the tenant identifier. Required value that cannot be {@literal null}. + * @return Hibernate connection provider for the current provider. A non-{@literal null} value is required. + */ + ConnectionProvider resolve(String tenantId); + +} diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/TenantResolver.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/TenantResolver.java new file mode 100644 index 0000000000000..54fa1e8dd64e2 --- /dev/null +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/tenant/TenantResolver.java @@ -0,0 +1,26 @@ +package io.quarkus.hibernate.orm.runtime.tenant; + +/** + * Resolves tenant identifier dynamically so that the proper configuration can be used. + * + * @author Michael Schnell + * + */ +public interface TenantResolver { + + /** + * Returns the identifier of the default tenant. + * + * @return Default tenant.A non-{@literal null} value is required. + */ + String getDefaultTenantId(); + + /** + * Returns the current tenant identifier. + * + * @return the tenant identifier. This value will be used to select the proper configuration at runtime. A + * non-{@literal null} value is required. + */ + String resolveTenantId(); + +} diff --git a/integration-tests/hibernate-tenancy/README.md b/integration-tests/hibernate-tenancy/README.md new file mode 100644 index 0000000000000..9fa24ee757a3d --- /dev/null +++ b/integration-tests/hibernate-tenancy/README.md @@ -0,0 +1,44 @@ +# Hibernate example with multitenancy + +## Running the tests + +By default, the tests of this module are disabled. + +To run the tests in a standard JVM with MariaDB started as a Docker container, you can run the following command: + +``` +mvn clean install -Dtest-mariadb -Ddocker +``` + +Please note that waiting on the availability of MariaDB port does not work on macOS. +This module does not work with `-Ddocker` option on this operating system. + +Additionally, you can generate a native image and run the tests for this native image by adding `-Dnative`: + +``` +mvn clean install -Dtest-mariadb -Ddocker -Dnative +``` + +If you don't want to run MariaDB as a Docker container, you can start your own MariaDB server. It needs to listen on the default port and have a database called `hibernate_orm_test` and a root user with the password `secret`. + +You can then run the tests as follows (either with `-Dnative` or not): + +``` +mvn clean install -Dtest-mariadb +``` + +If you have specific requirements, you can define a specific connection URL with `-Djdbc:mariadb://localhost:3306/hibernate_orm_test`. + +To run the MariaDB server "manually" via command line for testing, the following command line could be useful: + +``` +docker run --ulimit memlock=-1:-1 -it --rm=true --memory-swappiness=0 --name quarkus_test_mariadb -e MYSQL_DATABASE=hibernate_orm_test -e MYSQL_ROOT_PASSWORD=secret -p 3306:3306 mariadb:10.4 +``` + +N.B. it takes a while for MariaDB to be actually booted and accepting connections. + +After it's fully booted, you can run all integration tests via + +``` +mvn clean install -Dtest-mariadb -Dnative +``` diff --git a/integration-tests/hibernate-tenancy/custom-mariadbconfig/custom.cnf b/integration-tests/hibernate-tenancy/custom-mariadbconfig/custom.cnf new file mode 100644 index 0000000000000..6310c25f4c4be --- /dev/null +++ b/integration-tests/hibernate-tenancy/custom-mariadbconfig/custom.cnf @@ -0,0 +1,35 @@ +# +# MariaDB tuning meant for fast integration test execution: +# data is meant to be lost. Never use for actual database needs! +# + +[mysqld] + +# Disabling symbolic-links is recommended to prevent assorted security risks +symbolic-links = 0 + +# http://www.percona.com/blog/2008/05/31/dns-achilles-heel-mysql-installation/ +skip_name_resolve + +max_connections = 10 + +# Some tuning for tmpfs : + +innodb_doublewrite = 0 +innodb_use_native_aio = 1 +innodb_flush_method = O_DSYNC +innodb_log_file_size = 2M +innodb_log_buffer_size = 2M +innodb_buffer_pool_size = 5242880 +innodb_file_per_table = 0 +innodb_flush_log_at_trx_commit = 0 +sync_binlog = 0 +innodb_fast_shutdown = 1 +innodb_temp_data_file_path = ibtmp1:2M:autoextend:max:512M + +# Generally useful for tests: +default-time-zone='+00:00' +character-set-server=utf8mb4 +collation-server=utf8mb4_unicode_ci +sql-mode="traditional" +strict_password_validation = 0 diff --git a/integration-tests/hibernate-tenancy/pom.xml b/integration-tests/hibernate-tenancy/pom.xml new file mode 100644 index 0000000000000..0269c486a95a5 --- /dev/null +++ b/integration-tests/hibernate-tenancy/pom.xml @@ -0,0 +1,251 @@ + + + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + ../ + + 4.0.0 + + quarkus-integration-test-hibernate-tenancy + Quarkus - Integration Tests - Hibernate - Multitenancy + Module that contains Hibernate Multitenancy related tests running with the MariaDB database + + + jdbc:mariadb://localhost:3306 + mariadb:10.4 + + + + + io.quarkus + quarkus-undertow + + + io.quarkus + quarkus-hibernate-orm + + + io.quarkus + quarkus-jdbc-mariadb + + + io.quarkus + quarkus-flyway + + + io.quarkus + quarkus-resteasy + + + io.quarkus + quarkus-resteasy-jsonb + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + + + + + src/main/resources + true + + + + + maven-surefire-plugin + + true + + + + maven-failsafe-plugin + + true + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + + + + + test-mariadb + + + test-mariadb + + + + + + maven-surefire-plugin + + false + + + + maven-failsafe-plugin + + false + + + + + + + + native-image + + + native + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + io.quarkus + quarkus-maven-plugin + + + native-image + + native-image + + + false + true + true + false + false + ${graalvmHome} + false + + + + + + + + + + docker-mariadb + + + docker + + + + jdbc:mariadb://localhost:3308 + + + + + io.fabric8 + docker-maven-plugin + + + + ${mariadb.image} + quarkus-test-mariadb + + + 3308:3306 + + + hibernate_orm_test + secret + + + MariaDB: + default + cyan + + + /var/lib/mysql + + + + mysqladmin ping -h localhost -uroot -psecret + + + + + ${project.basedir}/custom-mariadbconfig:/etc/mysql/conf.d/:Z + + + + + + + true + + + + docker-start + compile + + stop + start + + + + docker-stop + post-integration-test + + stop + + + + + + + + + + + diff --git a/integration-tests/hibernate-tenancy/src/main/java/io/quarkus/it/hibernate/multitenancy/CustomTenantResolver.java b/integration-tests/hibernate-tenancy/src/main/java/io/quarkus/it/hibernate/multitenancy/CustomTenantResolver.java new file mode 100644 index 0000000000000..3f53dea1d853f --- /dev/null +++ b/integration-tests/hibernate-tenancy/src/main/java/io/quarkus/it/hibernate/multitenancy/CustomTenantResolver.java @@ -0,0 +1,39 @@ +package io.quarkus.it.hibernate.multitenancy; + +import javax.enterprise.context.RequestScoped; +import javax.inject.Inject; + +import org.jboss.logging.Logger; + +import io.quarkus.arc.Unremovable; +import io.quarkus.hibernate.orm.runtime.tenant.TenantResolver; +import io.vertx.ext.web.RoutingContext; + +@RequestScoped +@Unremovable +public class CustomTenantResolver implements TenantResolver { + + private static final Logger LOG = Logger.getLogger(CustomTenantResolver.class); + + @Inject + RoutingContext context; + + @Override + public String getDefaultTenantId() { + return "base"; + } + + @Override + public String resolveTenantId() { + String path = context.request().path(); + final String tenantId; + if (path.startsWith("/mycompany")) { + tenantId = "mycompany"; + } else { + tenantId = getDefaultTenantId(); + } + LOG.debugv("TenantId = {0}", tenantId); + return tenantId; + } + +} diff --git a/integration-tests/hibernate-tenancy/src/main/java/io/quarkus/it/hibernate/multitenancy/Fruit.java b/integration-tests/hibernate-tenancy/src/main/java/io/quarkus/it/hibernate/multitenancy/Fruit.java new file mode 100644 index 0000000000000..440a8ea38502f --- /dev/null +++ b/integration-tests/hibernate-tenancy/src/main/java/io/quarkus/it/hibernate/multitenancy/Fruit.java @@ -0,0 +1,92 @@ +package io.quarkus.it.hibernate.multitenancy; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.NamedQuery; +import javax.persistence.SequenceGenerator; +import javax.persistence.Table; +import javax.xml.bind.annotation.XmlRootElement; + +@Entity +@Table(name = "known_fruits") +@NamedQuery(name = "Fruits.findAll", query = "SELECT f FROM Fruit f ORDER BY f.name") +@NamedQuery(name = "Fruits.findByName", query = "SELECT f FROM Fruit f WHERE f.name=:name") +@XmlRootElement(name = "fruit") +public class Fruit { + + @Id + @SequenceGenerator(name = "fruitsSequence", sequenceName = "known_fruits_id_seq", allocationSize = 1, initialValue = 10) + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "fruitsSequence") + private Integer id; + + @Column(length = 40, unique = true) + private String name; + + public Fruit() { + } + + public Fruit(String name) { + this.name = name; + } + + Fruit(Integer id, String name) { + this.id = id; + this.name = name; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((id == null) ? 0 : id.hashCode()); + result = prime * result + ((name == null) ? 0 : name.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + Fruit other = (Fruit) obj; + if (id == null) { + if (other.id != null) + return false; + } else if (!id.equals(other.id)) + return false; + if (name == null) { + if (other.name != null) + return false; + } else if (!name.equals(other.name)) + return false; + return true; + } + + @Override + public String toString() { + return "Fruit [id=" + id + ", name=" + name + "]"; + } + +} diff --git a/integration-tests/hibernate-tenancy/src/main/java/io/quarkus/it/hibernate/multitenancy/FruitResource.java b/integration-tests/hibernate-tenancy/src/main/java/io/quarkus/it/hibernate/multitenancy/FruitResource.java new file mode 100644 index 0000000000000..777d99f33f9bf --- /dev/null +++ b/integration-tests/hibernate-tenancy/src/main/java/io/quarkus/it/hibernate/multitenancy/FruitResource.java @@ -0,0 +1,207 @@ +package io.quarkus.it.hibernate.multitenancy; + +import java.util.List; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.json.Json; +import javax.json.JsonObjectBuilder; +import javax.persistence.EntityManager; +import javax.transaction.Transactional; +import javax.validation.constraints.NotNull; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.annotations.jaxrs.PathParam; + +@ApplicationScoped +@Produces("application/json") +@Consumes("application/json") +@Path("/") +public class FruitResource { + + private static final Logger LOG = Logger.getLogger(FruitResource.class.getName()); + + @Inject + EntityManager entityManager; + + @GET + @Path("fruits") + public Fruit[] getDefault() { + return get(); + } + + @GET + @Path("{tenant}/fruits") + public Fruit[] getTenant() { + return get(); + } + + private Fruit[] get() { + return entityManager.createNamedQuery("Fruits.findAll", Fruit.class) + .getResultList().toArray(new Fruit[0]); + } + + @GET + @Path("fruits/{id}") + public Fruit getSingleDefault(@PathParam("id") int id) { + return findById(id); + } + + @GET + @Path("{tenant}/fruits/{id}") + public Fruit getSingleTenant(@PathParam("id") int id) { + return findById(id); + } + + private Fruit findById(int id) { + Fruit entity = entityManager.find(Fruit.class, id); + if (entity == null) { + throw new WebApplicationException("Fruit with id of " + id + " does not exist.", 404); + } + return entity; + } + + @POST + @Transactional + @Path("fruits") + public Response createDefault(@NotNull Fruit fruit) { + return create(fruit); + } + + @POST + @Transactional + @Path("{tenant}/fruits") + public Response createTenant(@NotNull Fruit fruit) { + return create(fruit); + } + + private Response create(@NotNull Fruit fruit) { + if (fruit.getId() != null) { + throw new WebApplicationException("Id was invalidly set on request.", 422); + } + LOG.debugv("Create {0}", fruit.getName()); + entityManager.persist(fruit); + return Response.ok(fruit).status(201).build(); + } + + @PUT + @Path("fruits/{id}") + @Transactional + public Fruit updateDefault(@PathParam("id") int id, @NotNull Fruit fruit) { + return update(id, fruit); + } + + @PUT + @Path("{tenant}/fruits/{id}") + @Transactional + public Fruit updateTenant(@PathParam("id") int id, @NotNull Fruit fruit) { + return update(id, fruit); + } + + private Fruit update(@NotNull @PathParam("id") int id, @NotNull Fruit fruit) { + if (fruit.getName() == null) { + throw new WebApplicationException("Fruit Name was not set on request.", 422); + } + + Fruit entity = entityManager.find(Fruit.class, id); + if (entity == null) { + throw new WebApplicationException("Fruit with id of " + id + " does not exist.", 404); + } + entity.setName(fruit.getName()); + + LOG.debugv("Update #{0} {1}", fruit.getId(), fruit.getName()); + + return entity; + } + + @DELETE + @Path("fruits/{id}") + @Transactional + public Response deleteDefault(@PathParam("id") int id) { + return delete(id); + } + + @DELETE + @Path("{tenant}/fruits/{id}") + @Transactional + public Response deleteTenant(@PathParam("id") int id) { + return delete(id); + } + + private Response delete(int id) { + Fruit fruit = entityManager.getReference(Fruit.class, id); + if (fruit == null) { + throw new WebApplicationException("Fruit with id of " + id + " does not exist.", 404); + } + LOG.debugv("Delete #{0} {1}", fruit.getId(), fruit.getName()); + entityManager.remove(fruit); + return Response.status(204).build(); + } + + @GET + @Path("fruitsFindBy") + public Response findByDefault(@NotNull @QueryParam("type") String type, @NotNull @QueryParam("value") String value) { + return findBy(type, value); + } + + @GET + @Path("{tenant}/fruitsFindBy") + public Response findByTenant(@NotNull @QueryParam("type") String type, @NotNull @QueryParam("value") String value) { + return findBy(type, value); + } + + private Response findBy(@NotNull String type, @NotNull String value) { + if (!"name".equalsIgnoreCase(type)) { + throw new IllegalArgumentException("Currently only 'fruitsFindBy?type=name' is supported"); + } + List list = entityManager.createNamedQuery("Fruits.findByName", Fruit.class).setParameter("name", value) + .getResultList(); + if (list.size() == 0) { + return Response.status(404).build(); + } + Fruit fruit = list.get(0); + return Response.status(200).entity(fruit).build(); + } + + @Provider + public static class ErrorMapper implements ExceptionMapper { + + @Override + public Response toResponse(Exception exception) { + LOG.error("Failed to handle request", exception); + + int code = 500; + if (exception instanceof WebApplicationException) { + code = ((WebApplicationException) exception).getResponse().getStatus(); + } + + JsonObjectBuilder entityBuilder = Json.createObjectBuilder() + .add("exceptionType", exception.getClass().getName()) + .add("code", code); + + if (exception.getMessage() != null) { + entityBuilder.add("error", exception.getMessage()); + } + + return Response.status(code) + .type(MediaType.APPLICATION_JSON) + .entity(entityBuilder.build()) + .build(); + + } + + } +} diff --git a/integration-tests/hibernate-tenancy/src/main/resources/application.properties b/integration-tests/hibernate-tenancy/src/main/resources/application.properties new file mode 100644 index 0000000000000..5ab0a1a1565ac --- /dev/null +++ b/integration-tests/hibernate-tenancy/src/main/resources/application.properties @@ -0,0 +1,38 @@ +# Hibernate ORM settings +quarkus.hibernate-orm.database.generation=none +quarkus.hibernate-orm.multitenant=DATABASE +quarkus.hibernate-orm.validate-tenant-in-current-sessions=false + +# Maria DB URL +mariadb.url=jdbc:mariadb://localhost:3306 + +# Default DB Configuration +quarkus.datasource.db-kind=mariadb +quarkus.datasource.username=root +quarkus.datasource.password=secret +quarkus.datasource.jdbc.url=${mariadb.url}/hibernate_orm_test +quarkus.datasource.jdbc.max-size=1 +quarkus.datasource.jdbc.min-size=1 +quarkus.flyway.migrate-at-start=true +quarkus.flyway.locations=classpath:database/default + +# DATABASE Tenant 'base' Configuration +quarkus.datasource.base.db-kind=mariadb +quarkus.datasource.base.username=jane +quarkus.datasource.base.password=abc +quarkus.datasource.base.jdbc.url=${mariadb.url}/base +quarkus.datasource.base.jdbc.max-size=1 +quarkus.datasource.base.jdbc.min-size=1 +quarkus.flyway.base.migrate-at-start=true +quarkus.flyway.base.locations=classpath:database/base + +# DATABASE Tenant 'mycompany' Configuration +quarkus.datasource.mycompany.db-kind=mariadb +quarkus.datasource.mycompany.username=john +quarkus.datasource.mycompany.password=def +quarkus.datasource.mycompany.jdbc.url=${mariadb.url}/mycompany +quarkus.datasource.mycompany.jdbc.max-size=1 +quarkus.datasource.mycompany.jdbc.min-size=1 +quarkus.flyway.mycompany.migrate-at-start=true +quarkus.flyway.mycompany.locations=classpath:database/mycompany + diff --git a/integration-tests/hibernate-tenancy/src/main/resources/database/base/V1.0.0__create_fruits.sql b/integration-tests/hibernate-tenancy/src/main/resources/database/base/V1.0.0__create_fruits.sql new file mode 100644 index 0000000000000..2dc454a0dfc19 --- /dev/null +++ b/integration-tests/hibernate-tenancy/src/main/resources/database/base/V1.0.0__create_fruits.sql @@ -0,0 +1,9 @@ +CREATE TABLE known_fruits +( + id INT, + name VARCHAR(40) +); +CREATE SEQUENCE known_fruits_id_seq START WITH 4; +INSERT INTO known_fruits(id, name) VALUES (1, 'Cherry'); +INSERT INTO known_fruits(id, name) VALUES (2, 'Apple'); +INSERT INTO known_fruits(id, name) VALUES (3, 'Banana'); diff --git a/integration-tests/hibernate-tenancy/src/main/resources/database/default/V1.0.0__init_databases.sql b/integration-tests/hibernate-tenancy/src/main/resources/database/default/V1.0.0__init_databases.sql new file mode 100644 index 0000000000000..afb6961476893 --- /dev/null +++ b/integration-tests/hibernate-tenancy/src/main/resources/database/default/V1.0.0__init_databases.sql @@ -0,0 +1,9 @@ +CREATE DATABASE base; +CREATE USER 'jane'@'%' IDENTIFIED BY 'abc'; +GRANT ALL privileges ON base.* TO 'jane'@'%'; + +CREATE DATABASE mycompany; +CREATE USER 'john'@'%' IDENTIFIED BY 'def'; +GRANT ALL privileges ON mycompany.* TO 'john'@'%'; + +FLUSH PRIVILEGES; diff --git a/integration-tests/hibernate-tenancy/src/main/resources/database/mycompany/V1.0.0__create_fruits.sql b/integration-tests/hibernate-tenancy/src/main/resources/database/mycompany/V1.0.0__create_fruits.sql new file mode 100644 index 0000000000000..bd85f6e39532e --- /dev/null +++ b/integration-tests/hibernate-tenancy/src/main/resources/database/mycompany/V1.0.0__create_fruits.sql @@ -0,0 +1,9 @@ +CREATE TABLE known_fruits +( + id INT, + name VARCHAR(40) +); +CREATE SEQUENCE known_fruits_id_seq START WITH 4; +INSERT INTO known_fruits(id, name) VALUES (1, 'Avocado'); +INSERT INTO known_fruits(id, name) VALUES (2, 'Apricots'); +INSERT INTO known_fruits(id, name) VALUES (3, 'Blackberries'); diff --git a/integration-tests/hibernate-tenancy/src/test/java/io/quarkus/it/hibernate/multitenancy/HibernateTenancyFunctionalityInGraalITCase.java b/integration-tests/hibernate-tenancy/src/test/java/io/quarkus/it/hibernate/multitenancy/HibernateTenancyFunctionalityInGraalITCase.java new file mode 100644 index 0000000000000..b58d01b233942 --- /dev/null +++ b/integration-tests/hibernate-tenancy/src/test/java/io/quarkus/it/hibernate/multitenancy/HibernateTenancyFunctionalityInGraalITCase.java @@ -0,0 +1,11 @@ +package io.quarkus.it.hibernate.multitenancy; + +import io.quarkus.test.junit.NativeImageTest; + +/** + * Test various JPA operations running in native mode + */ +@NativeImageTest +public class HibernateTenancyFunctionalityInGraalITCase extends HibernateTenancyFunctionalityTest { + +} diff --git a/integration-tests/hibernate-tenancy/src/test/java/io/quarkus/it/hibernate/multitenancy/HibernateTenancyFunctionalityTest.java b/integration-tests/hibernate-tenancy/src/test/java/io/quarkus/it/hibernate/multitenancy/HibernateTenancyFunctionalityTest.java new file mode 100644 index 0000000000000..00c7de12670b6 --- /dev/null +++ b/integration-tests/hibernate-tenancy/src/test/java/io/quarkus/it/hibernate/multitenancy/HibernateTenancyFunctionalityTest.java @@ -0,0 +1,179 @@ +package io.quarkus.it.hibernate.multitenancy; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.arrayContaining; +import static org.hamcrest.Matchers.arrayWithSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; + +import javax.ws.rs.core.Response.Status; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.restassured.config.ObjectMapperConfig; +import io.restassured.config.RestAssuredConfig; +import io.restassured.http.ContentType; +import io.restassured.mapper.ObjectMapperType; +import io.restassured.response.Response; + +/** + * Test various Hibernate Multitenancy operations running in Quarkus + */ +@QuarkusTest +public class HibernateTenancyFunctionalityTest { + + private static RestAssuredConfig config; + + @BeforeAll + public static void beforeClass() { + config = RestAssured.config().objectMapperConfig(new ObjectMapperConfig(ObjectMapperType.JSONB)); + } + + @BeforeEach + public void cleanup() { + + deleteIfExists("", "Dragonfruit"); + deleteIfExists("", "Gooseberry"); + deleteIfExists("/mycompany", "Damson"); + deleteIfExists("/mycompany", "Grapefruit"); + + } + + @Test + public void testAddDeleteDefaultTenant() throws Exception { + + // Create fruit for default 'base' tenant + given().config(config).with().body(new Fruit("Delete")).contentType(ContentType.JSON).when().post("/fruits").then() + .assertThat().statusCode(is(Status.CREATED.getStatusCode())); + + // Get it + Fruit newFruit = findByName("", "Delete"); + + // Delete it + given().config(config).pathParam("id", newFruit.getId()).contentType("application/json").accept("application/json") + .when().delete("/fruits/{id}").then().assertThat().statusCode(is(Status.NO_CONTENT.getStatusCode())); + + } + + @Test + public void testGetFruitsDefaultTenant() throws Exception { + + Fruit[] fruits = given().config(config).when().get("/fruits").then().assertThat() + .statusCode(is(Status.OK.getStatusCode())).extract() + .as(Fruit[].class); + assertThat(fruits, arrayContaining(new Fruit(2, "Apple"), new Fruit(3, "Banana"), new Fruit(1, "Cherry"))); + + } + + @Test + public void testGetFruitsTenantMycompany() throws Exception { + + Fruit[] fruits = given().config(config).when().get("/mycompany/fruits").then().assertThat() + .statusCode(is(Status.OK.getStatusCode())).extract() + .as(Fruit[].class); + assertThat(fruits, arrayWithSize(3)); + assertThat(fruits, arrayContaining(new Fruit(2, "Apricots"), new Fruit(1, "Avocado"), new Fruit(3, "Blackberries"))); + + } + + @Test + public void testPostFruitDefaultTenant() throws Exception { + + // Create fruit for default 'base' tenant + Fruit newFruit = new Fruit("Dragonfruit"); + given().config(config).with().body(newFruit).contentType(ContentType.JSON).when().post("/fruits").then() + .assertThat() + .statusCode(is(Status.CREATED.getStatusCode())); + + // Getting it directly should return the new fruit + Fruit dragonFruit = findByName("", newFruit.getName()); + assertThat(dragonFruit, not(is(nullValue()))); + + // Getting fruit list should also contain the new fruit + Fruit[] baseFruits = given().config(config).when().get("/fruits").then().assertThat() + .statusCode(is(Status.OK.getStatusCode())).extract() + .as(Fruit[].class); + assertThat(baseFruits, arrayWithSize(4)); + assertThat(baseFruits, + arrayContaining(new Fruit(2, "Apple"), new Fruit(3, "Banana"), new Fruit(1, "Cherry"), dragonFruit)); + + // The other tenant should NOT have the new fruit + Fruit[] mycompanyFruits = given().config(config).when().get("/mycompany/fruits").then().assertThat() + .statusCode(is(Status.OK.getStatusCode())) + .extract().as(Fruit[].class); + assertThat(mycompanyFruits, arrayWithSize(3)); + assertThat(mycompanyFruits, + arrayContaining(new Fruit(2, "Apricots"), new Fruit(1, "Avocado"), new Fruit(3, "Blackberries"))); + + // Getting it directly should also NOT return the new fruit + assertThat(findByName("/mycompany", newFruit.getName()), is(nullValue())); + + } + + @Test + public void testUpdateFruitDefaultTenant() throws Exception { + + // Create fruits for both tenants + + Fruit newFruitBase = new Fruit("Dragonfruit"); + given().config(config).with().body(newFruitBase).contentType(ContentType.JSON).when().post("/fruits").then() + .assertThat() + .statusCode(is(Status.CREATED.getStatusCode())); + Fruit baseFruit = findByName("", newFruitBase.getName()); + assertThat(baseFruit, not(is(nullValue()))); + + Fruit newFruitMycompany = new Fruit("Damson"); + given().config(config).with().body(newFruitMycompany).contentType(ContentType.JSON).when().post("/mycompany/fruits") + .then().assertThat() + .statusCode(is(Status.CREATED.getStatusCode())); + Fruit mycompanyFruit = findByName("/mycompany", newFruitMycompany.getName()); + assertThat(mycompanyFruit, not(is(nullValue()))); + + // Update both + + String baseFruitName = "Gooseberry"; + baseFruit.setName(baseFruitName); + given().config(config).with().body(baseFruit).contentType(ContentType.JSON).when() + .put("/fruits/{id}", baseFruit.getId()).then().assertThat() + .statusCode(is(Status.OK.getStatusCode())); + + String mycompanyFruitName = "Grapefruit"; + mycompanyFruit.setName(mycompanyFruitName); + given().config(config).with().body(mycompanyFruit).contentType(ContentType.JSON).when() + .put("/mycompany/fruits/{id}", mycompanyFruit.getId()) + .then().assertThat().statusCode(is(Status.OK.getStatusCode())); + + // Check if we can get them back and they only exist for one tenant + + assertThat(findByName("", baseFruitName), is(not(nullValue()))); + assertThat(findByName("/mycompany", baseFruitName), is(nullValue())); + + assertThat(findByName("/mycompany", mycompanyFruitName), is(not(nullValue()))); + assertThat(findByName("", mycompanyFruitName), is(nullValue())); + + } + + private Fruit findByName(String tenantPath, String name) { + Response response = given().config(config).when().get(tenantPath + "/fruitsFindBy?type=name&value={name}", name); + if (response.getStatusCode() == Status.OK.getStatusCode()) { + return response.as(Fruit.class); + } + return null; + } + + private void deleteIfExists(String tenantPath, String name) { + Fruit dragonFruit = findByName(tenantPath, name); + if (dragonFruit != null) { + given().config(config).pathParam("id", dragonFruit.getId()).when().delete(tenantPath + "/fruits/{id}").then() + .assertThat() + .statusCode(is(Status.NO_CONTENT.getStatusCode())); + } + } + +}