/*
Copyright 2013 Red Hat, Inc. and/or its affiliates.
This file is part of lightblue.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program 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 General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.redhat.lightblue.savedsearch;
import java.util.Iterator;
import java.util.Objects;
import java.util.List;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.TextNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.redhat.lightblue.Request;
import com.redhat.lightblue.Response;
import com.redhat.lightblue.ClientIdentification;
import com.redhat.lightblue.EntityVersion;
import com.redhat.lightblue.config.SavedSearchConfiguration;
import com.redhat.lightblue.crud.FindRequest;
import com.redhat.lightblue.crud.CrudConstants;
import com.redhat.lightblue.mediator.Mediator;
import com.redhat.lightblue.metadata.EntityMetadata;
import com.redhat.lightblue.query.*;
import com.redhat.lightblue.util.Error;
import com.redhat.lightblue.util.Path;
import com.redhat.lightblue.util.JsonUtils;
/**
* This class is the main access point to saved searches. It loads
* saved searches from the db, and keeps them in a weak cache.
*
* We only provide the basics to implement saved search functionality
* at this level. The functionality must be enabled at the higher
* layer, REST or embedded application layers.
*
* The implementation should:
* <ul>
* <li>Instantiate a singleton instance of the SavedSearchCache. This
* should be shared among all threads.</li>
* <li>Determine the name of the saved search, the entity, and its version.
* The saved search name is meaningful only with its associated entity<li?.
* <li>Retrieve the saved search document using SavedSearchCache.getSavedSearch</li>
* <li>Collect the query parameters, and fill in defaults using FindRequestBuilder.fillDefaults</li>
* <li>Prepare a FindRequest using FindRequestBuilder.buildRequest.</li>
* <li>Call find()</li>
* </ul>
*/
public class SavedSearchCache {
private static final Logger LOGGER = LoggerFactory.getLogger(SavedSearchCache.class);
public static final String ERR_SAVED_SEARCH="crud:saved-search";
Cache<Key,ObjectNode> cache;
private static final Path P_NAME=new Path("name");
private static final Path P_ENTITY=new Path("entity");
private static final Path P_VERSIONS=new Path("versions");
private static final Value NULL_VALUE=new Value(null);
public final String savedSearchEntity;
public final String savedSearchVersion;
public static class RetrievalError extends RuntimeException {
public final List<Error> errors;
public RetrievalError(List<Error> errors) {
this.errors=errors;
}
@Override
public String toString() {
return errors.toString()+"\n"+super.toString();
}
}
private static class Key {
final String searchName;
final String entity;
final String version;
@Override
public boolean equals(Object o) {
if(o instanceof Key) {
return Objects.equals(((Key)o).searchName,searchName)&&
Objects.equals(((Key)o).entity,entity)&&
Objects.equals(((Key)o).version,version);
}
return false;
}
@Override
public int hashCode() {
return (version==null?1:version.hashCode())*searchName.hashCode()*entity.hashCode();
}
@Override
public String toString() {
return searchName+":"+entity+":"+version;
}
public Key(String searchName,String entity,String version) {
this.searchName=searchName;
this.entity=entity;
this.version=version;
}
}
public SavedSearchCache(SavedSearchConfiguration cfg) {
if(cfg!=null) {
savedSearchEntity=cfg.getEntity();
savedSearchVersion=cfg.getEntityVersion();
initializeCache(cfg.getCacheConfig());
} else {
savedSearchEntity="savedSearch";
savedSearchVersion=null;
initializeCache(null);
}
}
private void initializeCache(String spec) {
if(spec==null) {
cache=CacheBuilder.newBuilder().
maximumSize(2048).
expireAfterAccess(2,TimeUnit.MINUTES).
softValues().
build();
} else {
cache=CacheBuilder.from(spec).build();
}
}
/**
* Retrieves a saved search from the database.
*
* @param m Mediator instance
* @param clid The client id
* @param searchName name of the saved search
* @param entit Name of the entity
* @param version Entity version the search should run on
*
* The returned JsonNode can be an ObjectNode, or an ArrayNode
* containing zero or more documents. If there are more than one
* documents, only one of them is for the requested version, and
* the other is for the null version that applies to all
* versions. It returns null or empty array if nothing is found.
* In case of retrieval error, a RetrievalError is thrown
* containing the errors.
*/
public JsonNode getSavedSearchFromDB(Mediator m,
ClientIdentification clid,
String searchName,
String entity,
String version) {
FindRequest findRequest=new FindRequest();
findRequest.setEntityVersion(new EntityVersion(savedSearchEntity,savedSearchVersion));
findRequest.setClientId(clid);
List<Value> versionList=new ArrayList<>(2);
versionList.add(NULL_VALUE);
// Include all segments of the version in the search list
if(version!=null) {
int index=0;
while((index=version.indexOf('.',index))!=-1) {
versionList.add(new Value(version.substring(0,index)));
index++;
}
versionList.add(new Value(version));
}
QueryExpression q=new NaryLogicalExpression(NaryLogicalOperator._and,
new ValueComparisonExpression(P_NAME,
BinaryComparisonOperator._eq,
new Value(searchName)),
new ValueComparisonExpression(P_ENTITY,
BinaryComparisonOperator._eq,
new Value(entity)),
new ArrayContainsExpression(P_VERSIONS,
ContainsOperator._any,
versionList));
LOGGER.debug("Searching {}",q);
findRequest.setQuery(q);
findRequest.setProjection(FieldProjection.ALL);
Response response=m.find(findRequest);
if(response.getErrors()!=null&&!response.getErrors().isEmpty())
throw new RetrievalError(response.getErrors());
LOGGER.debug("Found {}",response.getEntityData());
return response.getEntityData();
}
/**
* Either loads the saved search from the db, or from the
* cache.
*
* @param m Mediator instance
* @param clid The client id
* @param searchName name of the saved search
* @param entit Name of the entity
* @param version Entity version the search should run on
*
* @return The saved search document, or null if not found
*/
public JsonNode getSavedSearch(Mediator m,
ClientIdentification clid,
String searchName,
String entity,
String version) {
LOGGER.debug("Loading {}:{}:{}",searchName,entity,version);
ObjectNode doc=null;
String loadVersion;
if(version==null) {
LOGGER.debug("{} version is null, attempting to find default version for entity",entity);
EntityMetadata md=m.metadata.getEntityMetadata(entity,null);
if(md==null)
throw Error.get(CrudConstants.ERR_UNKNOWN_ENTITY,entity+":"+version);
loadVersion=md.getVersion().getValue();
LOGGER.debug("Loading {}:{}:{}",searchName,entity,loadVersion);
} else {
loadVersion=version;
}
Key key=new Key(searchName,entity,loadVersion);
LOGGER.debug("Lookup {}",key);
doc=cache.getIfPresent(key);
if(doc==null) {
key=new Key(searchName,entity,null);
LOGGER.debug("Lookup {}",key);
doc=cache.getIfPresent(key);
}
if(doc==null) {
LOGGER.debug("Loading {} from DB",searchName);
JsonNode node=getSavedSearchFromDB(m,clid,searchName,entity,loadVersion);
if(node instanceof ObjectNode) {
LOGGER.debug("Loaded a single search");
doc=(ObjectNode)node;
store(doc);
} else if(node instanceof ArrayNode) {
LOGGER.debug("Loaded an array of searches");
store((ArrayNode)node);
doc=findDocForVersion((ArrayNode)node,loadVersion);
}
if(doc!=null) {
store(doc);
}
}
return doc;
}
private ObjectNode findDocForVersion(ArrayNode node,String version) {
LOGGER.debug("Searching {} in the array",version);
ObjectNode ret=null;
String matchedVersion=null;
for(Iterator<JsonNode> itr=node.elements();itr.hasNext();) {
JsonNode searchNode=itr.next();
if(searchNode instanceof ObjectNode) {
JsonNode versionsNode=searchNode.get("versions");
LOGGER.debug("Versions in search:{}",versionsNode);
if(versionsNode instanceof ArrayNode && versionsNode.size()>0) {
LOGGER.debug("Looking up in versions array");
for(Iterator<JsonNode> vitr=versionsNode.elements();vitr.hasNext();) {
JsonNode versionNode=vitr.next();
if(versionNode instanceof NullNode) {
if(betterMatch(version,null,matchedVersion)) {
ret=(ObjectNode)searchNode;
matchedVersion=null;
}
} else if(versionNode instanceof TextNode) {
String v=versionNode.asText();
if(betterMatch(version,v,matchedVersion)) {
ret=(ObjectNode)searchNode;
matchedVersion=v;
}
}
}
} else {
if(ret==null) {
ret=(ObjectNode)searchNode;
matchedVersion=null;
}
}
LOGGER.debug("Current best match after {}:{}",searchNode,ret);
}
}
LOGGER.debug("Best match for version {}:{}",version,ret);
return ret;
}
/**
* Returns true if newVersion is a better match to searchedVersion than matchedVersion
*/
private boolean betterMatch(String searchedVersion,String newVersion,String matchedVersion) {
if(searchedVersion==null) {
return newVersion==null;
} else {
if(newVersion==null) { // Match any version
return matchedVersion==null;
} else {
// newVersion not null
// If searched version is this version, than it is a perfect match
if(searchedVersion.equals(newVersion)) {
return true;
} else {
// if newVersion is a longer prefix of searchVersion than matchedVersion is, then it is a better match
int newMatchingPrefix=getMatchingPrefix(searchedVersion,newVersion);
int oldMatchingPrefix=getMatchingPrefix(searchedVersion,matchedVersion);
LOGGER.debug("Comparing to {}: {}: prefix={} {}: prefix={}",searchedVersion,newVersion,newMatchingPrefix,
matchedVersion,oldMatchingPrefix);
return newMatchingPrefix>oldMatchingPrefix;
}
}
}
}
/**
* Return the length of the matching version prefix
*/
private int getMatchingPrefix(String fullVersion,String prefix) {
if(prefix==null) {
return 0;
} else {
int p=prefix.length();
if(p<=fullVersion.length()) {
if(fullVersion.startsWith(prefix)) {
if(p<fullVersion.length()) {
if(fullVersion.charAt(p)=='.') {
return prefix.length();
} else {
return 0;
}
} else {
return fullVersion.length();
}
} else {
return 0;
}
} else {
return 0;
}
}
}
private synchronized void store(ObjectNode doc) {
String name=doc.get("name").asText();
String entity=doc.get("entity").asText();
ArrayNode arr=(ArrayNode)doc.get("versions");
Key key=null;
if(arr!=null) {
for(Iterator<JsonNode> itr=arr.elements();itr.hasNext();) {
JsonNode version=itr.next();
if(version instanceof NullNode) {
key=new Key(name,entity,null);
} else {
key=new Key(name,entity,version.asText());
}
}
}
if(key==null) {
key=new Key(name,entity,null);
}
LOGGER.debug("Adding {} to cache",key);
cache.put(key,doc);
}
private synchronized void store(ArrayNode arr) {
for(Iterator<JsonNode> itr=arr.elements();itr.hasNext();) {
JsonNode node=itr.next();
if(node instanceof ObjectNode)
store( (ObjectNode)node);
}
}
}