io.github.thunderz99.cosmos.impl.cosmosdb.CosmosDatabaseImpl Maven / Gradle / Ivy
Show all versions of java-cosmos Show documentation
package io.github.thunderz99.cosmos.impl.cosmosdb;
import java.util.*;
import java.util.stream.Collectors;
import com.azure.cosmos.CosmosClient;
import com.azure.cosmos.CosmosContainer;
import com.azure.cosmos.implementation.HttpConstants;
import com.azure.cosmos.models.*;
import com.google.common.base.Preconditions;
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.CosmosBatchResponseWrapper;
import io.github.thunderz99.cosmos.dto.CosmosBulkResult;
import io.github.thunderz99.cosmos.dto.CosmosSqlQuerySpec;
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.collections4.MapUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static io.github.thunderz99.cosmos.condition.Condition.getFormattedKey;
/**
* Class representing a database instance.
*
*
* Can do document' CRUD and find.
*
*/
public class CosmosDatabaseImpl implements CosmosDatabase {
private static Logger log = LoggerFactory.getLogger(CosmosDatabaseImpl.class);
static final int MAX_BATCH_NUMBER_OF_OPERATION = 100;
static final int FIND_PREFERRED_PAGE_SIZE = 10;
String db;
CosmosClient clientV4;
Cosmos cosmosAccount;
public CosmosDatabaseImpl(Cosmos cosmosAccount, String db) {
this.cosmosAccount = cosmosAccount;
this.db = db;
if (cosmosAccount instanceof CosmosImpl) {
this.clientV4 = ((CosmosImpl) cosmosAccount).getClientV4();
}
}
/**
* 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
* @param data data object
* @param partition partition name
* @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 objectMap = JsonUtil.toMap(data);
// add partition info
objectMap.put(CosmosImpl.getDefaultPartitionKey(), partition);
var collectionLink = LinkFormatUtil.getCollectionLink(db, coll);
checkValidId(objectMap);
var container = this.clientV4.getDatabase(db).getContainer(coll);
var response = RetryUtil.executeWithRetry(() -> container.createItem(
objectMap,
new PartitionKey(partition),
new CosmosItemRequestOptions()
));
var item = response.getItem();
log.info("created Document:{}/docs/{}, partition:{}, account:{}, request charge:{}",
collectionLink, getId(item), partition, getAccount(), response.getRequestCharge());
return new CosmosDocument(item);
}
static String getId(Object object) {
String id;
if (object instanceof String) {
id = (String) object;
} 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);
}
}
/**
* Create a document using default partition
*
* @param coll collection name
* @param data data Object
* @return CosmosDocument instance
* @throws Exception Cosmos client exception
*/
public CosmosDocument create(String coll, Object data) throws Exception {
return create(coll, data, coll);
}
/**
* @param coll collection name
* @param id id of the document
* @param partition partition name
* @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(db, coll, id);
var container = this.clientV4.getDatabase(db).getContainer(coll);
var response = RetryUtil.executeWithRetry(() -> container.readItem(
id,
new PartitionKey(partition),
mapInstance.getClass()
));
log.info("read Document:{}, partition:{}, account:{}, request charge: {}",
documentLink, partition, getAccount(), response.getRequestCharge());
return new CosmosDocument(response.getItem());
}
/**
* Read a document by coll and id
*
* @param coll collection name
* @param id id of document
* @return CosmosDocument instance
* @throws Exception Throw 404 Not Found Exception if object not exist
*/
public CosmosDocument read(String coll, String id) throws Exception {
return read(coll, id, coll);
}
/**
* 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 (CosmosImpl.isResourceNotFoundException(e)) {
return null;
}
throw e;
}
}
/**
* Read a document by coll and id. Return null if object not exist
*
* @param coll collection name
* @param id id of document
* @return CosmosDocument instance
* @throws Exception Cosmos client exception
*/
public CosmosDocument readSuppressing404(String coll, String id) throws Exception {
return readSuppressing404(coll, id, coll);
}
/**
* 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);
var documentLink = LinkFormatUtil.getDocumentLink(db, coll, id);
// add partition info
map.put(CosmosImpl.getDefaultPartitionKey(), partition);
var container = this.clientV4.getDatabase(db).getContainer(coll);
var response = RetryUtil.executeWithRetry(() -> container.replaceItem(
map, id,
new PartitionKey(partition),
new CosmosItemRequestOptions()
));
log.info("updated Document:{}, partition:{}, account:{}, request charge:{}",
documentLink, partition, getAccount(), response.getRequestCharge());
return new CosmosDocument(response.getItem());
}
/**
* Update existing data. if not exist, throw Not Found Exception.
*
* @param coll collection name
* @param data data object
* @return CosmosDocument instance
* @throws Exception Cosmos client exception
*/
public CosmosDocument update(String coll, Object data) throws Exception {
return update(coll, data, coll);
}
/**
* 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
*
*
* If you want more complex partial update / patch features, please use patch 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
* @param option partial update option
* @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);
// Remove partition key from patchData, because it is not needed for a patch action.
patchData.remove(CosmosImpl.getDefaultPartitionKey());
if (!option.checkETag || StringUtils.isEmpty(MapUtils.getString(patchData, CosmosImpl.ETAG))) {
// if don't check etag or etag is empty, remove it.
patchData.remove(CosmosImpl.ETAG);
}
return updatePartialByMerge(coll, id, patchData, partition, option);
}
/**
* Update a document with read / merge / upsert method. this will be used when patch operations' size exceed the limit of 10.
*
* @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.
*/
CosmosDocument updatePartialByMerge(String coll, String id, Map data, String partition) throws Exception {
return updatePartialByMerge(coll, id, data, partition, new PartialUpdateOption());
}
/**
* Update a document with read / merge / upsert method. this will be used when patch operations' size exceed the limit of 10.
*
* @param coll collection name
* @param id id of document
* @param data data object
* @param partition partition name
* @param option partial update option
* @return CosmosDocument instance
* @throws Exception Cosmos client exception. If not exist, throw Not Found Exception.
*/
CosmosDocument updatePartialByMerge(String coll, String id, Map data, String partition, PartialUpdateOption option) throws Exception {
var documentLink = LinkFormatUtil.getDocumentLink(db, coll, id);
var map = RetryUtil.executeWithRetry(() -> {
// we will not retry if checkETag is true, this will result in an OCC.
// if we do not checkETag, we will get the newest etag from DB and retry replaceDocument.
var maxRetry = option.checkETag ? 0 : 3;
return replaceDocumentWithRefreshingEtag(coll, id, data, maxRetry, partition);
}
);
log.info("updatePartial Document:{}, partition:{}, account:{}", documentLink, partition, getAccount());
return new CosmosDocument(map);
}
/**
* Helper function. Read original data and do a merge with partial update data, finally return the merged data.
*
* @param coll collection name
* @param id id of data
* @param data partial update data
* @param partition partition name
* @return merged data
* @throws Exception
*/
Map readAndMerge(String coll, String id, Map data, String partition) throws Exception {
var origin = read(coll, id, partition).toMap();
var newData = JsonUtil.toMap(data);
// add partition info
newData.put(CosmosImpl.getDefaultPartitionKey(), partition);
// this is like `Object.assign(origin, newData)` in JavaScript, but support nested merge.
var merged = merge(origin, newData);
checkValidId(merged);
return merged;
}
/**
* Helper function. Do a partial update which etag check and retry.
*
* @param coll collection name
* @param id id of data
* @param data partial update data
* @param maxRetry max retry count when etag not matches
* @param partition partition name
* @return Document
* @throws Exception
*/
Map replaceDocumentWithRefreshingEtag(String coll, String id, Map data, int maxRetry, String partition) throws Exception {
var documentLink = LinkFormatUtil.getDocumentLink(db, coll, id);
var retriedCount = 0;
var container = this.clientV4.getDatabase(db).getContainer(coll);
while (true) {
var merged = readAndMerge(coll, id, data, partition);
var etag = merged.getOrDefault(CosmosImpl.ETAG, "").toString();
try {
return container.replaceItem(
merged, id,
new PartitionKey(partition),
new CosmosItemRequestOptions().setIfMatchETag(etag)
).getItem();
} catch (com.azure.cosmos.CosmosException e) {
if (e.getStatusCode() == 412) {
// etag not match, 412 Precondition Failed
retriedCount++;
if (retriedCount <= maxRetry) {
// continue to retry if less than max retries
continue;
}
}
// throw the exception to outer, if code is not 412 or exceeds max retries
throw e;
}
}
}
/**
* Update existing data. Partial update supported(Only 1st json hierarchy supported). If not exist, throw Not Found Exception.
*
*
* see partial update official docs
*
*
* @param coll collection name
* @param id id of document
* @param data data object
* @return CosmosDocument instance
* @throws Exception Cosmos client exception
*/
public CosmosDocument updatePartial(String coll, String id, Object data) throws Exception {
return updatePartial(coll, id, data, coll);
}
/**
* 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 {
Checker.checkNotBlank(coll, "coll");
Checker.checkNotBlank(partition, "partition");
Checker.checkNotNull(data, "upsert data " + coll + " " + partition);
var map = JsonUtil.toMap(data);
var id = map.getOrDefault("id", "").toString();
Checker.checkNotBlank(id, "id");
checkValidId(id);
var collectionLink = LinkFormatUtil.getCollectionLink(db, coll);
// add partition info
map.put(CosmosImpl.getDefaultPartitionKey(), partition);
var container = this.clientV4.getDatabase(db).getContainer(coll);
var response = RetryUtil.executeWithRetry(() -> container.upsertItem(
map,
new PartitionKey(partition),
new CosmosItemRequestOptions()
));
log.info("upsert Document:{}/docs/{}, partition:{}, account:{}, request charge:{}",
collectionLink, id, partition, getAccount(), response.getRequestCharge());
return new CosmosDocument(response.getItem());
}
/**
* 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
* @return CosmosDocument instance
* @throws Exception Cosmos client exception
*/
public CosmosDocument upsert(String coll, Object data) throws Exception {
return upsert(coll, data, coll);
}
/**
* 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 CosmosDatabaseImpl 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(db, coll, id);
try {
var container = this.clientV4.getDatabase(db).getContainer(coll);
var response = RetryUtil.executeWithRetry(() -> container.deleteItem(
id,
new PartitionKey(partition),
new CosmosItemRequestOptions()
));
log.info("deleted Document:{}, partition:{}, account:{}, request charge:{}",
documentLink, partition, getAccount(), response.getRequestCharge());
} catch (Exception e) {
if (CosmosImpl.isResourceNotFoundException(e)) {
log.info("delete Document not exist. Ignored:{}, partition:{}, account:{}", documentLink, partition, getAccount());
return this;
}
throw e;
}
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 {
// do a find without aggregate
return find(coll, null, cond, partition);
}
/**
* A helper method to do find/aggregate by condition
*
* @param coll collection name
* @param aggregate aggregate settings. null if no aggregation needed.
* @param cond condition to find
* @param partition partition name
* @return CosmosDocumentList
* @throws Exception Cosmos client exception
*/
CosmosDocumentList find(String coll, Aggregate aggregate, Condition cond, String partition) throws Exception {
var collectionLink = LinkFormatUtil.getCollectionLink(db, coll);
var queryRequestOptions = new CosmosQueryRequestOptions();
if (cond.crossPartition) {
// In v4, do not set the partitionKey to do a cross partition query
} else {
queryRequestOptions.setPartitionKey(new PartitionKey(partition));
}
var querySpec = aggregate == null ?
cond.toQuerySpec() : // normal query
cond.toQuerySpecForAggregate(aggregate); // aggregate query//
var container = this.clientV4.getDatabase(db).getContainer(coll);
var ret = new CosmosDocumentList();
if (Objects.isNull(aggregate) && !cond.joinCondText.isEmpty() && !cond.returnAllSubArray) {
// process query with join
var jsonObjs = mergeSubArrayToDoc(coll, cond, querySpec, queryRequestOptions);
ret = new CosmosDocumentList(jsonObjs);
} else {
// process query without join
var docs = RetryUtil.executeWithRetry(() ->
container.queryItems(querySpec.toSqlQuerySpecV4(), queryRequestOptions, mapInstance.getClass()));
if (log.isInfoEnabled()) {
docs.iterableByPage(FIND_PREFERRED_PAGE_SIZE).forEach(response -> {
log.info("find Document:{}/docs/, partition:{}, result size:{}, request charge:{}", collectionLink, partition, response.getResults().size(), response.getRequestCharge());
});
}
var maps = docs.stream().collect(Collectors.toList());
if (aggregate != null) {
// Process result of aggregate. convert Long value to Integer if possible.
// Because "itemsCount: 1L" is not acceptable by some users. They prefer "itemsCount: 1" more.
maps = convertAggregateResultsToInteger(maps);
}
ret = new CosmosDocumentList(maps);
}
if (log.isInfoEnabled()) {
log.info("find Document:{}, cond:{}, partition:{}, account:{}", collectionLink, cond, cond.crossPartition ? "crossPartition" : partition, getAccount());
}
return ret;
}
/**
* Process result of aggregate. convert Long value to Integer if possible.
*
* Because "itemsCount: 1L" is not acceptable by some users. They prefer "itemsCount: 1" more.
*
*
* @param maps
* @return
*/
static List extends LinkedHashMap> convertAggregateResultsToInteger(List extends LinkedHashMap> maps) {
if (CollectionUtils.isEmpty(maps)) {
return maps;
}
for (var map : maps) {
map.replaceAll((key, value) -> {
// Check if the value is an instance of Long
if (value instanceof Number) {
var numberValue = (Number) value;
return NumberUtil.convertNumberToIntIfCompatible(numberValue);
}
return value; // Return the original value if no conversion is needed
});
}
return maps;
}
/**
* Merge the sub array to origin array
* This function will traverse the result of join part and replaced by new result that is found by sub query.
*
* @param coll collection name
* @param cond merge the content of the sub array to origin array
* @param querySpec querySpec
* @param requestOptions request options
* @return docs list
* @throws Exception error exception
*/
List