/*
Copyright 2015 CrushPaper.com.
This file is part of CrushPaper.
CrushPaper is free software: you can redistribute it and/or modify
it under the terms of version 3 of the GNU Affero General Public
License as published by the Free Software Foundation.
CrushPaper is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with CrushPaper. If not, see <http://www.gnu.org/licenses/>.
*/
package com.crushpaper;
import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.FlushModeType;
import javax.persistence.Persistence;
import javax.persistence.Query;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.TermQuery;
import org.hibernate.search.jpa.FullTextEntityManager;
import org.hibernate.search.query.dsl.QueryBuilder;
/**
* This class is uses JPA/Hibernate/H2 database for persistence. It has a cache
* of uncommitted data to compensate for JPA/Hibernate.
*/
public class JpaDb implements DbInterface {
private IdGenerator idGenerator;
private File dbDirectory;
public JpaDb(IdGenerator idGenerator, File dbDirectory) {
this.idGenerator = idGenerator;
this.dbDirectory = dbDirectory;
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#indexByParentId(String,
* com.crushpaper.Entry)
*/
@Override
public void indexByParentId(String parentId, Entry entry) {
HashMap<String, HashSet<Entry>> parentIdToEntryCache = getCacheForEntryByParentId();
HashSet<Entry> newSet = parentIdToEntryCache.get(parentId);
if (newSet == null) {
parentIdToEntryCache.put(parentId, newSet = new HashSet<Entry>());
}
newSet.add(entry);
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#unindexByParentId(String,
* com.crushpaper.Entry)
*/
@Override
public void unindexByParentId(String parentId, Entry entry) {
HashMap<String, HashSet<Entry>> parentIdToEntryCache = getCacheForEntryByParentId();
HashSet<Entry> oldSet = parentIdToEntryCache.get(parentId);
if (oldSet != null) {
oldSet.remove(entry);
}
}
/*
* (non-Javadoc)
*
* @see
* com.crushpaper.DbInterface#setIdGenerator(com.crushpaper.IdGenerator)
*/
@Override
public void indexEntryById(String id, Entry entry) {
getCacheForEntryById().put(id, entry);
}
/*
* (non-Javadoc)
*
* @see
* com.crushpaper.DbInterface#setIdGenerator(com.crushpaper.IdGenerator)
*/
@Override
public void setIdGenerator(IdGenerator idGenerator) {
this.idGenerator = idGenerator;
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#getEntryById(java.lang.String)
*/
@Override
public Entry getEntryById(String id) {
if (id == null || id.isEmpty() || !idGenerator.isIdWellFormed(id)) {
return null;
}
Entry entry = getCacheForEntryById().get(id);
if (entry == null) {
entry = (Entry) getFirstOrNull(getOrCreateEntityManager()
.createNamedQuery("Entry.getById").setParameter("id", id));
if (entry != null && !wasEntryDeletedInThisTransaction(entry)) {
entry.index(this);
}
}
return entry;
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#getAllEntries()
*/
@Override
public List<?> getAllEntries() {
List<?> dbResults = getOrCreateEntityManager().createNamedQuery(
"Entry.getAll").getResultList();
// If there is nothing in the cache just return what is in the DB.
if (!areAnyEntriesInCache()) {
for (Object object : dbResults) {
Entry entry = (Entry) object;
entry.index(this);
}
return dbResults;
}
// Index the db results.
HashSet<Entry> realResults = new HashSet<Entry>();
for (Object object : dbResults) {
Entry entry = (Entry) object;
realResults.add(entry);
entry.index(this);
}
// Add any entries that have not been persisted yet.
HashMap<String, Entry> entryByIdCache = getCacheForEntryById();
realResults.addAll(entryByIdCache.values());
// Remove anything that has been deleted.
realResults.removeAll(getCacheForDeletedEntries());
return new ArrayList<Object>(realResults);
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#getAllParentlessEntries()
*/
@Override
public List<?> getAllParentlessEntries() {
List<?> dbResults = getOrCreateEntityManager().createNamedQuery(
"Entry.getAllParentless").getResultList();
// If there is nothing in the cache just return what is in the DB.
if (!areAnyEntriesInCache()) {
for (Object object : dbResults) {
Entry entry = (Entry) object;
entry.index(this);
}
return dbResults;
}
// Remove any entries that have been modified in the cache to have a
// parent.
HashSet<Entry> realResults = new HashSet<Entry>();
for (Object object : dbResults) {
Entry entry = (Entry) object;
if (entry.getParentId() == null) {
realResults.add(entry);
entry.index(this);
}
}
// Add any entries that have been modified in the cache to have no
// parent.
HashMap<String, HashSet<Entry>> entryByParentIdCache = getCacheForEntryByParentId();
HashSet<Entry> cachedMatches = entryByParentIdCache.get(null);
if (cachedMatches != null) {
realResults.addAll(cachedMatches);
}
// Remove anything that has been deleted.
realResults.removeAll(getCacheForDeletedEntries());
return new ArrayList<Object>(realResults);
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#getEntriesByUserId(java.lang.String)
*/
@Override
public List<?> getEntriesByUserId(String userId) {
if (userId == null) {
return new ArrayList<Entry>();
}
List<?> dbResults = getOrCreateEntityManager()
.createNamedQuery("Entry.getByUserId")
.setParameter("userId", userId).getResultList();
// If there is nothing in the cache just return what is in the db.
if (!areAnyEntriesInCache()) {
for (Object object : dbResults) {
Entry entry = (Entry) object;
entry.index(this);
}
return dbResults;
}
// Remove any entries that have been modified in the cache to have a
// different userid.
HashSet<Entry> realResults = new HashSet<Entry>();
for (Object object : dbResults) {
Entry entry = (Entry) object;
if (userId.equals(entry.getUserId())) {
realResults.add(entry);
entry.index(this);
}
}
// Add any entries that have been modified in the cache to have the
// parent.
HashMap<String, Entry> entryByIdCache = getCacheForEntryById();
for (Entry entry : entryByIdCache.values()) {
if (userId.equals(entry.getUserId())) {
realResults.add(entry);
}
}
// Remove anything that has been deleted.
realResults.removeAll(getCacheForDeletedEntries());
return new ArrayList<Object>(realResults);
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#getAllUsers(int, int)
*/
@Override
public List<?> getAllUsers(int startPosition, int maxResults) {
return getOrCreateEntityManager().createNamedQuery("User.getAll")
.setFirstResult(startPosition).setMaxResults(maxResults)
.getResultList();
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#getEntriesByParentId(java.lang.String)
*/
@Override
public List<?> getEntriesByParentId(String parentId) {
if (parentId == null || parentId.isEmpty()
|| !idGenerator.isIdWellFormed(parentId)) {
return new ArrayList<Entry>();
}
List<?> dbResults = getOrCreateEntityManager()
.createNamedQuery("Entry.getByParentId")
.setParameter("parentId", parentId).getResultList();
// If there is nothing in the cache just return what is in the db.
if (!areAnyEntriesInCache()) {
for (Object object : dbResults) {
Entry entry = (Entry) object;
entry.index(this);
}
return dbResults;
}
// Remove any entries that have been modified in the cache to have a
// different or null parent.
HashSet<Entry> realResults = new HashSet<Entry>();
for (Object object : dbResults) {
Entry entry = (Entry) object;
if (parentId.equals(entry.getParentId())) {
realResults.add(entry);
entry.index(this);
}
}
// Add any entries that have been modified in the cache have the parent.
HashMap<String, HashSet<Entry>> entryByParentIdCache = getCacheForEntryByParentId();
HashSet<Entry> cachedMatches = entryByParentIdCache.get(parentId);
if (cachedMatches != null) {
realResults.addAll(cachedMatches);
}
// Remove anything that has been deleted.
realResults.removeAll(getCacheForDeletedEntries());
return new ArrayList<Object>(realResults);
}
/*
* (non-Javadoc)
*
* @see
* com.crushpaper.DbInterface#doesTableOfContentsHaveAnyNotebooks(java.lang
* .String)
*/
@Override
public boolean doesTableOfContentsHaveAnyNotebooks(String tableOfContentsId) {
List<?> dbResults = getOrCreateEntityManager()
.createNamedQuery("Entry.getByParentId")
.setParameter("parentId", tableOfContentsId).setMaxResults(1)
.getResultList();
return dbResults.size() > 0;
}
/*
* (non-Javadoc)
*
* @see
* com.crushpaper.DbInterface#searchEntriesForUserHelper(java.lang.String,
* java.lang.String, java.lang.String, int, int)
*/
@Override
public List<?> searchEntriesForUserHelper(String userId, String field,
String query, int startPosition, int maxResults) {
if (userId == null || userId.isEmpty()
|| !idGenerator.isIdWellFormed(userId)) {
return new ArrayList<Entry>();
}
if (query == null) {
return new ArrayList<Entry>();
}
if (field == null) {
return new ArrayList<Entry>();
}
FullTextEntityManager fullTextEntityManager = org.hibernate.search.jpa.Search
.getFullTextEntityManager(getOrCreateEntityManager());
QueryBuilder qb = fullTextEntityManager.getSearchFactory()
.buildQueryBuilder().forEntity(Entry.class).get();
org.apache.lucene.search.Query luceneQuery = qb
.bool()
.must(qb.keyword().onField(field).matching(query).createQuery())
.must(new TermQuery(new Term("userId", userId))).createQuery();
javax.persistence.Query jpaQuery = fullTextEntityManager
.createFullTextQuery(luceneQuery, Entry.class)
.setFirstResult(startPosition).setMaxResults(maxResults);
return jpaQuery.getResultList();
}
/**
* Returns the first result of the query or null because
* Query.getSingleResult() would throw a NoResultException.
*/
private Object getFirstOrNull(Query query) {
List<?> result = query.getResultList();
if (result == null || result.isEmpty()) {
return null;
}
return result.get(0);
}
/*
* (non-Javadoc)
*
* @see
* com.crushpaper.DbInterface#wasEntryDeletedInThisTransaction(com.crushpaper
* .Entry)
*/
@Override
public boolean wasEntryDeletedInThisTransaction(Entry entry) {
return getCacheForDeletedEntries().contains(entry);
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#getUserHelper(java.lang.String)
*/
@Override
public User getUserHelper(String userName) {
return (User) getFirstOrNull(getOrCreateEntityManager()
.createNamedQuery("User.getByUserName").setParameter(
"userName", userName));
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#persistEntry(com.crushpaper.Entry)
*/
@Override
public void persistEntry(Entry entry) {
getOrCreateEntityManager().persist(entry);
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#persistUser(com.crushpaper.User)
*/
@Override
public void persistUser(User user) {
getOrCreateEntityManager().persist(user);
getCacheForUserById().put(user.getId(), user);
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#removeEntry(com.crushpaper.Entry)
*/
@Override
public void removeEntry(Entry entry) {
// Index it as removed.
getCacheForDeletedEntries().add(entry);
// Mark it for removal.
getOrCreateEntityManager().remove(entry);
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#getUserById(java.lang.String)
*/
@Override
public User getUserById(String id) {
if (id == null) {
return null;
}
User user = getCacheForUserById().get(id);
if (user == null) {
user = (User) getFirstOrNull(getOrCreateEntityManager()
.createNamedQuery("User.getById").setParameter("id", id));
if (user != null) {
getCacheForUserById().put(id, user);
}
}
return user;
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#getEntriesBySourceId(java.lang.String,
* int, int)
*/
@Override
public List<?> getEntriesBySourceId(String sourceId, int startPosition,
int maxResults) {
if (sourceId == null || sourceId.isEmpty()
|| !idGenerator.isIdWellFormed(sourceId)) {
return new ArrayList<Entry>();
}
List<?> dbResults = getOrCreateEntityManager()
.createNamedQuery("Entry.getBySourceId")
.setParameter("sourceId", sourceId)
.setFirstResult(startPosition).setMaxResults(maxResults)
.getResultList();
// If there is nothing in the cache just return what is in the db.
if (!areAnyEntriesInCache()) {
for (Object object : dbResults) {
Entry entry = (Entry) object;
entry.index(this);
}
return dbResults;
}
// Index and add to the results.
// Remove anything that has been deleted.
HashSet<Entry> deletedEntries = getCacheForDeletedEntries();
List<Entry> realResults = new ArrayList<Entry>();
for (Object object : dbResults) {
Entry entry = (Entry) object;
if (deletedEntries.contains(entry)) {
continue;
}
realResults.add(entry);
entry.index(this);
}
return realResults;
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#getEntryByUserIdAndUrl(java.lang.String,
* java.lang.String)
*/
@Override
public Entry getEntryByUserIdAndUrl(String userId, String url) {
return (Entry) getFirstOrNull(getOrCreateEntityManager()
.createNamedQuery("Entry.getByUserIdAndUrl")
.setParameter("userId", userId).setParameter("sourceUrl", url));
}
/*
* (non-Javadoc)
*
* @see
* com.crushpaper.DbInterface#getEntriesByUserIdAndType(java.lang.String,
* java.lang.String, int, int)
*/
@Override
public List<?> getEntriesByUserIdAndType(String userId, String type,
int startPosition, int maxResults) {
return getOrCreateEntityManager()
.createNamedQuery("Entry.getByUserIdAndType")
.setParameter("userId", userId).setParameter("type", type)
.setFirstResult(startPosition).setMaxResults(maxResults)
.getResultList();
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#clearData()
*/
@Override
public void clearData() {
getOrCreateEntityManager().createNativeQuery("truncate table USR")
.executeUpdate();
getOrCreateEntityManager().createNativeQuery("truncate table ENTRY")
.executeUpdate();
final StringBuffer out = new StringBuffer();
final StringBuffer err = new StringBuffer();
String indexPath = new File(dbDirectory, "com.crushpaper.Entry")
.getAbsolutePath();
CommandLineUtil.removeDirectory(out, err, indexPath);
commit();
}
private static final ThreadLocal<EntityManager> entityManagerThreadLocal = new ThreadLocal<EntityManager>();
/** Creates a new entity manager that does not auto commit. */
private EntityManager createEntityManager() {
EntityManager entityManager = entityManagerFactory
.createEntityManager();
entityManager.setFlushMode(FlushModeType.COMMIT);
entityManagerThreadLocal.set(entityManager);
return entityManager;
}
/**
* Returns an entity manager and creates it if needed. Also recreates it if
* it has been spontaneously closed which is a real thing that can happen.
*
* @return
*/
private EntityManager getOrCreateEntityManager() {
buildEntityManagerFactory();
EntityManager entityManager = entityManagerThreadLocal.get();
if (entityManager == null) {
entityManager = createEntityManager();
}
if (!entityManager.isOpen()) {
entityManager = createEntityManager();
}
EntityTransaction transaction = entityManager.getTransaction();
if (!transaction.isActive())
transaction.begin();
return entityManager;
}
/** Returns the entity manager if one exists. It might not even be open. */
private EntityManager getEntityManager() {
return entityManagerThreadLocal.get();
}
/** Closes an entity manager if it is open. */
private void closeEntityManager() {
EntityManager entityManager = getEntityManager();
if (entityManager != null && entityManager.isOpen()) {
entityManager.close();
entityManagerThreadLocal.set(null);
}
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#commit()
*/
@Override
public void commit() {
clearCache();
EntityManager entityManager = getEntityManager();
if (entityManager != null && entityManager.isOpen()) {
EntityTransaction transaction = entityManager.getTransaction();
if (transaction.isActive()) {
transaction.commit();
}
}
closeEntityManager();
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#rollback()
*/
@Override
public void rollback() {
clearCache();
EntityManager entityManager = getEntityManager();
if (entityManager != null && entityManager.isOpen()) {
EntityTransaction transaction = entityManager.getTransaction();
if (transaction.isActive()) {
transaction.rollback();
}
}
closeEntityManager();
}
private static final ThreadLocal<HashMap<String, User>> idToUserCacheThreadLocal = new ThreadLocal<HashMap<String, User>>() {
@Override
protected HashMap<String, User> initialValue() {
return new HashMap<String, User>();
}
};
private static final ThreadLocal<HashSet<Entry>> deletedEntriesCacheThreadLocal = new ThreadLocal<HashSet<Entry>>() {
@Override
protected HashSet<Entry> initialValue() {
return new HashSet<Entry>();
}
};
private static final ThreadLocal<HashMap<String, Entry>> idToEntryCacheThreadLocal = new ThreadLocal<HashMap<String, Entry>>() {
@Override
protected HashMap<String, Entry> initialValue() {
return new HashMap<String, Entry>();
}
};
private static final ThreadLocal<HashMap<String, HashSet<Entry>>> parentIdToEntryCacheThreadLocal = new ThreadLocal<HashMap<String, HashSet<Entry>>>() {
@Override
protected HashMap<String, HashSet<Entry>> initialValue() {
return new HashMap<String, HashSet<Entry>>();
}
};
private boolean areAnyEntriesInCache() {
return !idToEntryCacheThreadLocal.get().isEmpty()
|| !deletedEntriesCacheThreadLocal.get().isEmpty();
}
private HashMap<String, Entry> getCacheForEntryById() {
return idToEntryCacheThreadLocal.get();
}
private HashMap<String, HashSet<Entry>> getCacheForEntryByParentId() {
return parentIdToEntryCacheThreadLocal.get();
}
private HashSet<Entry> getCacheForDeletedEntries() {
return deletedEntriesCacheThreadLocal.get();
}
private HashMap<String, User> getCacheForUserById() {
return idToUserCacheThreadLocal.get();
}
/**
* Clear the cache of records that have been created, modified or queried
* during the transaction.
*/
private void clearCache() {
getCacheForEntryById().clear();
getCacheForEntryByParentId().clear();
getCacheForDeletedEntries().clear();
getCacheForUserById().clear();
}
EntityManagerFactory entityManagerFactory;
/**
* Create a JPA entity manager factory which is used to create entity
* managers which are used to query and commit to the DB.
*/
private synchronized void buildEntityManagerFactory() {
if (entityManagerFactory != null) {
return;
}
registerShutdownHook(this);
Map<String, Object> configOverrides = new HashMap<String, Object>();
String dbPath = dbDirectory.getAbsolutePath();
configOverrides.put("hibernate.search.default.indexBase", dbPath);
configOverrides.put("hibernate.connection.url", "jdbc:h2:" + dbPath
+ File.separator + "db" +
// Make sure that transactions are fully isolated.
";LOCK_MODE=1" +
// Make sure the database is not closed if all of the entity
// managers are closed.
";DB_CLOSE_DELAY=-1");
// This string has to match what is in persistence.xml.
entityManagerFactory = Persistence.createEntityManagerFactory(
"manager", configOverrides);
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#doCsvBackup(java.lang.String)
*/
@Override
public int doCsvBackup(String destination) {
Object numUserRows = getFirstOrNull(getOrCreateEntityManager()
.createNativeQuery(
"CALL CSVWRITE('" + destination
+ "/usr.csv', 'SELECT * FROM USR')"));
Object numEntryRows = getFirstOrNull(getOrCreateEntityManager()
.createNativeQuery(
"CALL CSVWRITE('" + destination
+ "/entry.csv', 'SELECT * FROM ENTRY')"));
if (numUserRows != null && numEntryRows != null) {
return ((Integer) numUserRows).intValue()
+ ((Integer) numEntryRows).intValue();
}
return -1;
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#createDb()
*/
@Override
public void createDb() {
buildEntityManagerFactory();
}
/*
* (non-Javadoc)
*
* @see com.crushpaper.DbInterface#shutDown()
*/
@Override
public void shutDown() {
// Close caches and connection pools.
if (entityManagerFactory != null & entityManagerFactory.isOpen()) {
entityManagerFactory.close();
}
}
private static boolean registeredShutdownHook = false;
/**
* Helper method that requests that the DB is shutdown once and only once
* when the application is cleanly shutdown. This is not guaranteed to work.
*/
private static void registerShutdownHook(final DbInterface jpaDb) {
if (registeredShutdownHook) {
return;
}
registeredShutdownHook = true;
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
jpaDb.shutDown();
}
});
}
}