package com.contentful.java.cda;
import java.util.HashMap;
import java.util.Map;
import static com.contentful.java.cda.Util.checkNotEmpty;
import static com.contentful.java.cda.Util.checkNotNull;
import static com.contentful.java.cda.Util.resourcePath;
import static java.lang.String.format;
/**
* Root of all queries.
* <p>
* This class includes options to query for entries, limit the amount of
* responses and more.
*
* @param <Resource> The type of the resource to be returned by this query.
* @param <Query> The query type to be returned on chaining to avoid casting on client side.
*/
public abstract class AbsQuery<Resource extends CDAResource, Query extends AbsQuery<Resource, Query>> {
private static final String PARAMETER_CONTENT_TYPE = "content_type";
private static final String PARAMETER_SELECT = "select";
private static final String PARAMETER_ORDER = "order";
private static final String PARAMETER_LIMIT = "limit";
private static final String PARAMETER_SKIP = "skip";
private static final String PARAMETER_INCLUDE = "include";
final Class<Resource> type;
final CDAClient client;
final Map<String, String> params = new HashMap<String, String>();
AbsQuery(Class<Resource> type, CDAClient client) {
this.type = type;
this.client = client;
}
/**
* Requesting a specific content type.
* <p>
* The content type is especially useful if you want to limit the result of this query to only one
* content model type.
* <p>
* You must specify a content type <b>before</b> querying a specific <b>field</b> on a query, an
* exception will be thrown otherwise.
*
* @param contentType the content type to be used.
* @return the calling query for chaining.
* @throws IllegalArgumentException if contentType is null.
* @throws IllegalArgumentException if contentType is empty.
* @throws IllegalStateException if contentType was set before.
*/
@SuppressWarnings("unchecked")
public Query withContentType(String contentType) {
checkNotEmpty(contentType, "ContentType must not be empty.");
if (hasContentTypeSet()) {
throw new IllegalStateException(
format("ContentType \"%s\" is already present in query.", contentType)
);
} else {
params.put(PARAMETER_CONTENT_TYPE, contentType);
}
return (Query) this;
}
/**
* Limit response to only selected properties.
* <p>
* Returns an object in which fields not specified will be <b>null</b>, resulting in potentially
* smaller response from Contentful.
* <p>
* The complete <b>sys</b> object will always be returned.
*
* @param selection to be used. Should be 'fields.name' or similar.
* @return the calling query for chaining.
* @throws NullPointerException if selection is null.
* @throws IllegalArgumentException if selection is empty.
* @throws IllegalStateException if no content type was queried for before.
* @throws IllegalArgumentException if tried to request deeper then the name of a selection.
*/
@SuppressWarnings("unchecked")
public Query select(String selection) {
checkNotEmpty(selection, "Selection must not be empty.");
if (countDots(selection) >= 2) {
throw new IllegalArgumentException("Cannot request children of fields. " +
"('fields.author'(✔) vs. 'fields.author.name'(✖))");
}
if (selection.startsWith("fields.") && !hasContentTypeSet()) {
throw new IllegalStateException("Cannot use field selection without " +
"specifying a content type first. Use '.withContentType(\"{typeid}\")' first.");
}
if (selection.startsWith("sys.") || selection.equals("sys")) {
if (params.containsKey(PARAMETER_SELECT)) {
// nothing to be done here, a select is already present
} else {
params.put(PARAMETER_SELECT, "sys");
}
} else if (params.containsKey(PARAMETER_SELECT)) {
params.put(PARAMETER_SELECT, params.get(PARAMETER_SELECT) + "," + selection);
} else {
params.put(PARAMETER_SELECT, "sys," + selection);
}
return (Query) this;
}
/**
* Convenient method for chaining several select queries together.
* <p>
* This method makes it easier to select multiple properties from one method call. It calls
* select for all of its arguments.
*
* @param selections field names to be requested.
* @return the calling query for chaining.
* @throws NullPointerException if a field is null.
* @throws IllegalArgumentException if a field is of zero length, aka empty.
* @throws IllegalStateException if no contentType was queried for before.
* @throws IllegalArgumentException if tried to request deeper then the name of a field.
* @throws IllegalArgumentException if no selections were requested.
* @see #select(String)
*/
@SuppressWarnings("unchecked")
public Query select(String... selections) {
checkNotNull(selections, "Selections cannot be null. Please specify at least one.");
if (selections.length == 0) {
throw new IllegalArgumentException("Please provide a selection to be selected.");
}
for (int i = 0; i < selections.length; i++) {
try {
select(selections[i]);
} catch (IllegalStateException stateException) {
throw new IllegalStateException(stateException);
} catch (IllegalArgumentException argumentException) {
throw new IllegalArgumentException(
format("Could not select %d. field (\"%s\").", i, selections[i]),
argumentException);
}
}
return (Query) this;
}
/**
* Complex where query.
* <p>
* Use this for a more controlled and versatile way of doing specialized search requests.
*
* @param <T> value type the operation uses.
* @param name which attribute should be checked?
* @param queryOperation specify the queryOperation here.
* @param values a list of values to be checked.
* @return the calling query for chaining.
* @throws IllegalArgumentException if name is empty or null.
* @throws IllegalArgumentException if queryOperation is not set.
* @throws IllegalArgumentException if values is not set.
* @throws IllegalArgumentException if values does not contain valid values.
* @throws IllegalArgumentException if one value was null or empty.
* @throws IllegalStateException if no content type was set first, but a field was requested.
* @throws IllegalArgumentException if name does not start with either sys or field.
* @see QueryOperation
*/
@SuppressWarnings("unchecked")
public <T> Query where(String name, QueryOperation<T> queryOperation, T... values) {
checkNotEmpty(name, "Name cannot be empty/null, please specify a name to apply operations on.");
checkNotNull(queryOperation, "QueryOperation cannot be null.");
checkNotNull(values, "Values to be compared with need to be set to something.");
if (values.length == 0 && !queryOperation.hasDefaultValue()) {
throw new IllegalArgumentException("Please specify at least one value to be searched for.");
}
for (int i = 0; i < values.length; ++i) {
final T value = values[i];
checkNotNull(value, "Value at position %d must not be null.", i);
if (value instanceof CharSequence) {
checkNotEmpty(value.toString(), "Value at position %d must not be empty.", i);
}
}
if ((!name.startsWith("sys.") && !name.startsWith("fields."))
&& !CDAContentType.class.isAssignableFrom(type)) {
throw new IllegalArgumentException("Please specify either a \"sys.\" or a \"fields.\" " +
"attribute to be searched for. (Remember to specify a ContentType for \"fields.\" " +
"searches when querying entries.)");
}
if (name.startsWith("fields.") && !hasContentTypeSet()) {
throw new IllegalStateException("Cannot request fields of an entry without having a " +
"content type set first.");
}
if (values.length == 0) {
params.put(name + queryOperation.operator, queryOperation.defaultValue.toString());
} else {
params.put(name + queryOperation.operator, join(values));
}
return (Query) this;
}
/**
* Simple `where` query.
* <p>
* This query will be used if there are not specialized queries available. Please use the more
* concrete methods in order to gain type safety and early exceptions, without requesting the API.
*
* @param key the key to be added to the query.
* @param value the value to be added.
* @return the calling query for chaining.
*/
@SuppressWarnings("unchecked")
public Query where(String key, String value) {
params.put(key, value);
return (Query) this;
}
/**
* Order result by the given key.
* <p>
* Please do not forget to include the content type if you are requesting to order
* by a field.
*
* @param key the key to be ordered by.
* @return the calling query for chaining.
* @throws IllegalArgumentException if key is null.
* @throws IllegalArgumentException if key is empty.
* @throws IllegalStateException if key requests a field, but no content type is requested.
* @see #withContentType(String)
*/
@SuppressWarnings("unchecked")
public Query orderBy(String key) {
checkNotEmpty(key, "Key to order by must not be empty.");
if (key.startsWith("fields.") && !hasContentTypeSet()) {
throw new IllegalStateException("\"fields.\" cannot be used without setting a content type " +
"first.");
}
this.params.put(PARAMETER_ORDER, key);
return (Query) this;
}
/**
* Order result by the multiple keys.
* <p>
* Please do not forget to include the content type if you are requesting to order
* by a field.
*
* @param keys the keys to be ordered by.
* @return the calling query for chaining.
* @throws IllegalArgumentException if keys is null.
* @throws IllegalArgumentException if keys is empty.
* @throws IllegalArgumentException if one key is null.
* @throws IllegalArgumentException if one key is empty.
* @throws IllegalStateException if one key requests a field, but no content type is requested.
* @see #withContentType(String)
* @see #orderBy(String)
*/
@SuppressWarnings("unchecked")
public Query orderBy(String... keys) {
checkNotNull(keys, "Keys should not be null.");
if (keys.length == 0) {
throw new IllegalArgumentException("Cannot have an empty keys array.");
}
for (int i = 0; i < keys.length; ++i) {
final String key = keys[i];
checkNotEmpty(key, "Key at %d to order by must not be empty.", i);
if (key.startsWith("fields.") && !hasContentTypeSet()) {
throw new IllegalStateException(format("Key at %d uses \"fields.\" but cannot be used" +
" without setting a content type first.", i));
}
}
this.params.put(PARAMETER_ORDER, join(keys));
return (Query) this;
}
/**
* Order result by the given key, reversing the order.
* <p>
* Please do not forget to include the content type if you are requesting to order
* by a field.
*
* @param key the key to be reversely ordered by.
* @return the calling query for chaining.
* @throws IllegalArgumentException if key is null.
* @throws IllegalArgumentException if key is empty.
* @throws IllegalStateException if key requests a field, but no content type is requested.
* @see #withContentType(String)
*/
@SuppressWarnings("unchecked")
public Query reverseOrderBy(String key) {
checkNotEmpty(key, "Key to order by must not be empty");
if (key.startsWith("fields.") && !hasContentTypeSet()) {
throw new IllegalStateException("\"fields.\" cannot be used without setting a content type " +
"first.");
}
this.params.put(PARAMETER_ORDER, "-" + key);
return (Query) this;
}
/**
* Limits the amount of elements to a given number.
* <p>
* If more then the number given elements are present, you can use skip(int) and limit(int) to
* simulate pagination.
*
* @param limit a non negative number less than 1001 to include elements.
* @return the calling query for chaining.
* @see #skip(int)
*/
@SuppressWarnings("unchecked")
public Query limit(int limit) {
if (limit < 0) {
throw new IllegalArgumentException(format("Limit of %d is negative.", limit));
}
if (limit > 1000) {
throw new IllegalArgumentException(format("Limit of %d is greater than 1000.", limit));
}
params.put(PARAMETER_LIMIT, Integer.toString(limit));
return (Query) this;
}
/**
* Skips the first elements of a response.
* <p>
* If more limit(int) elements are present, you can use skip(int) to simulate pagination.
*
* @param skip a non negative number to exclude the first elements.
* @return the calling query for chaining.
* @see #limit(int)
*/
@SuppressWarnings("unchecked")
public Query skip(int skip) {
if (skip < 0) {
throw new IllegalArgumentException(format("Limit of %d is negative.", skip));
}
params.put(PARAMETER_SKIP, Integer.toString(skip));
return (Query) this;
}
/**
* Include references entries and their entries up to the given level.
* <p>
* A level of inclusion of 0 means, do not include references referenced, but not requested.
* Please note also, that more then 10 include levels cannot be specified.
*
* @param level the number of recursion of inclusion to be used.
* @return the calling query for chaining.
*/
@SuppressWarnings("unchecked")
public Query include(int level) {
if (level < 0) {
throw new IllegalArgumentException(format("Include level of %d is negative.", level));
}
if (level > 10) {
throw new IllegalArgumentException(format("Include level of %d is to high.", level));
}
params.put(PARAMETER_INCLUDE, Integer.toString(level));
return (Query) this;
}
@SuppressWarnings("unchecked")
Query where(Map<String, String> params) {
this.params.clear();
this.params.putAll(params);
return (Query) this;
}
String path() {
return resourcePath(type);
}
private boolean hasContentTypeSet() {
if (CDAAsset.class.isAssignableFrom(type)) {
return true;
} else {
return params.containsKey(PARAMETER_CONTENT_TYPE);
}
}
private <T> String join(T[] values) {
final StringBuilder builder = new StringBuilder();
String separator = "";
for (final T value : values) {
builder.append(separator);
separator = ",";
builder.append(value);
}
return builder.toString();
}
private int countDots(String text) {
int count = 0;
for (int i = 0; i < text.length(); ++i) {
if (text.charAt(i) == '.') {
count++;
}
}
return count;
}
}