From 855ab18b75a01ea145050f7424fbe93bec2ccb1f Mon Sep 17 00:00:00 2001 From: Justin Tay <49700559+justin-tay@users.noreply.github.com> Date: Sun, 4 Jun 2023 23:15:22 +0800 Subject: [PATCH] Support multiple `JpaDataStore` (#2998) * Add data store customizer * Add jta transaction manager for testing * Fix reverse the order that the transactions are committed * Add support in annotation to list managed classes * Add datastore customization * Fix test due to lock contention * Update pom * Remove synchronized blocks --- .gitignore | 1 + .../datastore/inmemory/HashMapDataStore.java | 49 +- .../inmemory/HashMapStoreTransaction.java | 124 ++-- .../yahoo/elide/core/utils/ObjectCloner.java | 21 + .../yahoo/elide/core/utils/ObjectCloners.java | 96 +++ .../elide/core/utils/ClassScannerTest.java | 2 +- .../core/utils/ObjectClonerTestObject.java | 44 ++ .../elide/core/utils/ObjectClonersTest.java | 62 ++ .../metadata/MetaDataStoreTransaction.java | 57 +- .../inmemory/HashMapDataStoreTest.java | 131 +++- .../elide/datastores/jpa/JpaDataStore.java | 28 +- .../datastores/jpa/JpaDataStoreTest.java | 7 + .../multiplex/MultiplexManager.java | 39 ++ .../multiplex/MultiplexTransaction.java | 61 +- .../multiplex/MultiplexWriteTransaction.java | 45 +- .../multiplex/MultiplexManagerTest.java | 93 ++- elide-integration-tests/pom.xml | 6 +- .../InMemoryDataStoreHarness.java | 11 +- .../elide-spring-boot-autoconfigure/pom.xml | 6 + .../spring/config/ElideAutoConfiguration.java | 222 ++++--- .../datastore/config/DataStoreBuilder.java | 57 ++ .../config/DataStoreBuilderCustomizer.java | 15 + .../orm/jpa/PlatformJpaTransaction.java | 13 +- .../orm/jpa/config/EnableJpaDataStore.java | 44 ++ .../orm/jpa/config/EnableJpaDataStores.java | 23 + .../jpa/config/JpaDataStoreRegistration.java | 48 ++ .../jpa/config/JpaDataStoreRegistrations.java | 104 +++ .../JpaDataStoreRegistrationsBuilder.java | 40 ++ ...taStoreRegistrationsBuilderCustomizer.java | 16 + .../config/ElideAutoConfigurationTest.java | 100 --- ...ElideAutoConfigurationTransactionTest.java | 599 ++++++++++++++++++ .../config/DataStoreBuilderTest.java | 70 ++ .../models/jpa/v3/ArtifactGroupV3.java | 27 + .../example/models/jpa/v3/package-info.java | 12 + .../src/test/resources/application.yaml | 3 + .../test/resources/transactions.properties | 1 + pom.xml | 110 +++- 37 files changed, 2025 insertions(+), 362 deletions(-) create mode 100644 elide-core/src/main/java/com/yahoo/elide/core/utils/ObjectCloner.java create mode 100644 elide-core/src/main/java/com/yahoo/elide/core/utils/ObjectCloners.java create mode 100644 elide-core/src/test/java/com/yahoo/elide/core/utils/ObjectClonerTestObject.java create mode 100644 elide-core/src/test/java/com/yahoo/elide/core/utils/ObjectClonersTest.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/datastore/config/DataStoreBuilder.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/datastore/config/DataStoreBuilderCustomizer.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/EnableJpaDataStore.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/EnableJpaDataStores.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistration.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistrations.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistrationsBuilder.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistrationsBuilderCustomizer.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/test/java/com/yahoo/elide/spring/config/ElideAutoConfigurationTransactionTest.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/test/java/com/yahoo/elide/spring/datastore/config/DataStoreBuilderTest.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v3/ArtifactGroupV3.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v3/package-info.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/test/resources/transactions.properties diff --git a/.gitignore b/.gitignore index c618f5bbe7..4f5d67d5ef 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ dependency-reduced-pom.xml *.factorypath *.vscode .DS_Store +tmlog*.log \ No newline at end of file diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapDataStore.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapDataStore.java index 1140001fc0..f23f2df4eb 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapDataStore.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapDataStore.java @@ -14,7 +14,8 @@ import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.type.Type; import com.yahoo.elide.core.utils.ClassScanner; -import com.google.common.collect.Sets; +import com.yahoo.elide.core.utils.ObjectCloner; +import com.yahoo.elide.core.utils.ObjectCloners; import lombok.Getter; @@ -27,6 +28,8 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; /** * Simple in-memory only database. @@ -35,29 +38,42 @@ public class HashMapDataStore implements DataStore, DataStoreTestHarness { protected final Map, Map> dataStore = Collections.synchronizedMap(new HashMap<>()); @Getter protected EntityDictionary dictionary; @Getter private final ConcurrentHashMap, AtomicLong> typeIds = new ConcurrentHashMap<>(); + private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); + private final ObjectCloner objectCloner; public HashMapDataStore(ClassScanner scanner, Package beanPackage) { - this(scanner, Sets.newHashSet(beanPackage)); + this(scanner, beanPackage, ObjectCloners::clone); + } + + public HashMapDataStore(ClassScanner scanner, Package beanPackage, ObjectCloner objectCloner) { + this(scanner, Collections.singleton(beanPackage), objectCloner); } public HashMapDataStore(ClassScanner scanner, Set beanPackages) { + this(scanner, beanPackages, ObjectCloners::clone); + } + + public HashMapDataStore(ClassScanner scanner, Set beanPackages, ObjectCloner objectCloner) { + this.objectCloner = objectCloner; for (Package beanPackage : beanPackages) { - scanner.getAllClasses(beanPackage.getName()).stream() - .map(ClassType::new) - .filter(modelType -> EntityDictionary.getFirstAnnotation(modelType, - Arrays.asList(Include.class, Exclude.class)) instanceof Include) - .forEach(modelType -> dataStore.put(modelType, - Collections.synchronizedMap(new LinkedHashMap<>()))); + process(scanner.getAllClasses(beanPackage.getName())); } } public HashMapDataStore(Collection> beanClasses) { - beanClasses.stream() - .map(ClassType::new) + this(beanClasses, ObjectCloners::clone); + } + + public HashMapDataStore(Collection> beanClasses, ObjectCloner objectCloner) { + this.objectCloner = objectCloner; + process(beanClasses); + } + + protected void process(Collection> beanClasses) { + beanClasses.stream().map(ClassType::of) .filter(modelType -> EntityDictionary.getFirstAnnotation(modelType, Arrays.asList(Include.class, Exclude.class)) instanceof Include) - .forEach(modelType -> dataStore.put(modelType, - Collections.synchronizedMap(new LinkedHashMap<>()))); + .forEach(modelType -> dataStore.put(modelType, Collections.synchronizedMap(new LinkedHashMap<>()))); } @Override @@ -71,7 +87,14 @@ public void populateEntityDictionary(EntityDictionary dictionary) { @Override public DataStoreTransaction beginTransaction() { - return new HashMapStoreTransaction(dataStore, dictionary, typeIds); + return new HashMapStoreTransaction(this.readWriteLock, this.dataStore, this.dictionary, + this.typeIds, this.objectCloner, false); + } + + @Override + public DataStoreTransaction beginReadTransaction() { + return new HashMapStoreTransaction(this.readWriteLock, this.dataStore, this.dictionary, + this.typeIds, this.objectCloner, true); } @Override diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapStoreTransaction.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapStoreTransaction.java index b026c15216..b31e6d6d92 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapStoreTransaction.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapStoreTransaction.java @@ -14,6 +14,7 @@ import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.request.Relationship; import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.ObjectCloner; import com.yahoo.elide.core.utils.coerce.converters.Serde; import jakarta.persistence.GeneratedValue; @@ -21,9 +22,12 @@ import java.io.IOException; import java.io.Serializable; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReadWriteLock; /** * HashMapDataStore transaction handler. @@ -33,13 +37,27 @@ public class HashMapStoreTransaction implements DataStoreTransaction { private final List operations; private final EntityDictionary dictionary; private final Map, AtomicLong> typeIds; + private final Lock lock; + private final boolean readOnly; + private final ObjectCloner objectCloner; + private boolean committed = false; - public HashMapStoreTransaction(Map, Map> dataStore, - EntityDictionary dictionary, Map, AtomicLong> typeIds) { + public HashMapStoreTransaction(ReadWriteLock readWriteLock, Map, Map> dataStore, + EntityDictionary dictionary, Map, AtomicLong> typeIds, ObjectCloner objectCloner, + boolean readOnly) { + this.readOnly = readOnly; this.dataStore = dataStore; this.dictionary = dictionary; this.operations = new ArrayList<>(); this.typeIds = typeIds; + this.objectCloner = objectCloner; + + if (readWriteLock != null) { + this.lock = readOnly ? readWriteLock.readLock() : readWriteLock.writeLock(); + this.lock.lock(); + } else { + this.lock = null; + } } @Override @@ -74,24 +92,21 @@ public void delete(Object object, RequestScope requestScope) { @Override public void commit(RequestScope scope) { - synchronized (dataStore) { - operations.stream() - .filter(op -> op.getInstance() != null) - .forEach(op -> { - Object instance = op.getInstance(); - String id = op.getId(); - Map data = dataStore.get(op.getType()); - if (op.getOpType() == Operation.OpType.DELETE) { - data.remove(id); - } else { - if (op.getOpType() == Operation.OpType.CREATE && data.get(id) != null) { - throw new TransactionException(new IllegalStateException("Duplicate key")); - } - data.put(id, instance); - } - }); - operations.clear(); - } + operations.stream().filter(op -> op.getInstance() != null).forEach(op -> { + Object instance = op.getInstance(); + String id = op.getId(); + Map data = dataStore.get(op.getType()); + if (op.getOpType() == Operation.OpType.DELETE) { + data.remove(id); + } else { + if (op.getOpType() == Operation.OpType.CREATE && data.get(id) != null) { + throw new TransactionException(new IllegalStateException("Duplicate key")); + } + data.put(id, instance); + } + }); + operations.clear(); + committed = true; } @Override @@ -108,10 +123,7 @@ public void createObject(Object entity, RequestScope scope) { //GeneratedValue means the DB needs to assign the ID. if (dictionary.getAttributeOrRelationAnnotation(entityClass, GeneratedValue.class, idFieldName) != null) { // TODO: Id's are not necessarily numeric. - AtomicLong nextId; - synchronized (dataStore) { - nextId = getId(entityClass); - } + AtomicLong nextId = getId(entityClass); id = String.valueOf(nextId.getAndIncrement()); setId(entity, id); } else { @@ -138,10 +150,9 @@ public DataStoreIterable getToManyRelation(DataStoreTransaction relation @Override public DataStoreIterable loadObjects(EntityProjection projection, RequestScope scope) { - synchronized (dataStore) { - Map data = dataStore.get(projection.getType()); - return new DataStoreIterableBuilder<>(data.values()).allInMemory().build(); - } + Map data = dataStore.get(projection.getType()); + cacheForRollback(projection.getType(), data); + return new DataStoreIterableBuilder<>(data.values()).allInMemory().build(); } @Override @@ -149,21 +160,60 @@ public Object loadObject(EntityProjection projection, Serializable id, RequestSc EntityDictionary dictionary = scope.getDictionary(); - synchronized (dataStore) { - Map data = dataStore.get(projection.getType()); - if (data == null) { - return null; - } - Serde serde = dictionary.getSerdeLookup().apply(id.getClass()); + Map data = dataStore.get(projection.getType()); + cacheForRollback(projection.getType(), data); + if (data == null) { + return null; + } + Serde serde = dictionary.getSerdeLookup().apply(id.getClass()); + + String idString = (serde == null) ? id.toString() : (String) serde.serialize(id); + return data.get(idString); + } + + /** + * Contains a copy of the objects loaded from the hash map store as they may be + * updated like a persistent object. Since what is returned is a reference to + * the object in the underlying store when updated it immediately reflects in + * the store. As such a copy of the original objects need to made in order to + * rollback. + */ + private Map, Map> rollbackCache = new HashMap<>(); - String idString = (serde == null) ? id.toString() : (String) serde.serialize(id); - return data.get(idString); + protected void cacheForRollback(Type type, Map data) { + if (!readOnly) { + this.rollbackCache.computeIfAbsent(type, key -> { + if (data != null) { + Map copy = new HashMap<>(); + data.entrySet().stream().forEach(entry -> { + Object value = this.objectCloner.clone(entry.getValue(), type); + copy.put(entry.getKey(), value); + }); + return copy; + } + return null; + }); } } @Override public void close() throws IOException { - operations.clear(); + try { + if (!committed && !readOnly) { + rollback(); + } + operations.clear(); + } finally { + if (this.lock != null) { + this.lock.unlock(); + } + } + } + + public void rollback() { + // Rollback data + dataStore.putAll(this.rollbackCache); + this.rollbackCache.clear(); } private boolean containsObject(Object obj) { diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/ObjectCloner.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/ObjectCloner.java new file mode 100644 index 0000000000..04a74722ff --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/ObjectCloner.java @@ -0,0 +1,21 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.utils; + +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; + +/** + * Clones an object. + */ +@FunctionalInterface +public interface ObjectCloner { + T clone(T source, Type cls); + + default T clone(T source) { + return clone(source, ClassType.of(source.getClass())); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/ObjectCloners.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/ObjectCloners.java new file mode 100644 index 0000000000..e91e31b210 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/ObjectCloners.java @@ -0,0 +1,96 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.utils; + +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Field; +import com.yahoo.elide.core.type.Method; +import com.yahoo.elide.core.type.Type; + +/** + * Utility methods to clone an object. + */ +public class ObjectCloners { + private ObjectCloners() { + } + + /** + * Attempt to shallow clone an object. + * + * @param source the object to clone + * @return cloned object if successful or original object + */ + public static T clone(T source) { + return clone(source, ClassType.of(source.getClass())); + } + + /** + * Attempt to shallow clone an object. + * + * @param source the object to clone + * @param cls the type of object + * @return cloned object if successful or original object + */ + public static T clone(T source, Type cls) { + try { + @SuppressWarnings("unchecked") + T target = (T) cls.newInstance(); + copyProperties(source, target, cls); + return target; + } catch (InstantiationException | IllegalAccessException e) { + // ignore + } + // Failed to clone return original object + return source; + } + + /** + * Attempt to copy properties from the source to the target. + * @param source the bean to copy from + * @param target the bean to copy to + * @param cls the class + */ + public static void copyProperties(Object source, Object target, Type cls) { + for (Field field : cls.getFields()) { + try { + field.set(target, field.get(source)); + } catch (IllegalArgumentException | IllegalAccessException | SecurityException e) { + // ignore + } + } + for (Field field : cls.getDeclaredFields()) { + try { + field.setAccessible(true); + field.set(target, field.get(source)); + } catch (IllegalArgumentException | IllegalAccessException | SecurityException e) { + // ignore + } + } + for (Method method : cls.getMethods()) { + if (method.getName().startsWith("set")) { + try { + Method getMethod = cls.getMethod("get" + method.getName().substring(3)); + method.invoke(target, getMethod.invoke(source)); + } catch (NoSuchMethodException e) { + try { + Method isMethod = cls.getMethod("is" + method.getName().substring(3)); + method.invoke(target, isMethod.invoke(source)); + } catch (IllegalStateException + | IllegalArgumentException + | ReflectiveOperationException + | SecurityException e2) { + // ignore + } + } catch (IllegalStateException + | IllegalArgumentException + | ReflectiveOperationException + | SecurityException e) { + // ignore + } + } + } + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/utils/ClassScannerTest.java b/elide-core/src/test/java/com/yahoo/elide/core/utils/ClassScannerTest.java index 6a93c07795..0fdd6c5cbf 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/utils/ClassScannerTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/utils/ClassScannerTest.java @@ -26,7 +26,7 @@ public ClassScannerTest() { @Test public void testGetAllClasses() { Set> classes = scanner.getAllClasses("com.yahoo.elide.core.utils"); - assertEquals(34, classes.size()); + assertEquals(39, classes.size()); assertTrue(classes.contains(ClassScannerTest.class)); } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/utils/ObjectClonerTestObject.java b/elide-core/src/test/java/com/yahoo/elide/core/utils/ObjectClonerTestObject.java new file mode 100644 index 0000000000..618f144095 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/utils/ObjectClonerTestObject.java @@ -0,0 +1,44 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.utils; + +import java.util.List; + +/** + * Test object for ObjectCloner. + */ +public class ObjectClonerTestObject { + private Long id; + private boolean admin; + private String name; + private List list; + + public void setId(Long id) { + this.id = id; + } + public boolean isAdmin() { + return admin; + } + public void setAdmin(boolean admin) { + this.admin = admin; + } + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + public List getList() { + return list; + } + public void setList(List list) { + this.list = list; + } + + public Long id() { + return this.id; + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/utils/ObjectClonersTest.java b/elide-core/src/test/java/com/yahoo/elide/core/utils/ObjectClonersTest.java new file mode 100644 index 0000000000..0370080904 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/utils/ObjectClonersTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +/** + * Test for ObjectCloner. + */ +public class ObjectClonersTest { + public static class NoDefaultConstructorObject { + private Long id; + + public NoDefaultConstructorObject(NoDefaultConstructorObject copy) { + this.id = copy.id; + } + + public NoDefaultConstructorObject(Long id) { + this.id = id; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + } + + @Test + void testClone() { + ObjectClonerTestObject object = new ObjectClonerTestObject(); + object.setId(1L); + object.setAdmin(true); + object.setName("name"); + object.setList(Collections.singletonList("value")); + + ObjectClonerTestObject clone = ObjectCloners.clone(object); + assertTrue(clone.isAdmin()); + assertEquals("name", clone.getName()); + assertEquals("value", clone.getList().get(0)); + assertEquals(1L, clone.id()); + assertTrue(clone != object); + } + + @Test + void testShouldReturnOriginalObject() { + NoDefaultConstructorObject object = new NoDefaultConstructorObject(1L); + NoDefaultConstructorObject clone = ObjectCloners.clone(object); + assertEquals(1L, clone.getId()); + assertTrue(clone == object); + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStoreTransaction.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStoreTransaction.java index 85a6c1c706..b8d4e8d5a2 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStoreTransaction.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStoreTransaction.java @@ -6,6 +6,7 @@ package com.yahoo.elide.datastores.aggregation.metadata; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStore; import com.yahoo.elide.core.datastore.DataStoreIterable; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; @@ -16,6 +17,7 @@ import java.io.IOException; import java.io.Serializable; +import java.util.HashMap; import java.util.Map; import java.util.function.Function; @@ -30,6 +32,8 @@ public class MetaDataStoreTransaction implements DataStoreTransaction { private final Map hashMapDataStores; + private final Map transactions = new HashMap<>(); + public MetaDataStoreTransaction(Map hashMapDataStores) { this.hashMapDataStores = hashMapDataStores; } @@ -66,10 +70,8 @@ public DataStoreIterable getToManyRelation( Relationship relationship, RequestScope scope ) { - return hashMapDataStores - .computeIfAbsent(scope.getApiVersion(), REQUEST_ERROR) - .beginTransaction() - .getToManyRelation(relationTx, entity, relationship, scope); + DataStoreTransaction dataStoreTransaction = getTransaction(scope); + return dataStoreTransaction.getToManyRelation(relationTx, entity, relationship, scope); } @Override @@ -79,31 +81,52 @@ public Object getToOneRelation( Relationship relationship, RequestScope scope ) { - return hashMapDataStores - .computeIfAbsent(scope.getApiVersion(), REQUEST_ERROR) - .beginTransaction() - .getToOneRelation(relationTx, entity, relationship, scope); + DataStoreTransaction dataStoreTransaction = getTransaction(scope); + return dataStoreTransaction.getToOneRelation(relationTx, entity, relationship, scope); } @Override public DataStoreIterable loadObjects(EntityProjection projection, RequestScope scope) { - return hashMapDataStores - .computeIfAbsent(scope.getApiVersion(), REQUEST_ERROR) - .beginTransaction() - .loadObjects(projection, scope); + DataStoreTransaction dataStoreTransaction = getTransaction(scope); + return dataStoreTransaction.loadObjects(projection, scope); } @Override public Object loadObject(EntityProjection projection, Serializable id, RequestScope scope) { - return hashMapDataStores - .computeIfAbsent(scope.getApiVersion(), REQUEST_ERROR) - .beginTransaction() - .loadObject(projection, id, scope); + DataStoreTransaction dataStoreTransaction = getTransaction(scope); + return dataStoreTransaction.loadObject(projection, id, scope); + } + + protected DataStoreTransaction getTransaction(RequestScope scope) { + DataStore dataStore = hashMapDataStores.computeIfAbsent(scope.getApiVersion(), REQUEST_ERROR); + return transactions.computeIfAbsent(scope.getApiVersion(), + key -> dataStore.beginReadTransaction()); } @Override public void close() throws IOException { - // Do nothing + IOException exception = null; + for (DataStoreTransaction transaction : transactions.values()) { + try { + transaction.close(); + } catch (IOException e) { + if (exception == null) { + exception = e; + } else { + exception.addSuppressed(e); + } + } catch (RuntimeException e) { + if (exception == null) { + exception = new IOException(e); + } else { + exception.addSuppressed(e); + } + } + } + transactions.clear(); + if (exception != null) { + throw exception; + } } @Override diff --git a/elide-datastore/elide-datastore-inmemorydb/src/test/java/com/yahoo/elide/datastores/inmemory/HashMapDataStoreTest.java b/elide-datastore/elide-datastore-inmemorydb/src/test/java/com/yahoo/elide/datastores/inmemory/HashMapDataStoreTest.java index 80dfb4de49..5b0553b402 100644 --- a/elide-datastore/elide-datastore-inmemorydb/src/test/java/com/yahoo/elide/datastores/inmemory/HashMapDataStoreTest.java +++ b/elide-datastore/elide-datastore-inmemorydb/src/test/java/com/yahoo/elide/datastores/inmemory/HashMapDataStoreTest.java @@ -10,8 +10,12 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.type.ClassType; @@ -30,25 +34,29 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; /** * HashMapDataStore tests. */ public class HashMapDataStoreTest { - private InMemoryDataStore inMemoryDataStore; + private HashMapDataStore hashMapDataStore; private EntityDictionary entityDictionary; @BeforeEach public void setup() { entityDictionary = EntityDictionary.builder().build(); - inMemoryDataStore = new InMemoryDataStore(DefaultClassScanner.getInstance(), FirstBean.class.getPackage()); - inMemoryDataStore.populateEntityDictionary(entityDictionary); + hashMapDataStore = new HashMapDataStore(DefaultClassScanner.getInstance(), FirstBean.class.getPackage()); + hashMapDataStore.populateEntityDictionary(entityDictionary); } private T createNewInheritanceObject(Class type) throws IOException, InstantiationException, IllegalAccessException { T obj = type.newInstance(); - try (DataStoreTransaction t = inMemoryDataStore.beginTransaction()) { + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { t.createObject(obj, null); t.commit(null); } @@ -57,13 +65,13 @@ private T createNewInheritanceObject(Class type) @Test public void dataStoreTestInheritance() throws IOException, InstantiationException, IllegalAccessException { - Map entry = inMemoryDataStore.get(ClassType.of(FirstBean.class)); + Map entry = hashMapDataStore.get(ClassType.of(FirstBean.class)); assertEquals(0, entry.size()); FirstChildBean child = createNewInheritanceObject(FirstChildBean.class); // Adding Child object, adds a parent entry. - try (DataStoreTransaction t = inMemoryDataStore.beginTransaction()) { + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { Iterable beans = t.loadObjects(EntityProjection.builder() .type(FirstBean.class) .build(), null); @@ -88,20 +96,20 @@ public void dataStoreTestInheritance() throws IOException, InstantiationExceptio @Test public void dataStoreTestInheritanceDelete() throws IOException, InstantiationException, IllegalAccessException { - Map entry = inMemoryDataStore.get(ClassType.of(FirstBean.class)); + Map entry = hashMapDataStore.get(ClassType.of(FirstBean.class)); assertEquals(0, entry.size()); FirstChildBean child = createNewInheritanceObject(FirstChildBean.class); createNewInheritanceObject(FirstBean.class); // Delete Child - try (DataStoreTransaction t = inMemoryDataStore.beginTransaction()) { + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { t.delete(child, null); t.commit(null); } // Only 1 parent entry should remain. - try (DataStoreTransaction t = inMemoryDataStore.beginTransaction()) { + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { Iterable beans = t.loadObjects(EntityProjection.builder() .type(FirstBean.class) .build(), null); @@ -114,21 +122,21 @@ public void dataStoreTestInheritanceDelete() throws IOException, InstantiationEx @Test public void dataStoreTestInheritanceUpdate() throws IOException, InstantiationException, IllegalAccessException { - Map entry = inMemoryDataStore.get(ClassType.of(FirstBean.class)); + Map entry = hashMapDataStore.get(ClassType.of(FirstBean.class)); assertEquals(0, entry.size()); FirstChildBean child = createNewInheritanceObject(FirstChildBean.class); createNewInheritanceObject(FirstBean.class); // update Child - try (DataStoreTransaction t = inMemoryDataStore.beginTransaction()) { + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { child.setNickname("hello"); t.save(child, null); t.commit(null); } // Only 1 parent entry should remain. - try (DataStoreTransaction t = inMemoryDataStore.beginTransaction()) { + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { Iterable beans = t.loadObjects(EntityProjection.builder() .type(FirstBean.class) .build(), null); @@ -142,7 +150,7 @@ public void dataStoreTestInheritanceUpdate() throws IOException, InstantiationEx @Test public void checkLoading() { - final EntityDictionary entityDictionary = inMemoryDataStore.getDictionary(); + final EntityDictionary entityDictionary = hashMapDataStore.getDictionary(); assertNotNull(entityDictionary.getJsonAliasFor(ClassType.of(FirstBean.class))); assertNotNull(entityDictionary.getJsonAliasFor(ClassType.of(SecondBean.class))); assertThrows(IllegalArgumentException.class, () -> entityDictionary.getJsonAliasFor(ClassType.of(NonEntity.class))); @@ -154,7 +162,7 @@ public void testValidCommit() throws Exception { FirstBean object = new FirstBean(); object.id = "0"; object.name = "Test"; - try (DataStoreTransaction t = inMemoryDataStore.beginTransaction()) { + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { assertFalse(t.loadObjects(EntityProjection.builder() .type(FirstBean.class) .build(), null).iterator().hasNext()); @@ -164,7 +172,7 @@ public void testValidCommit() throws Exception { .build(), null).iterator().hasNext()); t.commit(null); } - try (DataStoreTransaction t = inMemoryDataStore.beginTransaction()) { + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { Iterable beans = t.loadObjects(EntityProjection.builder() .type(FirstBean.class) .build(), null); @@ -182,7 +190,7 @@ public void testCanGenerateIdsAfterDataCommitted() throws Exception { object.id = "1"; object.name = "number one"; - try (DataStoreTransaction t = inMemoryDataStore.beginTransaction()) { + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { t.createObject(object, null); t.save(object, null); t.commit(null); @@ -193,7 +201,7 @@ public void testCanGenerateIdsAfterDataCommitted() throws Exception { object2.id = null; object2.name = "number two"; - try (DataStoreTransaction t = inMemoryDataStore.beginTransaction()) { + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { t.createObject(object2, null); t.save(object2, null); t.commit(null); @@ -201,7 +209,7 @@ public void testCanGenerateIdsAfterDataCommitted() throws Exception { // and a meaningful ID is assigned Set names = new HashSet<>(); - try (DataStoreTransaction t = inMemoryDataStore.beginTransaction()) { + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { for (Object objBean : t.loadObjects(EntityProjection.builder() .type(FirstBean.class) .build(), null)) { @@ -213,4 +221,91 @@ public void testCanGenerateIdsAfterDataCommitted() throws Exception { assertEquals(ImmutableSet.of("number one", "number two"), names); } + + @Test + public void testRollback() throws Exception { + FirstBean object = new FirstBean(); + object.id = "1"; + object.name = "number one"; + + RequestScope scope = mock(RequestScope.class); + when(scope.getDictionary()).thenReturn(entityDictionary); + + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { + t.createObject(object, null); + t.save(object, null); + t.commit(null); + } + + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { + // The FirstBean loaded is the same reference from the HashMapDataStore so + // modifying it actually updates the underlying store + FirstBean loaded = t.loadObject(EntityProjection.builder().type(FirstBean.class).build(), "1", scope); + loaded.name = "updated"; + + // There is no commit so this will rollback + } + + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { + FirstBean loaded = t.loadObject(EntityProjection.builder().type(FirstBean.class).build(), "1", scope); + assertEquals("number one", loaded.name); + } + } + + /** + * Tests if another thread reading the hash map data store will read dirty + * uncommitted data. Typically a read write lock is required to ensure readers + * don't read dirty data. + * + * @throws Exception the exception + */ + @Test + public void testShouldNotReadDirtyData() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(1); + try { + FirstBean object = new FirstBean(); + object.id = "1"; + object.name = "number one"; + + RequestScope scope = mock(RequestScope.class); + when(scope.getDictionary()).thenReturn(entityDictionary); + + Future future; + + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { + t.createObject(object, null); + t.save(object, null); + t.commit(null); + } + + try (DataStoreTransaction t = hashMapDataStore.beginTransaction()) { + // The FirstBean loaded is the same reference from the HashMapDataStore so + // modifying it actually updates the underlying store making it dirty + FirstBean loaded = t.loadObject(EntityProjection.builder().type(FirstBean.class).build(), "1", scope); + loaded.name = "updated"; + + future = executor.submit(() -> { + try (DataStoreTransaction r = hashMapDataStore.beginReadTransaction()) { + FirstBean other = r.loadObject(EntityProjection.builder().type(FirstBean.class).build(), "1", scope); + return other; + } + }); + + Thread.sleep(1000); + // There is no commit so this should rollback + } + + try (DataStoreTransaction r = hashMapDataStore.beginReadTransaction()) { + // Verify that the rolled back first bean is still number one + FirstBean rolledBack = r.loadObject(EntityProjection.builder().type(FirstBean.class).build(), "1", scope); + assertEquals("number one", rolledBack.name); + } + + // Verify that the first bean read in the different thread did not read the dirty bean + FirstBean firstBean = future.get(30, TimeUnit.SECONDS); + assertEquals("number one", firstBean.name); + } finally { + executor.shutdownNow(); + } + } } diff --git a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/JpaDataStore.java b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/JpaDataStore.java index 64154df865..b5694a19b4 100644 --- a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/JpaDataStore.java +++ b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/JpaDataStore.java @@ -35,48 +35,56 @@ public class JpaDataStore implements JPQLDataStore { protected final JpaTransactionSupplier writeTransactionSupplier; protected final MetamodelSupplier metamodelSupplier; protected final Set> modelsToBind; - protected final QueryLogger logger; + protected final QueryLogger queryLogger; private JpaDataStore(EntityManagerSupplier entityManagerSupplier, JpaTransactionSupplier readTransactionSupplier, JpaTransactionSupplier writeTransactionSupplier, - QueryLogger logger, + QueryLogger queryLogger, MetamodelSupplier metamodelSupplier, - Type ... models) { + Type[] models) { this.entityManagerSupplier = entityManagerSupplier; this.readTransactionSupplier = readTransactionSupplier; this.writeTransactionSupplier = writeTransactionSupplier; this.metamodelSupplier = metamodelSupplier; - this.logger = logger; + this.queryLogger = queryLogger; this.modelsToBind = new HashSet<>(); - Collections.addAll(this.modelsToBind, models); + if (models != null) { + Collections.addAll(this.modelsToBind, models); + } + if (this.metamodelSupplier == null && this.modelsToBind.isEmpty()) { + throw new IllegalArgumentException( + "Either the metamodel supplier or the explicit models to bind needs to be provided."); + } } public JpaDataStore(EntityManagerSupplier entityManagerSupplier, JpaTransactionSupplier readTransactionSupplier, JpaTransactionSupplier writeTransactionSupplier, + QueryLogger queryLogger, MetamodelSupplier metamodelSupplier) { - this(entityManagerSupplier, readTransactionSupplier, writeTransactionSupplier, DEFAULT_LOGGER, - metamodelSupplier); + this(entityManagerSupplier, readTransactionSupplier, writeTransactionSupplier, queryLogger, + metamodelSupplier, null); } public JpaDataStore(EntityManagerSupplier entityManagerSupplier, JpaTransactionSupplier readTransactionSupplier, JpaTransactionSupplier writeTransactionSupplier, + QueryLogger queryLogger, Type ... models) { - this(entityManagerSupplier, readTransactionSupplier, writeTransactionSupplier, DEFAULT_LOGGER, null, models); + this(entityManagerSupplier, readTransactionSupplier, writeTransactionSupplier, queryLogger, null, models); } public JpaDataStore(EntityManagerSupplier entityManagerSupplier, JpaTransactionSupplier transactionSupplier, MetamodelSupplier metamodelSupplier) { - this(entityManagerSupplier, transactionSupplier, transactionSupplier, metamodelSupplier); + this(entityManagerSupplier, transactionSupplier, transactionSupplier, DEFAULT_LOGGER, metamodelSupplier); } public JpaDataStore(EntityManagerSupplier entityManagerSupplier, JpaTransactionSupplier transactionSupplier, Type ... models) { - this(entityManagerSupplier, transactionSupplier, transactionSupplier, models); + this(entityManagerSupplier, transactionSupplier, transactionSupplier, DEFAULT_LOGGER, models); } @Override diff --git a/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreTest.java b/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreTest.java index 46f211a295..8d27e28486 100644 --- a/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreTest.java +++ b/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreTest.java @@ -7,6 +7,7 @@ package com.yahoo.elide.datastores.jpa; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -97,4 +98,10 @@ class Test { assertNotNull(dictionary.lookupBoundClass(ClassType.of(Test.class))); } + + @Test + void shouldThrowIllegalArgumentExceptionIfNoMetamodelSupplier() { + EntityManager managerMock = mock(EntityManager.class); + assertThrows(IllegalArgumentException.class, () -> new JpaDataStore(() -> managerMock, unused -> null)); + } } diff --git a/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexManager.java b/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexManager.java index 94b7fe9ce2..7cc49797c1 100644 --- a/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexManager.java +++ b/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexManager.java @@ -12,6 +12,8 @@ import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.security.PermissionExecutor; import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.ObjectCloner; +import com.yahoo.elide.core.utils.ObjectCloners; import lombok.AccessLevel; import lombok.Setter; @@ -21,6 +23,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; +import java.util.function.Predicate; /** * Allows multiple database handlers to each process their own beans while keeping the main @@ -41,6 +44,8 @@ public final class MultiplexManager implements DataStore { protected final List dataStores; protected final ConcurrentHashMap, DataStore> dataStoreMap = new ConcurrentHashMap<>(); + protected final Predicate applyCompensatingTransactions; + protected final ObjectCloner objectCloner; @Setter(AccessLevel.PROTECTED) private EntityDictionary dictionary; @@ -50,7 +55,41 @@ public final class MultiplexManager implements DataStore { * @param dataStores list of sub-managers */ public MultiplexManager(DataStore... dataStores) { + this(ObjectCloners::clone, dataStore -> true, dataStores); + } + + /** + * Create a single DataStore to handle provided managers within a single + * transaction. + * + * @param objectCloner to use for cloning objects to apply to compensating + * transaction + * @param dataStores list of sub-managers + */ + public MultiplexManager(ObjectCloner objectCloner, DataStore... dataStores) { + this(objectCloner, dataStore -> true, dataStores); + } + + /** + * Create a single DataStore to handle provided managers within a single + * transaction. + * + * @param objectCloner to use for cloning objects to apply to + * compensating transaction + * @param applyCompensatingTransactions apply compensating transactions on + * rollback to previously committed + * datastores + * @param dataStores list of sub-managers + */ + public MultiplexManager(ObjectCloner objectCloner, Predicate applyCompensatingTransactions, + DataStore... dataStores) { + this.objectCloner = objectCloner; this.dataStores = Arrays.asList(dataStores); + this.applyCompensatingTransactions = applyCompensatingTransactions; + } + + protected boolean isApplyCompensatingTransactions(DataStore dataStore) { + return this.applyCompensatingTransactions.test(dataStore); } @Override diff --git a/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexTransaction.java b/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexTransaction.java index 33c724ea50..0a20157d1e 100644 --- a/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexTransaction.java +++ b/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexTransaction.java @@ -23,9 +23,12 @@ import java.io.IOException; import java.io.Serializable; +import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; +import java.util.ListIterator; import java.util.Set; +import java.util.function.Consumer; /** * Multiplex transaction handler. Process each sub-database transactions within a single transaction. @@ -66,42 +69,60 @@ public DataStoreIterable loadObjects( } @Override - public void flush(RequestScope requestScope) { - transactions.values().stream() - .filter(dataStoreTransaction -> dataStoreTransaction != null) - .forEach(dataStoreTransaction -> dataStoreTransaction.flush(requestScope)); + public void flush(RequestScope scope) { + processTransactions(dataStoreTransaction -> dataStoreTransaction.flush(scope)); } @Override public void preCommit(RequestScope scope) { - transactions.values().stream() - .filter(dataStoreTransaction -> dataStoreTransaction != null) - .forEach(tx -> tx.preCommit(scope)); + processTransactions(dataStoreTransaction -> dataStoreTransaction.preCommit(scope)); } @Override public void commit(RequestScope scope) { // flush all before commit flush(scope); - transactions.values().stream() - .filter(dataStoreTransaction -> dataStoreTransaction != null) - .forEach(dataStoreTransaction -> dataStoreTransaction.commit(scope)); + processTransactions(dataStoreTransaction -> dataStoreTransaction.commit(scope)); + } + + /** + * Processes the transactions in reverse order and is non null. + * + * @param processor process the transaction + */ + protected void processTransactions(Consumer processor) { + // Transactions must be processed in reverse order + ListIterator iterator = new ArrayList<>(transactions.values()) + .listIterator(transactions.size()); + while (iterator.hasPrevious()) { + DataStoreTransaction dataStoreTransaction = iterator.previous(); + if (dataStoreTransaction != null) { + processor.accept(dataStoreTransaction); + } + } } @Override public void close() throws IOException { IOException cause = null; - for (DataStoreTransaction transaction : transactions.values()) { - try { - transaction.close(); - } catch (IOException | Error | RuntimeException e) { - if (cause != null) { - cause.addSuppressed(e); - } else if (e instanceof IOException) { - cause = (IOException) e; - } else { - cause = new IOException(e); + + // Transactions must be processed in reverse order + ListIterator iterator = new ArrayList<>(transactions.values()) + .listIterator(transactions.size()); + while (iterator.hasPrevious()) { + DataStoreTransaction dataStoreTransaction = iterator.previous(); + if (dataStoreTransaction != null) { + try { + dataStoreTransaction.close(); + } catch (IOException | Error | RuntimeException e) { + if (cause != null) { + cause.addSuppressed(e); + } else if (e instanceof IOException ioException) { + cause = ioException; + } else { + cause = new IOException(e); + } } } } diff --git a/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexWriteTransaction.java b/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexWriteTransaction.java index 35339309ed..6e40a6daaf 100644 --- a/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexWriteTransaction.java +++ b/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexWriteTransaction.java @@ -15,8 +15,6 @@ import com.yahoo.elide.core.exceptions.TransactionException; import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.request.Relationship; -import com.yahoo.elide.core.type.Field; -import com.yahoo.elide.core.type.Method; import com.yahoo.elide.core.type.Type; import jakarta.ws.rs.WebApplicationException; @@ -28,6 +26,7 @@ import java.util.Collections; import java.util.IdentityHashMap; import java.util.List; +import java.util.ListIterator; import java.util.Map.Entry; /** @@ -67,11 +66,18 @@ public void commit(RequestScope scope) { // flush all before commits flush(scope); - ArrayList commitList = new ArrayList<>(); - for (Entry entry : transactions.entrySet()) { + List commitList = new ArrayList<>(); + + // Transactions must be committed in reverse order + ListIterator> iterator = new ArrayList<>(transactions.entrySet()) + .listIterator(transactions.size()); + while (iterator.hasPrevious()) { + Entry entry = iterator.previous(); try { entry.getValue().commit(scope); - commitList.add(entry.getKey()); + if (this.multiplexManager.isApplyCompensatingTransactions(entry.getKey())) { + commitList.add(entry.getKey()); + } } catch (HttpStatusException | WebApplicationException e) { reverseTransactions(commitList, e, scope); throw e; @@ -88,7 +94,7 @@ public void commit(RequestScope scope) { * @param restoreList List of database managers to reverse the last commit * @param cause cause to add any suppressed exceptions */ - private void reverseTransactions(ArrayList restoreList, Throwable cause, RequestScope requestScope) { + private void reverseTransactions(List restoreList, Throwable cause, RequestScope requestScope) { for (DataStore dataStore : restoreList) { try (DataStoreTransaction transaction = dataStore.beginTransaction()) { List list = dirtyObjects.get(dataStore); @@ -97,7 +103,10 @@ private void reverseTransactions(ArrayList restoreList, Throwable cau if (cloned == NEWLY_CREATED_OBJECT) { transaction.delete(dirtyObject, requestScope); } else { - transaction.save(cloned, requestScope); + // If cloned is null this is an update to an object that wasn't created yet + if (cloned != null) { + transaction.save(cloned, requestScope); + } } } transaction.commit(requestScope); @@ -149,27 +158,7 @@ private Object cloneObject(Object object) { } Type cls = multiplexManager.getDictionary().lookupBoundClass(EntityDictionary.getType(object)); - try { - Object clone = cls.newInstance(); - for (Field field : cls.getFields()) { - field.set(clone, field.get(object)); - } - for (Method method : cls.getMethods()) { - if (method.getName().startsWith("set")) { - try { - Method getMethod = cls.getMethod("get" + method.getName().substring(3)); - method.invoke(clone, getMethod.invoke(object)); - } catch (IllegalStateException | IllegalArgumentException - | ReflectiveOperationException | SecurityException e) { - return null; - } - } - } - return clone; - } catch (InstantiationException | IllegalAccessException e) { - // ignore - } - return null; + return this.multiplexManager.objectCloner.clone(object, cls); } @Override diff --git a/elide-datastore/elide-datastore-multiplex/src/test/java/com/yahoo/elide/datastores/multiplex/MultiplexManagerTest.java b/elide-datastore/elide-datastore-multiplex/src/test/java/com/yahoo/elide/datastores/multiplex/MultiplexManagerTest.java index ac8d282955..ab081aaa59 100644 --- a/elide-datastore/elide-datastore-multiplex/src/test/java/com/yahoo/elide/datastores/multiplex/MultiplexManagerTest.java +++ b/elide-datastore/elide-datastore-multiplex/src/test/java/com/yahoo/elide/datastores/multiplex/MultiplexManagerTest.java @@ -33,6 +33,7 @@ import lombok.extern.slf4j.Slf4j; import java.io.IOException; +import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; /** @@ -90,8 +91,23 @@ public void testValidCommit() throws IOException { } } + /** + * Tests the case where there is no commit to the hash map data store occurs and + * a rollback occurs subsequently. + * + * It is expected that the update to the FirstBean from the hash map data store + * to set the name to update does not update the underlying store if there is no + * commit to the hash map data store. + * + * @throws IOException the exception + * @throws IllegalArgumentException the exception + * @throws InvocationTargetException the exception + * @throws NoSuchMethodException the exception + * @throws SecurityException the exception + */ @Test - public void partialCommitFailure() throws IOException { + public void partialCommitFailureNoCommit() throws IOException, IllegalArgumentException, InvocationTargetException, + NoSuchMethodException, SecurityException { final EntityDictionary entityDictionary = EntityDictionary.builder().build(); final HashMapDataStore ds1 = new HashMapDataStore(DefaultClassScanner.getInstance(), FirstBean.class.getPackage()); @@ -107,7 +123,7 @@ public void partialCommitFailure() throws IOException { .type(FirstBean.class) .build(), null).iterator().hasNext()); - FirstBean firstBean = FirstBean.class.newInstance(); + FirstBean firstBean = FirstBean.class.getDeclaredConstructor().newInstance(); firstBean.setName("name"); t.createObject(firstBean, null); //t.save(firstBean); @@ -124,7 +140,7 @@ public void partialCommitFailure() throws IOException { .build(), null).iterator().next(); firstBean.setName("update"); t.save(firstBean, null); - OtherBean otherBean = OtherBean.class.newInstance(); + OtherBean otherBean = OtherBean.class.getDeclaredConstructor().newInstance(); t.createObject(otherBean, null); //t.save(firstBean); @@ -139,8 +155,75 @@ public void partialCommitFailure() throws IOException { .build(), null); assertNotNull(beans); ArrayList list = Lists.newArrayList(beans.iterator()); - assertEquals(list.size(), 1); - assertEquals(((FirstBean) list.get(0)).getName(), "name"); + assertEquals(1, list.size()); + assertEquals("name", ((FirstBean) list.get(0)).getName()); + } + } + + /** + * Tests the case where the commit to the hash map data store occurs and a + * rollback occurs subsequently which the multiplex manager will attempt to + * reverse. + * + * @throws IOException the exception + * @throws IllegalArgumentException the exception + * @throws InvocationTargetException the exception + * @throws NoSuchMethodException the exception + * @throws SecurityException the exception + */ + @Test + public void partialCommitFailureReverseTransactions() throws IOException, IllegalArgumentException, + InvocationTargetException, NoSuchMethodException, SecurityException { + final EntityDictionary entityDictionary = EntityDictionary.builder().build(); + final HashMapDataStore ds1 = new HashMapDataStore(DefaultClassScanner.getInstance(), + FirstBean.class.getPackage()); + final DataStore ds2 = new TestDataStore(OtherBean.class.getPackage()); + final MultiplexManager multiplexManager = new MultiplexManager(ds1, ds2); + multiplexManager.populateEntityDictionary(entityDictionary); + + assertEquals(ds1, multiplexManager.getSubManager(ClassType.of(FirstBean.class))); + assertEquals(ds2, multiplexManager.getSubManager(ClassType.of(OtherBean.class))); + + try (DataStoreTransaction t = ds1.beginTransaction()) { + assertFalse(t.loadObjects(EntityProjection.builder() + .type(FirstBean.class) + .build(), null).iterator().hasNext()); + + FirstBean firstBean = FirstBean.class.getDeclaredConstructor().newInstance(); + firstBean.setName("name"); + t.createObject(firstBean, null); + //t.save(firstBean); + assertFalse(t.loadObjects(EntityProjection.builder() + .type(FirstBean.class) + .build(), null).iterator().hasNext()); + t.commit(null); + } catch (InstantiationException | IllegalAccessException e) { + log.error("", e); + } + try (DataStoreTransaction t = multiplexManager.beginTransaction()) { + OtherBean otherBean = OtherBean.class.getDeclaredConstructor().newInstance(); + t.createObject(otherBean, null); + + FirstBean firstBean = (FirstBean) t.loadObjects(EntityProjection.builder() + .type(FirstBean.class) + .build(), null).iterator().next(); + firstBean.setName("update"); + //t.save(firstBean, null); + t.save(firstBean, null); + + assertThrows(TransactionException.class, () -> t.commit(null)); + } catch (InstantiationException | IllegalAccessException e) { + log.error("", e); + } + // verify state + try (DataStoreTransaction t = ds1.beginTransaction()) { + Iterable beans = t.loadObjects(EntityProjection.builder() + .type(FirstBean.class) + .build(), null); + assertNotNull(beans); + ArrayList list = Lists.newArrayList(beans.iterator()); + assertEquals(1, list.size()); + assertEquals("name", ((FirstBean) list.get(0)).getName()); } } diff --git a/elide-integration-tests/pom.xml b/elide-integration-tests/pom.xml index 0fb9f8ab04..a2a6950112 100644 --- a/elide-integration-tests/pom.xml +++ b/elide-integration-tests/pom.xml @@ -50,7 +50,11 @@ elide-async ${elide.version} - + + com.yahoo.elide + elide-datastore-multiplex + ${elide.version} + org.projectlombok diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/InMemoryDataStoreHarness.java b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/InMemoryDataStoreHarness.java index 17cbdff134..fdf9d15b24 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/InMemoryDataStoreHarness.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/InMemoryDataStoreHarness.java @@ -12,6 +12,7 @@ import com.yahoo.elide.core.datastore.inmemory.InMemoryDataStore; import com.yahoo.elide.core.datastore.test.DataStoreTestHarness; import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.datastores.multiplex.MultiplexManager; import com.google.common.collect.Sets; import example.Address; import example.Company; @@ -29,6 +30,7 @@ public class InMemoryDataStoreHarness implements DataStoreTestHarness { private InMemoryDataStore memoryStore; private HashMapDataStore mapStore; + private HashMapDataStore asyncStore; public InMemoryDataStoreHarness() { Set beanPackages = Sets.newHashSet( @@ -37,14 +39,18 @@ public InMemoryDataStoreHarness() { Invoice.class.getPackage(), Manager.class.getPackage(), BookV2.class.getPackage(), - AsyncQuery.class.getPackage(), Company.class.getPackage(), Address.class.getPackage() + ); + Set asyncBeanPackages = Sets.newHashSet( + AsyncQuery.class.getPackage() ); + mapStore = new HashMapDataStore(DefaultClassScanner.getInstance(), beanPackages); - memoryStore = new InMemoryDataStore(mapStore); + asyncStore = new HashMapDataStore(DefaultClassScanner.getInstance(), asyncBeanPackages); + memoryStore = new InMemoryDataStore(new MultiplexManager(mapStore, asyncStore)); } @Override @@ -55,5 +61,6 @@ public DataStore getDataStore() { @Override public void cleanseTestData() { mapStore.cleanseTestData(); + asyncStore.cleanseTestData(); } } diff --git a/elide-spring/elide-spring-boot-autoconfigure/pom.xml b/elide-spring/elide-spring-boot-autoconfigure/pom.xml index b35e4b71bf..baf75f24cf 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/pom.xml +++ b/elide-spring/elide-spring-boot-autoconfigure/pom.xml @@ -268,6 +268,12 @@ junit-jupiter-engine test + + + com.atomikos + transactions-spring-boot3-starter + test + org.junit.platform diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java index 0620d9f93f..0bc66acff0 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java @@ -43,9 +43,6 @@ import com.yahoo.elide.datastores.aggregation.queryengines.sql.query.AggregateBeforeJoinOptimizer; import com.yahoo.elide.datastores.aggregation.validator.TemplateConfigValidator; import com.yahoo.elide.datastores.jpa.JpaDataStore; -import com.yahoo.elide.datastores.jpa.JpaDataStore.EntityManagerSupplier; -import com.yahoo.elide.datastores.jpa.JpaDataStore.JpaTransactionSupplier; -import com.yahoo.elide.datastores.multiplex.MultiplexManager; import com.yahoo.elide.graphql.QueryRunners; import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.jsonapi.links.DefaultJSONApiLinks; @@ -63,9 +60,15 @@ import com.yahoo.elide.spring.controllers.ExportController; import com.yahoo.elide.spring.controllers.GraphqlController; import com.yahoo.elide.spring.controllers.JsonApiController; +import com.yahoo.elide.spring.datastore.config.DataStoreBuilder; +import com.yahoo.elide.spring.datastore.config.DataStoreBuilderCustomizer; import com.yahoo.elide.spring.jackson.ObjectMapperBuilder; -import com.yahoo.elide.spring.orm.jpa.EntityManagerProxySupplier; -import com.yahoo.elide.spring.orm.jpa.PlatformJpaTransactionSupplier; +import com.yahoo.elide.spring.orm.jpa.config.EnableJpaDataStore; +import com.yahoo.elide.spring.orm.jpa.config.EnableJpaDataStores; +import com.yahoo.elide.spring.orm.jpa.config.JpaDataStoreRegistration; +import com.yahoo.elide.spring.orm.jpa.config.JpaDataStoreRegistrations; +import com.yahoo.elide.spring.orm.jpa.config.JpaDataStoreRegistrationsBuilder; +import com.yahoo.elide.spring.orm.jpa.config.JpaDataStoreRegistrationsBuilderCustomizer; import com.yahoo.elide.swagger.OpenApiBuilder; import com.yahoo.elide.utils.HeaderUtils; import com.fasterxml.jackson.databind.ObjectMapper; @@ -75,7 +78,6 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.boot.autoconfigure.AutoConfigureAfter; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -83,6 +85,7 @@ import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Scope; @@ -90,8 +93,6 @@ import org.springframework.core.annotation.Order; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.TransactionDefinition; -import org.springframework.transaction.support.DefaultTransactionDefinition; import org.springframework.util.function.SingletonSupplier; import graphql.execution.DataFetcherExceptionHandler; @@ -114,7 +115,6 @@ import java.util.Optional; import java.util.Set; import java.util.TimeZone; -import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; @@ -354,85 +354,169 @@ public QueryEngine buildQueryEngine(DataSource defaultDataSource, } /** - * Create a JPA Transaction Supplier to use. - * @param transactionManager Spring Platform Transaction Manager - * @param entityManagerFactory An instance of EntityManagerFactory - * @param settings Elide configuration settings. - * @return the JpaTransactionSupplier + * Creates the default JpaDataStoreRegistrationsBuilder and applies + * customizations. + * + *

+ * If this bean is already defined Elide will not attempt to discover + * JpaDataStore registrations and the JpaDataStores to create can be fully + * configured. + * + *

+ * If only minor customizations are required a + * {@link JpaDataStoreRegistrationsBuilderCustomizer} can be defined to customize the + * builder. + * + * @param applicationContext the application context. + * @param settings Elide configuration settings. + * @param optionalQueryLogger the optional query logger. + * @param customizerProviders the customizer providers. + * @return the default JpaDataStoreRegistrationsBuilder. */ @Bean @ConditionalOnMissingBean - @ConditionalOnBean(PlatformTransactionManager.class) @Scope(SCOPE_PROTOTYPE) - public JpaTransactionSupplier jpaTransactionSupplier(PlatformTransactionManager transactionManager, - EntityManagerFactory entityManagerFactory, ElideConfigProperties settings) { - return new PlatformJpaTransactionSupplier( - new DefaultTransactionDefinition(TransactionDefinition.PROPAGATION_REQUIRED), transactionManager, - entityManagerFactory, settings.getJpaStore().isDelegateToInMemoryStore()); + public JpaDataStoreRegistrationsBuilder jpaDataStoreRegistrationsBuilder( + ApplicationContext applicationContext, + ElideConfigProperties settings, + Optional optionalQueryLogger, + ObjectProvider customizerProviders) { + JpaDataStoreRegistrationsBuilder builder = new JpaDataStoreRegistrationsBuilder(); + String[] entityManagerFactoryNames = applicationContext.getBeanNamesForType(EntityManagerFactory.class); + String[] platformTransactionManagerNames = applicationContext + .getBeanNamesForType(PlatformTransactionManager.class); + + Map beans = new HashMap<>(); + beans.putAll(applicationContext.getBeansWithAnnotation(EnableJpaDataStore.class)); + beans.putAll(applicationContext.getBeansWithAnnotation(EnableJpaDataStores.class)); + if (!beans.isEmpty()) { + // If there is explicit configuration + beans.values().stream().forEach(bean -> { + EnableJpaDataStore[] annotations = bean.getClass() + .getAnnotationsByType(EnableJpaDataStore.class); + for (EnableJpaDataStore annotation : annotations) { + String entityManagerFactoryName = annotation.entityManagerFactoryRef(); + String platformTransactionManagerName = annotation.transactionManagerRef(); + if (!StringUtils.isBlank(entityManagerFactoryName) + && !StringUtils.isBlank(platformTransactionManagerName)) { + builder.add(buildJpaDataStoreRegistration(applicationContext, entityManagerFactoryName, + platformTransactionManagerName, settings, optionalQueryLogger, + annotation.managedClasses())); + } + } + }); + } else if (entityManagerFactoryNames.length == 1 && platformTransactionManagerNames.length == 1) { + // If there is no explicit configuration but just one entity manager factory and + // transaction manager configure it + String platformTransactionManagerName = platformTransactionManagerNames[0]; + String entityManagerFactoryName = entityManagerFactoryNames[0]; + builder.add(buildJpaDataStoreRegistration(applicationContext, entityManagerFactoryName, + platformTransactionManagerName, settings, optionalQueryLogger, new Class[] {})); + } + + customizerProviders.orderedStream().forEach(customizer -> customizer.customize(builder)); + return builder; } /** - * Create an Entity Manager Supplier to use. - * @return the EntityManagerSupplier + * Creates a JpaDataStore registration from inputs. + * + * @param applicationContext the application context + * @param entityManagerFactoryName the bean name of the entity manager factory + * @param platformTransactionManagerName the bean name of the platform transaction manager + * @param settings the settings + * @param optionalQueryLogger the optional query logger + * @return the JpaDataStoreRegistration read from the application context. */ - @Bean - @ConditionalOnMissingBean - @Scope(SCOPE_PROTOTYPE) - public EntityManagerSupplier entityManagerSupplier() { - return new EntityManagerProxySupplier(); + private JpaDataStoreRegistration buildJpaDataStoreRegistration(ApplicationContext applicationContext, + String entityManagerFactoryName, String platformTransactionManagerName, ElideConfigProperties settings, + Optional optionalQueryLogger, + Class[] managedClasses) { + PlatformTransactionManager platformTransactionManager = applicationContext + .getBean(platformTransactionManagerName, PlatformTransactionManager.class); + EntityManagerFactory entityManagerFactory = applicationContext.getBean(entityManagerFactoryName, + EntityManagerFactory.class); + return JpaDataStoreRegistrations.buildJpaDataStoreRegistration(entityManagerFactoryName, entityManagerFactory, + platformTransactionManagerName, platformTransactionManager, settings, optionalQueryLogger, + managedClasses); } /** - * Creates the DataStore Elide. Override to use a different store. - * @param settings Elide configuration settings. - * @param entityManagerFactory The JPA factory which creates entity managers. - * @param scanner Class Scanner - * @param jpaTransactionSupplier JPA Transaction supplier - * @param entityManagerSupplier Entity Manager supplier + * Creates the default DataStoreBuilder to build the DataStore and applies + * customizations. + *

+ * Override this if the default auto configured DataStores are not desirable. + * + *

+ * If only minor customizations are required a + * {@link DataStoreBuilderCustomizer} can be defined to customize the builder. + * + * @param builder JpaDataStoreRegistrationsBuilder. + * @param settings Elide configuration settings. + * @param scanner Class Scanner * @param optionalQueryEngine QueryEngine instance for aggregation data store. - * @param optionalCache Analytics query cache + * @param optionalCache Analytics query cache * @param optionalQueryLogger Analytics query logger - * @param customizerProvider Provide customizers to add to the data store - * @return An instance of a JPA DataStore. + * @param customizerProvider Provide customizers to add to the data store + * @return the DataStoreBuilder. */ @Bean @ConditionalOnMissingBean @Scope(SCOPE_PROTOTYPE) - public DataStore dataStore(ElideConfigProperties settings, EntityManagerFactory entityManagerFactory, - ClassScanner scanner, JpaTransactionSupplier jpaTransactionSupplier, - EntityManagerSupplier entityManagerSupplier, Optional optionalQueryEngine, + public DataStoreBuilder dataStoreBuilder(JpaDataStoreRegistrationsBuilder builder, ElideConfigProperties settings, + ClassScanner scanner, Optional optionalQueryEngine, Optional optionalCache, Optional optionalQueryLogger, - ObjectProvider>> customizerProvider) { - return buildDataStore(settings, entityManagerFactory, scanner, jpaTransactionSupplier, jpaTransactionSupplier, - entityManagerSupplier, optionalQueryEngine, optionalCache, optionalQueryLogger, Optional.of( - stores -> customizerProvider.orderedStream().forEach(customizer -> customizer.accept(stores)))); + ObjectProvider customizerProvider) { + return buildDataStoreBuilder(builder, settings, scanner, optionalQueryEngine, optionalCache, + optionalQueryLogger, + Optional.of( + dataStoreBuilder -> customizerProvider.orderedStream() + .forEach(customizer -> customizer.customize(dataStoreBuilder)))); + } + + /** + * Creates the DataStore. Override to use a different store. + * + * @param dataStoreBuilder + * @return the DataStore to be used by Elide. + */ + @Bean + @ConditionalOnMissingBean + @Scope(SCOPE_PROTOTYPE) + public DataStore dataStore(DataStoreBuilder dataStoreBuilder) { + return dataStoreBuilder.build(); } /** - * Creates the DataStore Elide. + * Creates the default DataStoreBuilder to build the DataStore. + * @param builder JpaDataStoreRegistrationsBuilder. * @param settings Elide configuration settings. - * @param entityManagerFactory The JPA factory which creates entity managers. * @param scanner Class Scanner - * @param readJpaTransactionSupplier Read JPA Transaction supplier - * @param writeJpaTransactionSupplier Write JPA Transaction supplier - * @param entityManagerSupplier Entity Manager supplier * @param optionalQueryEngine QueryEngine instance for aggregation data store. * @param optionalCache Analytics query cache * @param optionalQueryLogger Analytics query logger * @param optionalCustomizer Provide customizers to add to the data store - * @return An instance of a JPA DataStore. + * @return the DataStoreBuilder. */ - public static DataStore buildDataStore(ElideConfigProperties settings, EntityManagerFactory entityManagerFactory, - ClassScanner scanner, JpaTransactionSupplier readJpaTransactionSupplier, - JpaTransactionSupplier writeJpaTransactionSupplier, EntityManagerSupplier entityManagerSupplier, + public static DataStoreBuilder buildDataStoreBuilder(JpaDataStoreRegistrationsBuilder builder, + ElideConfigProperties settings, + ClassScanner scanner, Optional optionalQueryEngine, Optional optionalCache, - Optional optionalQueryLogger, Optional>> optionalCustomizer) { - List stores = new ArrayList<>(); - - JpaDataStore jpaDataStore = new JpaDataStore(entityManagerSupplier, readJpaTransactionSupplier, - writeJpaTransactionSupplier, entityManagerFactory::getMetamodel); - - stores.add(jpaDataStore); + Optional optionalQueryLogger, + Optional optionalCustomizer) { + DataStoreBuilder dataStoreBuilder = new DataStoreBuilder(); + + builder.build().forEach(registration -> { + if (registration.getManagedClasses() != null && !registration.getManagedClasses().isEmpty()) { + dataStoreBuilder.dataStore(new JpaDataStore(registration.getEntityManagerSupplier(), + registration.getReadTransactionSupplier(), registration.getWriteTransactionSupplier(), + registration.getQueryLogger(), registration.getManagedClasses().toArray(Type[]::new))); + } else { + dataStoreBuilder.dataStore(new JpaDataStore(registration.getEntityManagerSupplier(), + registration.getReadTransactionSupplier(), registration.getWriteTransactionSupplier(), + registration.getQueryLogger(), registration.getMetamodelSupplier())); + } + }); if (isAggregationStoreEnabled(settings)) { AggregationDataStore.AggregationDataStoreBuilder aggregationDataStoreBuilder = AggregationDataStore @@ -443,9 +527,10 @@ public static DataStore buildDataStore(ElideConfigProperties settings, EntityMan optionalQueryEngine.ifPresent(queryEngine -> aggregationDataStoreBuilder .dynamicCompiledClasses(queryEngine.getMetaDataStore().getDynamicTypes())); if (settings.getAggregationStore().getDynamicConfig().getConfigApi().isEnabled()) { - stores.add(new ConfigDataStore(settings.getAggregationStore().getDynamicConfig().getPath(), - new TemplateConfigValidator(scanner, - settings.getAggregationStore().getDynamicConfig().getPath()))); + dataStoreBuilder + .dataStore(new ConfigDataStore(settings.getAggregationStore().getDynamicConfig().getPath(), + new TemplateConfigValidator(scanner, + settings.getAggregationStore().getDynamicConfig().getPath()))); } } optionalCache.ifPresent(aggregationDataStoreBuilder::cache); @@ -453,16 +538,11 @@ public static DataStore buildDataStore(ElideConfigProperties settings, EntityMan AggregationDataStore aggregationDataStore = aggregationDataStoreBuilder.build(); // meta data store needs to be put at first to populate meta data models - optionalQueryEngine.ifPresent(queryEngine -> stores.add(queryEngine.getMetaDataStore())); - stores.add(aggregationDataStore); - } - optionalCustomizer.ifPresent(customizer -> customizer.accept(stores)); - - if (stores.size() == 1) { - return stores.get(0); - } else { - return new MultiplexManager(stores.toArray(DataStore[]::new)); + optionalQueryEngine.ifPresent(queryEngine -> dataStoreBuilder.dataStore(queryEngine.getMetaDataStore())); + dataStoreBuilder.dataStore(aggregationDataStore); } + optionalCustomizer.ifPresent(customizer -> customizer.customize(dataStoreBuilder)); + return dataStoreBuilder; } /** diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/datastore/config/DataStoreBuilder.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/datastore/config/DataStoreBuilder.java new file mode 100644 index 0000000000..d5764ad2a6 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/datastore/config/DataStoreBuilder.java @@ -0,0 +1,57 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.datastore.config; + +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.datastores.multiplex.MultiplexManager; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * Builder used a build the DataStore. + *

+ * By default this will use the {@link MultiplexManager} when there are multiple + * DataStores. + */ +public class DataStoreBuilder { + private final List dataStores = new ArrayList<>(); + private Function multiplexer = MultiplexManager::new; + + public DataStoreBuilder dataStores(List dataStores) { + this.dataStores.clear(); + this.dataStores.addAll(dataStores); + return this; + } + + public DataStoreBuilder dataStores(Consumer> customizer) { + customizer.accept(this.dataStores); + return this; + } + + public DataStoreBuilder dataStore(DataStore dataStore) { + this.dataStores.add(dataStore); + return this; + } + + public DataStoreBuilder multiplexer(Function multiplexer) { + this.multiplexer = Objects.requireNonNull(multiplexer, "Multiplexer cannot be null"); + return this; + } + + public DataStore build() { + if (this.dataStores.isEmpty()) { + return null; + } + if (this.dataStores.size() == 1) { + return this.dataStores.get(0); + } + return multiplexer.apply(this.dataStores.toArray(DataStore[]::new)); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/datastore/config/DataStoreBuilderCustomizer.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/datastore/config/DataStoreBuilderCustomizer.java new file mode 100644 index 0000000000..49ea25bc86 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/datastore/config/DataStoreBuilderCustomizer.java @@ -0,0 +1,15 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.datastore.config; + +/** + * Used to customize the DataStoreBuilder. + * + * @see DataStoreBuilder + */ +public interface DataStoreBuilderCustomizer { + void customize(DataStoreBuilder dataStoreBuilder); +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/PlatformJpaTransaction.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/PlatformJpaTransaction.java index dbf5093e4b..3ebe2be38a 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/PlatformJpaTransaction.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/PlatformJpaTransaction.java @@ -19,7 +19,6 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; -import java.util.Objects; import java.util.function.Consumer; /** @@ -51,9 +50,15 @@ public PlatformJpaTransaction(PlatformTransactionManager transactionManager, Tra public void begin() { this.status = this.transactionManager.getTransaction(this.definition); if (this.em instanceof SupplierEntityManager supplierEntityManager) { - EntityManagerHolder entityManagerHolder = (EntityManagerHolder) Objects - .requireNonNull(TransactionSynchronizationManager.getResource(this.entityManagerFactory)); - supplierEntityManager.setEntityManager(entityManagerHolder.getEntityManager()); + EntityManagerHolder entityManagerHolder = (EntityManagerHolder) TransactionSynchronizationManager + .getResource(this.entityManagerFactory); + if (entityManagerHolder != null) { + // This is for the JpaTransactionManager + supplierEntityManager.setEntityManager(entityManagerHolder.getEntityManager()); + } else { + // This is for the JtaTransactionManager + supplierEntityManager.setEntityManager(this.entityManagerFactory.createEntityManager()); + } } else { throw new IllegalStateException("Expected entity manager to be supplied by EntityManagerProxySupplier"); } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/EnableJpaDataStore.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/EnableJpaDataStore.java new file mode 100644 index 0000000000..a5916c8c0e --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/EnableJpaDataStore.java @@ -0,0 +1,44 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.orm.jpa.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to configure multiple JpaDataStores. + * + * @see com.yahoo.elide.datastores.jpa.JpaDataStore + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Repeatable(EnableJpaDataStores.class) +public @interface EnableJpaDataStore { + /** + * The entity manager factory bean name to be used for the JpaDataStore. + * + * @return the entity manager factory bean name. + */ + String entityManagerFactoryRef() default "entityManagerFactory"; + + /** + * The platform transaction manager bean name to be used for the JpaDataStore. + * + * @return the platform transaction manager bean name. + */ + String transactionManagerRef() default "transactionManager"; + + /** + * The entity classes to manage. Otherwise all entities in the entity manager + * factory will be managed. + * + * @return the entity classes to manage + */ + Class[] managedClasses() default {}; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/EnableJpaDataStores.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/EnableJpaDataStores.java new file mode 100644 index 0000000000..6f100480f8 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/EnableJpaDataStores.java @@ -0,0 +1,23 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.orm.jpa.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to configure multiple JpaDataStores. + * + * @see com.yahoo.elide.datastores.jpa.JpaDataStore + * @see com.yahoo.elide.spring.orm.jpa.config.EnableJpaDataStore + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface EnableJpaDataStores { + EnableJpaDataStore[] value(); +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistration.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistration.java new file mode 100644 index 0000000000..4e4d2e4a35 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistration.java @@ -0,0 +1,48 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.orm.jpa.config; + +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.datastores.jpa.JpaDataStore.EntityManagerSupplier; +import com.yahoo.elide.datastores.jpa.JpaDataStore.JpaTransactionSupplier; +import com.yahoo.elide.datastores.jpa.JpaDataStore.MetamodelSupplier; +import com.yahoo.elide.datastores.jpql.porting.QueryLogger; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.Set; + +/** + * Registration entry to configure a JpaDataStore. + * + * @see com.yahoo.elide.datastores.jpa.JpaDataStore + */ +@Builder +@AllArgsConstructor +public class JpaDataStoreRegistration { + @Getter + private final String name; + @Getter + private final EntityManagerSupplier entityManagerSupplier; + @Getter + private final JpaTransactionSupplier readTransactionSupplier; + @Getter + private final JpaTransactionSupplier writeTransactionSupplier; + @Getter + private final MetamodelSupplier metamodelSupplier; + @Getter + private final Set> managedClasses; + @Getter + private final QueryLogger queryLogger; + + /** + * Used to build a JpaDataStore registration. + */ + public static class JpaDataStoreRegistrationBuilder { + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistrations.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistrations.java new file mode 100644 index 0000000000..fa2b32ee95 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistrations.java @@ -0,0 +1,104 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.orm.jpa.config; + + +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.datastores.jpa.JpaDataStore; +import com.yahoo.elide.datastores.jpa.JpaDataStore.EntityManagerSupplier; +import com.yahoo.elide.datastores.jpa.JpaDataStore.JpaTransactionSupplier; +import com.yahoo.elide.spring.config.ElideConfigProperties; +import com.yahoo.elide.spring.orm.jpa.EntityManagerProxySupplier; +import com.yahoo.elide.spring.orm.jpa.PlatformJpaTransactionSupplier; +import com.yahoo.elide.spring.orm.jpa.config.JpaDataStoreRegistration.JpaDataStoreRegistrationBuilder; + +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.DefaultTransactionDefinition; + +import jakarta.persistence.EntityManagerFactory; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +/** + * JpaDataStoreRegistrations. + */ +public class JpaDataStoreRegistrations { + private JpaDataStoreRegistrations() { + } + + /** + * Creates a JpaDataStore registration from inputs. + * + * @param entityManagerFactory the bean name of the entity manager factory + * @param platformTransactionManager the bean name of the platform transaction manager + * @param settings the settings + * @param optionalQueryLogger the optional query logger + * @return the JpaDataStore registration. + */ + public static JpaDataStoreRegistration buildJpaDataStoreRegistration(String entityManagerFactoryName, + EntityManagerFactory entityManagerFactory, String platformTransactionManagerName, + PlatformTransactionManager platformTransactionManager, ElideConfigProperties settings, + Optional optionalQueryLogger, + Class[] managedClasses) { + DefaultTransactionDefinition writeJpaTransactionDefinition = new DefaultTransactionDefinition( + TransactionDefinition.PROPAGATION_REQUIRED); + writeJpaTransactionDefinition.setName( + "Elide Write Transaction (" + entityManagerFactoryName + "," + platformTransactionManagerName + ")"); + JpaTransactionSupplier writeJpaTransactionSupplier = buildJpaTransactionSupplier(platformTransactionManager, + entityManagerFactory, writeJpaTransactionDefinition, settings); + + DefaultTransactionDefinition readJpaTransactionDefinition = new DefaultTransactionDefinition( + TransactionDefinition.PROPAGATION_REQUIRED); + readJpaTransactionDefinition.setName( + "Elide Read Transaction (" + entityManagerFactoryName + "," + platformTransactionManagerName + ")"); + readJpaTransactionDefinition.setReadOnly(true); + JpaTransactionSupplier readJpaTransactionSupplier = buildJpaTransactionSupplier(platformTransactionManager, + entityManagerFactory, readJpaTransactionDefinition, settings); + + JpaDataStoreRegistrationBuilder builder = JpaDataStoreRegistration.builder().name(entityManagerFactoryName) + .entityManagerSupplier(buildEntityManagerSupplier()) + .readTransactionSupplier(readJpaTransactionSupplier) + .writeTransactionSupplier(writeJpaTransactionSupplier) + .queryLogger(optionalQueryLogger.orElse(JpaDataStore.DEFAULT_LOGGER)); + if (managedClasses != null && managedClasses.length > 0) { + Set> models = new HashSet<>(); + Arrays.stream(managedClasses).map(ClassType::of).forEach(models::add); + builder.managedClasses(models); + } else { + builder.metamodelSupplier(entityManagerFactory::getMetamodel); + } + + return builder.build(); + } + + /** + * Create a JPA Transaction Supplier to use. + * @param transactionManager Spring Platform Transaction Manager + * @param entityManagerFactory An instance of EntityManagerFactory + * @param settings Elide configuration settings. + * @return the JpaTransactionSupplier. + */ + public static JpaTransactionSupplier buildJpaTransactionSupplier(PlatformTransactionManager transactionManager, + EntityManagerFactory entityManagerFactory, TransactionDefinition transactionDefinition, + ElideConfigProperties settings) { + return new PlatformJpaTransactionSupplier( + transactionDefinition, transactionManager, + entityManagerFactory, settings.getJpaStore().isDelegateToInMemoryStore()); + } + + /** + * Create an Entity Manager Supplier to use. + * @return a EntityManagerProxySupplier. + */ + public static EntityManagerSupplier buildEntityManagerSupplier() { + return new EntityManagerProxySupplier(); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistrationsBuilder.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistrationsBuilder.java new file mode 100644 index 0000000000..8adf806247 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistrationsBuilder.java @@ -0,0 +1,40 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.orm.jpa.config; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Builder used to configure registration entries for building a JpaDataStore. + * + * @see com.yahoo.elide.datastores.jpa.JpaDataStore + * @see com.yahoo.elide.spring.orm.jpa.config.JpaDataStoreRegistrationsBuilderCustomizer + */ +public class JpaDataStoreRegistrationsBuilder { + private final List registrations = new ArrayList<>(); + + public JpaDataStoreRegistrationsBuilder registrations(Consumer> customizer) { + customizer.accept(this.registrations); + return this; + } + + public JpaDataStoreRegistrationsBuilder registrations(List registrations) { + this.registrations.clear(); + this.registrations.addAll(registrations); + return this; + } + + public JpaDataStoreRegistrationsBuilder add(JpaDataStoreRegistration registration) { + this.registrations.add(registration); + return this; + } + + public List build() { + return this.registrations; + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistrationsBuilderCustomizer.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistrationsBuilderCustomizer.java new file mode 100644 index 0000000000..987c7cbca1 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/orm/jpa/config/JpaDataStoreRegistrationsBuilderCustomizer.java @@ -0,0 +1,16 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.orm.jpa.config; + +/** + * Customizer to customize registration entries for building JpaDataStores. + * + * @see com.yahoo.elide.spring.orm.jpa.config.JpaDataStoreRegistrationsBuilder + */ +@FunctionalInterface +public interface JpaDataStoreRegistrationsBuilderCustomizer { + void customize(JpaDataStoreRegistrationsBuilder builder); +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/com/yahoo/elide/spring/config/ElideAutoConfigurationTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/com/yahoo/elide/spring/config/ElideAutoConfigurationTest.java index 64591bf9ca..71e972b35a 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/com/yahoo/elide/spring/config/ElideAutoConfigurationTest.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/com/yahoo/elide/spring/config/ElideAutoConfigurationTest.java @@ -7,18 +7,12 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.yahoo.elide.core.datastore.DataStore; -import com.yahoo.elide.core.datastore.DataStoreTransaction; - -import example.models.jpa.ArtifactGroup; - import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; @@ -27,15 +21,8 @@ import org.springframework.cloud.autoconfigure.RefreshAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.orm.jpa.EntityManagerFactoryUtils; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.bind.annotation.RestController; -import jakarta.persistence.EntityManager; -import jakarta.persistence.EntityManagerFactory; - -import java.io.IOException; import java.util.Arrays; import java.util.HashSet; import java.util.Set; @@ -174,91 +161,4 @@ void overrideController(OverrideControllerInput input) { } }); } - - @Configuration(proxyBeanMethods = false) - @EntityScan(basePackages = "example.models.jpa") - public static class EntityConfiguration { - } - - @Test - void dataStoreTransaction() { - contextRunner.withPropertyValues("spring.cloud.refresh.enabled=false") - .withUserConfiguration(EntityConfiguration.class).run(context -> { - - DataStore dataStore = context.getBean(DataStore.class); - PlatformTransactionManager transactionManager = context.getBean(PlatformTransactionManager.class); - EntityManagerFactory entityManagerFactory = context.getBean(EntityManagerFactory.class); - - TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); - - try (DataStoreTransaction transaction = dataStore.beginTransaction()) { - ArtifactGroup artifactGroup = new ArtifactGroup(); - artifactGroup.setName("Group"); - transaction.createObject(artifactGroup, null); - transaction.flush(null); - - ArtifactGroup found = transactionTemplate.execute(status -> { - assertThat(status.isNewTransaction()).isFalse(); - EntityManager entityManager = EntityManagerFactoryUtils.getTransactionalEntityManager(entityManagerFactory); - return entityManager.find(ArtifactGroup.class, artifactGroup.getName()); - }); - assertThat(artifactGroup).isEqualTo(found); - // Not committed so should rollback - } - - transactionTemplate.execute(status -> { - assertThat(status.isNewTransaction()).isTrue(); - EntityManager entityManager = EntityManagerFactoryUtils.getTransactionalEntityManager(entityManagerFactory); - ArtifactGroup found = entityManager.find(ArtifactGroup.class, "Group"); - assertThat(found).isNull(); - - try (DataStoreTransaction transaction = dataStore.beginTransaction()) { - ArtifactGroup artifactGroup = new ArtifactGroup(); - artifactGroup.setName("Group"); - transaction.createObject(artifactGroup, null); - transaction.commit(null); - } catch (IOException e) { - } - - found = entityManager.find(ArtifactGroup.class, "Group"); - assertThat(found).isNotNull(); - status.setRollbackOnly(); // Rollback - return null; - }); - - // Verify it has been rolled back - transactionTemplate.execute(status -> { - assertThat(status.isNewTransaction()).isTrue(); - EntityManager entityManager = EntityManagerFactoryUtils.getTransactionalEntityManager(entityManagerFactory); - ArtifactGroup found = entityManager.find(ArtifactGroup.class, "Group"); - assertThat(found).isNull(); - return null; - }); - - try (DataStoreTransaction transaction = dataStore.beginTransaction()) { - ArtifactGroup artifactGroup = new ArtifactGroup(); - artifactGroup.setName("Group"); - transaction.createObject(artifactGroup, null); - transaction.flush(null); - - ArtifactGroup found = transactionTemplate.execute(status -> { - assertThat(status.isNewTransaction()).isFalse(); - EntityManager entityManager = EntityManagerFactoryUtils.getTransactionalEntityManager(entityManagerFactory); - return entityManager.find(ArtifactGroup.class, artifactGroup.getName()); - }); - assertThat(artifactGroup).isEqualTo(found); - transaction.commit(null); - } - - // Verify that it has been committed - transactionTemplate.execute(status -> { - assertThat(status.isNewTransaction()).isTrue(); - EntityManager entityManager = EntityManagerFactoryUtils.getTransactionalEntityManager(entityManagerFactory); - ArtifactGroup found = entityManager.find(ArtifactGroup.class, "Group"); - assertThat(found).isNotNull(); - return null; - }); - - }); - } } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/com/yahoo/elide/spring/config/ElideAutoConfigurationTransactionTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/com/yahoo/elide/spring/config/ElideAutoConfigurationTransactionTest.java new file mode 100644 index 0000000000..99b899ec1f --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/com/yahoo/elide/spring/config/ElideAutoConfigurationTransactionTest.java @@ -0,0 +1,599 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static org.assertj.core.api.Assertions.assertThat; + +import com.yahoo.elide.RefreshableElide; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.datastores.jpa.JpaDataStore; +import com.yahoo.elide.datastores.multiplex.MultiplexManager; +import com.yahoo.elide.spring.orm.jpa.config.EnableJpaDataStore; +import com.yahoo.elide.spring.orm.jpa.config.JpaDataStoreRegistration; +import com.yahoo.elide.spring.orm.jpa.config.JpaDataStoreRegistrations; +import com.atomikos.spring.AtomikosAutoConfiguration; +import com.atomikos.spring.AtomikosDataSourceBean; + +import example.models.jpa.ArtifactGroup; +import example.models.jpa.v2.ArtifactGroupV2; +import example.models.jpa.v3.ArtifactGroupV3; + +import org.hibernate.cfg.AvailableSettings; +import org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.EntityManagerFactoryBuilderCustomizer; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; +import org.springframework.boot.orm.jpa.hibernate.SpringJtaPlatform; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.autoconfigure.RefreshAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.orm.jpa.EntityManagerFactoryUtils; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.persistenceunit.PersistenceUnitManager; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.jta.JtaTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import javax.sql.DataSource; +import javax.sql.XADataSource; + +/** + * Tests for ElideAutoConfiguration transaction. + */ +class ElideAutoConfigurationTransactionTest { + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ElideAutoConfiguration.class, DataSourceAutoConfiguration.class, + HibernateJpaAutoConfiguration.class, TransactionAutoConfiguration.class, RefreshAutoConfiguration.class)); + + @Configuration(proxyBeanMethods = false) + @EntityScan(basePackages = "example.models.jpa") + public static class EntityConfiguration { + } + + @Test + void dataStoreTransaction() { + contextRunner.withPropertyValues("spring.cloud.refresh.enabled=false") + .withUserConfiguration(EntityConfiguration.class).run(context -> { + + DataStore dataStore = context.getBean(DataStore.class); + PlatformTransactionManager transactionManager = context.getBean(PlatformTransactionManager.class); + EntityManagerFactory entityManagerFactory = context.getBean(EntityManagerFactory.class); + + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + + try (DataStoreTransaction transaction = dataStore.beginTransaction()) { + ArtifactGroup artifactGroup = new ArtifactGroup(); + artifactGroup.setName("Group"); + transaction.createObject(artifactGroup, null); + transaction.flush(null); + + ArtifactGroup found = transactionTemplate.execute(status -> { + assertThat(status.isNewTransaction()).isFalse(); + EntityManager entityManager = EntityManagerFactoryUtils.getTransactionalEntityManager(entityManagerFactory); + return entityManager.find(ArtifactGroup.class, artifactGroup.getName()); + }); + assertThat(artifactGroup).isEqualTo(found); + // Not committed so should rollback + } + + transactionTemplate.execute(status -> { + assertThat(status.isNewTransaction()).isTrue(); + EntityManager entityManager = EntityManagerFactoryUtils.getTransactionalEntityManager(entityManagerFactory); + ArtifactGroup found = entityManager.find(ArtifactGroup.class, "Group"); + assertThat(found).isNull(); + + try (DataStoreTransaction transaction = dataStore.beginTransaction()) { + ArtifactGroup artifactGroup = new ArtifactGroup(); + artifactGroup.setName("Group"); + transaction.createObject(artifactGroup, null); + transaction.commit(null); + } catch (IOException e) { + } + + found = entityManager.find(ArtifactGroup.class, "Group"); + assertThat(found).isNotNull(); + status.setRollbackOnly(); // Rollback + return null; + }); + + // Verify it has been rolled back + transactionTemplate.execute(status -> { + assertThat(status.isNewTransaction()).isTrue(); + EntityManager entityManager = EntityManagerFactoryUtils.getTransactionalEntityManager(entityManagerFactory); + ArtifactGroup found = entityManager.find(ArtifactGroup.class, "Group"); + assertThat(found).isNull(); + return null; + }); + + try (DataStoreTransaction transaction = dataStore.beginTransaction()) { + ArtifactGroup artifactGroup = new ArtifactGroup(); + artifactGroup.setName("Group"); + transaction.createObject(artifactGroup, null); + transaction.flush(null); + + ArtifactGroup found = transactionTemplate.execute(status -> { + assertThat(status.isNewTransaction()).isFalse(); + EntityManager entityManager = EntityManagerFactoryUtils.getTransactionalEntityManager(entityManagerFactory); + return entityManager.find(ArtifactGroup.class, artifactGroup.getName()); + }); + assertThat(artifactGroup).isEqualTo(found); + transaction.commit(null); + } + + // Verify that it has been committed + transactionTemplate.execute(status -> { + assertThat(status.isNewTransaction()).isTrue(); + EntityManager entityManager = EntityManagerFactoryUtils.getTransactionalEntityManager(entityManagerFactory); + ArtifactGroup found = entityManager.find(ArtifactGroup.class, "Group"); + assertThat(found).isNotNull(); + return null; + }); + + }); + } + + @Configuration(proxyBeanMethods = false) + public static class MultipleDataSourceJpaConfiguration { + @Bean + public DataSource dataSourceV2() { + return DataSourceBuilder.create().url("jdbc:h2:mem:db1;DB_CLOSE_DELAY=-1").username("sa").password("").build(); + } + + @Bean + public DataSource dataSourceV3() { + return DataSourceBuilder.create().url("jdbc:h2:mem:db2;DB_CLOSE_DELAY=-1").username("sa").password("").build(); + } + + @Bean + public EntityManagerFactoryBuilder entityManagerFactoryBuilder( + ObjectProvider persistenceUnitManager, + ObjectProvider customizers) { + EntityManagerFactoryBuilder builder = new EntityManagerFactoryBuilder(new HibernateJpaVendorAdapter(), + new HashMap<>(), persistenceUnitManager.getIfAvailable()); + customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); + return builder; + } + } + + /** + * This creates 2 entity manager factories each with its own JPA transaction + * manager. As they are using separate transaction managers, commits and + * rollbacks don't affect each other. + */ + @Configuration(proxyBeanMethods = false) + @EnableJpaDataStore(entityManagerFactoryRef = "entityManagerFactoryV2", transactionManagerRef = "transactionManagerV2") + @EnableJpaDataStore(entityManagerFactoryRef = "entityManagerFactoryV3", transactionManagerRef = "transactionManagerV3") + public static class MultipleEntityManagerFactoryJpaConfiguration { + @Bean + public LocalContainerEntityManagerFactoryBean entityManagerFactoryV2(EntityManagerFactoryBuilder builder, + DefaultListableBeanFactory beanFactory, DataSource dataSourceV2) { + Map vendorProperties = new HashMap<>(); + vendorProperties.put(AvailableSettings.HBM2DDL_AUTO, "create-drop"); + vendorProperties.put(AvailableSettings.JTA_PLATFORM, new NoJtaPlatform()); + final LocalContainerEntityManagerFactoryBean emf = builder.dataSource(dataSourceV2) + .packages("example.models.jpa.v2").properties(vendorProperties).build(); + return emf; + } + + @Bean + public LocalContainerEntityManagerFactoryBean entityManagerFactoryV3(EntityManagerFactoryBuilder builder, + DefaultListableBeanFactory beanFactory, DataSource dataSourceV3) { + Map vendorProperties = new HashMap<>(); + vendorProperties.put(AvailableSettings.HBM2DDL_AUTO, "create-drop"); + vendorProperties.put(AvailableSettings.JTA_PLATFORM, new NoJtaPlatform()); + final LocalContainerEntityManagerFactoryBean emf = builder.dataSource(dataSourceV3) + .packages("example.models.jpa.v3").properties(vendorProperties).build(); + return emf; + } + + @Bean + public PlatformTransactionManager transactionManagerV2(EntityManagerFactory entityManagerFactoryV2) { + return new JpaTransactionManager(entityManagerFactoryV2); + } + + @Bean + public PlatformTransactionManager transactionManagerV3(EntityManagerFactory entityManagerFactoryV3) { + return new JpaTransactionManager(entityManagerFactoryV3); + } + } + + @Test + void multiplexDataStoreJpaTransaction() { + contextRunner.withPropertyValues("spring.cloud.refresh.enabled=false") + .withUserConfiguration(MultipleDataSourceJpaConfiguration.class, MultipleEntityManagerFactoryJpaConfiguration.class).run(context -> { + DataStore dataStore = context.getBean(DataStore.class); + assertThat(dataStore).isInstanceOf(MultiplexManager.class); + + // The data store will only be initialized properly by elide to populate the dictionary + RefreshableElide refreshableElide = context.getBean(RefreshableElide.class); + dataStore = refreshableElide.getElide().getDataStore(); + + RequestScope scope = new RequestScope(null, null, NO_VERSION, null, null, null, null, null, + UUID.randomUUID(), refreshableElide.getElide().getElideSettings()); + + try (DataStoreTransaction transaction = dataStore.beginTransaction()) { + ArtifactGroupV2 artifactGroupV2 = new ArtifactGroupV2(); + artifactGroupV2.setName("JPA Group V2a"); + transaction.save(artifactGroupV2, null); + transaction.commit(null); + } + + try (DataStoreTransaction transaction = dataStore.beginTransaction()) { + ArtifactGroupV3 artifactGroupV3 = new ArtifactGroupV3(); + artifactGroupV3.setName("JPA Group V3a"); + transaction.save(artifactGroupV3, null); + transaction.commit(null); + } + + try (DataStoreTransaction transaction = dataStore.beginTransaction()) { + ArtifactGroupV2 artifactGroupV2 = new ArtifactGroupV2(); + artifactGroupV2.setName("JPA Group V2b"); + + ArtifactGroupV3 artifactGroupV3 = new ArtifactGroupV3(); + artifactGroupV3.setName("JPA Group V3b"); + + transaction.save(artifactGroupV2, null); + transaction.save(artifactGroupV3, null); + + transaction.commit(null); + } + + try (DataStoreTransaction transaction = dataStore.beginTransaction()) { + ArtifactGroupV2 artifactGroupV2 = transaction.loadObject( + EntityProjection.builder().type(ArtifactGroupV2.class).build(), "JPA Group V2b", scope); + assertThat(artifactGroupV2).isNotNull(); + + ArtifactGroupV3 artifactGroupV3 = transaction.loadObject( + EntityProjection.builder().type(ArtifactGroupV3.class).build(), "JPA Group V3b", scope); + assertThat(artifactGroupV3).isNotNull(); + } + + try (DataStoreTransaction transaction = dataStore.beginReadTransaction()) { + ArtifactGroupV2 artifactGroupV2 = transaction.loadObject( + EntityProjection.builder().type(ArtifactGroupV2.class).build(), "JPA Group V2b", scope); + assertThat(artifactGroupV2).isNotNull(); + + ArtifactGroupV3 artifactGroupV3 = transaction.loadObject( + EntityProjection.builder().type(ArtifactGroupV3.class).build(), "JPA Group V3b", scope); + assertThat(artifactGroupV3).isNotNull(); + } + }); + } + + @Test + void multipleDataStoreJpaTransaction() { + contextRunner.withPropertyValues("spring.cloud.refresh.enabled=false") + .withUserConfiguration(MultipleDataSourceJpaConfiguration.class, MultipleEntityManagerFactoryJpaConfiguration.class) + .run(context -> { + RefreshableElide refreshableElide = context.getBean(RefreshableElide.class); + RequestScope scope = new RequestScope(null, null, NO_VERSION, null, null, null, null, null, + UUID.randomUUID(), refreshableElide.getElide().getElideSettings()); + EntityManagerFactory entityManagerFactoryV2 = context.getBean("entityManagerFactoryV2", + EntityManagerFactory.class); + EntityManagerFactory entityManagerFactoryV3 = context.getBean("entityManagerFactoryV3", + EntityManagerFactory.class); + JpaTransactionManager transactionManagerV2 = context.getBean("transactionManagerV2", JpaTransactionManager.class); + JpaTransactionManager transactionManagerV3 = context.getBean("transactionManagerV3", JpaTransactionManager.class); + ElideConfigProperties settings = new ElideConfigProperties(); + settings.getJpaStore().setDelegateToInMemoryStore(true); + JpaDataStoreRegistration registrationV2 = JpaDataStoreRegistrations.buildJpaDataStoreRegistration( + "entityManagerFactoryV2", entityManagerFactoryV2, "transactionManagerV2", + transactionManagerV2, settings, Optional.empty(), null); + JpaDataStoreRegistration registrationV3 = JpaDataStoreRegistrations.buildJpaDataStoreRegistration( + "entityManagerFactoryV3", entityManagerFactoryV3, "transactionManagerV3", + transactionManagerV3, settings, Optional.empty(), null); + + EntityDictionary entityDictionary = EntityDictionary.builder().build(); + + JpaDataStore jpaDataStoreV2 = new JpaDataStore(registrationV2.getEntityManagerSupplier(), + registrationV2.getReadTransactionSupplier(), registrationV2.getWriteTransactionSupplier(), + registrationV2.getQueryLogger(), registrationV2.getMetamodelSupplier()); + + JpaDataStore jpaDataStoreV3 = new JpaDataStore(registrationV3.getEntityManagerSupplier(), + registrationV3.getReadTransactionSupplier(), registrationV3.getWriteTransactionSupplier(), + registrationV3.getQueryLogger(), registrationV3.getMetamodelSupplier()); + + jpaDataStoreV2.populateEntityDictionary(entityDictionary); + jpaDataStoreV3.populateEntityDictionary(entityDictionary); + + // Test that the rollback in the outer transaction works + try (DataStoreTransaction transaction1 = jpaDataStoreV2.beginTransaction()) { + ArtifactGroupV2 artifactGroupV2 = new ArtifactGroupV2(); + artifactGroupV2.setName("JPA V2"); + transaction1.save(artifactGroupV2, null); + + try (DataStoreTransaction transaction2 = jpaDataStoreV3.beginTransaction()) { + ArtifactGroupV3 artifactGroupV3 = new ArtifactGroupV3(); + artifactGroupV3.setName("JPA V3"); + transaction2.save(artifactGroupV3, null); + transaction2.commit(null); + } + // transaction1 wasn't committed and should rollback + } + + try (DataStoreTransaction transaction2 = jpaDataStoreV3.beginTransaction()) { + ArtifactGroupV3 artifactGroupV3 = transaction2.loadObject( + EntityProjection.builder().type(ArtifactGroupV3.class).build(), "JPA V3", scope); + // although the outer transaction was rolled back the entity was created as the + // 2 transaction managers are separate a compensating transaction would be + // required in this case to reverse the inner transaction + assertThat(artifactGroupV3).isNotNull(); + transaction2.delete(artifactGroupV3, null); + } + + // Test that the commit in the outer transaction works + try (DataStoreTransaction transaction1 = jpaDataStoreV2.beginTransaction()) { + ArtifactGroupV2 artifactGroupV2 = new ArtifactGroupV2(); + artifactGroupV2.setName("JPA V2"); + transaction1.save(artifactGroupV2, null); + + try (DataStoreTransaction transaction2 = jpaDataStoreV3.beginTransaction()) { + ArtifactGroupV3 artifactGroupV3 = new ArtifactGroupV3(); + artifactGroupV3.setName("JPA V3"); + transaction2.save(artifactGroupV3, null); + transaction2.commit(null); + } + transaction1.commit(null); + } + + try (DataStoreTransaction transaction1 = jpaDataStoreV2.beginTransaction()) { + ArtifactGroupV2 artifactGroupV2 = transaction1.loadObject( + EntityProjection.builder().type(ArtifactGroupV2.class).build(), "JPA V2", scope); + assertThat(artifactGroupV2).isNotNull(); + + try (DataStoreTransaction transaction2 = jpaDataStoreV3.beginTransaction()) { + ArtifactGroupV3 artifactGroupV3 = transaction2.loadObject( + EntityProjection.builder().type(ArtifactGroupV3.class).build(), "JPA V3", scope); + assertThat(artifactGroupV3).isNotNull(); + } + } + }); + } + + @Configuration(proxyBeanMethods = false) + public static class MultipleDataSourceJtaConfiguration { + @Bean + public DataSource dataSourceV2() { + XADataSource xaDataSource = DataSourceBuilder.create().url("jdbc:h2:mem:db1;DB_CLOSE_DELAY=-1") + .driverClassName("org.h2.Driver").type(org.h2.jdbcx.JdbcDataSource.class).username("sa") + .password("").build(); + AtomikosDataSourceBean atomikosDataSource = new AtomikosDataSourceBean(); + atomikosDataSource.setXaDataSource(xaDataSource); + return atomikosDataSource; + } + + @Bean + public DataSource dataSourceV3() { + XADataSource xaDataSource = DataSourceBuilder.create().url("jdbc:h2:mem:db2;DB_CLOSE_DELAY=-1") + .driverClassName("org.h2.Driver").type(org.h2.jdbcx.JdbcDataSource.class).username("sa") + .password("").build(); + AtomikosDataSourceBean atomikosDataSource = new AtomikosDataSourceBean(); + atomikosDataSource.setXaDataSource(xaDataSource); + return atomikosDataSource; + } + + @Bean + public EntityManagerFactoryBuilder entityManagerFactoryBuilder( + ObjectProvider persistenceUnitManager, + ObjectProvider customizers) { + EntityManagerFactoryBuilder builder = new EntityManagerFactoryBuilder(new HibernateJpaVendorAdapter(), + new HashMap<>(), persistenceUnitManager.getIfAvailable()); + customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); + return builder; + } + } + + /** + * This creates 2 entity manager factories with a shared JTA transaction + * manager. They will participate in a shared transaction. + */ + @Configuration(proxyBeanMethods = false) + @EnableJpaDataStore(entityManagerFactoryRef = "entityManagerFactoryV2", transactionManagerRef = "transactionManager") + @EnableJpaDataStore(entityManagerFactoryRef = "entityManagerFactoryV3", transactionManagerRef = "transactionManager") + public static class MultipleEntityManagerFactoryJtaConfiguration { + @Bean + public LocalContainerEntityManagerFactoryBean entityManagerFactoryV2(EntityManagerFactoryBuilder builder, + DefaultListableBeanFactory beanFactory, DataSource dataSourceV2, JtaTransactionManager transactionManager) { + Map vendorProperties = new HashMap<>(); + vendorProperties.put(AvailableSettings.HBM2DDL_AUTO, "create-drop"); + vendorProperties.put(AvailableSettings.JTA_PLATFORM, new SpringJtaPlatform(transactionManager)); + final LocalContainerEntityManagerFactoryBean emf = builder.dataSource(dataSourceV2) + .packages("example.models.jpa.v2").properties(vendorProperties).jta(true).build(); + return emf; + } + + @Bean + public LocalContainerEntityManagerFactoryBean entityManagerFactoryV3(EntityManagerFactoryBuilder builder, + DefaultListableBeanFactory beanFactory, DataSource dataSourceV3, JtaTransactionManager transactionManager) { + Map vendorProperties = new HashMap<>(); + vendorProperties.put(AvailableSettings.HBM2DDL_AUTO, "create-drop"); + vendorProperties.put(AvailableSettings.JTA_PLATFORM, new SpringJtaPlatform(transactionManager)); + final LocalContainerEntityManagerFactoryBean emf = builder.dataSource(dataSourceV3) + .packages("example.models.jpa.v3").properties(vendorProperties).jta(true).build(); + return emf; + } + } + + @Test + void multiplexDataStoreJtaTransaction() { + contextRunner + .withPropertyValues("spring.cloud.refresh.enabled=false", + "atomikos.properties.max-timeout=0") + .withConfiguration(AutoConfigurations.of(AtomikosAutoConfiguration.class)) + .withUserConfiguration(MultipleDataSourceJtaConfiguration.class, MultipleEntityManagerFactoryJtaConfiguration.class).run(context -> { + DataStore dataStore = context.getBean(DataStore.class); + assertThat(dataStore).isInstanceOf(MultiplexManager.class); + + // The data store will only be initialized properly by elide to populate the dictionary + RefreshableElide refreshableElide = context.getBean(RefreshableElide.class); + dataStore = refreshableElide.getElide().getDataStore(); + + RequestScope scope = new RequestScope(null, null, NO_VERSION, null, null, null, null, null, + UUID.randomUUID(), refreshableElide.getElide().getElideSettings()); + + try (DataStoreTransaction transaction = dataStore.beginTransaction()) { + ArtifactGroupV2 artifactGroupV2 = new ArtifactGroupV2(); + artifactGroupV2.setName("JTA Group V2a"); + transaction.save(artifactGroupV2, null); + transaction.commit(null); + } + + try (DataStoreTransaction transaction = dataStore.beginTransaction()) { + ArtifactGroupV3 artifactGroupV3 = new ArtifactGroupV3(); + artifactGroupV3.setName("JTA Group V3a"); + transaction.save(artifactGroupV3, null); + transaction.commit(null); + } + + try (DataStoreTransaction transaction = dataStore.beginTransaction()) { + ArtifactGroupV2 artifactGroupV2 = new ArtifactGroupV2(); + artifactGroupV2.setName("JTA Group V2b"); + + ArtifactGroupV3 artifactGroupV3 = new ArtifactGroupV3(); + artifactGroupV3.setName("JTA Group V3b"); + + transaction.save(artifactGroupV2, null); + transaction.save(artifactGroupV3, null); + + transaction.commit(null); + } + + try (DataStoreTransaction transaction = dataStore.beginTransaction()) { + ArtifactGroupV2 artifactGroupV2 = transaction.loadObject( + EntityProjection.builder().type(ArtifactGroupV2.class).build(), "JTA Group V2b", scope); + assertThat(artifactGroupV2).isNotNull(); + + ArtifactGroupV3 artifactGroupV3 = transaction.loadObject( + EntityProjection.builder().type(ArtifactGroupV3.class).build(), "JTA Group V3b", scope); + assertThat(artifactGroupV3).isNotNull(); + } + + try (DataStoreTransaction transaction = dataStore.beginReadTransaction()) { + ArtifactGroupV2 artifactGroupV2 = transaction.loadObject( + EntityProjection.builder().type(ArtifactGroupV2.class).build(), "JTA Group V2b", scope); + assertThat(artifactGroupV2).isNotNull(); + + ArtifactGroupV3 artifactGroupV3 = transaction.loadObject( + EntityProjection.builder().type(ArtifactGroupV3.class).build(), "JTA Group V3b", scope); + assertThat(artifactGroupV3).isNotNull(); + } + }); + } + + @Test + void multipleDataStoreJtaTransaction() { + contextRunner.withPropertyValues("spring.cloud.refresh.enabled=false", "atomikos.properties.max-timeout=0") + .withConfiguration(AutoConfigurations.of(AtomikosAutoConfiguration.class)) + .withUserConfiguration(MultipleDataSourceJtaConfiguration.class, + MultipleEntityManagerFactoryJtaConfiguration.class) + .run(context -> { + RefreshableElide refreshableElide = context.getBean(RefreshableElide.class); + RequestScope scope = new RequestScope(null, null, NO_VERSION, null, null, null, null, null, + UUID.randomUUID(), refreshableElide.getElide().getElideSettings()); + EntityManagerFactory entityManagerFactoryV2 = context.getBean("entityManagerFactoryV2", + EntityManagerFactory.class); + EntityManagerFactory entityManagerFactoryV3 = context.getBean("entityManagerFactoryV3", + EntityManagerFactory.class); + JtaTransactionManager jtaTransactionManager = context.getBean(JtaTransactionManager.class); + ElideConfigProperties settings = new ElideConfigProperties(); + settings.getJpaStore().setDelegateToInMemoryStore(true); + JpaDataStoreRegistration registrationV2 = JpaDataStoreRegistrations.buildJpaDataStoreRegistration( + "entityManagerFactoryV2", entityManagerFactoryV2, "transactionManager", + jtaTransactionManager, settings, Optional.empty(), null); + JpaDataStoreRegistration registrationV3 = JpaDataStoreRegistrations.buildJpaDataStoreRegistration( + "entityManagerFactoryV3", entityManagerFactoryV3, "transactionManager", + jtaTransactionManager, settings, Optional.empty(), null); + + EntityDictionary entityDictionary = EntityDictionary.builder().build(); + + JpaDataStore jpaDataStoreV2 = new JpaDataStore(registrationV2.getEntityManagerSupplier(), + registrationV2.getReadTransactionSupplier(), registrationV2.getWriteTransactionSupplier(), + registrationV2.getQueryLogger(), registrationV2.getMetamodelSupplier()); + + JpaDataStore jpaDataStoreV3 = new JpaDataStore(registrationV3.getEntityManagerSupplier(), + registrationV3.getReadTransactionSupplier(), registrationV3.getWriteTransactionSupplier(), + registrationV3.getQueryLogger(), registrationV3.getMetamodelSupplier()); + + jpaDataStoreV2.populateEntityDictionary(entityDictionary); + jpaDataStoreV3.populateEntityDictionary(entityDictionary); + + // Test that the rollback in the outer transaction works + try (DataStoreTransaction transaction1 = jpaDataStoreV2.beginTransaction()) { + ArtifactGroupV2 artifactGroupV2 = new ArtifactGroupV2(); + artifactGroupV2.setName("JTA V2"); + transaction1.save(artifactGroupV2, null); + + try (DataStoreTransaction transaction2 = jpaDataStoreV3.beginTransaction()) { + ArtifactGroupV3 artifactGroupV3 = new ArtifactGroupV3(); + artifactGroupV3.setName("JTA V3"); + transaction2.save(artifactGroupV3, null); + transaction2.commit(null); + } + // transaction1 wasn't committed and should rollback + } + + try (DataStoreTransaction transaction2 = jpaDataStoreV3.beginTransaction()) { + ArtifactGroupV3 artifactGroupV3 = transaction2.loadObject( + EntityProjection.builder().type(ArtifactGroupV3.class).build(), "JTA V3", scope); + // as the outer transaction was rolled back the entity isn't created + assertThat(artifactGroupV3).isNull(); + } + + // Test that the commit in the outer transaction works + try (DataStoreTransaction transaction1 = jpaDataStoreV2.beginTransaction()) { + ArtifactGroupV2 artifactGroupV2 = new ArtifactGroupV2(); + artifactGroupV2.setName("JTA V2"); + transaction1.save(artifactGroupV2, null); + + try (DataStoreTransaction transaction2 = jpaDataStoreV3.beginTransaction()) { + ArtifactGroupV3 artifactGroupV3 = new ArtifactGroupV3(); + artifactGroupV3.setName("JTA V3"); + transaction2.save(artifactGroupV3, null); + transaction2.commit(null); + } + transaction1.commit(null); + } + + try (DataStoreTransaction transaction1 = jpaDataStoreV2.beginTransaction()) { + ArtifactGroupV2 artifactGroupV2 = transaction1.loadObject( + EntityProjection.builder().type(ArtifactGroupV2.class).build(), "JTA V2", scope); + assertThat(artifactGroupV2).isNotNull(); + + try (DataStoreTransaction transaction2 = jpaDataStoreV3.beginTransaction()) { + ArtifactGroupV3 artifactGroupV3 = transaction2.loadObject( + EntityProjection.builder().type(ArtifactGroupV3.class).build(), "JTA V3", scope); + assertThat(artifactGroupV3).isNotNull(); + } + } + + }); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/com/yahoo/elide/spring/datastore/config/DataStoreBuilderTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/com/yahoo/elide/spring/datastore/config/DataStoreBuilderTest.java new file mode 100644 index 0000000000..817580c842 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/com/yahoo/elide/spring/datastore/config/DataStoreBuilderTest.java @@ -0,0 +1,70 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.datastore.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; + +import example.models.jpa.ArtifactGroup; +import example.models.jpa.ArtifactProduct; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +/** + * Tests for DataStoreBuilder. + */ +class DataStoreBuilderTest { + + @Test + void dataStore() { + DataStoreBuilder builder = new DataStoreBuilder(); + builder.dataStore(new HashMapDataStore(Arrays.asList(ArtifactGroup.class))); + assertThat(builder.build()).isInstanceOf(HashMapDataStore.class); + } + + @Test + void dataStores() { + DataStoreBuilder builder = new DataStoreBuilder(); + builder.dataStores(Collections.singletonList(new HashMapDataStore(Arrays.asList(ArtifactGroup.class)))); + assertThat(builder.build()).isInstanceOf(HashMapDataStore.class); + } + + @Test + void dataStoresCustomizer() { + DataStoreBuilder builder = new DataStoreBuilder(); + builder.dataStore(new HashMapDataStore(Arrays.asList(ArtifactGroup.class))); + builder.dataStores(dataStores -> dataStores.clear()); + assertThat(builder.build()).isNull(); + } + + @Test + void multiplexer() { + DataStoreBuilder builder = new DataStoreBuilder(); + builder.dataStore(new HashMapDataStore(Arrays.asList(ArtifactGroup.class))); + builder.dataStore(new HashMapDataStore(Arrays.asList(ArtifactProduct.class))); + builder.multiplexer(dataStores -> new CustomMultiplexManager()); + assertThat(builder.build()).isInstanceOf(CustomMultiplexManager.class); + } + + public static class CustomMultiplexManager implements DataStore { + + @Override + public void populateEntityDictionary(EntityDictionary dictionary) { + } + + @Override + public DataStoreTransaction beginTransaction() { + return null; + } + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v3/ArtifactGroupV3.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v3/ArtifactGroupV3.java new file mode 100644 index 0000000000..9aceac0de4 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v3/ArtifactGroupV3.java @@ -0,0 +1,27 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.models.jpa.v3; + +import com.yahoo.elide.annotation.Include; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Data; + +@Include(name = "group") +@Entity +@Data +@Table(name = "ArtifactGroup") +public class ArtifactGroupV3 { + @Id + private String name = ""; + + @Column(name = "commonName") + private String title = ""; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v3/package-info.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v3/package-info.java new file mode 100644 index 0000000000..61da7770a3 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v3/package-info.java @@ -0,0 +1,12 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +/** + * Models Package V3. + */ +@ApiVersion(version = "3.0") +package example.models.jpa.v3; + +import com.yahoo.elide.annotation.ApiVersion; diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/resources/application.yaml b/elide-spring/elide-spring-boot-autoconfigure/src/test/resources/application.yaml index 0a5964a45e..4a06e6f306 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/resources/application.yaml +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/resources/application.yaml @@ -38,6 +38,9 @@ elide: config-api: enabled: false spring: + autoconfigure: + exclude: + - com.atomikos.spring.AtomikosAutoConfiguration jpa: show-sql: true properties: diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/resources/transactions.properties b/elide-spring/elide-spring-boot-autoconfigure/src/test/resources/transactions.properties new file mode 100644 index 0000000000..80520bd830 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/resources/transactions.properties @@ -0,0 +1 @@ +com.atomikos.icatch.registered=1 diff --git a/pom.xml b/pom.xml index 0b15490ab3..46f6b36b73 100644 --- a/pom.xml +++ b/pom.xml @@ -85,8 +85,10 @@ ${project.basedir}/target/lombok + 1.10.13 4.12.0 2.28.0 + 6.0.0 1.34.0 3.1.6 4.8.158 @@ -97,14 +99,17 @@ 2.12.0 3.12.0 0.11.0 - 3.0.1 + 1.2.3 + 3.0.1 20.2 22.3.2 + 31.1-jre 4.3.1 6.2.3.Final 8.0.0.Final 6.1.8.Final 3.0.0 + 4.5.13 2.10.1 2.1.214 5.0.1 @@ -116,15 +121,21 @@ 2.0.0 3.1.0 3.0.2 + 3.29.2-GA + 2.4.0 3.1.2 11.0.15 4.3.2 + 1.5.1 + 1.0.3 2.8.0 2.2.14 5.9.3 1.4.7 1.18.26 5.3.0 + 2.2.21 + 2.1.0 2.0.7 2.0 6.0.9 @@ -132,11 +143,35 @@ 4.0.2 2.1.0 2.2.10 + 1.2.1 10.1.8 5.3.1 + 4.3.0 + 10.8.0 + 1.5 + 0.8.10 + 2.3.1 + 3.2.1 + 3.10.1 + 3.1.1 + 3.3.0 + 3.3.0 + 3.5.0 + 3.0.1 + 3.0.0 + 3.2.1 + 2.22.2 3.12.1 + 2.5.3 + 1.13.0 + 1.13.0 + 1.18.20.0 + 3.11.4 + 3.5.3 0.9.21 + 1.6.13 + true ${project.basedir}/.. @@ -163,6 +198,11 @@ artemis-jakarta-client-all ${artemis.version} + + com.atomikos + transactions-spring-boot3-starter + ${atomikos.version} + org.apache.calcite calcite-core @@ -211,7 +251,7 @@ com.apollographql.federation federation-graphql-java-support - ${federation-graphql-java-support-api} + ${federation-graphql-java-support-api.version} com.graphql-java @@ -347,17 +387,17 @@ com.google.guava guava - 31.1-jre + ${guava.version} cz.jirutka.rsql rsql-parser - 2.1.0 + ${rsql-parser.version} io.reactivex.rxjava2 rxjava - 2.2.21 + ${rxjava.version} org.springframework @@ -367,12 +407,12 @@ org.owasp.encoder encoder - 1.2.3 + ${encoder.version} org.fusesource.jansi jansi - 2.4.0 + ${jansi.version} @@ -406,12 +446,12 @@ org.apache.httpcomponents httpclient - 4.5.13 + ${httpclient.version} com.github.opendevl json2flat - 1.0.3 + ${json2flat.version} com.zaxxer @@ -423,22 +463,22 @@ org.apache.ant ant - 1.10.13 + ${ant.version} org.javassist javassist - 3.29.2-GA + ${javaassist.version} org.skyscreamer jsonassert - 1.5.1 + ${jsonassert.version} com.github.stefanbirkner system-lambda - 1.2.1 + ${system-lambda.version} ch.qos.logback @@ -541,7 +581,7 @@ org.apache.maven.plugins maven-gpg-plugin - 3.0.1 + ${maven-gpg-plugin.version} sign-artifacts @@ -561,7 +601,7 @@ org.sonatype.plugins nexus-staging-maven-plugin - 1.6.13 + ${nexus-staging-maven-plugin.version} true ossrh @@ -580,17 +620,17 @@ org.apache.maven.plugins maven-surefire-plugin - 3.0.0 + ${maven-surefire-plugin.version} org.apache.maven.plugins maven-failsafe-plugin - 2.22.2 + ${maven-failsafe-plugin.version} org.apache.maven.plugins maven-checkstyle-plugin - 3.2.1 + ${maven-checkstyle-plugin.version} validate @@ -619,7 +659,7 @@ com.puppycrawl.tools checkstyle - 10.8.0 + ${checkstyle.version} compile @@ -627,12 +667,12 @@ org.apache.maven.plugins maven-deploy-plugin - 3.1.1 + ${maven-deploy-plugin.version} org.apache.maven.plugins maven-jar-plugin - 3.3.0 + ${maven-jar-plugin.version} maven-site-plugin @@ -645,7 +685,7 @@ org.codehaus.gmaven gmaven-plugin - 1.5 + ${gmaven-plugin.version} 2.0 @@ -664,7 +704,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.10.1 + ${maven-compiler-plugin.version} ${source.jdk.version} ${target.jdk.version} @@ -684,7 +724,7 @@ org.apache.maven.plugins maven-source-plugin - 3.2.1 + ${maven-source-plugin.version} attach-sources @@ -697,7 +737,7 @@ org.projectlombok lombok-maven-plugin - 1.18.20.0 + ${lombok-maven-plugin.version} ${project.basedir}/src/main/java ${project.basedir}/target/generated-sources/antlr4 @@ -716,7 +756,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.5.0 + ${maven-javadoc-plugin.version} attach-javadocs @@ -735,18 +775,18 @@ org.apache.maven.plugins maven-release-plugin - 2.5.3 + ${maven-release-plugin.version} org.apache.maven.scm maven-scm-provider-gitexe - 1.13.0 + ${maven-scm-provider-gitexe.version} org.apache.maven.scm maven-scm-api - 1.13.0 + ${maven-scm-api.version} @@ -756,7 +796,7 @@ org.jacoco jacoco-maven-plugin - 0.8.10 + ${jacoco-maven-plugin.version} default-prepare-agent @@ -811,7 +851,7 @@ org.eluder.coveralls coveralls-maven-plugin - 4.3.0 + ${coveralls-maven-plugin.version} ${env.COVERALLS_REPO_TOKEN} @@ -819,14 +859,14 @@ javax.xml.bind jaxb-api - 2.3.1 + ${jaxb-api.version} com.versioneye versioneye-maven-plugin - 3.11.4 + ${versioneye-maven-plugin.version} ${env.versioneye_api_key} com.yahoo.elide @@ -838,7 +878,7 @@ org.apache.maven.plugins maven-enforcer-plugin - 3.3.0 + ${maven-enforcer-plugin.version} enforce-java @@ -860,7 +900,7 @@ org.apache.maven.wagon wagon-ssh-external - 3.5.3 + ${wagon-ssh-external.version}