package ru.dreamteam.couch;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Logger;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.StatusLine;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpHead;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.entity.BasicHttpEntity;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import static org.apache.http.HttpStatus.*;
import ru.dreamteam.couch.changes.ChangesQuery;
import ru.dreamteam.couch.query.Query;
import ru.dreamteam.couch.util.JSONUtils;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
/**
* Base class to work with documents in a database.<br/>
* To create a view in the database you need:
* <ul>
* <li> Create directory structure {@code views/[DBNAME]} in the root of classpath
* <li> Add {@code [VIEWNAME].json} file
* <li> Place your functions in this file
* </ul>
* See examples in tests.
* Views will be updated from json files each time the constructor of {@code Db} class executes.
*
* <br/>Date: 15.02.13
* @author DPokidov
*/
public class Db {
@SuppressWarnings("unused")
private final Logger log = Logger.getLogger(Db.class.getName());
private String dbName;
private Couch dbInstance;
private ObjectMapper mapper;
private String dbInfo;
Db(Couch dbInstance, String dbName) {
this.mapper = JSONUtils.createMapper();
this.dbInstance = dbInstance;
this.dbName = dbName;
this.dbInfo = String.format("Host: %s, port: %d, db: %s", dbInstance.getHost(), dbInstance.getPort(), dbName);
new DbManager(dbName, dbInstance, mapper).updateViews();
}
/**
* Saves a document to the database.
* Also, it will be setting a new modifyTime for {@code obj}
* @param obj a document to save
*/
public <T extends CouchEntity> void save(final T obj) {
final List<Attachment> attachments = prepareObjForSave(obj);
dbInstance.execute(new HttpCall<T>() {
@Override
public HttpRequest getRequest() throws URISyntaxException, IOException {
byte[] content = mapper.writeValueAsBytes(obj);
HttpPut put = new HttpPut(buildUri("/" + dbName + "/" + obj.getId()));
prepareCommandBody(put, content);
return put;
}
@Override
public T doWithResponse(HttpResponse response) throws IOException {
processSaveResponse(Arrays.asList(obj), response);
saveObjAttachments(obj, attachments);
return obj;
}
}, SC_OK, SC_CREATED);
}
private static class BulkWrapper {
@JsonProperty("docs")
private List<?> docs;
public void setDocs(List<?> docs) {
this.docs = docs;
}
}
/**
* Create or update objects in Couchdb using bulk POST request.
* @param objs documents to save
*/
public <T extends CouchEntity> void bulkSave(final List<T> objs) {
final List<List<Attachment>> attachments = new ArrayList<>();
for (CouchEntity e : objs) {
attachments.add(prepareObjForSave(e));
}
dbInstance.execute(new HttpCall<Void>() {
@Override
public HttpRequest getRequest() throws URISyntaxException,
IOException {
BulkWrapper wrapper = new BulkWrapper();
wrapper.setDocs(objs);
byte[] content = toBytes(wrapper);
HttpPost command = new HttpPost(buildUri("/" + dbName + "/_bulk_docs"));
prepareCommandBody(command, content);
return command;
}
@Override
public Void doWithResponse(HttpResponse response)
throws IOException {
processSaveResponse(objs, response);
for (int i = 0; i < objs.size(); i++) {
saveObjAttachments(objs.get(i), attachments.get(i));
}
return null;
}
}, SC_OK, SC_CREATED);
}
private <T extends CouchEntity> void processSaveResponse(List<T> objs, HttpResponse response) throws IOException {
String responseText = "";
responseText = EntityUtils.toString(response.getEntity());
JsonNode node = mapper.readTree(responseText);
if (node.isArray()) {
updateObjsRev(objs, node);
} else {
updateObjRev(objs.get(0), node);
}
}
private <T extends CouchEntity> void updateObjsRev(List<T> objs, JsonNode response) {
for (int i = 0; i < objs.size(); i++) {
CouchEntity obj = objs.get(i);
updateObjRev(obj, response.get(i));
}
}
private <T extends CouchEntity> void updateObjRev(T obj, JsonNode response) {
String revision = response.get("rev").asText();
obj.setRevision(revision);
}
private <T extends CouchEntity> void saveObjAttachments(T obj, List<Attachment> attachments) {
if (attachments == null) {
return;
}
for (Attachment a : attachments) {
saveAttach(obj, a);
obj.setRevision(getLastRevision(obj.getId()));
}
}
private <T extends CouchEntity> List<Attachment> prepareObjForSave(T obj) {
if (obj.getId() == null || obj.getId().isEmpty()) {
obj.setId(UUID.randomUUID().toString());
}
List<Attachment> attachments = null;
if (!StringUtils.isEmpty(obj.getId()) && obj.getAttachments() != null) {
attachments = new ArrayList<>();
for (String fileName : obj.getAttachments()) {
attachments.add(getAttach(obj.getId(), fileName));
}
}
obj.setModifyTime(new Date().getTime());
return attachments;
}
private void prepareCommandBody(HttpEntityEnclosingRequestBase request, byte[] content) {
BasicHttpEntity entity = new BasicHttpEntity();
entity.setContentType("application/json");
entity.setContent(new ByteArrayInputStream(content));
entity.setContentLength(content.length);
request.setEntity(entity);
}
/**
* Gets a document with a specific id.
* This method loads only information about attachments without data.
* It's a shorthand method for {@link #getById(id, null, clazz)}
* @param id id of the document
* @param clazz class that representing this document
* @return document or {@code null} if document with specific {@code id} does not exist
*/
public <T extends CouchEntity> T getById(final String id, final Class<T> clazz) {
return getById(id, null, clazz);
}
/**
* Gets a document with a specific id and revision.
* This method loads only information about attachments without data.
* @param id id of the document
* @param clazz class that representing this document
* @return document or {@code null} if document with specific {@code id} does not exist
*/
public <T extends CouchEntity> T getById(final String id, final String rev, final Class<T> clazz) {
return dbInstance.execute(new HttpCall<T>() {
@Override
public HttpRequest getRequest() throws URISyntaxException, IOException {
String path = "/" + dbName + "/" + id;
URI uri = null;
if (StringUtils.isNotBlank(rev)) {
uri = buildUri(path, new BasicNameValuePair(CouchConstants.REV, rev));
} else {
uri = buildUri(path);
}
return new HttpGet(uri);
}
@Override
public T doWithResponse(HttpResponse response) throws IOException {
if (response.getStatusLine().getStatusCode() == 404) {
return null;
}
return mapper.readValue(response.getEntity().getContent(), clazz);
}
}, SC_OK, SC_NOT_FOUND);
}
/**
* Return information about all revisions for document
* @param id document id
* @return list of revisions
*/
public List<RevisionInfo> getRevisions(final String id) {
return dbInstance.execute(new HttpCall<List<RevisionInfo>>() {
@Override
public HttpRequest getRequest() throws URISyntaxException,
IOException {
return new HttpGet(buildUri("/" + dbName + "/" + id, new BasicNameValuePair(CouchConstants.REVS_INFO, "true")));
}
@Override
public List<RevisionInfo> doWithResponse(HttpResponse response)
throws IOException {
String responseBody = EntityUtils.toString(response.getEntity(), "UTF-8");
JsonNode node = mapper.readTree(responseBody);
JsonNode revsJson = node.get("_revs_info");
Iterator<JsonNode> revsJsonIt = revsJson.elements();
List<RevisionInfo> result = new ArrayList<>();
while (revsJsonIt.hasNext()) {
RevisionInfo revisionInfo = mapper.readValue(revsJsonIt.next().toString(), RevisionInfo.class);
result.add(revisionInfo);
}
return result;
}
}, SC_OK);
}
private class KeysWrapper {
@JsonProperty("keys")
private Set<String> keys;
public KeysWrapper(Set<String> keys) {
super();
this.keys = keys;
}
}
/**
* Get documents by multiple keys using one request
* @param clazz class of entity to get
* @param ids list of ids
* @return list of entities by ids
*/
public <T extends CouchEntity> List<T> getByIds(final Class<T> clazz, final String... ids) {
return dbInstance.execute(new HttpCall<List<T>>() {
@Override
public HttpRequest getRequest() throws URISyntaxException,
IOException {
HttpPost command = new HttpPost(buildUri("/" + dbName + "/_all_docs", new BasicNameValuePair(CouchConstants.INCLUDE_DOCS, "true")));
//Using LinkedHashSet to store ordering of ids
prepareCommandBody(command, toBytes(new KeysWrapper(new LinkedHashSet<>(Arrays.asList(ids)))));
return command;
}
@Override
public List<T> doWithResponse(HttpResponse response) throws IOException {
PagingResult<T> result = new PagingResult<>();
JsonNode node = mapper.readTree(response.getEntity().getContent());
if (node.get("total_rows") != null) {
result.setTotalRows(node.get("total_rows").asInt());
}
ArrayNode rowsNode = (ArrayNode) node.get("rows");
for (JsonNode aRowsNode : rowsNode) {
if (aRowsNode.has("doc")) {
result.add(mapper.<T>readValue(aRowsNode.get("doc").toString(), clazz));
}
}
return result;
}
});
}
/**
* Returns actual revision of a document
* @param id id of the document
* @return Actual revision of the document
*/
public String getLastRevision(final String id) {
return dbInstance.execute(new HttpCall<String>() {
@Override
public HttpRequest getRequest() throws URISyntaxException,
IOException {
return new HttpHead(buildUri("/" + dbName + "/" + id));
}
@Override
public String doWithResponse(HttpResponse response)
throws IOException {
String etag = response.getHeaders("ETag")[0].getValue();
return etag.substring(1, etag.length() - 1); //Remove quotes
}
});
}
/**
* Saves or creates an attachment in a document {@code parent}
* @param parent parent document for the attachment
* @param attachs attachment object to store.
* @return updated revision of entity
*/
public String saveAttach(final CouchEntity parent, final Attachment attach) {
return dbInstance.execute(new HttpCall<String>() {
@Override
public HttpRequest getRequest() throws URISyntaxException,
IOException {
HttpPut command = new HttpPut(buildUri("/" + dbName + "/" + parent.getId() + "/" + attach.getId(),
new BasicNameValuePair(CouchConstants.REV, parent.getRevision())));
BasicHttpEntity entity = new BasicHttpEntity();
entity.setContentLength(attach.getData().length);
entity.setContent(new ByteArrayInputStream(attach.getData()));
entity.setContentType(attach.getContentType());
command.setEntity(entity);
return command;
}
@Override
public String doWithResponse(HttpResponse response)
throws IOException {
return getLastRevision(parent.getId());
}
}, SC_CREATED);
}
/**
* Deletes an attachment from a document
* @param parent document which store the attachment
* @param attachId attachmentId to delete
*/
public void deleteAttach(final CouchEntity parent, final String attachId) {
dbInstance.execute(new HttpCall<Void>() {
@Override
public HttpRequest getRequest() throws URISyntaxException,
IOException {
return new HttpDelete(buildUri("/" + dbName + "/" + parent.getId() + "/" + attachId,
new BasicNameValuePair(CouchConstants.REV, parent.getRevision())));
}
@Override
public Void doWithResponse(HttpResponse response) throws IOException { return null; }
});
}
/**
* Deletes a document from a database <br>
* This is a shorthand method for {@code delete(id, false)}
* @param id id of the object
* @return deleted document revision
*/
public String delete(String id) {
return delete(id, false);
}
/**
* Delete an object from a database.<br>
* Can be use with batch mode.
* @param id id of an object
* @param batch if {@code true} then couchdb will make in-memory delete only without immediately flush to disk
* @return deleted document revision or {@code null} if it's not present (when perform batch delete, for example)
*/
public String delete(final String id, final boolean batch) {
return dbInstance.execute(new HttpCall<String>() {
@Override
public HttpRequest getRequest() throws URISyntaxException,
IOException {
List<BasicNameValuePair> queryParams = new ArrayList<>();
queryParams.add(new BasicNameValuePair(CouchConstants.REV, getLastRevision(id)));
if (batch) {
queryParams.add(new BasicNameValuePair(CouchConstants.BATCH, "ok"));
}
URIBuilder builder = new URIBuilder(
buildUri("/" + dbName + "/" + id, queryParams.toArray(new BasicNameValuePair[queryParams.size()]))
);
URI uri = builder.build();
return new HttpDelete(uri);
}
@Override
public String doWithResponse(HttpResponse response)
throws IOException {
Header[] etagHeader = response.getHeaders("ETag");
if (etagHeader != null && etagHeader.length == 1) {
String etag = etagHeader[0].getValue();
return etag.substring(1, etag.length() - 1); // remove quotes
}
return null;
}
}, SC_OK, SC_ACCEPTED);
}
/**
* Retrieves an attachment from a document.
* @param entityId document id.
* @param fileName filename of the attachment.
* @return
*/
public Attachment getAttach(final String entityId, final String fileName) {
return dbInstance.execute(new HttpCall<Attachment>() {
@Override
public HttpRequest getRequest() throws URISyntaxException, IOException {
return new HttpGet(buildUri("/" + dbName + "/" + entityId + "/" + fileName));
}
@Override
public Attachment doWithResponse(HttpResponse response) throws IOException {
Attachment attachment = new Attachment();
HttpEntity entity = response.getEntity();
attachment.setId(fileName);
attachment.setContentType(entity.getContentType().getValue());
attachment.setData(EntityUtils.toByteArray(entity));
return attachment;
}
});
}
/**
* Purge revs of specified document.
* If no revisions specified then purge all revisions of document.
* @param id document id
* @param revs list of revisions to purge
*/
public void purge(String id, String... revs) {
final Map<String, List<String>> purgeEntity = new HashMap<>();
if (revs == null || revs.length == 0) {
List<RevisionInfo> revsList = getRevisions(id);
List<String> revsStrList = new ArrayList<>(revsList.size());
for (RevisionInfo r : revsList) {
revsStrList.add(r.getRev());
}
purgeEntity.put(id, revsStrList);
} else {
purgeEntity.put(id, Arrays.asList(revs));
}
dbInstance.execute(new HttpCall<Void>() {
@Override
public HttpRequest getRequest() throws URISyntaxException, IOException {
HttpPost command = new HttpPost(buildUri("/" + dbName + "/_purge"));
prepareCommandBody(command, mapper.writeValueAsBytes(purgeEntity));
return command;
}
@Override
public Void doWithResponse(HttpResponse response) throws IOException { return null; }
});
}
/**
* Creates a new database query
* @param view name of the view
* @param viewFunc function name in the view
* @param clazz class of the result documents
* @return new query object
*/
public <T> Query<T> query(String view, String viewFunc, Class<T> clazz) {
return new Query<>(dbInstance, "/" + dbName + "/_design/" + view + "/_view/" + viewFunc, clazz);
}
/**
* Creates a new changes query.
* @return new changes query object.
*/
public ChangesQuery changes() {
return new ChangesQuery(dbInstance, dbName);
}
/**
* Shorthand method for query(view, viewFunc, clazz).list();
* @param view
* @param viewFunc
* @param clazz
* @param <T>
* @return
*/
public <T> PagingResult<T> list(String view, String viewFunc, Class<T> clazz) {
return new Query<>(dbInstance, "/" + dbName + "/_design/" + view + "/_view/" + viewFunc, clazz).list();
}
@Override
public String toString() {
return dbInfo;
}
public static byte[] toBytes(Object couchEntity, ObjectMapper mapper) {
try {
return mapper.writeValueAsBytes(couchEntity);
} catch (IOException e) {
throw new MarshallingException(e);
}
}
private byte[] toBytes(Object couchEntity) {
return toBytes(couchEntity, mapper);
}
public static URI buildUri(String path, BasicNameValuePair... params) throws URISyntaxException {
URIBuilder uriBuilder = new URIBuilder();
uriBuilder.setPath(path);
if (params != null) {
for (BasicNameValuePair p : params) {
uriBuilder.addParameter(p.getName(), p.getValue());
}
}
return uriBuilder.build();
}
}