/* * Copyright (c) 2014 Nuxeo SA (http://nuxeo.com/) and others. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * Florent Guillaume */ package org.nuxeo.ecm.core.storage.mongodb; import static java.lang.Boolean.TRUE; import static org.nuxeo.ecm.core.storage.State.NOP; import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_FULLTEXT_BINARY; import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_FULLTEXT_SCORE; import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_FULLTEXT_SIMPLE; import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ID; import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_IS_PROXY; import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_NAME; import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PARENT_ID; import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PROXY_IDS; import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PROXY_TARGET_ID; import java.io.Serializable; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.UUID; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.nuxeo.ecm.core.api.DocumentException; import org.nuxeo.ecm.core.api.model.Delta; import org.nuxeo.ecm.core.model.Repository; import org.nuxeo.ecm.core.query.sql.NXQL; import org.nuxeo.ecm.core.query.sql.model.Expression; import org.nuxeo.ecm.core.query.sql.model.OrderByClause; import org.nuxeo.ecm.core.query.sql.model.OrderByExpr; import org.nuxeo.ecm.core.query.sql.model.Reference; import org.nuxeo.ecm.core.storage.PartialList; import org.nuxeo.ecm.core.storage.State; import org.nuxeo.ecm.core.storage.State.ListDiff; import org.nuxeo.ecm.core.storage.State.StateDiff; import org.nuxeo.ecm.core.storage.dbs.DBSDocument; import org.nuxeo.ecm.core.storage.dbs.DBSExpressionEvaluator; import org.nuxeo.ecm.core.storage.dbs.DBSRepositoryBase; import com.mongodb.BasicDBObject; import com.mongodb.DB; import com.mongodb.DBCollection; import com.mongodb.DBCursor; import com.mongodb.DBObject; import com.mongodb.MongoClient; import com.mongodb.QueryOperators; import com.mongodb.ServerAddress; import com.mongodb.WriteResult; /** * MongoDB implementation of a {@link Repository}. * * @since 5.9.4 */ public class MongoDBRepository extends DBSRepositoryBase { private static final Log log = LogFactory.getLog(MongoDBRepository.class); private static final Long ZERO = Long.valueOf(0); private static final Long ONE = Long.valueOf(1); private static final Long MINUS_ONE = Long.valueOf(-1); public static final String DB_NAME = "nuxeo"; public static final String MONGODB_ID = "_id"; public static final String MONGODB_INC = "$inc"; public static final String MONGODB_SET = "$set"; public static final String MONGODB_UNSET = "$unset"; public static final String MONGODB_PUSH = "$push"; public static final String MONGODB_EACH = "$each"; public static final String MONGODB_META = "$meta"; public static final String MONGODB_TEXT_SCORE = "textScore"; private static final String MONGODB_INDEX_TEXT = "text"; private static final String MONGODB_INDEX_NAME = "name"; private static final String MONGODB_LANGUAGE_OVERRIDE = "language_override"; private static final String FULLTEXT_INDEX_NAME = "fulltext"; private static final String LANGUAGE_FIELD = "__language"; protected static final String COUNTER_NAME_UUID = "ecm:id"; protected static final String COUNTER_FIELD = "seq"; protected MongoClient mongoClient; protected DBCollection coll; protected DBCollection countersColl; public MongoDBRepository(MongoDBRepositoryDescriptor descriptor) { super(descriptor.name); try { mongoClient = newMongoClient(descriptor); coll = getCollection(descriptor, mongoClient); countersColl = getCountersCollection(descriptor, mongoClient); } catch (UnknownHostException e) { throw new RuntimeException(e); } initRepository(); } @Override public void shutdown() { super.shutdown(); mongoClient.close(); } // used also by unit tests public static MongoClient newMongoClient( MongoDBRepositoryDescriptor descriptor) throws UnknownHostException { ServerAddress addr = new ServerAddress(descriptor.server); // TODO sharding options // TODO mongoClient.setWriteConcern return new MongoClient(addr); } protected static DBCollection getCollection(MongoClient mongoClient, String name) { // TODO configure db name // TODO authentication DB db = mongoClient.getDB(DB_NAME); return db.getCollection(name); } // used also by unit tests public static DBCollection getCollection( MongoDBRepositoryDescriptor descriptor, MongoClient mongoClient) { return getCollection(mongoClient, descriptor.name); } // used also by unit tests public static DBCollection getCountersCollection( MongoDBRepositoryDescriptor descriptor, MongoClient mongoClient) { return getCollection(mongoClient, descriptor.name + ".counters"); } protected Object valueToBson(Object value) { if (value instanceof State) { return stateToBson((State) value); } else if (value instanceof List) { @SuppressWarnings("unchecked") List<Object> values = (List<Object>) value; return listToBson(values); } else if (value instanceof Object[]) { return listToBson(Arrays.asList((Object[]) value)); } else { return serializableToBson(value); } } protected DBObject stateToBson(State state) { DBObject ob = new BasicDBObject(); for (Entry<String, Serializable> en : state.entrySet()) { Object val = valueToBson(en.getValue()); if (val != null) { ob.put(en.getKey(), val); } } return ob; } protected List<Object> listToBson(List<Object> values) { ArrayList<Object> objects = new ArrayList<Object>(values.size()); for (Object value : values) { objects.add(valueToBson(value)); } return objects; } protected State bsonToState(DBObject ob) { if (ob == null) { return null; } State state = new State(ob.keySet().size()); for (String key : ob.keySet()) { Object val = ob.get(key); Serializable value; if (val instanceof List) { @SuppressWarnings("unchecked") List<Object> list = (List<Object>) val; if (list.isEmpty()) { value = null; } else { if (list.get(0) instanceof DBObject) { List<Serializable> l = new ArrayList<>(list.size()); for (Object el : list) { l.add(bsonToState((DBObject) el)); } value = (Serializable) l; } else { Object[] ar = new Object[list.size()]; int i = 0; for (Object el : list) { ar[i++] = scalarToSerializable(el); } value = ar; } } } else if (val instanceof DBObject) { value = bsonToState((DBObject) val); } else { if (MONGODB_ID.equals(key)) { // skip ObjectId continue; } value = scalarToSerializable(val); } state.put(key, value); } return state; } public static class Updates { public BasicDBObject set = new BasicDBObject(); public BasicDBObject unset = new BasicDBObject(); public BasicDBObject push = new BasicDBObject(); public BasicDBObject inc = new BasicDBObject(); } /** * Constructs a list of MongoDB updates from the given {@link StateDiff}. * <p> * We need a list because some cases need two operations to avoid conflicts. */ protected List<DBObject> diffToBson(StateDiff diff) { Updates updates = new Updates(); diffToUpdates(diff, null, updates); UpdateListBuilder builder = new UpdateListBuilder(); for (Entry<String, Object> en : updates.set.entrySet()) { builder.update(MONGODB_SET, en.getKey(), en.getValue()); } for (Entry<String, Object> en : updates.unset.entrySet()) { builder.update(MONGODB_UNSET, en.getKey(), en.getValue()); } for (Entry<String, Object> en : updates.push.entrySet()) { builder.update(MONGODB_PUSH, en.getKey(), en.getValue()); } for (Entry<String, Object> en : updates.inc.entrySet()) { builder.update(MONGODB_INC, en.getKey(), en.getValue()); } return builder.updateList; } /** * Update list builder to prevent several updates of the same field. * <p> * This happens if two operations act on two fields where one is a prefix of * the other. * <p> * Example: Cannot update 'mylist.0.string' and 'mylist' at the same time * (error 16837) * * @since 5.9.5 */ protected static class UpdateListBuilder { protected List<DBObject> updateList = new ArrayList<>(1); protected DBObject update; protected List<String> keys; protected UpdateListBuilder() { newUpdate(); } protected void newUpdate() { updateList.add(update = new BasicDBObject()); keys = new ArrayList<>(); } protected void update(String op, String key, Object value) { if (conflicts(key, keys)) { newUpdate(); } keys.add(key); DBObject map = (DBObject) update.get(op); if (map == null) { update.put(op, map = new BasicDBObject()); } map.put(key, value); } /** * Checks if the key conflicts with one of the previous keys. * <p> * A conflict occurs if one key is equals to or is a prefix of the * other. */ protected boolean conflicts(String key, List<String> previousKeys) { String keydot = key + '.'; for (String prev : previousKeys) { if (prev.equals(key) || prev.startsWith(keydot) || key.startsWith(prev + '.')) { return true; } } return false; } } protected void diffToUpdates(StateDiff diff, String prefix, Updates updates) { String elemPrefix = prefix == null ? "" : prefix + '.'; for (Entry<String, Serializable> en : diff.entrySet()) { String name = elemPrefix + en.getKey(); Serializable value = en.getValue(); if (value instanceof StateDiff) { diffToUpdates((StateDiff) value, name, updates); } else if (value instanceof ListDiff) { diffToUpdates((ListDiff) value, name, updates); } else if (value instanceof Delta) { diffToUpdates((Delta) value, name, updates); } else { // not a diff updates.set.put(name, valueToBson(value)); } } } protected void diffToUpdates(ListDiff listDiff, String prefix, Updates updates) { if (listDiff.diff != null) { String elemPrefix = prefix == null ? "" : prefix + '.'; int i = 0; for (Object value : listDiff.diff) { String name = elemPrefix + i; if (value instanceof StateDiff) { diffToUpdates((StateDiff) value, name, updates); } else if (value != NOP) { // set value updates.set.put(name, valueToBson(value)); } i++; } } if (listDiff.rpush != null) { Object pushed; if (listDiff.rpush.size() == 1) { // no need to use $each for one element pushed = valueToBson(listDiff.rpush.get(0)); } else { pushed = new BasicDBObject(MONGODB_EACH, listToBson(listDiff.rpush)); } updates.push.put(prefix, pushed); } } protected void diffToUpdates(Delta delta, String prefix, Updates updates) { Object inc = valueToBson(delta.getDeltaValue()); updates.inc.put(prefix, inc); } protected Object serializableToBson(Object value) { if (value instanceof Calendar) { return ((Calendar) value).getTime(); } return value; } protected Serializable scalarToSerializable(Object val) { if (val instanceof Date) { Calendar cal = Calendar.getInstance(); cal.setTime((Date) val); return cal; } return (Serializable) val; } protected void initRepository() { // create required indexes DBObject indexKeys = new BasicDBObject(); indexKeys.put(KEY_FULLTEXT_SIMPLE, MONGODB_INDEX_TEXT); indexKeys.put(KEY_FULLTEXT_BINARY, MONGODB_INDEX_TEXT); DBObject indexOptions = new BasicDBObject(); indexOptions.put(MONGODB_INDEX_NAME, FULLTEXT_INDEX_NAME); indexOptions.put(MONGODB_LANGUAGE_OVERRIDE, LANGUAGE_FIELD); coll.createIndex(indexKeys, indexOptions); // check root presence DBObject query = new BasicDBObject(KEY_ID, getRootId()); if (coll.findOne(query, justPresenceField()) != null) { return; } // create basic repository structure needed if (DEBUG_UUIDS) { // create the id counter DBObject idCounter = new BasicDBObject(); idCounter.put(MONGODB_ID, COUNTER_NAME_UUID); idCounter.put(COUNTER_FIELD, ZERO); countersColl.insert(idCounter); } initRoot(); } protected Long getNextUuidSeq() { DBObject query = new BasicDBObject(MONGODB_ID, COUNTER_NAME_UUID); DBObject update = new BasicDBObject(MONGODB_INC, new BasicDBObject( COUNTER_FIELD, ONE)); boolean returnNew = true; DBObject idCounter = countersColl.findAndModify(query, null, null, false, update, returnNew, false); if (idCounter == null) { throw new RuntimeException("Repository id counter not initialized"); } return (Long) idCounter.get(COUNTER_FIELD); } @Override public String generateNewId() { if (DEBUG_UUIDS) { Long id = getNextUuidSeq(); return "UUID_" + id; } else { return UUID.randomUUID().toString(); } } @Override public void createState(State state) throws DocumentException { DBObject ob = stateToBson(state); if (log.isTraceEnabled()) { log.trace("MongoDB: CREATE " + ob); } coll.insert(ob); // TODO dupe exception // throw new DocumentException("Already exists: " + id); } @Override public State readState(String id) { DBObject query = new BasicDBObject(KEY_ID, id); return findOne(query); } @Override public List<State> readStates(List<String> ids) { DBObject query = new BasicDBObject(KEY_ID, new BasicDBObject( QueryOperators.IN, ids)); return findAll(query, ids.size()); } @Override public void updateState(String id, StateDiff diff) throws DocumentException { DBObject query = new BasicDBObject(KEY_ID, id); for (DBObject update : diffToBson(diff)) { if (log.isTraceEnabled()) { log.trace("MongoDB: UPDATE " + id + ": " + update); } coll.update(query, update); // TODO dupe exception // throw new DocumentException("Missing: " + id); } } @Override public void deleteStates(Set<String> ids) throws DocumentException { DBObject query = new BasicDBObject(KEY_ID, new BasicDBObject( QueryOperators.IN, ids)); if (log.isTraceEnabled()) { log.trace("MongoDB: REMOVE " + ids); } WriteResult w = coll.remove(query); if (w.getN() != ids.size()) { log.error("Removed " + w.getN() + " docs for " + ids.size() + " ids: " + ids); } } @Override public State readChildState(String parentId, String name, Set<String> ignored) { DBObject query = getChildQuery(parentId, name, ignored); return findOne(query); } @Override public boolean hasChild(String parentId, String name, Set<String> ignored) { DBObject query = getChildQuery(parentId, name, ignored); return coll.findOne(query, justPresenceField()) != null; } protected DBObject getChildQuery(String parentId, String name, Set<String> ignored) { DBObject query = new BasicDBObject(); query.put(KEY_PARENT_ID, parentId); query.put(KEY_NAME, name); addIgnoredIds(query, ignored); return query; } protected void addIgnoredIds(DBObject query, Set<String> ignored) { if (!ignored.isEmpty()) { DBObject notInIds = new BasicDBObject(QueryOperators.NIN, new ArrayList<String>(ignored)); query.put(KEY_ID, notInIds); } } @Override public List<State> queryKeyValue(String key, String value, Set<String> ignored) { DBObject query = new BasicDBObject(key, value); addIgnoredIds(query, ignored); return findAll(query, 0); } @Override public void queryKeyValueArray(String key, Object value, Set<String> ids, Map<String, String> proxyTargets, Map<String, Object[]> targetProxies) { DBObject query = new BasicDBObject(key, value); DBObject fields = new BasicDBObject(); fields.put(MONGODB_ID, ZERO); fields.put(KEY_ID, ONE); fields.put(KEY_IS_PROXY, ONE); fields.put(KEY_PROXY_TARGET_ID, ONE); fields.put(KEY_PROXY_IDS, ONE); DBCursor cursor = coll.find(query, fields); try { for (DBObject ob : cursor) { String id = (String) ob.get(KEY_ID); ids.add(id); if (proxyTargets != null && TRUE.equals(ob.get(KEY_IS_PROXY))) { String targetId = (String) ob.get(KEY_PROXY_TARGET_ID); proxyTargets.put(id, targetId); } if (targetProxies != null) { Object[] proxyIds = (Object[]) ob.get(KEY_PROXY_IDS); if (proxyIds != null) { targetProxies.put(id, proxyIds); } } } } finally { cursor.close(); } } @Override public boolean queryKeyValuePresence(String key, String value, Set<String> ignored) { DBObject query = new BasicDBObject(key, value); addIgnoredIds(query, ignored); return coll.findOne(query, justPresenceField()) != null; } protected State findOne(DBObject query) { return bsonToState(coll.findOne(query)); } protected List<State> findAll(DBObject query, int sizeHint) { DBCursor cursor = coll.find(query); Set<String> seen = new HashSet<>(); try { List<State> list = new ArrayList<>(sizeHint); for (DBObject ob : cursor) { if (!seen.add((String) ob.get(KEY_ID))) { // MongoDB cursors may return the same // object several times continue; } list.add(bsonToState(ob)); } return list; } finally { cursor.close(); } } protected DBObject justPresenceField() { return new BasicDBObject(MONGODB_ID, ONE); } @Override public PartialList<State> queryAndFetch(Expression expression, DBSExpressionEvaluator evaluator, OrderByClause orderByClause, int limit, int offset, int countUpTo, boolean deepCopy, boolean fulltextScore) { MongoDBQueryBuilder builder = new MongoDBQueryBuilder( evaluator.pathResolver); DBObject query = builder.walkExpression(expression); addPrincipals(query, evaluator.principals); // order by BasicDBObject orderBy; boolean sortScore = false; if (orderByClause == null) { orderBy = null; } else { orderBy = new BasicDBObject(); for (OrderByExpr ob : orderByClause.elements) { Reference ref = ob.reference; boolean desc = ob.isDescending; String field = builder.walkReference(ref).field; if (!orderBy.containsField(field)) { Object value; if (KEY_FULLTEXT_SCORE.equals(field)) { if (!desc) { throw new RuntimeException("Cannot sort by " + NXQL.ECM_FULLTEXT_SCORE + " ascending"); } sortScore = true; value = new BasicDBObject(MONGODB_META, MONGODB_TEXT_SCORE); } else { value = desc ? MINUS_ONE : ONE; } orderBy.put(field, value); } } if (sortScore && orderBy.size() > 1) { throw new RuntimeException("Cannot sort by " + NXQL.ECM_FULLTEXT_SCORE + " and other criteria"); } } // projection DBObject keys; if (fulltextScore || sortScore) { if (!builder.hasFulltext) { throw new RuntimeException(NXQL.ECM_FULLTEXT_SCORE + " cannot be used without " + NXQL.ECM_FULLTEXT); } // because it's a $meta, it won't prevent all other keys // from being returned keys = new BasicDBObject(KEY_FULLTEXT_SCORE, new BasicDBObject( MONGODB_META, MONGODB_TEXT_SCORE)); } else { keys = null; // all } if (log.isTraceEnabled()) { log.trace("MongoDB: QUERY " + query + (orderBy == null ? "" : " ORDER BY " + orderBy) + " OFFSET " + offset + " LIMIT " + limit); } List<State> list; long totalSize; DBCursor cursor = coll.find(query, keys).skip(offset).limit(limit); try { if (orderBy != null) { cursor = cursor.sort(orderBy); } list = new ArrayList<>(); for (DBObject ob : cursor) { list.add(bsonToState(ob)); } if (countUpTo == -1) { // count full size if (limit == 0) { totalSize = list.size(); } else { totalSize = cursor.count(); } } else if (countUpTo == 0) { // no count totalSize = -1; // not counted } else { // count only if less than countUpTo if (limit == 0) { totalSize = list.size(); } else { totalSize = cursor.copy().limit(countUpTo + 1).count(); } if (totalSize > countUpTo) { totalSize = -2; // truncated } } } finally { cursor.close(); } if (log.isTraceEnabled() && list.size() != 0) { log.trace("MongoDB: -> " + list.size()); } return new PartialList<>(list, totalSize); } protected void addPrincipals(DBObject query, Set<String> principals) { if (principals != null) { DBObject inPrincipals = new BasicDBObject(QueryOperators.IN, new ArrayList<String>(principals)); query.put(DBSDocument.KEY_READ_ACL, inPrincipals); } } }