/*
* (C) Copyright 2017 Nuxeo (http://nuxeo.com/) and others.
*
* 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.
*
* Contributors:
* Funsho David
*
*/
package org.nuxeo.directory.mongodb;
import static org.nuxeo.directory.mongodb.MongoDBSerializationHelper.MONGODB_ID;
import static org.nuxeo.directory.mongodb.MongoDBSerializationHelper.MONGODB_SEQ;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import com.mongodb.client.model.Updates;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.bson.Document;
import org.bson.conversions.Bson;
import org.nuxeo.ecm.core.api.DocumentModel;
import org.nuxeo.ecm.core.api.DocumentModelList;
import org.nuxeo.ecm.core.api.PropertyException;
import org.nuxeo.ecm.core.api.impl.DocumentModelListImpl;
import org.nuxeo.ecm.core.api.model.Property;
import org.nuxeo.ecm.core.api.security.SecurityConstants;
import org.nuxeo.ecm.core.schema.types.Field;
import org.nuxeo.ecm.directory.BaseSession;
import org.nuxeo.ecm.directory.DirectoryException;
import org.nuxeo.ecm.directory.EntrySource;
import org.nuxeo.ecm.directory.PasswordHelper;
import org.nuxeo.ecm.directory.Reference;
import org.nuxeo.ecm.directory.Session;
import org.nuxeo.ecm.directory.BaseDirectoryDescriptor.SubstringMatchType;
import com.mongodb.MongoClient;
import com.mongodb.MongoWriteException;
import com.mongodb.client.FindIterable;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.model.FindOneAndUpdateOptions;
import com.mongodb.client.model.ReturnDocument;
import com.mongodb.client.result.DeleteResult;
import com.mongodb.client.result.UpdateResult;
/**
* MongoDB implementation of a {@link Session}
*
* @since 9.1
*/
public class MongoDBSession extends BaseSession implements EntrySource {
private static final Log log = LogFactory.getLog(MongoDBSession.class);
protected MongoClient client;
protected String dbName;
protected String schemaName;
protected String directoryName;
protected SubstringMatchType substringMatchType;
protected String countersCollectionName;
protected final Map<String, Field> schemaFieldMap;
protected final String passwordHashAlgorithm;
protected final boolean autoincrementId;
public MongoDBSession(MongoDBDirectory directory) {
super(directory);
MongoDBDirectoryDescriptor desc = directory.getDescriptor();
client = MongoDBConnectionHelper.newMongoClient(desc.getServerUrl());
dbName = desc.getDatabaseName();
directoryName = directory.getName();
countersCollectionName = directory.getCountersCollectionName();
schemaName = directory.getSchema();
substringMatchType = desc.getSubstringMatchType();
schemaFieldMap = directory.getSchemaFieldMap();
autoincrementId = desc.isAutoincrementIdField();
passwordHashAlgorithm = desc.passwordHashAlgorithm;
}
@Override
public MongoDBDirectory getDirectory() {
return (MongoDBDirectory) directory;
}
@Override
public DocumentModel getEntry(String id) throws DirectoryException {
return getEntry(id, true);
}
@Override
public DocumentModel getEntry(String id, boolean fetchReferences) throws DirectoryException {
if (!hasPermission(SecurityConstants.READ)) {
return null;
}
return directory.getCache().getEntry(id, this, fetchReferences);
}
@Override
public DocumentModelList getEntries() throws DirectoryException {
if (!hasPermission(SecurityConstants.READ)) {
return new DocumentModelListImpl();
}
return query(Collections.emptyMap());
}
@Override
public DocumentModel createEntry(Map<String, Object> fieldMap) throws DirectoryException {
checkPermission(SecurityConstants.WRITE);
String id;
if (autoincrementId) {
Document filter = MongoDBSerializationHelper.fieldMapToBson(MONGODB_ID, directoryName);
Bson update = Updates.inc(MONGODB_SEQ, 1L);
FindOneAndUpdateOptions options = new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER);
Long longId = getCollection(countersCollectionName).findOneAndUpdate(filter, update, options)
.getLong(MONGODB_SEQ);
fieldMap.put(getIdField(), longId);
id = String.valueOf(longId);
} else {
id = String.valueOf(fieldMap.get(getIdField()));
if (hasEntry(id)) {
throw new DirectoryException(String.format("Entry with id %s already exists", id));
}
}
if (fieldMap.get(getPasswordField()) != null) {
String password = (String) fieldMap.get(getPasswordField());
password = PasswordHelper.hashPassword(password, passwordHashAlgorithm);
fieldMap.put(getPasswordField(), password);
}
try {
Document bson = MongoDBSerializationHelper.fieldMapToBson(fieldMap);
getCollection().insertOne(bson);
DocumentModel docModel = BaseSession.createEntryModel(null, schemaName, id, fieldMap, isReadOnly());
// Add references fields
Field schemaIdField = schemaFieldMap.get(getIdField());
String idFieldName = schemaIdField.getName().getPrefixedName();
String sourceId = docModel.getId();
for (Reference reference : getDirectory().getReferences()) {
String referenceFieldName = schemaFieldMap.get(reference.getFieldName()).getName().getPrefixedName();
if (getDirectory().getReferences(reference.getFieldName()).size() > 1) {
log.warn("Directory " + getDirectory().getName() + " cannot create field "
+ reference.getFieldName() + " for entry " + fieldMap.get(idFieldName)
+ ": this field is associated with more than one reference");
continue;
}
@SuppressWarnings("unchecked")
List<String> targetIds = (List<String>) fieldMap.get(referenceFieldName);
if (reference instanceof MongoDBReference) {
MongoDBReference mongodbReference = (MongoDBReference) reference;
mongodbReference.addLinks(sourceId, targetIds, this);
} else {
reference.addLinks(sourceId, targetIds);
}
}
getDirectory().invalidateCaches();
return docModel;
} catch (MongoWriteException e) {
throw new DirectoryException(e);
}
}
@Override
public void updateEntry(DocumentModel docModel) throws DirectoryException {
checkPermission(SecurityConstants.WRITE);
Map<String, Object> fieldMap = new HashMap<>();
List<String> referenceFieldList = new LinkedList<>();
for (String fieldName : schemaFieldMap.keySet()) {
Property prop = docModel.getPropertyObject(schemaName, fieldName);
if (fieldName.equals(getPasswordField()) && StringUtils.isEmpty((String) prop.getValue())) {
continue;
}
if (prop != null && prop.isDirty()) {
Serializable value = prop.getValue();
if (fieldName.equals(getPasswordField())) {
value = PasswordHelper.hashPassword((String) value, passwordHashAlgorithm);
}
fieldMap.put(prop.getName(), value);
}
if (getDirectory().isReference(fieldName)) {
referenceFieldList.add(fieldName);
}
}
String id = docModel.getId();
Document bson = MongoDBSerializationHelper.fieldMapToBson(getIdField(), id);
List<Bson> updates = fieldMap.entrySet().stream().map(e -> Updates.set(e.getKey(), e.getValue())).collect(
Collectors.toList());
try {
UpdateResult result = getCollection().updateOne(bson, Updates.combine(updates));
// Throw an error if no document matched the update
if (!result.wasAcknowledged()) {
throw new DirectoryException(
"Error while updating the entry, the request has not been acknowledged by the server");
}
if (result.getMatchedCount() == 0) {
throw new DirectoryException(
String.format("Error while updating the entry, no document was found with the id %s", id));
}
} catch (MongoWriteException e) {
throw new DirectoryException(e);
}
// update reference fields
for (String referenceFieldName : referenceFieldList) {
List<Reference> references = directory.getReferences(referenceFieldName);
if (references.size() > 1) {
// not supported
log.warn("Directory " + getDirectory().getName() + " cannot update field " + referenceFieldName
+ " for entry " + docModel.getId() + ": this field is associated with more than one reference");
} else {
Reference reference = references.get(0);
@SuppressWarnings("unchecked")
List<String> targetIds = (List<String>) docModel.getProperty(schemaName, referenceFieldName);
if (reference instanceof MongoDBReference) {
MongoDBReference mongoReference = (MongoDBReference) reference;
mongoReference.setTargetIdsForSource(docModel.getId(), targetIds, this);
} else {
reference.setTargetIdsForSource(docModel.getId(), targetIds);
}
}
}
getDirectory().invalidateCaches();
}
@Override
public void deleteEntry(DocumentModel docModel) throws DirectoryException {
deleteEntry(docModel.getId());
}
@Override
public void deleteEntry(String id) throws DirectoryException {
checkPermission(SecurityConstants.WRITE);
checkDeleteConstraints(id);
for (Reference reference : getDirectory().getReferences()) {
if (reference instanceof MongoDBReference) {
MongoDBReference mongoDBReference = (MongoDBReference) reference;
mongoDBReference.removeLinksForSource(id, this);
} else {
reference.removeLinksForSource(id);
}
}
try {
DeleteResult result = getCollection().deleteOne(
MongoDBSerializationHelper.fieldMapToBson(getIdField(), id));
if (!result.wasAcknowledged()) {
throw new DirectoryException(
"Error while deleting the entry, the request has not been acknowledged by the server");
}
} catch (MongoWriteException e) {
throw new DirectoryException(e);
}
getDirectory().invalidateCaches();
}
@Override
public void deleteEntry(String id, Map<String, String> map) throws DirectoryException {
// TODO deprecate this as it's unused
deleteEntry(id);
}
@Override
public DocumentModelList query(Map<String, Serializable> filter) throws DirectoryException {
return query(filter, Collections.emptySet());
}
@Override
public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext) throws DirectoryException {
return query(filter, fulltext, new HashMap<>());
}
@Override
public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy)
throws DirectoryException {
return query(filter, fulltext, orderBy, false);
}
@Override
public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy,
boolean fetchReferences) throws DirectoryException {
return query(filter, fulltext, orderBy, fetchReferences, -1, -1);
}
@Override
public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy,
boolean fetchReferences, int limit, int offset) throws DirectoryException {
Document bson = buildQuery(filter, fulltext);
DocumentModelList entries = new DocumentModelListImpl();
FindIterable<Document> results = getCollection().find(bson).skip(offset);
if (limit > 0) {
results.limit(limit);
}
for (Document resultDoc : results) {
// Cast object to document model
Map<String, Object> fieldMap = MongoDBSerializationHelper.bsonToFieldMap(resultDoc);
DocumentModel doc = fieldMapToDocumentModel(fieldMap);
if (fetchReferences) {
Map<String, List<String>> targetIdsMap = new HashMap<>();
for (Reference reference : directory.getReferences()) {
List<String> targetIds;
if (reference instanceof MongoDBReference) {
MongoDBReference mongoReference = (MongoDBReference) reference;
targetIds = mongoReference.getTargetIdsForSource(doc.getId(), this);
} else {
targetIds = reference.getTargetIdsForSource(doc.getId());
}
targetIds = new ArrayList<>(targetIds);
Collections.sort(targetIds);
String fieldName = reference.getFieldName();
targetIdsMap.computeIfAbsent(fieldName, key -> new ArrayList<>()).addAll(targetIds);
}
for (Map.Entry<String, List<String>> entry : targetIdsMap.entrySet()) {
String fieldName = entry.getKey();
List<String> targetIds = entry.getValue();
try {
doc.setProperty(schemaName, fieldName, targetIds);
} catch (PropertyException e) {
throw new DirectoryException(e);
}
}
}
entries.add(doc);
}
if (orderBy != null && !orderBy.isEmpty()) {
getDirectory().orderEntries(entries, orderBy);
}
return entries;
}
protected Document buildQuery(Map<String, Serializable> fieldMap, Set<String> fulltext) {
Document bson = new Document();
for (Map.Entry<String, Serializable> entry : fieldMap.entrySet()) {
Object value = MongoDBSerializationHelper.valueToBson(entry.getValue());
if (value != null) {
String key = entry.getKey();
if (fulltext.contains(key)) {
String val = String.valueOf(value);
switch (substringMatchType) {
case subany:
addField(bson, key, Pattern.compile(val, Pattern.CASE_INSENSITIVE));
break;
case subinitial:
addField(bson, key, Pattern.compile('^' + val, Pattern.CASE_INSENSITIVE));
break;
case subfinal:
addField(bson, key, Pattern.compile(val + '$', Pattern.CASE_INSENSITIVE));
break;
}
} else {
addField(bson, key, value);
}
}
}
return bson;
}
protected void addField(Document bson, String key, Object value) {
bson.put(key, value);
}
@Override
public void close() throws DirectoryException {
client.close();
getDirectory().removeSession(this);
}
@Override
public List<String> getProjection(Map<String, Serializable> filter, String columnName) throws DirectoryException {
return getProjection(filter, Collections.emptySet(), columnName);
}
@Override
public List<String> getProjection(Map<String, Serializable> filter, Set<String> fulltext, String columnName)
throws DirectoryException {
DocumentModelList docList = query(filter, fulltext);
List<String> result = new ArrayList<>();
for (DocumentModel docModel : docList) {
Object obj = docModel.getProperty(schemaName, columnName);
String propValue = String.valueOf(obj);
result.add(propValue);
}
return result;
}
@Override
public boolean authenticate(String username, String password) throws DirectoryException {
Document user = getCollection().find(MongoDBSerializationHelper.fieldMapToBson(getIdField(), username)).first();
String storedPassword = user.getString(getPasswordField());
return PasswordHelper.verifyPassword(password, storedPassword);
}
@Override
public boolean hasEntry(String id) {
return getCollection().count(MongoDBSerializationHelper.fieldMapToBson(getIdField(), id)) > 0;
}
@Override
public DocumentModel createEntry(DocumentModel documentModel) {
return createEntry(documentModel.getProperties(schemaName));
}
@Override
public DocumentModel getEntryFromSource(String id, boolean fetchReferences) throws DirectoryException {
DocumentModelList result = query(Collections.singletonMap(getIdField(), id), Collections.emptySet(),
Collections.emptyMap(), fetchReferences, 1, -1);
return result.isEmpty() ? null : result.get(0);
}
/**
* Retrieve a collection
*
* @param collection the collection name
* @return the MongoDB collection
*/
public MongoCollection<Document> getCollection(String collection) {
return MongoDBConnectionHelper.getCollection(client, dbName, collection);
}
/**
* Retrieve the collection associated to this directory
*
* @return the MongoDB collection
*/
public MongoCollection<Document> getCollection() {
return getCollection(directoryName);
}
/**
* Check if the MongoDB server has the collection
*
* @param collection the collection name
* @return true if the server has the collection, false otherwise
*/
public boolean hasCollection(String collection) {
return MongoDBConnectionHelper.hasCollection(client, dbName, collection);
}
protected DocumentModel fieldMapToDocumentModel(Map<String, Object> fieldMap) {
String id = String.valueOf(fieldMap.get(getIdField()));
DocumentModel docModel = BaseSession.createEntryModel(null, schemaName, id, fieldMap, isReadOnly());
return docModel;
}
}