Java tutorial
/* * Copyright (c) 2011 Jeppetto and Jonathan Thompson * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.iternine.jeppetto.dao.mongodb; import org.iternine.jeppetto.dao.AccessControlContextProvider; import org.iternine.jeppetto.dao.AccessControllable; import org.iternine.jeppetto.dao.Condition; import org.iternine.jeppetto.dao.ConditionType; import org.iternine.jeppetto.dao.NoSuchItemException; import org.iternine.jeppetto.dao.Projection; import org.iternine.jeppetto.dao.ProjectionType; import org.iternine.jeppetto.dao.QueryModel; import org.iternine.jeppetto.dao.QueryModelDAO; import org.iternine.jeppetto.dao.Sort; import org.iternine.jeppetto.dao.SortDirection; import org.iternine.jeppetto.dao.annotation.AccessControl; import org.iternine.jeppetto.dao.annotation.AccessControlRule; import org.iternine.jeppetto.dao.annotation.AccessControlType; import org.iternine.jeppetto.dao.mongodb.enhance.DBObjectUtil; import org.iternine.jeppetto.dao.mongodb.enhance.DirtyableDBObject; import org.iternine.jeppetto.dao.mongodb.enhance.EnhancerHelper; import org.iternine.jeppetto.dao.mongodb.enhance.MongoDBCallback; import org.iternine.jeppetto.dao.mongodb.projections.ProjectionCommands; import org.iternine.jeppetto.enhance.Enhancer; import com.mongodb.BasicDBObject; import com.mongodb.DB; import com.mongodb.DBCallback; import com.mongodb.DBCollection; import com.mongodb.DBCursor; import com.mongodb.DBObject; import com.mongodb.WriteConcern; import org.bson.types.ObjectId; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; /** * Provides a QueryModelDAO implementation for Mongo. * <p/> * This QueryModelDAO implementation for MongoDB provides bi-directional ODM (Object to Document Mapping) as well * as the rich query capabilities found in the core org.iternine.jeppetto.dao package. It requires that the ID be a String. * * Instantiation requires a daoProperties Map<String, Object> that will look for the following keys (and expected * values): * <p/> * <table> * <tr> * <td>Key</td> * <td>Required?</td> * <td>Description</td> * </tr> * <tr> * <td>db</td> * <td>Yes</td> * <td>Instance of a configured com.mongodb.DB</td> * </tr> * <tr> * <td>uniqueIndexes</td> * <td>No</td> * <td>List<String> of various MongoDB index values that will be ensured to exist and must be unique.</td> * </tr> * <tr> * <td>nonUniqueIndexes</td> * <td>No</td> * <td>List<String> of various MongoDB index values that will be ensured to exist and need not be unique.</td> * </tr> * <tr> * <td>optimisticLockEnabled</td> * <td>No</td> * <td>Boolean to indicate if instances of the tracked type should be protected by a Jeppetto-managed lock version field.</td> * </tr> * <tr> * <td>shardKeyPattern</td> * <td>No</td> * <td>A comma-separated string of fields that are used to determine the shard key(s) for the collection.</td> * </tr> * <tr> * <td>saveNulls</td> * <td>No</td> * <td>Boolean to indicate whether null fields should be included in MongoDB documents.</td> * </tr> * <tr> * <td>writeConcern</td> * <td>No</td> * <td>String, one of the values as indicated at http://www.mongodb.org/display/DOCS/Replica+Set+Semantics. If * not specified, the DAO defaults to "SAFE".</td> * </tr> * <tr> * <td>showQueries</td> * <td>No</td> * <td>Boolean to indicate if executed queries should be logged. Note that logging will need to be enabled for * the DAO's package as well.</td> * </tr> * </table> * @param <T> the type of persistent object this DAO will manage. */ // TODO: support createdDate/lastModifiedDate (createdDate from get("_id").getTime()?) // TODO: Implement determineOptimalDBObject // - understand saveNulls // - understand field-level deltas // TODO: support per-call WriteConcerns (keep in mind session semantics) // TODO: investigate usage of ClassLoader so new instances are already enhanced public class MongoDBQueryModelDAO<T, ID> implements QueryModelDAO<T, ID>, AccessControllable<ID> { //------------------------------------------------------------- // Constants //------------------------------------------------------------- private static final String ID_FIELD = "_id"; private static final String OPTIMISTIC_LOCK_VERSION_FIELD = "__olv"; private static final String ACCESS_CONTROL_LIST_FIELD = "__acl"; //------------------------------------------------------------- // Variables - Private //------------------------------------------------------------- private DBCollection dbCollection; private Enhancer<T> enhancer; private AccessControlContextProvider accessControlContextProvider; private Map<String, Set<String>> uniqueIndexes; private boolean optimisticLockEnabled; private List<String> shardKeys; private boolean saveNulls; private WriteConcern defaultWriteConcern; private Logger queryLogger; //------------------------------------------------------------- // Constructors //------------------------------------------------------------- protected MongoDBQueryModelDAO(Class<T> entityClass, Map<String, Object> daoProperties) { this(entityClass, daoProperties, null); } @SuppressWarnings({ "unchecked" }) protected MongoDBQueryModelDAO(Class<T> entityClass, Map<String, Object> daoProperties, AccessControlContextProvider accessControlContextProvider) { this.dbCollection = ((DB) daoProperties.get("db")).getCollection(entityClass.getSimpleName()); this.enhancer = EnhancerHelper.getDirtyableDBObjectEnhancer(entityClass); dbCollection.setObjectClass(enhancer.getEnhancedClass()); DBCallback.FACTORY = new DBCallback.Factory() { @Override public DBCallback create(DBCollection dbCollection) { return new MongoDBCallback(dbCollection); } }; this.accessControlContextProvider = accessControlContextProvider; this.uniqueIndexes = ensureIndexes((List<String>) daoProperties.get("uniqueIndexes"), true); ensureIndexes((List<String>) daoProperties.get("nonUniqueIndexes"), false); this.optimisticLockEnabled = Boolean.parseBoolean((String) daoProperties.get("optimisticLockEnabled")); this.shardKeys = extractShardKeys((String) daoProperties.get("shardKeyPattern")); this.saveNulls = Boolean.parseBoolean((String) daoProperties.get("saveNulls")); if (daoProperties.containsKey("writeConcern")) { this.defaultWriteConcern = WriteConcern.valueOf((String) daoProperties.get("writeConcern")); } else { this.defaultWriteConcern = WriteConcern.SAFE; } if (Boolean.parseBoolean((String) daoProperties.get("showQueries"))) { queryLogger = LoggerFactory.getLogger(getClass()); } } //------------------------------------------------------------- // Implementation - GenericDAO //------------------------------------------------------------- @Override public T findById(ID id) throws NoSuchItemException { try { QueryModel queryModel = new QueryModel(); queryModel.addCondition(buildIdCondition(id)); if (accessControlContextProvider != null) { queryModel.setAccessControlContext(accessControlContextProvider.getCurrent()); } return findUniqueUsingQueryModel(queryModel); } catch (IllegalArgumentException e) { throw new NoSuchItemException(getCollectionClass().getSimpleName(), id.toString()); } } @Override public final Iterable<T> findAll() { QueryModel queryModel = new QueryModel(); if (accessControlContextProvider != null) { queryModel.setAccessControlContext(accessControlContextProvider.getCurrent()); } return findUsingQueryModel(queryModel); } @Override public final void save(T entity) { T enhancedEntity = enhancer.enhance(entity); DirtyableDBObject dbo = (DirtyableDBObject) enhancedEntity; if (!dbo.isPersisted()) { if (dbo.get(ID_FIELD) == null) { dbo.put(ID_FIELD, new ObjectId()); // If the id isn't explicitly set, assume intent is for mongo ids } if (accessControlContextProvider != null) { if (roleAllowsAccess(accessControlContextProvider.getCurrent().getRole()) || accessControlContextProvider.getCurrent().getAccessId() == null) { dbo.put(ACCESS_CONTROL_LIST_FIELD, Collections.<Object>emptyList()); } else { dbo.put(ACCESS_CONTROL_LIST_FIELD, Collections.singletonList(accessControlContextProvider.getCurrent().getAccessId())); } } } DBObject identifyingQuery = buildIdentifyingQuery(dbo); if (MongoDBSession.isActive()) { MongoDBSession.trackForSave(this, identifyingQuery, enhancedEntity, createIdentifyingQueries(dbo)); } else { trueSave(identifyingQuery, dbo); } } @Override public final void delete(T entity) { // TODO: Probably don't want to enhance this object as we may need a previously retrieved object so // we can construct an appropriate identifying query w/ __olv DBObject dbo = (DBObject) enhancer.enhance(entity); DBObject identifyingQuery = buildIdentifyingQuery(dbo); for (String shardKey : shardKeys) { identifyingQuery.put(shardKey, dbo.get(shardKey)); } deleteByIdentifyingQuery(identifyingQuery); } @Override public final void deleteById(ID id) { deleteByIdentifyingQuery(buildIdentifyingQuery(id)); } @Override public final void flush() { if (MongoDBSession.isActive()) { MongoDBSession.flush(this); } } //------------------------------------------------------------- // Implementation - QueryModelDAO //------------------------------------------------------------- public T findUniqueUsingQueryModel(QueryModel queryModel) throws NoSuchItemException { // Need to revisit te way caching works as it will miss some items...focus only on the identifyingQuery // instead of secondary cache keys... MongoDBCommand command = buildCommand(queryModel); if (MongoDBSession.isActive()) { // noinspection unchecked T cached = (T) MongoDBSession.getObjectFromCache(dbCollection.getName(), command.getQuery()); if (cached != null) { DBObject identifyingQuery = buildIdentifyingQuery((DBObject) cached); MongoDBSession.trackForSave(this, identifyingQuery, cached, createIdentifyingQueries((DBObject) cached)); return cached; } } // noinspection unchecked T result = (T) command.singleResult(dbCollection); if (MongoDBSession.isActive()) { DBObject identifyingQuery = buildIdentifyingQuery((DBObject) result); MongoDBSession.trackForSave(this, identifyingQuery, result, createIdentifyingQueries((DBObject) result)); } return result; } public Iterable<T> findUsingQueryModel(QueryModel queryModel) { MongoDBCommand command = buildCommand(queryModel); DBCursor dbCursor = command.cursor(dbCollection); if (queryModel.getSorts() != null) { dbCursor.sort(processSorts(queryModel.getSorts())); } if (queryModel.getFirstResult() > 0) { dbCursor = dbCursor.skip(queryModel.getFirstResult()); // dbCursor is zero-indexed, firstResult is one-indexed } if (queryModel.getMaxResults() > 0) { dbCursor = dbCursor.limit(queryModel.getMaxResults()); } final DBCursor finalDbCursor = dbCursor; return new Iterable<T>() { @Override public Iterator<T> iterator() { return new Iterator<T>() { @Override public boolean hasNext() { return finalDbCursor.hasNext(); } @Override @SuppressWarnings({ "unchecked" }) public T next() { DBObject result = finalDbCursor.next(); ((DirtyableDBObject) result).markPersisted(); if (MongoDBSession.isActive()) { MongoDBSession.trackForSave(MongoDBQueryModelDAO.this, buildIdentifyingQuery(result), (T) result, createIdentifyingQueries(result)); } return (T) result; } @Override public void remove() { finalDbCursor.remove(); } }; } }; } public Object projectUsingQueryModel(QueryModel queryModel) { try { return buildCommand(queryModel).singleResult(dbCollection); } catch (NoSuchItemException e) { return null; // TODO: evaluate if correct } } @Override public Condition buildCondition(String conditionField, ConditionType conditionType, Iterator argsIterator) { if (conditionField.equals("id")) { return buildIdCondition(argsIterator.next()); } else { return new Condition(conditionField, MongoDBOperator.valueOf(conditionType.name()).buildConstraint(argsIterator)); } } @Override public Projection buildProjection(String projectionField, ProjectionType projectionType, Iterator argsIterator) { return new Projection(projectionField, projectionType); } //------------------------------------------------------------- // Implementation - AccessControllable //------------------------------------------------------------- @Override public void grantAccess(ID id, String accessId) { DBObject dbObject; try { dbObject = (DBObject) findById(id); } catch (NoSuchItemException e) { throw new RuntimeException(e); } @SuppressWarnings({ "unchecked" }) List<String> accessControlList = (List<String>) dbObject.get(ACCESS_CONTROL_LIST_FIELD); if (accessControlList == null) { accessControlList = new ArrayList<String>(); } // TODO: handle dups... accessControlList.add(accessId); dbObject.put(ACCESS_CONTROL_LIST_FIELD, accessControlList); //noinspection unchecked save((T) dbObject); } @Override public void revokeAccess(ID id, String accessId) { DBObject dbObject; try { dbObject = (DBObject) findById(id); } catch (NoSuchItemException e) { throw new RuntimeException(e); } @SuppressWarnings({ "unchecked" }) List<String> accessControlList = (List<String>) dbObject.get(ACCESS_CONTROL_LIST_FIELD); if (accessControlList == null) { return; } // TODO: verify no dups accessControlList.remove(accessId); dbObject.put(ACCESS_CONTROL_LIST_FIELD, accessControlList); //noinspection unchecked save((T) dbObject); } @Override public List<String> getAccessIds(ID id) { DBObject dbObject; try { dbObject = (DBObject) findById(id); } catch (NoSuchItemException e) { throw new RuntimeException(e); } //noinspection unchecked return (List<String>) dbObject.get(ACCESS_CONTROL_LIST_FIELD); } @Override public AccessControlContextProvider getAccessControlContextProvider() { return accessControlContextProvider; } //------------------------------------------------------------- // Methods - Protected //------------------------------------------------------------- /** * This method allows subclasses an opportunity to add any important data to a cache key, such * as the __acl from the AccessControlMongoDAO. * * TODO: revsit these methods... * * @param key key to augment * @return augmented key */ protected DBObject augmentObjectCacheKey(DBObject key) { return key; } protected DBObject[] createIdentifyingQueries(DBObject dbObject) { int uniqueIndexCount = uniqueIndexes.size() + 1; List<DBObject> queries = new ArrayList<DBObject>(uniqueIndexCount); queries.add(buildIdentifyingQuery(dbObject)); for (Collection<String> indexFields : uniqueIndexes.values()) { DBObject query = new BasicDBObject(); for (String indexField : indexFields) { query.put(indexField, getFieldValueFrom(dbObject, indexField)); } queries.add(augmentObjectCacheKey(query)); } return queries.toArray(new DBObject[uniqueIndexCount]); } //------------------------------------------------------------- // Methods - Protected - Final //------------------------------------------------------------- protected final DBObject buildIdentifyingQuery(DBObject dbObject) { //noinspection unchecked return buildIdentifyingQuery((ID) dbObject.get(ID_FIELD)); } protected final DBObject buildIdentifyingQuery(ID id) { if (id == null) { throw new IllegalArgumentException("Id cannot be null."); } else if (id instanceof String && ObjectId.isValid((String) id)) { return new BasicDBObject(ID_FIELD, new ObjectId((String) id)); } else { return new BasicDBObject(ID_FIELD, id); } } protected final void deleteByIdentifyingQuery(DBObject identifyingQuery) { if (MongoDBSession.isActive()) { MongoDBSession.trackForDelete(this, identifyingQuery); } else { trueRemove(identifyingQuery); } } protected final void trueSave(final DBObject identifyingQuery, final DirtyableDBObject dbo) { if (optimisticLockEnabled) { Integer optimisticLockVersion = (Integer) dbo.get(OPTIMISTIC_LOCK_VERSION_FIELD); if (optimisticLockVersion == null) { dbo.put(OPTIMISTIC_LOCK_VERSION_FIELD, 1); } else { // TODO: should this modification of identifyingQuery been done earlier (in save())? identifyingQuery.put(OPTIMISTIC_LOCK_VERSION_FIELD, optimisticLockVersion); dbo.put(OPTIMISTIC_LOCK_VERSION_FIELD, optimisticLockVersion + 1); } } for (String shardKey : shardKeys) { identifyingQuery.put(shardKey, dbo.get(shardKey)); } final DBObject optimalDbo = determineOptimalDBObject(dbo); if (queryLogger != null) { queryLogger.debug("Saving {} identified by {} with document {}", new Object[] { getCollectionClass().getSimpleName(), identifyingQuery.toMap(), optimalDbo.toMap() }); } dbCollection.update(identifyingQuery, optimalDbo, true, false, getWriteConcern()); dbo.markPersisted(); } protected final void trueRemove(final DBObject identifyingQuery) { if (optimisticLockEnabled) { // TODO: } if (queryLogger != null) { queryLogger.debug("Removing {}s matching {}", new Object[] { getCollectionClass().getSimpleName(), identifyingQuery.toMap() }); } dbCollection.remove(identifyingQuery, getWriteConcern()); } protected final DBCollection getDbCollection() { return dbCollection; } protected final Class<?> getCollectionClass() { return enhancer.getBaseClass(); } //------------------------------------------------------------- // Methods - Private //------------------------------------------------------------- private DBObject processSorts(List<Sort> sorts) { DBObject orderBy = new BasicDBObject(); for (Sort sort : sorts) { orderBy.put(sort.getField(), sort.getSortDirection() == SortDirection.Ascending ? 1 : -1); } return orderBy; } // Special case for 'id' queries as it maps to _id within MongoDB. private Condition buildIdCondition(Object argument) { if (argument instanceof String && ObjectId.isValid((String) argument)) { return new Condition(ID_FIELD, new ObjectId((String) argument)); } else if (Iterable.class.isAssignableFrom(argument.getClass())) { List<ObjectId> objectIds = new ArrayList<ObjectId>(); //noinspection ConstantConditions for (Object argumentItem : (Iterable) argument) { if (argumentItem == null) { objectIds.add(null); } else if (argumentItem instanceof ObjectId) { objectIds.add((ObjectId) argumentItem); } else if (argumentItem instanceof String) { objectIds.add(new ObjectId((String) argumentItem)); } else { throw new IllegalArgumentException( "Don't know how to handle class for 'id' mapping: " + argumentItem.getClass()); } } // TODO: create getter for MongoDBOperator.operator() ...? return new Condition(ID_FIELD, new BasicDBObject("$in", objectIds)); } else { return new Condition(ID_FIELD, argument); } } private Object getFieldValueFrom(DBObject dbo, String field) { if (!field.contains(".")) { return dbo.get(field); } Object value = dbo; for (String subField : field.split(".")) { value = ((DBObject) value).get(subField); if (value == null) { break; } } return value; } private MongoDBCommand buildCommand(QueryModel queryModel) { BasicDBObject query = buildQueryObject(queryModel); MongoDBCommand command; if (queryModel.getProjection() == null) { command = new BasicDBObjectCommand(query); } else { command = ProjectionCommands.forProjection(queryModel.getProjection(), query); } if (queryLogger != null) { return QueryLoggingCommand.wrap(command, queryLogger); } else { return command; } } private Map<String, Set<String>> ensureIndexes(List<String> uniqueIndexes, final boolean unique) { if (uniqueIndexes == null || uniqueIndexes.size() == 0) { return Collections.emptyMap(); } Map<String, Set<String>> result = new HashMap<String, Set<String>>(); for (final String uniqueIndex : uniqueIndexes) { final DBObject index = new BasicDBObject(); String[] indexFields = uniqueIndex.split(","); for (String indexField : indexFields) { indexField = indexField.trim(); if (indexField.startsWith("+")) { index.put(indexField.substring(1), 1); } else if (indexField.startsWith("-")) { index.put(indexField.substring(1), -1); } else { index.put(indexField, 1); } } result.put(uniqueIndex, index.keySet()); if (queryLogger != null) { queryLogger.debug("Ensuring index {} on {}", new Object[] { index.toMap(), getCollectionClass().getSimpleName() }); } dbCollection.ensureIndex(index, createIndexName(uniqueIndex), unique); } return result; } private String createIndexName(String indexSpec) { return indexSpec.replace(',', '-'); } private List<String> extractShardKeys(String shardKeyPattern) { if (shardKeyPattern == null) { return Collections.emptyList(); } String[] shardKeyParts = shardKeyPattern.split(","); List<String> shardKeys = new ArrayList<String>(shardKeyParts.length); for (String shardKey : shardKeyParts) { shardKey = shardKey.trim(); if (shardKey.equals("id") || shardKey.equals("_id")) { continue; } shardKeys.add(shardKey); } return shardKeys; } private BasicDBObject buildQueryObject(QueryModel queryModel) { BasicDBObject query = new BasicDBObject(); List<Condition> allCriteria = new ArrayList<Condition>(); if (queryModel.getConditions() != null) { allCriteria.addAll(queryModel.getConditions()); } for (Map.Entry<String, List<Condition>> associationConditions : queryModel.getAssociationConditions() .entrySet()) { for (Condition condition : associationConditions.getValue()) { condition.setField(associationConditions.getKey() + "." + condition.getField()); allCriteria.add(condition); } } // TODO: optimize this -- iterating over everything above and again here for (Condition condition : allCriteria) { // we need to ensure that all condition objects are mongodb-safe // examples of "unsafe" things are: Sets, Enum, POJOs. Object rawConstraint = condition.getConstraint(); Object constraint = (rawConstraint == null) ? null : DBObjectUtil.toDBObject(rawConstraint.getClass(), rawConstraint); // XXX : if annotation specifies multiple conditions on single field the // first condition will be overwritten here query.put(condition.getField(), constraint); } if (accessControlContextProvider != null) { if (!roleAllowsAccess(queryModel.getAccessControlContext().getRole())) { // NB: will match any item w/in the ACL list. query.put(ACCESS_CONTROL_LIST_FIELD, queryModel.getAccessControlContext().getAccessId()); } } return query; } private DBObject determineOptimalDBObject(DBObject dbo) { // TODO: handle saveNulls... return dbo; } private WriteConcern getWriteConcern() { // TODO: Add ability to swap a concern out on a per-call basis...if nothing overwrites/changes, then // return the default. return defaultWriteConcern; } private boolean roleAllowsAccess(String role) { AccessControl accessControl; if (role == null || role.isEmpty()) { return false; } if ((accessControl = getAccessControlAnnotation()) == null) { return false; } for (AccessControlRule accessControlRule : accessControl.rules()) { if (accessControlRule.type() == AccessControlType.Role && accessControlRule.value().equals(role)) { return true; } } return false; } private AccessControl getAccessControlAnnotation() { Class collectionClass = getCollectionClass(); while (collectionClass != null) { // noinspection unchecked AccessControl accessControl = (AccessControl) collectionClass.getAnnotation(AccessControl.class); if (accessControl != null) { return accessControl; } collectionClass = collectionClass.getSuperclass(); } return null; } }