package com.google.sitebricks.persist.disk;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.ImmutableMap;
import org.apache.lucene.document.*;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexableField;
import org.apache.lucene.index.Term;
import org.apache.lucene.queryparser.classic.QueryParser;
import org.apache.lucene.search.*;
import org.apache.lucene.util.BytesRef;
import com.google.sitebricks.persist.EntityMetadata;
import com.google.sitebricks.persist.EntityQuery;
import com.google.sitebricks.persist.EntityStore;
import com.google.sitebricks.persist.Indexed;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.persistence.Id;
import javax.persistence.Lob;
import java.io.IOException;
import java.io.Serializable;
import java.lang.annotation.Annotation;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
/**
* @author dhanji@gmail.com (Dhanji R. Prasanna)
*/
@Singleton
class DiskEntityStore extends EntityStore {
public static final String SB_TYPE = "__sb$type$";
public static final String SB_OBJECT = "__sb$object$";
private final ReentrantLock writeLock = new ReentrantLock(true);
private IndexSet indexSet;
@Inject
private EntityMetadata metadata;
@Inject
private ObjectMapper objectMapper;
public void init(IndexSet indexSet) {
this.indexSet = indexSet;
}
void lock() {
// Ensure we only lock once from this thread.
if (!writeLock.isHeldByCurrentThread())
writeLock.lock();
}
@Override
public <T> Serializable save(T t) {
EntityMetadata.EntityDescriptor descriptor = metadata.of(t.getClass());
if (descriptor == null)
throw new IllegalArgumentException("Object of unknown type provided. Did you remember" +
" to register it in your persistence module via scan() or addPersistent()? [" +
t.getClass() + "]");
lock();
Map<String, EntityMetadata.MemberReader> fields = descriptor.fields();
IndexWriter writer = indexSet.current().writer;
Serializable idValue = null;
Document document = new Document();
for (Map.Entry<String, EntityMetadata.MemberReader> entry : fields.entrySet()) {
EntityMetadata.MemberReader reader = entry.getValue();
Collection<Annotation> annotations = reader.annotations();
boolean indexed = false, id = false, lob = false;
for (Annotation annotation : annotations) {
if (Indexed.class.isInstance(annotation))
indexed = true;
else if (Id.class.isInstance(annotation))
id = true;
else if (Lob.class.isInstance(annotation))
lob = true;
}
// Only index the desired fields.
if (!indexed && !id)
continue;
Class<?> type = reader.type();
IndexableField field;
Object value = reader.value(t);
if (id) {
if (value == null)
throw new IllegalArgumentException("You must provide an id (disk store does not autopopulate id");
else
idValue = (Serializable) value;
}
if (type == int.class || type == Integer.class)
field = new IntField(entry.getKey(), (Integer) value, Field.Store.NO);
else if (type == long.class || type == Long.class)
field = new LongField(entry.getKey(), (Long) value, Field.Store.NO);
else if (type == double.class || type == Double.class)
field = new DoubleField(entry.getKey(), (Double) value, Field.Store.NO);
else if (type == float.class || type == Float.class)
field = new FloatField(entry.getKey(), (Float) value, Field.Store.NO);
else {
if (lob)
field = new TextField(entry.getKey(), (String) value, Field.Store.NO);
else {
if (value != null)
value = value.toString();
field = new StringField(entry.getKey(), (String) value, Field.Store.NO);
}
}
document.add(field);
}
try {
// Store the entire object as JSON.
document.add(new StringField(SB_TYPE, t.getClass().getName(), Field.Store.NO));
document.add(new StoredField(SB_OBJECT, objectMapper.writeValueAsBytes(t)));
writer.addDocument(document);
} catch (IOException e) {
throw new RuntimeException(e);
}
return idValue;
}
@Override
public <T> T find(Class<T> type, Serializable key) {
// Reading methods do not lock.
EntityMetadata.EntityDescriptor descriptor = metadata.of(type);
String idField = descriptor.idField();
EntityMetadata.MemberReader idReader = descriptor.fields().get(idField);
if (!idReader.type().isAssignableFrom(key.getClass()))
throw new IllegalArgumentException("Given key is not of compatible type with @Id field of "
+ type + ". Expected: " + idReader.type() + " but found: " + key.getClass());
Map<String, EntityQuery.FieldMatcher<?>> query =
new HashMap<String, EntityQuery.FieldMatcher<?>>(1);
query.put(idField, EntityQuery.FieldMatcher.is(key));
List<T> results = execute(type, query, 0, 1);
return results != null ? results.get(0) : null;
}
@Override
public <T> void remove(Class<T> type, Serializable key) {
EntityMetadata.EntityDescriptor descriptor = metadata.of(type);
try {
Map<String, EntityQuery.FieldMatcher<?>> deletionQuery =
new HashMap<String, EntityQuery.FieldMatcher<?>>(1);
deletionQuery.put(descriptor.idField(), EntityQuery.FieldMatcher.is(key));
indexSet.current().writer.deleteDocuments(queryFrom(type, deletionQuery));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
protected <T> void executeDelete(Class<T> type,
Map<String, EntityQuery.FieldMatcher<?>> matcherMap) {
try {
indexSet.current().writer.deleteDocuments(queryFrom(type, matcherMap));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override public <T> List<T> all(Class<T> type) {
return execute(type, ImmutableMap.<String, EntityQuery.FieldMatcher<?>>of(), 0, Integer.MAX_VALUE);
}
@Override
protected <T> List<T> execute(Class<T> type, Map<String, EntityQuery.FieldMatcher<?>> query,
int offset, int limit) {
BooleanQuery booleanQuery = queryFrom(type, query);
IndexSearcher searcher = indexSet.current().searcher.get();
try {
TopDocs results = searcher.search(booleanQuery, limit);
if (results.scoreDocs.length == 0)
return null;
List<T> resultList = new ArrayList<T>(results.scoreDocs.length - offset);
for (int i = offset; i < results.scoreDocs.length; i++) {
Document document = searcher.doc(results.scoreDocs[i].doc);
resultList.add(objectMapper.readValue(document.getField(SB_OBJECT).binaryValue().bytes, type));
}
return resultList;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private void addNumericConstraint(Number low, Number high, String field, BooleanQuery toQuery,
boolean includeLow, boolean includeHigh) {
if (high == null)
high = low;
// Account for various numeric types.
if (low instanceof Integer)
toQuery.add(NumericRangeQuery.newIntRange(field, (Integer) low, (Integer) high, includeLow, includeHigh),
BooleanClause.Occur.MUST);
else if (low instanceof Double)
toQuery.add(NumericRangeQuery.newDoubleRange(field, (Double) low, (Double) high, includeLow, includeHigh),
BooleanClause.Occur.MUST);
else
toQuery.add(NumericRangeQuery.newLongRange(field, (Long) low, (Long) high, includeLow, includeHigh),
BooleanClause.Occur.MUST);
}
private <T> BooleanQuery queryFrom(Class<T> type, Map<String, EntityQuery.FieldMatcher<?>> query) {
BooleanQuery booleanQuery = new BooleanQuery();
booleanQuery.add(new TermQuery(new Term(SB_TYPE, QueryParser.escape(type.getName()))),
BooleanClause.Occur.MUST);
for (Map.Entry<String, EntityQuery.FieldMatcher<?>> matchers : query.entrySet()) {
String field = matchers.getKey();
EntityQuery.FieldMatcher<?> value = matchers.getValue();
if (value.low instanceof Number) {
switch (value.kind) {
case IS:
addNumericConstraint((Number)value.low, null, field, booleanQuery, true, true);
break;
case NOT:
addNumericConstraint((Number)value.low, null, field, booleanQuery, false, false);
break;
case BELOW:
addNumericConstraint(Integer.MIN_VALUE, (Number)value.low, field, booleanQuery, false, false);
break;
case ABOVE:
addNumericConstraint((Number)value.low, Integer.MAX_VALUE, field, booleanQuery, false, false);
break;
case BELOW_INCLUDING:
addNumericConstraint(Integer.MIN_VALUE, (Number)value.low, field, booleanQuery, false, true);
break;
case ABOVE_INCLUDING:
addNumericConstraint((Number)value.low, Integer.MAX_VALUE, field, booleanQuery, true, false);
break;
case BETWEEN:
addNumericConstraint((Number)value.low, (Number)value.high, field, booleanQuery, false, false);
break;
default:
throw new UnsupportedOperationException("Query type not supported for numbers in this datastore: "
+ value.kind);
}
} else {
switch (value.kind) {
case IS:
booleanQuery.add(new TermQuery(new Term(field, value.low.toString())),
BooleanClause.Occur.MUST);
break;
case NOT:
booleanQuery.add(new TermQuery(new Term(field, value.low.toString())),
BooleanClause.Occur.MUST_NOT);
break;
case LIKE:
// Prefix search.
booleanQuery.add(new PrefixQuery(new Term(field, value.low.toString())),
BooleanClause.Occur.MUST);
break;
case SIMILAR_TO:
booleanQuery.add(new FuzzyQuery(new Term(field, value.low.toString())),
BooleanClause.Occur.MUST);
break;
case BETWEEN:
booleanQuery.add(new TermRangeQuery(field, new BytesRef(value.low.toString()),
new BytesRef(value.high.toString()), false, false), BooleanClause.Occur.MUST);
break;
default:
throw new UnsupportedOperationException("Query type not supported for strings in this datastore: "
+ value.kind);
}
}
}
return booleanQuery;
}
@Override
public Object delegate() {
return indexSet;
}
void complete(boolean commit) {
IndexWriter writer = indexSet.current().writer;
try {
if (commit)
writer.commit();
else
writer.rollback();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
// Ensure we completely release this lock to other threads.
while (writeLock.isHeldByCurrentThread())
writeLock.unlock();
}
}
}