package com.googlecode.objectify.impl; import com.google.appengine.api.datastore.Cursor; import com.google.appengine.api.datastore.FetchOptions; import com.google.appengine.api.datastore.KeyFactory; import com.google.appengine.api.datastore.PropertyProjection; import com.google.appengine.api.datastore.Query.CompositeFilterOperator; import com.google.appengine.api.datastore.Query.Filter; import com.google.appengine.api.datastore.Query.FilterOperator; import com.google.appengine.api.datastore.Query.SortDirection; import com.google.appengine.api.datastore.QueryResultIterable; import com.google.appengine.api.datastore.QueryResultIterator; import com.googlecode.objectify.Key; import com.googlecode.objectify.LoadResult; import com.googlecode.objectify.ObjectifyFactory; import com.googlecode.objectify.annotation.Subclass; import com.googlecode.objectify.cmd.Query; import com.googlecode.objectify.impl.translate.ClassTranslator; import com.googlecode.objectify.util.DatastoreUtils; import com.googlecode.objectify.util.IteratorFirstResult; import com.googlecode.objectify.util.MakeListResult; import com.googlecode.objectify.util.ResultProxy; import java.util.Iterator; import java.util.List; /** * Implementation of Query. * * @author Jeff Schnitzer <jeff@infohazard.org> */ public class QueryImpl<T> extends SimpleQueryImpl<T> implements Query<T>, Cloneable { /** * Because we process @Load batches, we need to always work in chunks. So we should always specify * a chunk size to the query. This is the default if user does not specify an explicit chunk size. */ static final int DEFAULT_CHUNK_SIZE = 30; /** We need to track this because it enables the ability to filter/sort by id */ Class<T> classRestriction; /** The actual datastore query constructed by this object */ com.google.appengine.api.datastore.Query actual; /** */ int limit; int offset; Cursor startAt; Cursor endAt; Integer chunk; /** Three states; null is "figure it out automatically" */ Boolean hybrid; /** */ QueryImpl(LoaderImpl<?> loader) { super(loader); this.actual = new com.google.appengine.api.datastore.Query(); } /** */ QueryImpl(LoaderImpl<?> loader, String kind, Class<T> clazz) { super(loader); this.actual = new com.google.appengine.api.datastore.Query(kind); // If this is a polymorphic subclass, add an extra filter if (clazz != null) { Subclass sub = clazz.getAnnotation(Subclass.class); if (sub != null) { String discriminator = sub.name().length() > 0 ? sub.name() : clazz.getSimpleName(); this.addFilter(FilterOperator.EQUAL.of(ClassTranslator.DISCRIMINATOR_INDEX_PROPERTY, discriminator)); } this.classRestriction = clazz; } } /* (non-Javadoc) * @see com.googlecode.objectify.impl.cmd.QueryBase#createQuery() */ @Override QueryImpl<T> createQuery() { return this.clone(); } /* (non-Javadoc) * @see com.googlecode.objectify.cmd.Query#filter(java.lang.String, java.lang.Object) */ @Override public QueryImpl<T> filter(String condition, Object value) { QueryImpl<T> q = createQuery(); q.addFilter(condition, value); return q; } /* */ @Override public QueryImpl<T> filter(Filter filter) { QueryImpl<T> q = createQuery(); q.addFilter(filter); return q; } /* (non-Javadoc) * @see com.googlecode.objectify.cmd.Query#order(java.lang.String) */ @Override public QueryImpl<T> order(String condition) { QueryImpl<T> q = createQuery(); q.addOrder(condition); return q; } /** @return the underlying datastore query object */ private com.google.appengine.api.datastore.Query getActualQuery() { return this.actual; } /** Modifies the instance */ void addFilter(String condition, Object value) { String[] parts = condition.trim().split(" "); if (parts.length < 1 || parts.length > 2) throw new IllegalArgumentException("'" + condition + "' is not a legal filter condition"); String prop = parts[0].trim(); FilterOperator op = (parts.length == 2) ? this.translate(parts[1]) : FilterOperator.EQUAL; // If we have a class restriction, check to see if the property is the @Parent or @Id. We used to try to convert // filtering on the id field to a __key__ query, but that tended to confuse users about the real capabilities // of GAE and Objectify. So let's force users to use filterKey() instead. if (this.classRestriction != null) { KeyMetadata<?> meta = loader.ofy.factory().keys().getMetadataSafe(this.classRestriction); if (prop.equals(meta.getParentFieldName())) { throw new IllegalArgumentException("@Parent fields cannot be filtered on. Perhaps you wish to use filterKey() or ancestor() instead?"); } else if (prop.equals(meta.getIdFieldName())) { throw new IllegalArgumentException("@Id fields cannot be filtered on. Perhaps you wish to use filterKey() instead?"); } } // Convert to something filterable, possibly extracting/converting keys value = loader.getObjectifyImpl().makeFilterable(value); addFilter(op.of(prop, value)); } /** * Add the filter as an AND to whatever is currently set as the actual filter. */ void addFilter(Filter filter) { if (actual.getFilter() == null) { actual.setFilter(filter); } else { actual.setFilter(CompositeFilterOperator.and(actual.getFilter(), filter)); } } /** * Converts the textual operator (">", "<=", etc) into a FilterOperator. * Forgiving about the syntax; != and <> are NOT_EQUAL, = and == are EQUAL. */ protected FilterOperator translate(String operator) { operator = operator.trim(); if (operator.equals("=") || operator.equals("==")) return FilterOperator.EQUAL; else if (operator.equals(">")) return FilterOperator.GREATER_THAN; else if (operator.equals(">=")) return FilterOperator.GREATER_THAN_OR_EQUAL; else if (operator.equals("<")) return FilterOperator.LESS_THAN; else if (operator.equals("<=")) return FilterOperator.LESS_THAN_OR_EQUAL; else if (operator.equals("!=") || operator.equals("<>")) return FilterOperator.NOT_EQUAL; else if (operator.toLowerCase().equals("in")) return FilterOperator.IN; else throw new IllegalArgumentException("Unknown operator '" + operator + "'"); } /** Modifies the instance */ void addOrder(String condition) { condition = condition.trim(); SortDirection dir = SortDirection.ASCENDING; if (condition.startsWith("-")) { dir = SortDirection.DESCENDING; condition = condition.substring(1).trim(); } // Prevent ordering by @Id or @Parent fields, which are really part of the key if (this.classRestriction != null) { KeyMetadata<?> meta = loader.ofy.factory().keys().getMetadataSafe(this.classRestriction); if (condition.equals(meta.getParentFieldName())) throw new IllegalArgumentException("You cannot order by @Parent field. Perhaps you wish to order by __key__ instead?"); if (condition.equals(meta.getIdFieldName())) { throw new IllegalArgumentException("You cannot order by @Id field. Perhaps you wish to order by __key__ instead?"); } } this.actual.addSort(condition, dir); } /** Modifies the instance */ void setAncestor(Object keyOrEntity) { this.actual.setAncestor(loader.ofy.factory().keys().anythingToRawKey(keyOrEntity)); } /** Modifies the instance */ void setLimit(int value) { this.limit = value; if (this.chunk == null) this.chunk = value; } /** Modifies the instance */ void setOffset(int value) { this.offset = value; } /** Modifies the instance */ void setStartCursor(Cursor value) { this.startAt = value; } /** Modifies the instance */ void setEndCursor(Cursor value) { this.endAt = value; } /** Modifies the instance */ void setChunk(int value) { this.chunk = value; } /** Modifies the instance */ void setHybrid(boolean force) { this.hybrid = force; } /** Modifies the instance */ void setKeysOnly() { if (!this.actual.getProjections().isEmpty()) throw new IllegalStateException("You cannot ask for both keys-only and projections in the same query. That makes no sense!"); this.actual.setKeysOnly(); } /** Modifies the instance, switching directions */ void toggleReverse() { this.actual = this.actual.reverse(); } /** Modifies the instance */ void setDistinct(boolean value) { this.actual.setDistinct(value); } /** Modifies the instance */ void addProjection(String... fields) { if (this.actual.isKeysOnly()) throw new IllegalStateException("You cannot ask for both keys-only and projections in the same query. That makes no sense!"); if (this.hybrid != null && this.hybrid) throw new IllegalStateException("You cannot ask for both hybrid and projections in the same query. That makes no sense!"); for (String field: fields) { this.actual.addProjection(new PropertyProjection(field, null)); } } /* (non-Javadoc) * @see java.lang.Object#toString() */ @Override public String toString() { StringBuilder bld = new StringBuilder(this.getClass().getName()); bld.append("{kind="); bld.append(this.actual.getKind()); bld.append(",ancestor="); if (this.actual.getAncestor() != null) bld.append(KeyFactory.keyToString(this.actual.getAncestor())); // Filter and sort don't necessarily produce stable values (ordering might be different), but this is not // necessarily a fatal problem. Just a potential inefficiency if the query is being used as a cache key. if (this.actual.getFilter() != null) bld.append(",filter=").append(this.actual.getFilter()); if (!this.actual.getSortPredicates().isEmpty()) bld.append(",sort=").append(this.actual.getSortPredicates()); if (this.limit > 0) bld.append(",limit=").append(this.limit); if (this.offset > 0) bld.append(",offset=").append(this.offset); if (this.startAt != null) bld.append(",startAt=").append(this.startAt.toWebSafeString()); if (this.endAt != null) bld.append(",endAt=").append(this.endAt.toWebSafeString()); if (this.actual.getDistinct()) bld.append(",distinct=true"); if (!this.actual.getProjections().isEmpty()) bld.append(",projections=").append(this.actual.getProjections()); bld.append('}'); return bld.toString(); } /* (non-Javadoc) * @see com.googlecode.objectify.cmd.Query#first() */ @Override public LoadResult<T> first() { // By the way, this is the same thing that PreparedQuery.asSingleEntity() does internally Iterator<T> it = this.limit(1).resultIterable().iterator(); return new LoadResult<>(null, new IteratorFirstResult<>(it)); } /* (non-Javadoc) * @see com.googlecode.objectify.Query#count() */ @Override public int count() { return loader.createQueryEngine().queryCount(this.getActualQuery(), this.fetchOptions()); } /* (non-Javadoc) * @see com.googlecode.objectify.cmd.QueryExecute#iterable() */ @Override public QueryResultIterable<T> iterable() { return resultIterable(); } /* (non-Javadoc) * @see com.google.appengine.api.datastore.QueryResultIterable#iterator() */ @Override public QueryResultIterator<T> iterator() { return iterable().iterator(); } /* (non-Javadoc) * @see com.googlecode.objectify.cmd.Query#list() */ @Override public List<T> list() { return ResultProxy.create(List.class, new MakeListResult<>(this.chunk(Integer.MAX_VALUE).iterator())); } /** * Get an iterator over the keys. Not part of the public api, but used by QueryKeysImpl. Assumes * that setKeysOnly() has already been set. */ public QueryResultIterable<Key<T>> keysIterable() { assert actual.isKeysOnly(); return loader.createQueryEngine().queryKeysOnly(this.getActualQuery(), this.fetchOptions()); } /** Produces the basic iterable on results based on the current query. Used to generate other iterables via transformation. */ private QueryResultIterable<T> resultIterable() { if (!actual.getProjections().isEmpty()) return loader.createQueryEngine().queryProjection(this.getActualQuery(), this.fetchOptions()); else if (shouldHybridize()) return loader.createQueryEngine().queryHybrid(this.getActualQuery(), this.fetchOptions()); else return loader.createQueryEngine().queryNormal(this.getActualQuery(), this.fetchOptions()); } /** * @return true if we should hybridize this query */ private boolean shouldHybridize() { if (hybrid != null) return hybrid; // If the class is cacheable if (classRestriction != null && loader.getObjectifyImpl().getCache() && fact().getMetadata(classRestriction).getCacheExpirySeconds() != null) return true; return false; } /* (non-Javadoc) * @see java.lang.Object#clone() */ @SuppressWarnings({"unchecked", "CloneDoesntDeclareCloneNotSupportedException"}) public QueryImpl<T> clone() { try { QueryImpl<T> impl = (QueryImpl<T>)super.clone(); impl.actual = DatastoreUtils.cloneQuery(this.actual); return impl; } catch (CloneNotSupportedException e) { // impossible throw new RuntimeException(e); } } /** * @return a set of fetch options for the current limit, offset, and cursors, * based on the default fetch options. There will always be options even if default. */ private FetchOptions fetchOptions() { FetchOptions opts = FetchOptions.Builder.withDefaults(); if (this.startAt != null) opts = opts.startCursor(this.startAt); if (this.endAt != null) opts = opts.endCursor(this.endAt); if (this.limit != 0) opts = opts.limit(this.limit); if (this.offset != 0) opts = opts.offset(this.offset); if (this.chunk == null) opts = opts.chunkSize(DEFAULT_CHUNK_SIZE); else opts = opts.chunkSize(this.chunk); return opts; } /** Convenience method */ private ObjectifyFactory fact() { return loader.getObjectify().factory(); } }