diff --git a/elide-core/src/main/java/com/yahoo/elide/core/RequestScope.java b/elide-core/src/main/java/com/yahoo/elide/core/RequestScope.java index dd5204d3b4..d34ed75b4a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/RequestScope.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/RequestScope.java @@ -36,6 +36,7 @@ import com.yahoo.elide.security.User; import com.yahoo.elide.security.executors.ActivePermissionExecutor; import lombok.Getter; +import lombok.Setter; import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; @@ -74,6 +75,8 @@ public class RequestScope implements com.yahoo.elide.security.RequestScope { @Getter private final boolean useFilterExpressions; @Getter private final int updateStatusCode; @Getter private final boolean mutatingMultipleEntities; + @Getter @Setter private Long historicalRevision = null; + @Getter @Setter private Long historicalDatestamp = null; @Getter private final MultipleFilterDialect filterDialect; private final Map expressionsByType; @@ -187,6 +190,8 @@ public RequestScope(String path, this.sparseFields = parseSparseFields(queryParams); this.sorting = Sorting.parseQueryParams(queryParams); this.pagination = Pagination.parseQueryParams(queryParams, this.getElideSettings()); + this.setHistoricalDatestamp(parseHistoricalDatestamp(queryParams)); + this.setHistoricalRevision(parseHistoricalRevisionNumber(queryParams)); } else { this.sparseFields = Collections.emptyMap(); this.sorting = Sorting.getDefaultEmptyInstance(); @@ -263,6 +268,30 @@ private static Map> parseSparseFields(MultivaluedMap queryParams) { + Map> result = new HashMap<>(); + + for (Map.Entry> kv : queryParams.entrySet()) { + String key = kv.getKey(); + if (key.equals("__historicaldatestamp")) { + return Long.parseLong(kv.getValue().get(0)); + } + } + return null; + } + + private static Long parseHistoricalRevisionNumber(MultivaluedMap queryParams) { + Map> result = new HashMap<>(); + + for (Map.Entry> kv : queryParams.entrySet()) { + String key = kv.getKey(); + if (key.equals("__historicalversion")) { + return Long.parseLong(kv.getValue().get(0)); + } + } + return null; + } + /** * Get filter expression for a specific collection type. * @param type The name of the type diff --git a/elide-datastore/elide-datastore-hibernate/pom.xml b/elide-datastore/elide-datastore-hibernate/pom.xml index 88d7cc98a8..edb2780a94 100644 --- a/elide-datastore/elide-datastore-hibernate/pom.xml +++ b/elide-datastore/elide-datastore-hibernate/pom.xml @@ -17,6 +17,10 @@ 4.0-beta-6 + + 5.0.2.Final + + The Apache Software License, Version 2.0 @@ -43,6 +47,12 @@ com.yahoo.elide elide-core + + org.hibernate + hibernate-envers + ${hibernate5.version} + compile + com.fasterxml.jackson.core jackson-databind diff --git a/elide-datastore/elide-datastore-hibernate/src/main/java/com/yahoo/elide/core/filter/EnverseFilterOperation.java b/elide-datastore/elide-datastore-hibernate/src/main/java/com/yahoo/elide/core/filter/EnverseFilterOperation.java new file mode 100644 index 0000000000..83e8519363 --- /dev/null +++ b/elide-datastore/elide-datastore-hibernate/src/main/java/com/yahoo/elide/core/filter/EnverseFilterOperation.java @@ -0,0 +1,115 @@ +package com.yahoo.elide.core.filter; + + +import com.yahoo.elide.annotation.Audit; +import com.yahoo.elide.core.EntityDictionary; +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.filter.expression.AndFilterExpression; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.expression.FilterExpressionVisitor; +import com.yahoo.elide.core.filter.expression.NotFilterExpression; +import com.yahoo.elide.core.filter.expression.OrFilterExpression; +import org.hibernate.envers.query.AuditEntity; +import org.hibernate.envers.query.criteria.AuditCriterion; +import org.hibernate.envers.query.criteria.AuditProperty; + +public class EnverseFilterOperation implements FilterOperation { + + private EntityDictionary dictionary; + private Class entityClass; + + public EnverseFilterOperation(EntityDictionary dictionary) { + this.dictionary = dictionary; + } + @Override + public AuditCriterion apply(FilterPredicate filterPredicate) { + if (filterPredicate.getPath().getPathElements().size() > 1) { + throw new RuntimeException("Entity traversal is not supported in revision datastore"); + } + Path.PathElement field = filterPredicate.getPath().getPathElements().get(0); + String fieldName = field.getFieldName(); + Class fieldType = field.getFieldType(); + Class entityType = field.getType(); + + switch(filterPredicate.getOperator()) { + case IN: + AuditCriterion criterion = null; + if (dictionary.getRelationships(entityType).contains(fieldName)) { + if (dictionary.getRelationshipType(entityType, fieldName).isToMany()) { + throw new RuntimeException("FilterPath can move only along ToOne relationships"); + } + for (Object value : filterPredicate.getValues()){ + if (criterion == null){ + criterion = AuditEntity.relatedId(fieldName).eq(value); + } else { + criterion = AuditEntity.or(criterion, AuditEntity.relatedId(fieldName).eq(value)); + } + } + } else if (dictionary.getIdFieldName(entityType).equals(fieldName)){ + for (Object value : filterPredicate.getValues()){ + if (criterion == null){ + criterion = AuditEntity.id().eq(value); + } else { + criterion = AuditEntity.or(criterion, AuditEntity.id().eq(value)); + } + } + + } else { + criterion = AuditEntity.property(fieldName).in(filterPredicate.getValues()); + } + return criterion; + case GE: + return AuditEntity.property(fieldName).ge(filterPredicate.getParameters().get(0)); + case LE: + return AuditEntity.property(fieldName).le(filterPredicate.getParameters().get(0)); + case GT: + return AuditEntity.property(fieldName).gt(filterPredicate.getParameters().get(0)); + case LT: + return AuditEntity.property(fieldName).lt(filterPredicate.getParameters().get(0)); + case FALSE: + return AuditEntity.not(AuditEntity.property(fieldName).eqProperty(fieldName)); + case TRUE: + return AuditEntity.property(fieldName).eqProperty(fieldName); + default: + throw new RuntimeException("unsupported operation"); + } + } + + public AuditCriterion apply(FilterExpression filterExpression) { + AuditCriterionVisitor visitor = new AuditCriterionVisitor(); + return filterExpression.accept(visitor); + + } + + + public class AuditCriterionVisitor implements FilterExpressionVisitor { + public static final String TWO_NON_FILTERING_EXPRESSIONS = + "Cannot build a filter from two non-filtering expressions"; + private boolean prefixWithAlias; + + @Override + public AuditCriterion visitPredicate(FilterPredicate filterPredicate) { + return apply(filterPredicate); + } + + @Override + public AuditCriterion visitAndExpression(AndFilterExpression expression) { + FilterExpression left = expression.getLeft(); + FilterExpression right = expression.getRight(); + return AuditEntity.and(left.accept(this), right.accept(this)); + } + + @Override + public AuditCriterion visitOrExpression(OrFilterExpression expression) { + FilterExpression left = expression.getLeft(); + FilterExpression right = expression.getRight(); + return AuditEntity.or(left.accept(this), right.accept(this)); + } + + @Override + public AuditCriterion visitNotExpression(NotFilterExpression expression) { + AuditCriterion negated = expression.getNegated().accept(this); + return AuditEntity.not(negated); + } + } +} diff --git a/elide-datastore/elide-datastore-hibernate/src/main/java/com/yahoo/elide/core/hibernate/enverse/RootCollectionFetchRevisionQueryBuilder.java b/elide-datastore/elide-datastore-hibernate/src/main/java/com/yahoo/elide/core/hibernate/enverse/RootCollectionFetchRevisionQueryBuilder.java new file mode 100644 index 0000000000..e6a1113896 --- /dev/null +++ b/elide-datastore/elide-datastore-hibernate/src/main/java/com/yahoo/elide/core/hibernate/enverse/RootCollectionFetchRevisionQueryBuilder.java @@ -0,0 +1,28 @@ +package com.yahoo.elide.core.hibernate.enverse; + +import com.yahoo.elide.core.EntityDictionary; +import com.yahoo.elide.core.filter.expression.FilterExpression; + +import java.util.Optional; + +public class RootCollectionFetchRevisionQueryBuilder { + + private Class entityClass; + private EntityDictionary dictionary; + private FilterExpression filterExpression; + + public RootCollectionFetchRevisionQueryBuilder(Class entityClass, + EntityDictionary dictionary) { + this.entityClass = entityClass; + this.dictionary = dictionary; + } + + public RootCollectionFetchRevisionQueryBuilder withPossibleFilterExpression(Optional filterExpression) { + this.filterExpression = filterExpression.get(); + return this; + } + + + + +} diff --git a/elide-datastore/elide-datastore-hibernate5/pom.xml b/elide-datastore/elide-datastore-hibernate5/pom.xml index fcb04469ca..4e49dff4b4 100644 --- a/elide-datastore/elide-datastore-hibernate5/pom.xml +++ b/elide-datastore/elide-datastore-hibernate5/pom.xml @@ -65,7 +65,6 @@ elide-integration-tests 4.0-beta-6 test-jar - test org.mockito @@ -160,7 +159,7 @@ org.hibernate hibernate-envers ${hibernate5.version} - test + compile diff --git a/elide-datastore/elide-datastore-hibernate5/src/main/java/com/yahoo/elide/datastores/hibernate5/HibernateRevisionsDataStore.java b/elide-datastore/elide-datastore-hibernate5/src/main/java/com/yahoo/elide/datastores/hibernate5/HibernateRevisionsDataStore.java new file mode 100644 index 0000000000..0a44ea079e --- /dev/null +++ b/elide-datastore/elide-datastore-hibernate5/src/main/java/com/yahoo/elide/datastores/hibernate5/HibernateRevisionsDataStore.java @@ -0,0 +1,32 @@ +package com.yahoo.elide.datastores.hibernate5; + +import com.google.common.base.Preconditions; +import com.yahoo.elide.core.DataStore; +import com.yahoo.elide.core.DataStoreTransaction; +import com.yahoo.elide.core.EntityDictionary; +import org.hibernate.ScrollMode; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.envers.AuditReaderFactory; +import org.hibernate.jpa.HibernateEntityManager; +import org.hibernate.metadata.ClassMetadata; + +import javax.persistence.Entity; +import javax.persistence.EntityManager; + +public class HibernateRevisionsDataStore extends HibernateSessionFactoryStore { + + + public HibernateRevisionsDataStore(SessionFactory sessionFactory) { + super(sessionFactory, false, ScrollMode.SCROLL_SENSITIVE); + } + + @Override + @SuppressWarnings("resource") + public DataStoreTransaction beginTransaction() { + Session session = sessionFactory.getCurrentSession(); + Preconditions.checkNotNull(session); + session.beginTransaction(); + return new HibernateRevisionsTransaction(AuditReaderFactory.get(session), session); + } +} diff --git a/elide-datastore/elide-datastore-hibernate5/src/main/java/com/yahoo/elide/datastores/hibernate5/HibernateRevisionsTransaction.java b/elide-datastore/elide-datastore-hibernate5/src/main/java/com/yahoo/elide/datastores/hibernate5/HibernateRevisionsTransaction.java new file mode 100644 index 0000000000..ba975934f8 --- /dev/null +++ b/elide-datastore/elide-datastore-hibernate5/src/main/java/com/yahoo/elide/datastores/hibernate5/HibernateRevisionsTransaction.java @@ -0,0 +1,119 @@ +package com.yahoo.elide.datastores.hibernate5; + +import com.yahoo.elide.core.DataStoreTransaction; +import com.yahoo.elide.core.EntityDictionary; +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.filter.EnverseFilterOperation; +import com.yahoo.elide.core.filter.FilterPredicate; +import com.yahoo.elide.core.filter.Operator; +import com.yahoo.elide.core.filter.expression.AndFilterExpression; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.hibernate.hql.RootCollectionFetchQueryBuilder; +import com.yahoo.elide.core.pagination.Pagination; +import com.yahoo.elide.core.sort.Sorting; +import com.yahoo.elide.datastores.hibernate5.porting.QueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.ObjectNotFoundException; +import org.hibernate.Query; +import org.hibernate.ScrollMode; +import org.hibernate.Session; +import org.hibernate.envers.AuditReader; +import org.hibernate.envers.query.AuditEntity; +import org.hibernate.envers.query.criteria.AuditCriterion; + +import java.io.Serializable; +import java.util.Collections; +import java.util.Optional; + +@Slf4j +public class HibernateRevisionsTransaction extends HibernateTransaction { + + private AuditReader auditReader; + + public HibernateRevisionsTransaction(AuditReader auditReader, Session session) { + super(session, false, ScrollMode.SCROLL_INSENSITIVE); + this.auditReader = auditReader; + } + + /** + * load a single record with id and filter. + * + * @param entityClass class of query object + * @param id id of the query object + * @param filterExpression FilterExpression contains the predicates + * @param scope Request scope associated with specific request + */ + @Override + public Object loadObject(Class entityClass, + Serializable id, + Optional filterExpression, + RequestScope scope) { + if (!isHistory(scope)) { + return super.loadObject(entityClass, id, filterExpression, scope); + } + try { + EntityDictionary dictionary = scope.getDictionary(); + Class idType = dictionary.getIdType(entityClass); + String idField = dictionary.getIdFieldName(entityClass); + + //Construct a predicate that selects an individual element of the relationship's parent (Author.id = 3). + FilterPredicate idExpression; + Path.PathElement idPath = new Path.PathElement(entityClass, idType, idField); + if (id != null) { + idExpression = new FilterPredicate(idPath, Operator.IN, Collections.singletonList(id)); + } else { + idExpression = new FilterPredicate(idPath, Operator.FALSE, Collections.emptyList()); + } + + FilterExpression joinedExpression = filterExpression + .map(fe -> (FilterExpression) new AndFilterExpression(fe, idExpression)) + .orElse(idExpression); + + EnverseFilterOperation operation = new EnverseFilterOperation(scope.getDictionary()); + AuditCriterion criteria = operation.apply(joinedExpression); + return auditReader.createQuery().forEntitiesAtRevision(entityClass, getRevision(scope)).add(criteria).getSingleResult(); + } catch (ObjectNotFoundException e) { + return null; + } + } + + @Override + public Iterable loadObjects( + Class entityClass, + Optional filterExpression, + Optional sorting, + Optional pagination, + RequestScope scope) { + log.debug(String.format("Revision: %d", getRevision(scope))); + if (!isHistory(scope)) { + return super.loadObjects(entityClass, filterExpression, sorting, pagination, scope); + } + + EnverseFilterOperation operation = new EnverseFilterOperation(scope.getDictionary()); + if (filterExpression.isPresent()) { + AuditCriterion criteria = operation.apply(filterExpression.get()); + return auditReader.createQuery().forEntitiesAtRevision(entityClass, getRevision(scope)).add(criteria).getResultList(); + } else { + return auditReader.createQuery().forEntitiesAtRevision(entityClass, getRevision(scope)).getResultList(); + } + } + + private boolean isHistory(RequestScope scope) { + return scope.getHistoricalRevision() != null || scope.getHistoricalDatestamp() != null; + } + + private Integer getRevision(RequestScope scope) { + if (scope.getHistoricalRevision() != null) { + return scope.getHistoricalRevision().intValue(); + } else { + Query query = this.session.createSQLQuery("SELECT MAX(REV) from REVINFO WHERE REVTSTMP <= :timestamp"); + query.setParameter("timestamp", scope.getHistoricalDatestamp()); + log.debug(String.format("Query: %s", query.toString())); + log.debug(String.format("ts: %d", scope.getHistoricalDatestamp())); + return query.uniqueResult() != null + ? (Integer) query.uniqueResult() + : 1; + } + } +} diff --git a/elide-datastore/elide-datastore-hibernate5/src/main/java/com/yahoo/elide/datastores/hibernate5/HibernateTransaction.java b/elide-datastore/elide-datastore-hibernate5/src/main/java/com/yahoo/elide/datastores/hibernate5/HibernateTransaction.java index d866a38a84..bed777e5e4 100644 --- a/elide-datastore/elide-datastore-hibernate5/src/main/java/com/yahoo/elide/datastores/hibernate5/HibernateTransaction.java +++ b/elide-datastore/elide-datastore-hibernate5/src/main/java/com/yahoo/elide/datastores/hibernate5/HibernateTransaction.java @@ -47,7 +47,7 @@ @Slf4j public class HibernateTransaction implements DataStoreTransaction { - private final Session session; + protected final Session session; private final SessionWrapper sessionWrapper; private final LinkedHashSet deferredTasks = new LinkedHashSet<>(); private final boolean isScrollEnabled; diff --git a/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/datastores/hibernate5/RevisionDataStoreSupplier.java b/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/datastores/hibernate5/RevisionDataStoreSupplier.java new file mode 100644 index 0000000000..1179aed57e --- /dev/null +++ b/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/datastores/hibernate5/RevisionDataStoreSupplier.java @@ -0,0 +1,43 @@ +package com.yahoo.elide.datastores.hibernate5; + +import com.yahoo.elide.core.DataStore; +import com.yahoo.elide.utils.ClassScanner; +import example.Parent; +import org.hibernate.MappingException; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.boot.spi.MetadataImplementor; +import org.hibernate.cfg.Environment; +import org.hibernate.envers.Audited; + +import javax.persistence.Entity; +import java.util.function.Supplier; + +public class RevisionDataStoreSupplier implements Supplier { + + @Override + public DataStore get() { + MetadataSources metadataSources = new MetadataSources( + new StandardServiceRegistryBuilder() + .configure("hibernate.cfg.xml") + .applySetting(Environment.CURRENT_SESSION_CONTEXT_CLASS, "thread") + .applySetting(Environment.URL, "jdbc:mysql://localhost:" + + System.getProperty("mysql.port", "3306") + + "/root?serverTimezone=UTC") + .applySetting("hibernate.hbm2ddl.auto", "create-drop") + .applySetting(Environment.USER, "root") + .applySetting(Environment.PASS, "root") + .build()); + + try { + ClassScanner.getAnnotatedClasses(Parent.class.getPackage(), Entity.class) + .stream().filter(c -> c.isAnnotationPresent(Audited.class)) + .forEach(metadataSources::addAnnotatedClass); + } catch (MappingException e) { + throw new RuntimeException(e); + } + + MetadataImplementor metadataImplementor = (MetadataImplementor) metadataSources.buildMetadata(); + return new HibernateRevisionsDataStore(metadataImplementor.buildSessionFactory()); + } +} diff --git a/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/tests/RevisionStoreIT.java b/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/tests/RevisionStoreIT.java new file mode 100644 index 0000000000..44401a8c1b --- /dev/null +++ b/elide-datastore/elide-datastore-hibernate5/src/test/java/com/yahoo/elide/tests/RevisionStoreIT.java @@ -0,0 +1,221 @@ +package com.yahoo.elide.tests; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideResponse; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.audit.TestAuditLogger; +import com.yahoo.elide.core.DataStoreTransaction; +import com.yahoo.elide.core.EntityDictionary; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.datastores.hibernate5.HibernateRevisionsDataStore; +import com.yahoo.elide.utils.ClassScanner; +import example.AddressFragment; +import example.House; +import example.Parent; +import example.Person; +import org.hibernate.MappingException; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.spi.MetadataImplementor; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import com.yahoo.elide.initialization.AbstractIntegrationTestInitializer; +import com.yahoo.elide.utils.JsonParser; +import example.Author; +import example.Book; +import example.Chapter; +import example.Filtered; +import example.TestCheckMappings; +import org.hibernate.cfg.Environment; +import org.mockito.Mockito; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import javax.persistence.Entity; +import javax.ws.rs.core.MultivaluedHashMap; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import static org.testng.Assert.assertEquals; + +public class RevisionStoreIT extends AbstractIntegrationTestInitializer { + + private static final String CLASH_OF_KINGS = "A Clash of Kings"; + private static final String STORM_OF_SWORDS = "A Storm of Swords"; + private static final String SONG_OF_ICE_AND_FIRE = "A Song of Ice and Fire"; + private static final String DATA = "data"; + private static final String ATTRIBUTES = "attributes"; + private static final String NAME = "name"; + private static final String ADDRESS = "address"; + private static final String HOUSE = "house"; + private static final String RELATIONS = "relationships"; + private static final String CHAPTER_COUNT = "chapterCount"; + + private final JsonParser jsonParser; + private final ObjectMapper mapper; + private final Elide elide; + + public RevisionStoreIT() { + jsonParser = new JsonParser(); + mapper = new ObjectMapper(); + elide = new Elide(new ElideSettingsBuilder(AbstractIntegrationTestInitializer.getDatabaseManager()) + .withAuditLogger(new TestAuditLogger()) + .withEntityDictionary(new EntityDictionary(TestCheckMappings.MAPPINGS)) + .build()); + + } + + @BeforeClass + public static void setup() throws IOException { + RequestScope scope = Mockito.mock(RequestScope.class); + Person person; + try (DataStoreTransaction tx = dataStore.beginTransaction()) { + + //tx.save(tx.createNewObject(Filtered.class), null); + //tx.save(tx.createNewObject(Filtered.class), null); + //tx.save(tx.createNewObject(Filtered.class), null); + + person = new Person(); + person.setName("person"); + AddressFragment af = new AddressFragment(); + af.state = "IL"; + af.street = "Illinois St"; + person.setAddress(af); + tx.save(person, scope); + tx.commit(scope); + } + + try (DataStoreTransaction tx = dataStore.beginTransaction()) { + //Person person = (Person) tx.loadObject(Person.class, 1, Optional.empty(), scope); + person.setName("new name"); + + tx.save(person, scope); + tx.commit(scope); + } + + House house1, house2; + try (DataStoreTransaction tx = dataStore.beginTransaction()) { + //Person person = (Person) tx.loadObject(Person.class, 1, Optional.empty(), scope); + house1 = addHouse(1, person, tx); + + //tx.save(person, scope); + //tx.commit(scope); + } + + try (DataStoreTransaction tx = dataStore.beginTransaction()) { + //Person person = (Person) tx.loadObject(Person.class, 1, Optional.empty(), scope); + house2 = addHouse(2, person, tx); + + //tx.save(person, scope); + //tx.commit(scope); + } + + try (DataStoreTransaction tx = dataStore.beginTransaction()) { + AddressFragment af = new AddressFragment(); + af.street = "changes address" + "Street"; + house2.setAddress(af); + tx.save(house2, scope); + tx.commit(scope); + } + } + + private static House addHouse(int number, Person person, DataStoreTransaction tx) { + House house = new House(); + AddressFragment af = new AddressFragment(); + af.street = number + "Street"; + house.setAddress(af); + house.setOwner(person); + Set houses = person.getHouse() == null + ? new HashSet<>() + : person.getHouse(); + houses.add(house); + person.setHouse(houses); + tx.save(house, null); + tx.save(person, null); + tx.commit(null); + return house; + } + + @Test + public void testRootEntityFormulaFetch() throws Exception { + MultivaluedHashMap queryParams = new MultivaluedHashMap<>(); + queryParams.put("fields[person]", Arrays.asList("name,address")); + ElideResponse response = elide.get("/person", queryParams, 1); + + JsonNode result = mapper.readTree(response.getBody()); + assertEquals(result.get(DATA).size(), 1); + assertEquals(result.get(DATA).get(0).get(ATTRIBUTES).get(NAME).asText(), "person"); + assertEquals(result.get(DATA).get(0).get(ATTRIBUTES).get(ADDRESS).toString(), "{\"street\":\"Illinois St\",\"state\":\"IL\",\"zip\":null}"); + } + + @Test + public void testSubCollectionEntityFormulaFetch() throws Exception { + MultivaluedHashMap queryParams = new MultivaluedHashMap<>(); + queryParams.put("fields[house]", Arrays.asList("address")); + ElideResponse response = elide.get("/person/1/house", queryParams, 1); + JsonNode result = mapper.readTree(response.getBody()); + assertEquals(result.get(DATA).size(), 2); + assertEquals(result.get(DATA).get(0).get(ATTRIBUTES).get(ADDRESS).toString(), + "{\"street\":\"0Street\",\"state\":null,\"zip\":null}"); + assertEquals(result.get(DATA).get(1).get(ATTRIBUTES).get(ADDRESS).toString(), + "{\"street\":\"1Street\",\"state\":null,\"zip\":null}"); + } + + @Test + public void testRootEntityVersion() throws Exception { + MultivaluedHashMap queryParams = new MultivaluedHashMap<>(); + + queryParams.put("__historicalversion", Arrays.asList(Long.toString(1))); + ElideResponse response = elide.get("/person/1", queryParams, 1); + + JsonNode result = mapper.readTree(response.getBody()); + assertEquals(result.get(DATA).get(ATTRIBUTES).get(NAME).asText(), "person"); + assertEquals(result.get(DATA).get(ATTRIBUTES).get(ADDRESS).toString(), + "{\"street\":\"Illinois St\",\"state\":\"IL\",\"zip\":null}"); + + queryParams.put("__historicalversion", Arrays.asList(Long.toString(2))); + response = elide.get("/person/1", queryParams, 1); + + result = mapper.readTree(response.getBody()); + assertEquals(result.get(DATA).get(ATTRIBUTES).get(NAME).asText(), "new name"); + assertEquals(result.get(DATA).get(ATTRIBUTES).get(ADDRESS).toString(), + "{\"street\":\"Illinois St\",\"state\":\"IL\",\"zip\":null}"); + + queryParams.put("__historicalversion", Arrays.asList(Long.toString(3))); + response = elide.get("/person/1", queryParams, 1); + + result = mapper.readTree(response.getBody()); + assertEquals(result.get(DATA).get(RELATIONS).get(HOUSE).toString(), + "{\"data\":[{\"type\":\"house\",\"id\":\"1\"}]}"); + + queryParams.put("__historicalversion", Arrays.asList(Long.toString(4))); + response = elide.get("/person/1", queryParams, 1); + + result = mapper.readTree(response.getBody()); + assertEquals(result.get(DATA).get(RELATIONS).get(HOUSE).toString(), + "{\"data\":[{\"type\":\"house\",\"id\":\"1\"}," + + "{\"type\":\"house\",\"id\":\"2\"}]}"); + } + + @Test + public void testSubCollectionEntityVersion() throws Exception { + MultivaluedHashMap queryParams = new MultivaluedHashMap<>(); + + queryParams.put("__historicalversion", Arrays.asList(Long.toString(4))); + ElideResponse response = elide.get("/person/1/house/1", queryParams, 1); + + JsonNode result = mapper.readTree(response.getBody()); + assertEquals(result.get(DATA).get(ATTRIBUTES).get(ADDRESS).toString(), + "{\"street\":\"1Street\",\"state\":null,\"zip\":null}"); + + queryParams.put("__historicalversion", Arrays.asList(Long.toString(5))); + response = elide.get("/person/1/house/2", queryParams, 1); + + result = mapper.readTree(response.getBody()); + assertEquals(result.get(DATA).get(ATTRIBUTES).get(ADDRESS).toString(), + "{\"street\":\"changes addressStreet\",\"state\":null,\"zip\":null}"); + } +} diff --git a/elide-datastore/elide-datastore-hibernate5/src/test/java/example/House.java b/elide-datastore/elide-datastore-hibernate5/src/test/java/example/House.java new file mode 100644 index 0000000000..638fd557db --- /dev/null +++ b/elide-datastore/elide-datastore-hibernate5/src/test/java/example/House.java @@ -0,0 +1,50 @@ +package example; + +import com.yahoo.elide.annotation.Include; +import lombok.Setter; +import org.hibernate.annotations.Parameter; +import org.hibernate.annotations.Type; +import org.hibernate.envers.Audited; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; +import java.util.Set; + +@Entity +@Include +@Audited // Ensure envers does not cause any issues +public class House { + @Setter + private long id; + + @Setter + private AddressFragment address; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + public long getId() { + return id; + } + + @Column(name = "address", columnDefinition = "TEXT") + @Type(type = "com.yahoo.elide.datastores.hibernate5.usertypes.JsonType", parameters = { + @Parameter(name = "class", value = "example.AddressFragment") + }) + public AddressFragment getAddress() { + return address; + } + + private Person owner; + @ManyToOne + public Person getOwner() { + return owner; + } + public void setOwner(Person owner) { + this.owner = owner; + } +} diff --git a/elide-datastore/elide-datastore-hibernate5/src/test/java/example/Person.java b/elide-datastore/elide-datastore-hibernate5/src/test/java/example/Person.java index ec2bdfe613..fa5f067754 100644 --- a/elide-datastore/elide-datastore-hibernate5/src/test/java/example/Person.java +++ b/elide-datastore/elide-datastore-hibernate5/src/test/java/example/Person.java @@ -12,9 +12,14 @@ import org.hibernate.annotations.Type; import org.hibernate.envers.Audited; +import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; import javax.persistence.Id; +import javax.persistence.OneToMany; +import java.util.Set; @Entity @Include(rootLevel = true) @@ -31,6 +36,7 @@ public class Person { private AddressFragment address; @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) public long getId() { return id; } @@ -42,4 +48,14 @@ public long getId() { public AddressFragment getAddress() { return address; } + + private Set houses; + @OneToMany(targetEntity = House.class, + mappedBy = "owner") + public Set getHouse() { + return houses; + } + public void setHouse(Set houses) { + this.houses = houses; + } } diff --git a/elide-graphql/pom.xml b/elide-graphql/pom.xml index ab622735f9..0533a48cdd 100644 --- a/elide-graphql/pom.xml +++ b/elide-graphql/pom.xml @@ -42,6 +42,12 @@ + + + org.hibernate + hibernate-envers + 5.0.2.Final + com.yahoo.elide elide-core diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/Environment.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/Environment.java index 4a8f41f14d..25fca2d907 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/Environment.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/Environment.java @@ -32,6 +32,7 @@ public class Environment { public final Optional first; public final Object rawSource; public final GraphQLContainer container; + public final Map arguments; public final PersistentResource parentResource; public final GraphQLType parentType; @@ -79,7 +80,10 @@ public Environment(DataFetchingEnvironment environment) { } else { data = (List>) args.get(ModelBuilder.ARGUMENT_DATA); } + this.data = Optional.ofNullable(data); + + this.arguments = environment.getArguments(); } public boolean isRoot() { diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java index 4d16158cc5..a804dc30f8 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java @@ -21,6 +21,7 @@ import graphql.schema.GraphQLTypeReference; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections.CollectionUtils; +import org.hibernate.envers.Audited; import java.util.HashMap; import java.util.HashSet; @@ -172,14 +173,49 @@ public GraphQLSchema build() { .type(buildConnectionObject(clazz))); } - GraphQLObjectType queryRoot = root.build(); - GraphQLObjectType mutationRoot = root.name("__mutation_root").build(); - /* * Walk the object graph (avoiding cycles) and construct the GraphQL output object types. */ dictionary.walkEntityGraph(rootClasses, this::buildConnectionObject); + /* Construct history object */ + GraphQLObjectType.Builder history = newObject().name("__history"); + + // Get the list of Audited Entities + Set> auditedEntities = allClasses.stream().filter(this::isAuditedEntity).collect(Collectors.toSet()); + for (Class clazz : auditedEntities) { + String entityName = dictionary.getJsonAliasFor(clazz); + log.info("Audited entity name {}", entityName); + history.field(newFieldDefinition() + .name(entityName) + .dataFetcher(dataFetcher) + .argument(relationshipOpArg) + .argument(idArgument) + .argument(filterArgument) + .argument(sortArgument) + .argument(pageFirstArgument) + .argument(pageOffsetArgument) + .argument(buildInputObjectArgument(clazz, true)) + .type(buildConnectionObject(clazz))); + } + + root.field(newFieldDefinition() + .name("__history") + .dataFetcher(dataFetcher) + .argument(newArgument() + .name("revision") + .type(new GraphQLList(Scalars.GraphQLLong)) + .build() + ) + .argument(newArgument() + .name("date") + .type(new GraphQLList(Scalars.GraphQLLong)) + .build() + ) + .type(history)); + + GraphQLObjectType queryRoot = root.build(); + GraphQLObjectType mutationRoot = root.name("__mutation_root").build(); /* Construct the schema */ GraphQLSchema schema = GraphQLSchema.newSchema() .query(queryRoot) @@ -192,6 +228,22 @@ public GraphQLSchema build() { return schema; } + /** + * Builds a GraphQL History object from an entity class. + * + * @param entityClass The class to use to construct the output object + * @return The GraphQL object. + */ + private boolean isAuditedEntity(Class entityClass) { + Audited audited = dictionary.getAnnotation(entityClass, Audited.class); + if (audited != null) { + return true; + } + + return false; + } + + /** * Builds a GraphQL connection object from an entity class. * diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/HistoryContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/HistoryContainer.java new file mode 100644 index 0000000000..67cb04766d --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/HistoryContainer.java @@ -0,0 +1,34 @@ +/* + * Copyright 2018, Oath Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.graphql.containers; + +import com.yahoo.elide.graphql.Environment; + +import java.util.List; +import java.util.Map; + +public class HistoryContainer extends RootContainer { + public static final String HISTORY_KEY = "__history"; + private static final String DATE_KEY = "date"; + private static final String REVISION_KEY = "revision"; + + /** + * Constructor. + * + * @param context Context containing history field. + */ + HistoryContainer(Environment context) { + Map args = context.arguments; + if (args != null) { + if (args.get(DATE_KEY) != null && ((List)args.get(DATE_KEY)).size() == 1 ) { + context.requestScope.setHistoricalDatestamp((Long) ((List) args.get(DATE_KEY)).get(0)); + } + if (args.get(REVISION_KEY) != null && ((List)args.get(REVISION_KEY)).size() == 1 ) { + context.requestScope.setHistoricalRevision((Long) ((List) args.get(REVISION_KEY)).get(0)); + } + } + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/RootContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/RootContainer.java index 6f7cf7a5eb..84dc199dad 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/RootContainer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/RootContainer.java @@ -9,13 +9,24 @@ import com.yahoo.elide.graphql.Environment; import com.yahoo.elide.graphql.PersistentResourceFetcher; import graphql.language.Field; +import lombok.extern.slf4j.Slf4j; + +import static com.yahoo.elide.graphql.containers.HistoryContainer.HISTORY_KEY; /** * Root container for GraphQL requests. */ +@Slf4j public class RootContainer implements GraphQLContainer { + @Override public Object processFetch(Environment context, PersistentResourceFetcher fetcher) { + log.debug(String.format("Field %s arguments %s", context.field, + context.arguments.toString())); + if (isHistorySelection(context.field)) { + return new HistoryContainer(context); + } + EntityDictionary dictionary = context.requestScope.getDictionary(); Class entityClass = dictionary.getEntityClass(context.field.getName()); boolean generateTotals = requestContainsPageInfo(context.field); @@ -28,4 +39,8 @@ public static boolean requestContainsPageInfo(Field field) { .anyMatch(f -> f instanceof Field && ConnectionContainer.PAGE_INFO_KEYWORD.equals(((Field) f).getName())); } + + private boolean isHistorySelection(Field field) { + return field.getName().equalsIgnoreCase(HISTORY_KEY); + } } diff --git a/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneSettings.java b/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneSettings.java index 123bbc3dfa..d428b33809 100644 --- a/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneSettings.java +++ b/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneSettings.java @@ -10,6 +10,7 @@ import com.yahoo.elide.core.DataStore; import com.yahoo.elide.core.EntityDictionary; import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.datastores.hibernate5.RevisionDataStoreSupplier; import com.yahoo.elide.resources.DefaultOpaqueUserFunction; import com.yahoo.elide.security.checks.Check; import com.yahoo.elide.standalone.Util; @@ -51,7 +52,7 @@ default Map> getCheckMappings() { */ default ElideSettings getElideSettings(ServiceLocator injector) { DataStore dataStore = new InjectionAwareHibernateStore( - injector, Util.getSessionFactory(getHibernate5ConfigPath(), getModelPackageName())); + injector, Util.getSessionFactory(getHibernate5ConfigPath(), getModelPackageName())); EntityDictionary dictionary = new EntityDictionary(getCheckMappings()); return new ElideSettingsBuilder(dataStore) .withUseFilterExpressions(true)