io.github.thunderz99.cosmos.impl.mongo.MongoDatabaseImpl Maven / Gradle / Ivy
Show all versions of java-cosmos Show documentation
package io.github.thunderz99.cosmos.impl.mongo;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import com.azure.cosmos.implementation.guava25.collect.Lists;
import com.azure.cosmos.models.CosmosQueryRequestOptions;
import com.google.common.base.Preconditions;
import com.mongodb.MongoException;
import com.mongodb.client.MongoClient;
import com.mongodb.client.model.*;
import io.github.thunderz99.cosmos.*;
import io.github.thunderz99.cosmos.condition.Aggregate;
import io.github.thunderz99.cosmos.condition.Condition;
import io.github.thunderz99.cosmos.dto.CosmosBulkResult;
import io.github.thunderz99.cosmos.dto.CosmosSqlQuerySpec;
import io.github.thunderz99.cosmos.dto.FilterOptions;
import io.github.thunderz99.cosmos.dto.PartialUpdateOption;
import io.github.thunderz99.cosmos.util.*;
import io.github.thunderz99.cosmos.v4.PatchOperations;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.NotImplementedException;
import org.apache.commons.lang3.StringUtils;
import org.bson.BsonObjectId;
import org.bson.Document;
import org.bson.conversions.Bson;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static com.mongodb.client.model.Filters.eq;
import static com.mongodb.client.model.Projections.*;
/**
* Class representing a database instance.
*
*
* Can do document' CRUD and find.
*
*/
public class MongoDatabaseImpl implements CosmosDatabase {
private static Logger log = LoggerFactory.getLogger(MongoDatabaseImpl.class);
static final int MAX_BATCH_NUMBER_OF_OPERATION = 100;
String db;
MongoClient client;
Cosmos cosmosAccount;
public MongoDatabaseImpl(Cosmos cosmosAccount, String db) {
this.cosmosAccount = cosmosAccount;
this.db = db;
if (cosmosAccount instanceof MongoImpl) {
this.client = ((MongoImpl) cosmosAccount).getClient();
}
}
/**
* An instance of LinkedHashMap, used to get the class instance in a convenience way.
*/
static final LinkedHashMap mapInstance = new LinkedHashMap<>();
/**
* Create a document
*
* @param coll collection name(use collection name for database for mongodb)
* @param data data object
* @param partition partition name(use partition name for collection for mongodb)
* @return CosmosDocument instance
* @throws Exception Cosmos Client Exception
*/
public CosmosDocument create(String coll, Object data, String partition) throws Exception {
Checker.checkNotBlank(coll, "coll");
Checker.checkNotBlank(partition, "partition");
Checker.checkNotNull(data, "create data " + coll + " " + partition);
Map map = JsonUtil.toMap(data);
// add partition info
map.put(MongoImpl.getDefaultPartitionKey(), partition);
// set id(for java-cosmos) and _id(for mongo) before insert
addId4Mongo(map);
// add timestamp field "_ts"
addTimestamp(map);
var collectionLink = LinkFormatUtil.getCollectionLink(coll, partition);
checkValidId(map);
var container = this.client.getDatabase(coll).getCollection(partition);
var response = RetryUtil.executeWithRetry(() -> container.insertOne(
new Document(map)
));
var item = response.getInsertedId();
log.info("created Document:{}/docs/{}, partition:{}, account:{}", collectionLink, getId(item), partition, getAccount());
return getCosmosDocument(map);
}
/**
* set _id correctly by "id" for mongodb
*
* @param objectMap
*/
static String addId4Mongo(Map objectMap) {
var id = objectMap.getOrDefault("id", UUID.randomUUID()).toString();
checkValidId(id);
objectMap.put("id", id);
objectMap.put("_id", id);
return id;
}
static String getId(Object object) {
// TODO, extract util method
String id;
if (object instanceof String) {
id = (String) object;
} else if(object instanceof BsonObjectId) {
id = ((BsonObjectId) object).getValue().toHexString();
} else {
var map = JsonUtil.toMap(object);
id = map.getOrDefault("id", "").toString();
}
return id;
}
static void checkValidId(List> data) {
for (Object datum : data) {
if (datum instanceof String) {
checkValidId((String) datum);
} else {
Map map = JsonUtil.toMap(datum);
checkValidId(map);
}
}
}
/**
* Id cannot contain "\t", "\r", "\n", or cosmosdb will create invalid data.
*
* @param objectMap
*/
static void checkValidId(Map objectMap) {
if (objectMap == null) {
return;
}
var id = getId(objectMap);
checkValidId(id);
}
static void checkValidId(String id) {
if (StringUtils.containsAny(id, "\t", "\n", "\r", "/")) {
throw new IllegalArgumentException("id cannot contain \\t or \\n or \\r or /. id:" + id);
}
}
/**
* @param coll collection name(used as mongodb database)
* @param id id of the document
* @param partition partition name(used as mongodb collection)
* @return CosmosDocument instance
* @throws Exception Throw 404 Not Found Exception if object not exist
*/
public CosmosDocument read(String coll, String id, String partition) throws Exception {
Checker.checkNotBlank(id, "id");
Checker.checkNotBlank(coll, "coll");
Checker.checkNotBlank(partition, "partition");
var documentLink = LinkFormatUtil.getDocumentLink(coll, partition, id);
var container = this.client.getDatabase(coll).getCollection(partition);
var response = RetryUtil.executeWithRetry(() -> container.find(eq("_id", id)).first()
);
log.info("read Document:{}, partition:{}, account:{}", documentLink, partition, getAccount());
return checkAndGetCosmosDocument(response);
}
/**
* check whether the response is null and return CosmosDocument. if null, throw CosmosException(404 Not Found)
*
* @param response
* @return cosmos document
*/
static CosmosDocument checkAndGetCosmosDocument(Document response) {
if (response == null) {
throw new CosmosException(404, "404", "Resource Not Found. code: NotFound");
}
return getCosmosDocument(response);
}
/**
* process precision of timestamp and get CosmosDucment instance from response
*
* @param map
* @return cosmos document
*/
static CosmosDocument getCosmosDocument(Map map) {
TimestampUtil.processTimestampPrecision(map);
return new CosmosDocument(map);
}
/**
* Read a document by coll and id. Return null if object not exist
*
* @param coll collection name
* @param id id of document
* @param partition partition name
* @return CosmosDocument instance
* @throws Exception Cosmos client exception
*/
public CosmosDocument readSuppressing404(String coll, String id, String partition) throws Exception {
try {
return read(coll, id, partition);
} catch (Exception e) {
if (MongoImpl.isResourceNotFoundException(e)) {
return null;
}
throw e;
}
}
/**
* Update existing data. if not exist, throw Not Found Exception.
*
* @param coll collection name
* @param data data object
* @param partition partition name
* @return CosmosDocument instance
* @throws Exception Cosmos client exception
*/
public CosmosDocument update(String coll, Object data, String partition) throws Exception {
Checker.checkNotBlank(coll, "coll");
Checker.checkNotBlank(partition, "partition");
Checker.checkNotNull(data, "update data " + coll + " " + partition);
var map = JsonUtil.toMap(data);
var id = getId(map);
Checker.checkNotBlank(id, "id");
checkValidId(id);
// process id for mongo
map.put("_id", id);
// add timestamp field "_ts"
addTimestamp(map);
var documentLink = LinkFormatUtil.getDocumentLink(coll, partition, id);
// add partition info
map.put(MongoImpl.getDefaultPartitionKey(), partition);
var container = this.client.getDatabase(coll).getCollection(partition);
var document = RetryUtil.executeWithRetry(() -> container.findOneAndReplace(eq("_id", id),
new Document(map),
new FindOneAndReplaceOptions().upsert(false).returnDocument(ReturnDocument.AFTER))
);
log.info("updated Document:{}, id:{}, partition:{}, account:{}", documentLink, id, partition, getAccount());
return checkAndGetCosmosDocument(document);
}
/**
* Partial update existing data(Simple version). Input is a map, and the key/value in the map would be patched to the target document in SET mode.
*
*
* see partial update official docs
*
*
* If you want more complex partial update / patch features, please use patch(TODO) method, which supports ADD / SET / REPLACE / DELETE / INCREMENT and etc.
*
*
* @param coll collection name
* @param id id of document
* @param data data object
* @param partition partition name
* @return CosmosDocument instance
* @throws Exception Cosmos client exception. If not exist, throw Not Found Exception.
*/
public CosmosDocument updatePartial(String coll, String id, Object data, String partition)
throws Exception {
return updatePartial(coll, id, data, partition, new PartialUpdateOption());
}
/**
* Partial update existing data(Simple version). Input is a map, and the key/value in the map would be patched to the target document in SET mode.
*
*
* see partial update official docs
*
*
* @param coll collection name
* @param id id of document
* @param data data object
* @param partition partition name
* @param option partial update option (no effect for mongodb at present. not implemented)
* @return CosmosDocument instance
* @throws Exception Cosmos client exception. If not exist, throw Not Found Exception.
*/
public CosmosDocument updatePartial(String coll, String id, Object data, String partition, PartialUpdateOption option)
throws Exception {
Checker.checkNotBlank(id, "id");
Checker.checkNotBlank(coll, "coll");
Checker.checkNotBlank(partition, "partition");
Checker.checkNotNull(data, "updatePartial data " + coll + " " + partition);
checkValidId(id);
var patchData = JsonUtil.toMap(data);
addTimestamp(patchData);
// Remove partition key from patchData, because it is not needed for a patch action.
patchData.remove(MongoImpl.getDefaultPartitionKey());
// flatten the map to "address.country.street" format to be used in mongo update method.
var flatMap = MapUtil.toFlatMapWithPeriod(patchData);
var documentLink = LinkFormatUtil.getDocumentLink(coll, partition, id);
var container = this.client.getDatabase(coll).getCollection(partition);
var document = RetryUtil.executeWithRetry(() -> container.findOneAndUpdate(eq("_id", id),
new Document("$set", new Document(flatMap)),
new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER))
);
log.info("updated Document:{}, id:{}, partition:{}, account:{}", documentLink, id, partition, getAccount());
return checkAndGetCosmosDocument(document);
}
/**
* Add "_ts" field to data automatically, for compatibility for cosmosdb
*
* @param data
*/
static void addTimestamp(Map data) {
// format: 1714546148.123
// we use milli instead of second in order to get a more stable sort when using "sort" : ["_ts", "DESC"]
data.put("_ts", Instant.now().toEpochMilli() / 1000d);
}
/**
* Update existing data. Create a new one if not exist. "id" field must be contained in data.
*
* @param coll collection name
* @param data data object
* @param partition partition name
* @return CosmosDocument instance
* @throws Exception Cosmos client exception
*/
public CosmosDocument upsert(String coll, Object data, String partition) throws Exception {
var map = JsonUtil.toMap(data);
var id = map.getOrDefault("id", "").toString();
Checker.checkNotBlank(id, "id");
checkValidId(id);
Checker.checkNotBlank(coll, "coll");
Checker.checkNotBlank(partition, "partition");
Checker.checkNotNull(data, "upsert data " + coll + " " + partition);
// process id for mongo
map.put("_id", id);
// add timestamp field "_ts"
addTimestamp(map);
var collectionLink = LinkFormatUtil.getCollectionLink(coll, partition);
// add partition info
map.put(MongoImpl.getDefaultPartitionKey(), partition);
var container = this.client.getDatabase(coll).getCollection(partition);
var document = RetryUtil.executeWithRetry(() -> container.findOneAndReplace(eq("_id", id),
new Document(map),
new FindOneAndReplaceOptions().upsert(true).returnDocument(ReturnDocument.AFTER))
);
log.info("upsert Document:{}/docs/{}, partition:{}, account:{}", collectionLink, id, partition, getAccount());
return checkAndGetCosmosDocument(document);
}
/**
* Delete a document. Do nothing if object not exist
*
* @param coll collection name
* @param id id of document
* @param partition partition name
* @return CosmosDatabase instance
* @throws Exception Cosmos client exception
*/
public CosmosDatabase delete(String coll, String id, String partition) throws Exception {
Checker.checkNotBlank(coll, "coll");
Checker.checkNotBlank(id, "id");
Checker.checkNotBlank(partition, "partition");
var documentLink = LinkFormatUtil.getDocumentLink(coll, partition, id);
var container = this.client.getDatabase(coll).getCollection(partition);
var response = RetryUtil.executeWithRetry(() -> container.deleteOne(eq("_id", id))
);
log.info("deleted Document:{}, partition:{}, account:{}", documentLink, partition, getAccount());
return this;
}
/**
* find data by condition
*
* {@code
* var cond = Condition.filter(
* "id>=", "id010", // id greater or equal to 'id010'
* "lastName", "Banks" // last name equal to Banks
* )
* .order("lastName", "ASC") //optional order
* .offset(0) //optional offset
* .limit(100); //optional limit
*
* var users = db.find("Collection1", cond, "Users").toList(User.class);
*
* }
*
* @param coll collection name
* @param cond condition to find
* @param partition partition name
* @return CosmosDocumentList
* @throws Exception Cosmos client exception
*/
public CosmosDocumentList find(String coll, Condition cond, String partition) throws Exception {
if (cond == null) {
cond = new Condition();
}
if (CollectionUtils.isNotEmpty(cond.join) && !cond.returnAllSubArray) {
// When doing join and only return the matching part of subArray,
// we have to use findWithJoin method, which do a special aggregate pipeline to achieve this
return findWithJoin(coll, cond, partition);
}
var collectionLink = LinkFormatUtil.getCollectionLink(db, coll);
// TODO crossPartition query
var filter = ConditionUtil.toBsonFilter(cond);
// process sort
var sort = ConditionUtil.toBsonSort(cond.sort);
var container = this.client.getDatabase(coll).getCollection(partition);
var ret = new CosmosDocumentList();
var findIterable = container.find(filter)
.sort(sort).skip(cond.offset).limit(cond.limit);
var fields = ConditionUtil.processFields(cond.fields);
if (!fields.isEmpty()) {
// process fields
findIterable.projection(fields(excludeId(), include(fields)));
}
var docs = RetryUtil.executeWithRetry(() -> findIterable.into(new ArrayList<>()));
ret = new CosmosDocumentList(docs);
if (log.isInfoEnabled()) {
log.info("find Document:{}, cond:{}, partition:{}, account:{}", collectionLink, cond, cond.crossPartition ? "crossPartition" : partition, getAccount());
}
return ret;
}
/**
* Find documents when JOIN is used and returnAllSubArray is false.
* In mongo this is implemented by aggregate pipeline and using $project stage and $filter
*
*
* For further information, look at "docs/find-with-join.md"
*
*
* @param coll
* @param cond
* @param partition
* @return documents
*/
CosmosDocumentList findWithJoin(String coll, Condition cond, String partition) {
Checker.check(CollectionUtils.isNotEmpty(cond.join), "join cannot be empty in findWithJoin");
Checker.check(!cond.negative, "Top negative condition is not supported for findWithJoin");
Checker.check(!cond.returnAllSubArray, "findWithJoin should be used when returnAllSubArray = false");
var container = this.client.getDatabase(coll).getCollection(partition);
// Create the aggregation pipeline stages
var pipeline = new ArrayList();
// 1.1 match stage
// Add the first $match stage based on filter, which narrows the pipeline significantly.
// Process the condition into a BSON filter
// @see docs/find-with-join.md
var filter = ConditionUtil.toBsonFilter(cond);
// Add the match stage based on the filter
if (filter != null) {
pipeline.add(Aggregates.match(filter));
}
// 1.2 sort stage
var sort = ConditionUtil.toBsonSort(cond.sort);
if (sort != null) {
pipeline.add(Aggregates.sort(sort));
}
// 1.3 skip / limit stage
pipeline.add(Aggregates.skip(cond.offset));
pipeline.add(Aggregates.limit(cond.limit));
// 2. project stage
// Add the $project stage to filter(using $filter) values in arrays specified by cond.join
// extract the filters related to join in order to get the matching array elements
var joinRelatedFilters = JoinUtil.extractJoinFilters(cond.filter, cond.join);
var projectStage = new Document();
projectStage.append("original", "$$ROOT");
for (var joinField : cond.join) {
// Generate a new field for each join-related field with the matching logic
var matchingFieldName = "matching_" + AggregateUtil.convertFieldNameIncludingDot(joinField);
var fieldNameInDocument = "$" + joinField;
// extract the joinFilter starts with the same joinField
var joinFilter = joinRelatedFilters.entrySet().stream()
.filter(entry -> entry.getKey().startsWith(joinField))
.collect(Collectors.toMap(
entry -> entry.getKey().substring(joinField.length() + 1), // Remove the joinField prefix
Map.Entry::getValue
));
// Create the BSON expression for the filtered field
// Construct the filter condition as needed
var filterConditions = new ArrayList();
joinFilter.forEach((key, value) -> {
var singleFilter = ConditionUtil.toBsonFilter("$$this." + key, value, FilterOptions.create().join(cond.join).innerCond(true));
if (singleFilter != null) {
filterConditions.add(singleFilter);
}
});
// Assuming only one condition to match, you can use $and or just directly the condition
projectStage.append(matchingFieldName, new Document("$filter",
new Document("input", fieldNameInDocument)
.append("cond", filterConditions.size() == 1 ? filterConditions.get(0) : Filters.and(filterConditions))
));
}
pipeline.add(Aggregates.project(projectStage));
// 3. replaceRoot stage
// Merge the original document with the new fields using $replaceRoot and $mergeObjects
var mergeObjectsFields = new ArrayList<>();
mergeObjectsFields.add("$original");
for (var joinField : cond.join) {
var matchingFieldName = "matching_" + AggregateUtil.convertFieldNameIncludingDot(joinField);
mergeObjectsFields.add(new Document(matchingFieldName, "$" + matchingFieldName));
}
pipeline.add(Aggregates.replaceRoot(new Document("$mergeObjects", mergeObjectsFields)));
// 4. replaceWith stage
// Because returnAllSubArray is false, replace the original documents' array with matched elements only
/*
// Use $replaceWith to replace nested fields
{
$replaceWith: {
$mergeObjects: [
"$$ROOT",
{
area: {
city: {
street: {
name: "$area.city.street.name", // Preserve original street name
rooms: "$matching_area__city__street__name" // Replace rooms with matched rooms
}
}
}
},
{
"room*no-01": "$matching_room*no-01" // Replace room*no-01 with matched elements
}
]
}
}
*/
List