All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.atsid.play.controllers.BaseCrudController Maven / Gradle / Ivy

The newest version!
package com.atsid.play.controllers;

import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.lang3.StringUtils;

import com.atsid.play.common.EbeanUtil;
import com.atsid.play.common.SchemaBuilder;
import com.atsid.play.common.exceptions.InvalidFieldException;
import com.atsid.play.models.AssociationFinder;
import com.atsid.play.models.schema.FieldDescriptor;
import com.atsid.play.models.schema.FieldType;
import com.avaje.ebean.Ebean;
import com.avaje.ebean.Expr;
import com.avaje.ebean.Expression;
import com.avaje.ebean.ExpressionList;
import com.avaje.ebean.FetchConfig;
import com.avaje.ebean.Junction;
import com.avaje.ebean.Query;
import com.avaje.ebean.bean.EntityBean;
import com.fasterxml.jackson.databind.JsonNode;

import play.Logger;
import play.Play;
import play.data.Form;
import play.db.ebean.Model;
import play.libs.F;
import play.libs.F.Function0;
import play.libs.F.Promise;
import play.libs.F.Tuple;
import play.libs.Json;
import play.mvc.BodyParser;
import play.mvc.Controller;
import play.mvc.Result;

public abstract class BaseCrudController extends Controller {
	private final Class modelClass;
	private final Class idClass;
	private Boolean validate = true;

	public BaseCrudController(Class idType, Class clazz) {
		modelClass = clazz;
		idClass = idType;
	}

	protected Class getBaseModelClass() {
		return modelClass;
	}

	/**
	 * Create a new entity with the json body of the request. If the body is an
	 * array, it will bulk create.
	 * 
	 * @return Result object with the created entities
	 */
	@BodyParser.Of(BodyParser.Json.class)
	public Promise create() {
		return Promise.promise(new Function0() {
			public Result apply() {
				JsonNode json = request().body().asJson();

				if (json.isArray()) {
					ResultOrValue> modelList = createModelListFromJson(json);
					if (modelList.result == null) {
						return create(modelList.value);
					}
					return modelList.result;
				}
				ResultOrValue model = createModelFromJson(json);
				if (model.result == null) {
					return create(model.value);
				}
				return model.result;
			}

		});
	}

	/**
	 * Create a new model entity
	 * 
	 * @param model
	 *            The new model
	 * @return A JSON result
	 */
	public Result create(final T model) {
		ResultOrValue rov = isModelValid(model);
		if (rov.result != null) {
			return rov.result;
		}
		Result r = CrudResults.successCreate(this.saveModel(model));
		return r;
	}

	/**
	 * Create a list of new model entities
	 * 
	 * @param modelList
	 *            A list of new models.
	 * @return A JSON result
	 */
	public Result create(final List modelList) {
		for (T model : modelList) {
			ResultOrValue rov = isModelValid(model);
			if (rov.result != null) {
				return rov.result;
			}
		}

		List savedModels = saveModel(modelList);
		return CrudResults.successCreate(savedModels);
	}

	/**
	 * Action Query a list of models.
	 * 
	 * @param offset
	 *            An offset from the first item to start filtering from. Used for
	 *            paging.
	 * @param count
	 *            The total count to query. This is the length of items to query
	 *            after the offset. Used for paging.
	 * @param orderBy
	 *            Order the queried models in order by the given properties of the
	 *            model.
	 * @param fields
	 *            The fields, or properties, of the models to retrieve.
	 * @param fetches
	 *            If the model has 1to1 relationships, use this to retrieve those
	 *            relationships. By default, returns the id of each relationship.
	 * @param queryString
	 *            Filter the models with a comma-delimited query string in the
	 *            format of "property:value".
	 * @return A promise containing the results
	 */
	public Promise list(final int offset, final Integer count, final String orderBy, final String fields,
			final String fetches, final String queryString) {
		Promise, List>> promise = listModelObject(offset, count, orderBy, fields, fetches,
				queryString);
		return promise.flatMap(new F.Function, List>, Promise>() {
			@Override
			public Promise apply(final Tuple, List> tuple) {
				return Promise.promise(new Function0() {
					@Override
					public Result apply() throws Throwable {
						return CrudResults.successCount(tuple._1.getMaxRows(), tuple._2.size(), tuple._2);
					}
				});
			}
		});
	}

	/**
	 * Action Query a list of models.
	 * 
	 * @param offset
	 *            An offset from the first item to start filtering from. Used for
	 *            paging.
	 * @param count
	 *            The total count to query. This is the length of items to query
	 *            after the offset. Used for paging.
	 * @param orderBy
	 *            Order the queried models in order by the given properties of the
	 *            model.
	 * @param fields
	 *            The fields, or properties, of the models to retrieve.
	 * @param fetches
	 *            If the model has 1to1 relationships, use this to retrieve those
	 *            relationships. By default, returns the id of each relationship.
	 * @param queryString
	 *            Filter the models with a comma-delimited query string in the
	 *            format of "property:value".
	 * @return A promise containing the list of model objects
	 */
	protected Promise, List>> listModelObject(final int offset, final Integer count,
			final String orderBy, final String fields, final String fetches, final String queryString) {
		return Promise.promise(new Function0, List>>() {
			public Tuple, List> apply() {
				if (count == null) {
					Logger.warn("No count specified on request: " + request().uri());
				}
				ServiceParams params = new ServiceParams(offset, count, orderBy, fields, fetches, queryString);
				Query query = createQuery(params);
				updateQuery(query, params);

				List modelList = queryList(query, params);
				return new Tuple, List>(query, modelList);
			}
		});
	}

	/**
	 * Action Read a single model.
	 * 
	 * @param id
	 *            The id of the model.
	 * @param fields
	 *            Fields to retrieve off the model. If not provided, retrieves all.
	 * @param fetches
	 *            Get any 1to1 relationships off the model. By default, it returns
	 *            only the id off the relationships.
	 * @return A promise containing the results
	 */
	public Promise read(final I id, final String fields, final String fetches) {
		return Promise.promise(new Function0() {
			public Result apply() {
				ServiceParams params = new ServiceParams(fields, fetches);
				Query query = createQuery(params);
				T model = null;
				model = queryOne(query, id, params);

				if (model != null) {
					return CrudResults.success(model);
				}
				return CrudResults.notFoundError(getBaseModelClass(), id);
			}
		});
	}

	/**
	 * Action Update a model.
	 * 
	 * @param id
	 *            The id of the model
	 * @return A promise containing the results
	 */
	@BodyParser.Of(BodyParser.Json.class)
	public Promise update(final I id) {
		return Promise.promise(new Function0() {
			public Result apply() {
				ResultOrValue model = createModelFromJson(request().body().asJson());
				if (model.result == null) {
					return update(id, model.value);
				}
				return model.result;
			}
		});
	}

	/**
	 * Update a model.
	 * 
	 * @param id
	 *            The id of the model to update.
	 * @param model
	 *            The model to update.
	 * @return Json Result
	 */
	public Result update(final I id, final T model) {
		ResultOrValue rov = isModelValid(model);
		if (rov.result != null) {
			return rov.result;
		}

		// Make sure it actually exists in the DB
		T dbModel = Ebean.createQuery(getBaseModelClass()).where().idEq(id).findUnique();
		if (dbModel == null) {
			return CrudResults.notFoundError(getBaseModelClass(), id);
		}
		updateModel(model);

		T updated = Ebean.createQuery(getBaseModelClass()).where().idEq(id).findUnique();
		JsonNode json = Util.toJson(updated, true);
		return CrudResults.ok(json);
	}

	/**
	 * Action Update a list of models
	 * 
	 * @return A promise containing the results
	 */
	@BodyParser.Of(BodyParser.Json.class)
	public Promise updateBulk() {
		return Promise.promise(new Function0() {
			public Result apply() {
				ResultOrValue> modelList = createModelListFromJson(request().body().asJson());
				if (modelList.result == null) {
					return updateBulk(modelList.value);
				}
				return modelList.result;
			}
		});
	}

	public Result updateBulk(final List modelList) {
		List ids = new ArrayList();
		ResultOrValue rov = null;
		for (T model : modelList) {
			rov = isModelValid(model);
			if (rov.result != null) {
				return rov.result;
			} else {
				ids.add((I) EbeanUtil.getFieldValue(model, "id"));
			}
		}

		// Verify they exist in the DB
		List dbModels = createQuery(new ServiceParams()).select("id").where().in("id", ids).findList();

		// If we don't have the same number of DB results as ids, then at least one of
		// them is missing
		if (dbModels.size() != ids.size()) {
			return CrudResults.notFoundError(this.modelClass, "");
		}

		// FIXME: This should follow the same format as other results. Left like this
		// for compatibility.
		return play.mvc.Results.ok(Util.toJson(updateModel(modelList)));
	}

	/**
	 * Delete a model
	 * 
	 * @param id
	 *            The id of the model
	 * @return A promise containing the results. Returns no content.
	 */
	public Promise delete(final I id) {
		return Promise.promise(new Function0() {
			public Result apply() {
				T model = createQuery(new ServiceParams()).where().eq("id", id).findUnique();
				if (model == null) {
					return CrudResults.notFoundError(getBaseModelClass(), id);
				}
				return delete(model);
			}
		});
	}

	/**
	 * Delete a model
	 * 
	 * @param model
	 *            The model to delete.
	 * @return A promise containing the results. Returns no content.
	 */
	public Result delete(T model) {
		model.delete();
		return play.mvc.Results.noContent();
	}

	/**
	 * Bulk delete models. The body should be an array of objects with an id.
	 * 
	 * @return A promise containing the results. Returns no content.
	 */
	@play.db.ebean.Transactional
	@BodyParser.Of(BodyParser.Json.class)
	public Promise deleteBulk() {
		return Promise.promise(new Function0() {
			public Result apply() {
				List items = getItemsFromRequest();
				if (items != null) {
					List models = createQuery(new ServiceParams()).where().in("id", items).findList();

					// Some of the items are missing
					if (models.size() != items.size()) {
						return CrudResults.notFoundError(modelClass, "");
					}

					// TODO: Try to get Ebean.delete() working properly.
					for (T t : models) {
						t.delete();
					}
					return noContent();
				}
				return CrudResults.error("Array of objects with ids required");
			}
		});
	}

	/**
	 * Gets a list of associations for the base model object
	 * 
	 * @param id
	 *            The id of the model.
	 * @return A promise containing the results.
	 */
	public Promise associations(I id) {
		return Promise.promise(new Function0() {
			public Result apply() {
				List> associations = AssociationFinder
						.findClassAssociations(getBaseModelClass());
				List simpleAssociations = new ArrayList();
				for (Class assoc : associations) {
					simpleAssociations.add(assoc.getSimpleName());
				}
				return CrudResults.successCount(simpleAssociations.size(), simpleAssociations.size(),
						simpleAssociations);
			}
		});
	}

	public ResultOrValue createModelFromJson(JsonNode json) {
		T model = Json.fromJson(json, getBaseModelClass());
		if (model != null) {
			return new ResultOrValue(model);
		}
		return new ResultOrValue(CrudResults.badRequest("Invalid json for object."));
	}

	public ResultOrValue> createModelListFromJson(JsonNode jsonArray) {
		if (jsonArray == null || !jsonArray.isArray()) {
			return new ResultOrValue>(CrudResults.error("Array of objects with ids required"));
		}

		List modelList = new ArrayList();
		Integer index = 0;
		for (final JsonNode modelNode : jsonArray) {
			ResultOrValue model = createModelFromJson(modelNode);
			if (model.result != null) {
				return new ResultOrValue>(
						CrudResults.badRequest(String.format("Invalid json for object at index %d", index)));
			}
			index += 1;
			modelList.add(model.value);
		}
		return new ResultOrValue>(modelList);
	}

	/**
	 * Create a new model.
	 * 
	 * @param model
	 * @return
	 */
	public T saveModel(T model) {
		// model.refresh(); //TODO Test to see if this is needed.
		Ebean.save(model);
		return model;
	}

	public List saveModel(List modelList) {
		Ebean.save(modelList);
		return modelList;
	}

	protected T updateModel(T model) {
		Ebean.update(model);
		// EntityBean ref = Ebean.getReference(getBaseModelClass(),
		// EbeanUtil.getFieldValue(model, "id"));
		return model;
	}

	@play.db.ebean.Transactional
	protected List updateModel(List modelList) {
		List refs = new ArrayList();
		for (final T model : modelList) {
			model.update();
			refs.add(Ebean.getReference(getBaseModelClass(), EbeanUtil.getFieldValue(model, "id")));
		}
		return modelList;
	}

	protected Query createQuery(ServiceParams params) {
		return createRawQuery(params);
	}

	protected Query createRawQuery(ServiceParams params) {
		return Ebean.createQuery(getBaseModelClass());
	}

	protected void updateQuery(Query query, ServiceParams params) {
		// turn query into like statements, with everything or'd
		// ?q=name:%bri%,location:seattle%
		handleSearchQuery(query, params);

		// ?orderBy=name asc
		handleOrderBy(query, getSchemaModel(), params);

		handleFieldsAndFetches(query, params);
	}

	protected List queryList(Query query, ServiceParams params) {
		int offset = params.offset;
		Integer count = params.count;
		query.setFirstRow(offset);
		count = count == null ? Play.application().configuration().getInt("crud.defaultCount") : count;
		query.setMaxRows(count == null ? 100 : count);
		List modelList = query.findList();
		return modelList;
	}

	protected T queryOne(Query query, I id, ServiceParams params) {
		handleFieldsAndFetches(query, getSchemaModel(), params);
		return query.where().idEq(id).findUnique();
	}

	/**
	 * Handles the search query passed to the service
	 * 
	 * @param query
	 *            The database query
	 * @param params
	 *            The service parameters
	 */
	protected void handleSearchQuery(Query query, ServiceParams params) {
		handleSearchQuery(query, getSchemaModel(), params);
	}

	/**
	 * Handles the search query passed to the service
	 * 
	 * @param query
	 *            The database query
	 * @param params
	 *            The service parameters
	 */
	protected void handleSearchQuery(Query query, Class modelClass, ServiceParams params) {
		if (params.queryString != null && !params.queryString.isEmpty()) {
			String queryString = params.queryString;
			Matcher matcher = Pattern.compile("AND|OR").matcher(queryString);
			ExpressionList list = query.where();
			if (matcher.find()) {
				int lastIndex = 0;
				boolean lastIsOr = false;
				boolean isOr;
				Junction junction = null;
				do {
					String currentField = queryString.substring(lastIndex, matcher.start());
					lastIndex = matcher.end();
					isOr = matcher.group(0).equals("OR");
					if (junction == null || lastIsOr != isOr) {
						if (junction != null) {
							list = junction.endJunction();
						}
						junction = isOr ? list.disjunction() : list.conjunction();
						list = junction;
					}
					junction.add(getSearchExpression(currentField, modelClass));
					lastIsOr = isOr;
				} while (matcher.find());
				junction.add(getSearchExpression(queryString.substring(lastIndex), modelClass));
				junction.endJunction();
			} else {
				Expression ex = getSearchExpression(queryString, modelClass);
				if (ex != null) {
					list.add(ex);
				}
			}
		}
	}

	/**
	 * Creates a search expression from a string
	 * 
	 * @param fieldQuery
	 *            The field query string
	 * @param modelClass
	 *            The class of the model to search
	 * @return
	 */
	protected Expression getSearchExpression(String fieldQuery, Class modelClass) {
		int colonIndex = fieldQuery.indexOf(":");
		boolean negate = false;
		Expression e = null;
		if (colonIndex > -1) {
			String finalField = fieldQuery.substring(0, colonIndex);
			String value = fieldQuery.substring(colonIndex + 1);
			if (value.startsWith("!")) {
				value = value.substring(1);
				negate = true;
			}

			// TODO: Wont work so well on nested props
			FieldDescriptor descriptor = SchemaBuilder.lookupFieldDescriptor(modelClass, finalField);
			if (descriptor != null) {
				FieldType type = descriptor.type;

				if (type == FieldType.DATE) {
					finalField = Util.isUsingH2Database() ? "CAST(" + finalField + " as DATE)"
							: "DATE(" + finalField + ")";
					value = value.replaceFirst("[T].*", "");
				}

				if (value.startsWith(">=")) {
					value = value.substring(2);
					e = Expr.ge(finalField, value);
				} else if (value.startsWith("<=")) {
					value = value.substring(2);
					e = Expr.le(finalField, value);
				} else if (value.startsWith(">")) {
					value = value.substring(1);
					e = Expr.gt(finalField, value);
				} else if (value.startsWith("<")) {
					value = value.substring(1);
					e = Expr.lt(finalField, value);
				} else {
					if (type != FieldType.STRING) {
						if (type == FieldType.BOOLEAN) {
							// Convert it to boolean, so ebean can handle it appropriately
							e = Expr.eq(finalField, value.equalsIgnoreCase("true"));
						} else if (negate) {
							// Include null / empty values. Doesn't seem to work with Expr.not(), so it
							// returns directly.
							return Expr.or(Expr.isNull(finalField), Expr.ne(finalField, value));
						} else {
							e = Expr.eq(finalField, value);
						}
					} else {
						e = Expr.ilike(finalField, value);
					}
				}
				if (negate) {
					e = Expr.not(e);
				}
			} else {
				throw new InvalidFieldException(finalField);
			}
		}
		return e;
	}

	/**
	 * Handles the order by passed to the service
	 * 
	 * @param query
	 * @param params
	 *            The service parameters
	 * @param modelClass
	 */
	protected void handleOrderBy(Query query, Class modelClass, ServiceParams params) {
		if (params.orderBy != null && params.orderBy.length > 0) {
			query.orderBy(formatOrderBy(params.orderBy, modelClass));
		}
	}

	/**
	 * Formats the order by string passed to the service
	 * 
	 * @param orderBy
	 * @param parentClass
	 * @return
	 */
	protected String formatOrderBy(String[] orderBy, Class parentClass) {
		// String[] fields = orderBy.split(","); // csv of orderBy's
		List sorts = new ArrayList();
		for (String f : orderBy) {
			String[] split = f.trim().split(" "); // split by space
			FieldDescriptor desc = SchemaBuilder.lookupFieldDescriptor(parentClass, split[0]);
			if (desc != null) {
				// LOWER it if it's a string
				if (desc.type == FieldType.STRING) {
					sorts.add("LOWER(" + split[0] + ") " + (split.length > 1 ? split[1] : "asc"));
				} else {
					sorts.add(f);
				}
			} else {
				throw new InvalidFieldException(split[0]);
			}

		}

		return StringUtils.join(sorts, ',');
	}

	/**
	 * Takes care of adding fields/fetches to the query
	 * 
	 * @param query
	 *            Query to modify
	 * @param params
	 *            The service parameters
	 */
	protected void handleFieldsAndFetches(Query query, ServiceParams params) {
		this.handleFieldsAndFetches(query, getSchemaModel(), params);
	}

	/**
	 * Takes care of adding fields/fetches to the query
	 * 
	 * @param query
	 *            Query to modify
	 * @param params
	 *            The service parameters
	 */
	protected void handleFieldsAndFetches(Query query, Class queryModelClass, ServiceParams params) {
		handleFields(query, queryModelClass, params);
		handleFetches(query, queryModelClass, params);
	}
	//
	// /**
	// * Takes care of adding fields/fetches to the query
	// * @param query Query to modify
	// * @param params The service parameters
	// */
	// protected void handleFieldsAndFetches(Query query, Class modelClass,
	// ServiceParams params, String prepend) {
	// handleFields(query, modelClass, params, prepend);
	// handleFetches(query, modelClass, params, prepend);
	// }

	/**
	 * Takes care of adding fields to the query.
	 * 
	 * @param query
	 *            Query to modify
	 * @param params
	 *            The service parameters
	 */
	protected void handleFields(Query query, Class modelClass, ServiceParams params) {
		if (params.fields != null && !params.fields.isEmpty()) {
			String[] fieldsArray = params.fields.toArray(new String[0]);
			// remove fields that contain periods, they'll get used by fetches
			String rootFields = "";
			for (String field : fieldsArray) {

				// Validate if valid field
				if (SchemaBuilder.lookupFieldDescriptor(modelClass, field) == null) {
					throw new InvalidFieldException(field);
				}

				if (!field.contains(".")) {
					if (rootFields.length() > 0) {
						rootFields += ",";
					}
					rootFields += field;
				}
			}
			query.select(rootFields);
		}
	}

	/**
	 * Fetches sub objects with support for partials if fields is not null. Fetches
	 * is a comma separated list of sub objects on this model. Examples:
	 * ?fetches=organization,organization.location
	 * ?fetches=organization&fields=organization.name
	 */
	protected void handleFetches(Query query, Class modelClass, ServiceParams params) {
		if (params.fetches != null && !params.fetches.isEmpty()) {
			String[] fetchAttrs = params.fetches.toArray(new String[0]);
			// no fields, just do the fetches
			if (params.fields == null) {
				for (String attr : fetchAttrs) {

					// Validate if valid field
					if (SchemaBuilder.lookupFieldDescriptor(modelClass, attr) == null) {
						throw new InvalidFieldException(attr);
					}

					query.fetch(attr, new FetchConfig().query());
				}
			} else {
				// if there are fields, find the ones that are relevant to the sub objects
				for (String attr : fetchAttrs) {

					// Validate if valid field
					if (SchemaBuilder.lookupFieldDescriptor(modelClass, attr) == null) {
						throw new InvalidFieldException(attr);
					}

					String[] fieldsArray = params.fields.toArray(new String[0]);
					String fetchFields = "";
					for (String field : fieldsArray) {

						// Validate if valid field
						if (SchemaBuilder.lookupFieldDescriptor(modelClass, field) == null) {
							throw new InvalidFieldException(field);
						}

						String finalField = field.replace(attr + ".", "");
						if (finalField.length() < field.length() && !finalField.contains(".")) {
							if (fetchFields.length() > 0) {
								fetchFields += ",";
							}
							fetchFields += finalField;
						}
					}
					if (fetchFields.length() > 0) {
						query.fetch(attr, fetchFields, new FetchConfig().query());
					} else {
						query.fetch(attr, new FetchConfig().query());
					}
				}
			}
		}
	}

	/**
	 * Returns true if the given field should show up in the schema
	 */
	protected Boolean allowSchemaForField(String fieldName) {
		return true;
	}

	/**
	 * Returns the model to use for schema validation
	 */
	protected Class getSchemaModel() {
		return this.modelClass;
	}

	protected abstract List getItemsFromRequest();

	public void setValidate(Boolean validate) {
		this.validate = validate;
	}

	public Boolean getValidate() {
		return this.validate;
	}

	protected ResultOrValue isModelValid(T model) {
		if (validate == true) {
			Form form = new Form(getBaseModelClass()).bind(Util.toJson(model));
			if (form.hasErrors()) {
				return new ResultOrValue(CrudResults.formError(form));
			}
		}
		return new ResultOrValue(model);
	}
}