diff --git a/pom.xml b/pom.xml
index c4e668aa..86a52228 100644
--- a/pom.xml
+++ b/pom.xml
@@ -33,6 +33,7 @@
qqq-backend-module-api
qqq-backend-module-filesystem
qqq-backend-module-rdbms
+ qqq-backend-module-mongodb
qqq-language-support-javascript
qqq-middleware-picocli
qqq-middleware-javalin
diff --git a/qqq-backend-module-mongodb/pom.xml b/qqq-backend-module-mongodb/pom.xml
new file mode 100644
index 00000000..170ba8a1
--- /dev/null
+++ b/qqq-backend-module-mongodb/pom.xml
@@ -0,0 +1,120 @@
+
+
+
+
+ 4.0.0
+
+ qqq-backend-module-mongodb
+
+
+ com.kingsrook.qqq
+ qqq-parent-project
+ ${revision}
+
+
+
+
+
+
+
+
+
+
+ com.kingsrook.qqq
+ qqq-backend-core
+ ${revision}
+
+
+
+
+ org.mongodb
+ mongodb-driver-sync
+ 4.11.1
+
+
+ org.apache.logging.log4j
+ log4j-slf4j-impl
+ 2.17.1
+
+
+
+ org.testcontainers
+ mongodb
+ 1.19.3
+ test
+
+
+
+
+ org.apache.maven.plugins
+ maven-checkstyle-plugin
+
+
+ org.apache.logging.log4j
+ log4j-api
+
+
+ org.apache.logging.log4j
+ log4j-core
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 2.4.3
+
+ false
+
+
+ *:*
+
+ META-INF/*
+
+
+
+
+
+
+ ${plugin.shade.phase}
+
+ shade
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/MongoDBBackendModule.java b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/MongoDBBackendModule.java
new file mode 100644
index 00000000..cb205654
--- /dev/null
+++ b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/MongoDBBackendModule.java
@@ -0,0 +1,168 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.module.mongodb;
+
+
+import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
+import com.kingsrook.qqq.backend.core.actions.interfaces.AggregateInterface;
+import com.kingsrook.qqq.backend.core.actions.interfaces.CountInterface;
+import com.kingsrook.qqq.backend.core.actions.interfaces.DeleteInterface;
+import com.kingsrook.qqq.backend.core.actions.interfaces.InsertInterface;
+import com.kingsrook.qqq.backend.core.actions.interfaces.QueryInterface;
+import com.kingsrook.qqq.backend.core.actions.interfaces.UpdateInterface;
+import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
+import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableBackendDetails;
+import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
+import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface;
+import com.kingsrook.qqq.backend.module.mongodb.actions.AbstractMongoDBAction;
+import com.kingsrook.qqq.backend.module.mongodb.actions.MongoClientContainer;
+import com.kingsrook.qqq.backend.module.mongodb.actions.MongoDBAggregateAction;
+import com.kingsrook.qqq.backend.module.mongodb.actions.MongoDBCountAction;
+import com.kingsrook.qqq.backend.module.mongodb.actions.MongoDBDeleteAction;
+import com.kingsrook.qqq.backend.module.mongodb.actions.MongoDBInsertAction;
+import com.kingsrook.qqq.backend.module.mongodb.actions.MongoDBQueryAction;
+import com.kingsrook.qqq.backend.module.mongodb.actions.MongoDBTransaction;
+import com.kingsrook.qqq.backend.module.mongodb.actions.MongoDBUpdateAction;
+import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBBackendMetaData;
+import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBTableBackendDetails;
+
+
+/*******************************************************************************
+ ** QQQ Backend module for working with MongoDB
+ *******************************************************************************/
+public class MongoDBBackendModule implements QBackendModuleInterface
+{
+ static
+ {
+ QBackendModuleDispatcher.registerBackendModule(new MongoDBBackendModule());
+ }
+
+ /*******************************************************************************
+ ** Method where a backend module must be able to provide its type (name).
+ *******************************************************************************/
+ public String getBackendType()
+ {
+ return ("mongodb");
+ }
+
+
+
+ /*******************************************************************************
+ ** Method to identify the class used for backend meta data for this module.
+ *******************************************************************************/
+ @Override
+ public Class extends QBackendMetaData> getBackendMetaDataClass()
+ {
+ return (MongoDBBackendMetaData.class);
+ }
+
+
+
+ /*******************************************************************************
+ ** Method to identify the class used for table-backend details for this module.
+ *******************************************************************************/
+ @Override
+ public Class extends QTableBackendDetails> getTableBackendDetailsClass()
+ {
+ return (MongoDBTableBackendDetails.class);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public CountInterface getCountInterface()
+ {
+ return (new MongoDBCountAction());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public QueryInterface getQueryInterface()
+ {
+ return (new MongoDBQueryAction());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public InsertInterface getInsertInterface()
+ {
+ return (new MongoDBInsertAction());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public UpdateInterface getUpdateInterface()
+ {
+ return (new MongoDBUpdateAction());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public DeleteInterface getDeleteInterface()
+ {
+ return (new MongoDBDeleteAction());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public AggregateInterface getAggregateInterface()
+ {
+ return (new MongoDBAggregateAction());
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public QBackendTransaction openTransaction(AbstractTableActionInput input)
+ {
+ MongoDBBackendMetaData backend = (MongoDBBackendMetaData) input.getBackend();
+ MongoClientContainer mongoClientContainer = new AbstractMongoDBAction().openClient(backend, null);
+ return (new MongoDBTransaction(backend, mongoClientContainer.getMongoClient()));
+ }
+}
diff --git a/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/AbstractMongoDBAction.java b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/AbstractMongoDBAction.java
new file mode 100644
index 00000000..f51704bc
--- /dev/null
+++ b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/AbstractMongoDBAction.java
@@ -0,0 +1,541 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2024. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.module.mongodb.actions;
+
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Map;
+import java.util.regex.Pattern;
+import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.JoinsContext;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
+import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
+import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLockFilters;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.model.session.QSession;
+import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import com.kingsrook.qqq.backend.core.utils.ValueUtils;
+import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBBackendMetaData;
+import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBTableBackendDetails;
+import com.mongodb.ConnectionString;
+import com.mongodb.MongoClientSettings;
+import com.mongodb.MongoCredential;
+import com.mongodb.client.MongoClient;
+import com.mongodb.client.MongoClients;
+import com.mongodb.client.model.Filters;
+import org.bson.Document;
+import org.bson.conversions.Bson;
+import org.bson.types.ObjectId;
+
+
+/*******************************************************************************
+ ** Base class for all mongoDB module actions.
+ *******************************************************************************/
+public class AbstractMongoDBAction
+{
+ private static final QLogger LOG = QLogger.getLogger(AbstractMongoDBAction.class);
+
+
+
+ /*******************************************************************************
+ ** Open a MongoDB Client / session -- re-using the one in the input transaction
+ ** if it is present.
+ *******************************************************************************/
+ public MongoClientContainer openClient(MongoDBBackendMetaData backend, QBackendTransaction transaction)
+ {
+ if(transaction instanceof MongoDBTransaction mongoDBTransaction)
+ {
+ //////////////////////////////////////////////////////////////////////////////////////////
+ // re-use the connection from the transaction (indicating false in last parameter here) //
+ //////////////////////////////////////////////////////////////////////////////////////////
+ return (new MongoClientContainer(mongoDBTransaction.getMongoClient(), mongoDBTransaction.getClientSession(), false));
+ }
+
+ ConnectionString connectionString = new ConnectionString("mongodb://" + backend.getHost() + ":" + backend.getPort() + "/");
+
+ MongoCredential credential = MongoCredential.createCredential(backend.getUsername(), backend.getAuthSourceDatabase(), backend.getPassword().toCharArray());
+
+ MongoClientSettings settings = MongoClientSettings.builder()
+
+ ////////////////////////////////////////////////
+ // is this needed, what, for a cluster maybe? //
+ ////////////////////////////////////////////////
+ // .applyToClusterSettings(builder -> builder.hosts(seeds))
+
+ .applyConnectionString(connectionString)
+ .credential(credential)
+ .build();
+
+ MongoClient mongoClient = MongoClients.create(settings);
+
+ ////////////////////////////////////////////////////////////////////////////
+ // indicate that this connection was newly opened via the true param here //
+ ////////////////////////////////////////////////////////////////////////////
+ return (new MongoClientContainer(mongoClient, mongoClient.startSession(), true));
+ }
+
+
+
+ /*******************************************************************************
+ ** Get the name to use for a field in the mongoDB, from the fieldMetaData.
+ **
+ ** That is, field.backendName if set -- else, field.name
+ *******************************************************************************/
+ protected String getFieldBackendName(QFieldMetaData field)
+ {
+ if(field.getBackendName() != null)
+ {
+ return (field.getBackendName());
+ }
+ return (field.getName());
+ }
+
+
+
+ /*******************************************************************************
+ ** Get the name to use for a table in the mongoDB, from the table's backendDetails.
+ **
+ ** else, the table's name.
+ *******************************************************************************/
+ protected String getBackendTableName(QTableMetaData table)
+ {
+ if(table.getBackendDetails() != null)
+ {
+ String backendTableName = ((MongoDBTableBackendDetails) table.getBackendDetails()).getTableName();
+ if(StringUtils.hasContent(backendTableName))
+ {
+ return (backendTableName);
+ }
+ }
+ return table.getName();
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ protected int getPageSize()
+ {
+ return (1000);
+ }
+
+
+
+ /*******************************************************************************
+ ** Convert a mongodb document to a QRecord.
+ *******************************************************************************/
+ protected QRecord documentToRecord(QTableMetaData table, Document document)
+ {
+ QRecord record = new QRecord();
+ record.setTableName(table.getName());
+
+ ///////////////////////////////////////////////////////////////////////////
+ // todo - this - or iterate over the values in the document?? //
+ // seems like, maybe, this is an attribute in the table-backend-details? //
+ ///////////////////////////////////////////////////////////////////////////
+ Map values = record.getValues();
+ for(QFieldMetaData field : table.getFields().values())
+ {
+ String fieldBackendName = getFieldBackendName(field);
+ Object value = document.get(fieldBackendName);
+ String fieldName = field.getName();
+
+ setValue(values, fieldName, value);
+ }
+ return (record);
+ }
+
+
+
+ /*******************************************************************************
+ ** Recursive helper method to put a value in a map - where mongodb documents
+ ** are recursively expanded, and types are mapped to QQQ expectations.
+ *******************************************************************************/
+ private void setValue(Map values, String fieldName, Object value)
+ {
+ if(value instanceof ObjectId objectId)
+ {
+ values.put(fieldName, objectId.toString());
+ }
+ else if(value instanceof java.util.Date date)
+ {
+ values.put(fieldName, date.toInstant());
+ }
+ else if(value instanceof Document document)
+ {
+ LinkedHashMap subValues = new LinkedHashMap<>();
+ values.put(fieldName, subValues);
+
+ for(String subFieldName : document.keySet())
+ {
+ Object subValue = document.get(subFieldName);
+ setValue(subValues, subFieldName, subValue);
+ }
+ }
+ else if(value instanceof Serializable s)
+ {
+ values.put(fieldName, s);
+ }
+ else if(value != null)
+ {
+ values.put(fieldName, String.valueOf(value));
+ }
+ else
+ {
+ values.put(fieldName, null);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Convert a QRecord to a mongodb document.
+ *******************************************************************************/
+ protected Document recordToDocument(QTableMetaData table, QRecord record)
+ {
+ Document document = new Document();
+
+ ///////////////////////////////////////////////////////////////////////////
+ // todo - this - or iterate over the values in the record?? //
+ // seems like, maybe, this is an attribute in the table-backend-details? //
+ ///////////////////////////////////////////////////////////////////////////
+ for(QFieldMetaData field : table.getFields().values())
+ {
+ if(field.getName().equals(table.getPrimaryKeyField()) && record.getValue(field.getName()) == null)
+ {
+ ////////////////////////////////////
+ // let mongodb client generate id //
+ ////////////////////////////////////
+ continue;
+ }
+
+ String fieldBackendName = getFieldBackendName(field);
+ document.append(fieldBackendName, record.getValue(field.getName()));
+ }
+ return (document);
+ }
+
+
+
+ /*******************************************************************************
+ ** Convert QQueryFilter to Bson search query document - including security
+ ** for the table if needed.
+ *******************************************************************************/
+ protected Bson makeSearchQueryDocument(QTableMetaData table, QQueryFilter filter) throws QException
+ {
+ Bson searchQueryWithoutSecurity = makeSearchQueryDocumentWithoutSecurity(table, filter);
+ QQueryFilter securityFilter = makeSecurityQueryFilter(table);
+ if(!securityFilter.hasAnyCriteria())
+ {
+ return (searchQueryWithoutSecurity);
+ }
+
+ Bson searchQueryForSecurity = makeSearchQueryDocumentWithoutSecurity(table, securityFilter);
+ return (Filters.and(searchQueryWithoutSecurity, searchQueryForSecurity));
+ }
+
+
+
+ /*******************************************************************************
+ ** Build a QQueryFilter to apply record-level security to the query.
+ ** Note, it may be empty, if there are no lock fields, or all are all-access.
+ **
+ ** Originally copied from RDBMS module... should this be shared?
+ ** and/or, how big of a re-write did that get in the joins-enhancements branch...
+ *******************************************************************************/
+ private QQueryFilter makeSecurityQueryFilter(QTableMetaData table) throws QException
+ {
+ QQueryFilter securityFilter = new QQueryFilter();
+ securityFilter.setBooleanOperator(QQueryFilter.BooleanOperator.AND);
+
+ for(RecordSecurityLock recordSecurityLock : RecordSecurityLockFilters.filterForReadLocks(CollectionUtils.nonNullList(table.getRecordSecurityLocks())))
+ {
+ addSubFilterForRecordSecurityLock(QContext.getQInstance(), QContext.getQSession(), table, securityFilter, recordSecurityLock, null, table.getName(), false);
+ }
+
+ return (securityFilter);
+ }
+
+
+
+ /*******************************************************************************
+ ** Helper for makeSecuritySearchQuery.
+ **
+ ** Originally copied from RDBMS module... should this be shared?
+ ** and/or, how big of a re-write did that get in the joins-enhancements branch...
+ *******************************************************************************/
+ private static void addSubFilterForRecordSecurityLock(QInstance instance, QSession session, QTableMetaData table, QQueryFilter securityFilter, RecordSecurityLock recordSecurityLock, JoinsContext joinsContext, String tableNameOrAlias, boolean isOuter) throws QException
+ {
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // check if the key type has an all-access key, and if so, if it's set to true for the current user/session //
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ QSecurityKeyType securityKeyType = instance.getSecurityKeyType(recordSecurityLock.getSecurityKeyType());
+ if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName()))
+ {
+ if(session.hasSecurityKeyValue(securityKeyType.getAllAccessKeyName(), true, QFieldType.BOOLEAN))
+ {
+ ///////////////////////////////////////////////////////////////////////////////
+ // if we have all-access on this key, then we don't need a criterion for it. //
+ ///////////////////////////////////////////////////////////////////////////////
+ return;
+ }
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////////
+ // some differences from RDBMS here, due to not yet having joins support in mongo... //
+ ///////////////////////////////////////////////////////////////////////////////////////
+ // String fieldName = tableNameOrAlias + "." + recordSecurityLock.getFieldName();
+ String fieldName = recordSecurityLock.getFieldName();
+ if(CollectionUtils.nullSafeHasContents(recordSecurityLock.getJoinNameChain()))
+ {
+ throw (new QException("Security locks in mongodb with joinNameChain is not yet supported"));
+ // fieldName = recordSecurityLock.getFieldName();
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////////////
+ // else - get the key values from the session and decide what kind of criterion to build //
+ ///////////////////////////////////////////////////////////////////////////////////////////
+ QQueryFilter lockFilter = new QQueryFilter();
+ List lockCriteria = new ArrayList<>();
+ lockFilter.setCriteria(lockCriteria);
+
+ QFieldType type = QFieldType.INTEGER;
+ try
+ {
+ if(joinsContext == null)
+ {
+ type = table.getField(fieldName).getType();
+ }
+ else
+ {
+ JoinsContext.FieldAndTableNameOrAlias fieldAndTableNameOrAlias = joinsContext.getFieldAndTableNameOrAlias(fieldName);
+ type = fieldAndTableNameOrAlias.field().getType();
+ }
+ }
+ catch(Exception e)
+ {
+ LOG.debug("Error getting field type... Trying Integer", e);
+ }
+
+ List securityKeyValues = session.getSecurityKeyValues(recordSecurityLock.getSecurityKeyType(), type);
+ if(CollectionUtils.nullSafeIsEmpty(securityKeyValues))
+ {
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // handle user with no values -- they can only see null values, and only iff the lock's null-value behavior is ALLOW //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(recordSecurityLock.getNullValueBehavior()))
+ {
+ lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IS_BLANK));
+ }
+ else
+ {
+ /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // else, if no user/session values, and null-value behavior is deny, then setup a FALSE condition, to allow no rows. //
+ // todo - make some explicit contradiction here - maybe even avoid running the whole query - as you're not allowed ANY records //
+ /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IN, Collections.emptyList()));
+ }
+ }
+ else
+ {
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // else, if user/session has some values, build an IN rule - //
+ // noting that if the lock's null-value behavior is ALLOW, then we actually want IS_NULL_OR_IN, not just IN //
+ //////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ if(RecordSecurityLock.NullValueBehavior.ALLOW.equals(recordSecurityLock.getNullValueBehavior()))
+ {
+ lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IS_NULL_OR_IN, securityKeyValues));
+ }
+ else
+ {
+ lockCriteria.add(new QFilterCriteria(fieldName, QCriteriaOperator.IN, securityKeyValues));
+ }
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ // if this field is on the outer side of an outer join, then if we do a straight filter on it, then we're basically //
+ // nullifying the outer join... so for an outer join use-case, OR the security field criteria with a primary-key IS NULL //
+ // which will make missing rows from the join be found. //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ if(isOuter)
+ {
+ lockFilter.setBooleanOperator(QQueryFilter.BooleanOperator.OR);
+ lockFilter.addCriteria(new QFilterCriteria(tableNameOrAlias + "." + table.getPrimaryKeyField(), QCriteriaOperator.IS_BLANK));
+ }
+
+ securityFilter.addSubFilter(lockFilter);
+ }
+
+
+
+ /*******************************************************************************
+ ** w/o considering security, just map a QQueryFilter to a Bson searchQuery.
+ *******************************************************************************/
+ @SuppressWarnings("checkstyle:Indentation")
+ private Bson makeSearchQueryDocumentWithoutSecurity(QTableMetaData table, QQueryFilter filter)
+ {
+ if(filter == null || !filter.hasAnyCriteria())
+ {
+ return (new Document());
+ }
+
+ List criteriaFilters = new ArrayList<>();
+
+ for(QFilterCriteria criteria : CollectionUtils.nonNullList(filter.getCriteria()))
+ {
+ List values = criteria.getValues() == null ? new ArrayList<>() : new ArrayList<>(criteria.getValues());
+ QFieldMetaData field = table.getField(criteria.getFieldName());
+ String fieldBackendName = getFieldBackendName(field);
+
+ if(field.getName().equals(table.getPrimaryKeyField()))
+ {
+ ListIterator iterator = values.listIterator();
+ while(iterator.hasNext())
+ {
+ Serializable value = iterator.next();
+ iterator.set(new ObjectId(String.valueOf(value)));
+ }
+ }
+
+ Serializable value0 = values.get(0);
+ criteriaFilters.add(switch(criteria.getOperator())
+ {
+ case EQUALS -> Filters.eq(fieldBackendName, value0);
+ case NOT_EQUALS -> Filters.ne(fieldBackendName, value0);
+ case NOT_EQUALS_OR_IS_NULL -> Filters.or(
+ Filters.eq(fieldBackendName, null),
+ Filters.ne(fieldBackendName, value0)
+ );
+ case IN -> filterIn(fieldBackendName, values);
+ case NOT_IN -> Filters.not(filterIn(fieldBackendName, values));
+ case IS_NULL_OR_IN -> Filters.or(
+ Filters.eq(fieldBackendName, null),
+ filterIn(fieldBackendName, values)
+ );
+ case LIKE -> filterRegex(fieldBackendName, null, ValueUtils.getValueAsString(value0).replaceAll("%", ".*"), null);
+ case NOT_LIKE -> Filters.not(filterRegex(fieldBackendName, null, ValueUtils.getValueAsString(value0).replaceAll("%", ".*"), null));
+ case STARTS_WITH -> filterRegex(fieldBackendName, null, value0, ".*");
+ case ENDS_WITH -> filterRegex(fieldBackendName, ".*", value0, null);
+ case CONTAINS -> filterRegex(fieldBackendName, ".*", value0, ".*");
+ case NOT_STARTS_WITH -> Filters.not(filterRegex(fieldBackendName, null, value0, ".*"));
+ case NOT_ENDS_WITH -> Filters.not(filterRegex(fieldBackendName, ".*", value0, null));
+ case NOT_CONTAINS -> Filters.not(filterRegex(fieldBackendName, ".*", value0, ".*"));
+ case LESS_THAN -> Filters.lt(fieldBackendName, value0);
+ case LESS_THAN_OR_EQUALS -> Filters.lte(fieldBackendName, value0);
+ case GREATER_THAN -> Filters.gt(fieldBackendName, value0);
+ case GREATER_THAN_OR_EQUALS -> Filters.gte(fieldBackendName, value0);
+ case IS_BLANK -> filterIsBlank(fieldBackendName);
+ case IS_NOT_BLANK -> Filters.not(filterIsBlank(fieldBackendName));
+ case BETWEEN -> filterBetween(fieldBackendName, values);
+ case NOT_BETWEEN -> Filters.not(filterBetween(fieldBackendName, values));
+ });
+ }
+
+ /////////////////////////////////////
+ // recursively process sub-filters //
+ /////////////////////////////////////
+ if(CollectionUtils.nullSafeHasContents(filter.getSubFilters()))
+ {
+ for(QQueryFilter subFilter : filter.getSubFilters())
+ {
+ criteriaFilters.add(makeSearchQueryDocumentWithoutSecurity(table, subFilter));
+ }
+ }
+
+ Bson bson = QQueryFilter.BooleanOperator.AND.equals(filter.getBooleanOperator()) ? Filters.and(criteriaFilters) : Filters.or(criteriaFilters);
+ return bson;
+ }
+
+
+
+ /*******************************************************************************
+ ** build a bson filter doing a regex (e.g., for LIKE, STARTS_WITH, etc)
+ *******************************************************************************/
+ private Bson filterRegex(String fieldBackendName, String prefix, Serializable mainRegex, String suffix)
+ {
+ if(prefix == null)
+ {
+ prefix = "";
+ }
+
+ if(suffix == null)
+ {
+ suffix = "";
+ }
+
+ String fullRegex = prefix + Pattern.quote(ValueUtils.getValueAsString(mainRegex) + suffix);
+ return (Filters.regex(fieldBackendName, Pattern.compile(fullRegex)));
+ }
+
+
+
+ /*******************************************************************************
+ ** build a bson filter doing IN
+ *******************************************************************************/
+ private static Bson filterIn(String fieldBackendName, List values)
+ {
+ return Filters.in(fieldBackendName, values);
+ }
+
+
+
+ /*******************************************************************************
+ ** build a bson filter doing BETWEEN
+ *******************************************************************************/
+ private static Bson filterBetween(String fieldBackendName, List values)
+ {
+ return Filters.and(
+ Filters.gte(fieldBackendName, values.get(0)),
+ Filters.lte(fieldBackendName, values.get(1))
+ );
+ }
+
+
+
+ /*******************************************************************************
+ ** build a bson filter doing BLANK (null or == "")
+ *******************************************************************************/
+ private static Bson filterIsBlank(String fieldBackendName)
+ {
+ return Filters.or(
+ Filters.eq(fieldBackendName, null),
+ Filters.eq(fieldBackendName, "")
+ );
+ }
+}
diff --git a/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoClientContainer.java b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoClientContainer.java
new file mode 100644
index 00000000..61289a46
--- /dev/null
+++ b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoClientContainer.java
@@ -0,0 +1,158 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2024. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.module.mongodb.actions;
+
+
+import com.mongodb.client.ClientSession;
+import com.mongodb.client.MongoClient;
+
+
+/*******************************************************************************
+ ** Wrapper around a MongoClient, ClientSession, and a boolean to help signal
+ ** where it was opened (e.g., so you know if you need to close it yourself, or
+ ** if it came from someone else (e.g., via an input transaction)).
+ *******************************************************************************/
+public class MongoClientContainer
+{
+ private MongoClient mongoClient;
+ private ClientSession mongoSession;
+ private boolean needToClose;
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public MongoClientContainer(MongoClient mongoClient, ClientSession mongoSession, boolean needToClose)
+ {
+ this.mongoClient = mongoClient;
+ this.mongoSession = mongoSession;
+ this.needToClose = needToClose;
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for mongoClient
+ *******************************************************************************/
+ public MongoClient getMongoClient()
+ {
+ return (this.mongoClient);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for mongoClient
+ *******************************************************************************/
+ public void setMongoClient(MongoClient mongoClient)
+ {
+ this.mongoClient = mongoClient;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for mongoClient
+ *******************************************************************************/
+ public MongoClientContainer withMongoClient(MongoClient mongoClient)
+ {
+ this.mongoClient = mongoClient;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for mongoSession
+ *******************************************************************************/
+ public ClientSession getMongoSession()
+ {
+ return (this.mongoSession);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for mongoSession
+ *******************************************************************************/
+ public void setMongoSession(ClientSession mongoSession)
+ {
+ this.mongoSession = mongoSession;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for mongoSession
+ *******************************************************************************/
+ public MongoClientContainer withMongoSession(ClientSession mongoSession)
+ {
+ this.mongoSession = mongoSession;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ ** Getter for needToClose
+ *******************************************************************************/
+ public boolean getNeedToClose()
+ {
+ return (this.needToClose);
+ }
+
+
+
+ /*******************************************************************************
+ ** Setter for needToClose
+ *******************************************************************************/
+ public void setNeedToClose(boolean needToClose)
+ {
+ this.needToClose = needToClose;
+ }
+
+
+
+ /*******************************************************************************
+ ** Fluent setter for needToClose
+ *******************************************************************************/
+ public MongoClientContainer withNeedToClose(boolean needToClose)
+ {
+ this.needToClose = needToClose;
+ return (this);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public void closeIfNeeded()
+ {
+ if(needToClose)
+ {
+ mongoSession.close();
+ mongoClient.close();
+ }
+ }
+}
diff --git a/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBAggregateAction.java b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBAggregateAction.java
new file mode 100644
index 00000000..60b34fde
--- /dev/null
+++ b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBAggregateAction.java
@@ -0,0 +1,251 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.module.mongodb.actions;
+
+
+import java.util.ArrayList;
+import java.util.List;
+import com.kingsrook.qqq.backend.core.actions.interfaces.AggregateInterface;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOutput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.QFilterOrderByAggregate;
+import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.QFilterOrderByGroupBy;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
+import com.kingsrook.qqq.backend.core.utils.ValueUtils;
+import com.kingsrook.qqq.backend.module.mongodb.MongoDBBackendModule;
+import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBBackendMetaData;
+import com.mongodb.client.AggregateIterable;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.MongoDatabase;
+import com.mongodb.client.model.Accumulators;
+import com.mongodb.client.model.Aggregates;
+import com.mongodb.client.model.BsonField;
+import org.bson.Document;
+import org.bson.conversions.Bson;
+
+
+/*******************************************************************************
+ **
+ *******************************************************************************/
+public class MongoDBAggregateAction extends AbstractMongoDBAction implements AggregateInterface
+{
+ private static final QLogger LOG = QLogger.getLogger(MongoDBBackendModule.class);
+
+ // todo? private ActionTimeoutHelper actionTimeoutHelper;
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @SuppressWarnings("checkstyle:indentation")
+ public AggregateOutput execute(AggregateInput aggregateInput) throws QException
+ {
+ MongoClientContainer mongoClientContainer = null;
+
+ try
+ {
+ AggregateOutput aggregateOutput = new AggregateOutput();
+ QTableMetaData table = aggregateInput.getTable();
+ String backendTableName = getBackendTableName(table);
+ MongoDBBackendMetaData backend = (MongoDBBackendMetaData) aggregateInput.getBackend();
+
+ mongoClientContainer = openClient(backend, null); // todo - aggregate input has no transaction!?
+ MongoDatabase database = mongoClientContainer.getMongoClient().getDatabase(backend.getDatabaseName());
+ MongoCollection collection = database.getCollection(backendTableName);
+
+ QQueryFilter filter = aggregateInput.getFilter();
+ Bson searchQuery = makeSearchQueryDocument(table, filter);
+
+ /////////////////////////////////////////////////////////////////////////
+ // we have to submit a list of BSON objects to the aggregate function. //
+ // the first one is the search query //
+ // second is the group-by stuff, which we'll explain as we build it //
+ /////////////////////////////////////////////////////////////////////////
+ List bsonList = new ArrayList<>();
+ bsonList.add(Aggregates.match(searchQuery));
+
+ //////////////////////////////////////////////////////////////////////////////////////
+ // if there are group-by fields, then we need to build a document with those fields //
+ // not sure what the whole name, $name is, but, go go mongo //
+ //////////////////////////////////////////////////////////////////////////////////////
+ Document groupValueDocument = new Document();
+ if(CollectionUtils.nullSafeHasContents(aggregateInput.getGroupBys()))
+ {
+ for(GroupBy groupBy : aggregateInput.getGroupBys())
+ {
+ String name = getFieldBackendName(table.getField(groupBy.getFieldName()));
+ groupValueDocument.append(name, "$" + name);
+ }
+ }
+
+ ////////////////////////////////////////////////////////////////////
+ // next build a list of accumulator fields - for aggregate values //
+ ////////////////////////////////////////////////////////////////////
+ List bsonFields = new ArrayList<>();
+ for(Aggregate aggregate : aggregateInput.getAggregates())
+ {
+ String fieldName = aggregate.getFieldName() + "_" + aggregate.getOperator().toString().toLowerCase();
+ String expression = "$" + getFieldBackendName(table.getField(aggregate.getFieldName()));
+
+ bsonFields.add(switch(aggregate.getOperator())
+ {
+ case COUNT -> Accumulators.sum(fieldName, 1); // count... do a sum of 1's
+ case COUNT_DISTINCT -> throw new QException("Count Distinct is not supported for MongoDB tables at this time.");
+ case SUM -> Accumulators.sum(fieldName, expression);
+ case MIN -> Accumulators.min(fieldName, expression);
+ case MAX -> Accumulators.max(fieldName, expression);
+ case AVG -> Accumulators.avg(fieldName, expression);
+ });
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////
+ // add the group-by fields and the aggregates in the group stage of the pipeline //
+ ///////////////////////////////////////////////////////////////////////////////////
+ bsonList.add(Aggregates.group(groupValueDocument, bsonFields));
+
+ //////////////////////////////////////////////
+ // if there are any order-bys, add them too //
+ //////////////////////////////////////////////
+ if(filter != null && CollectionUtils.nullSafeHasContents(filter.getOrderBys()))
+ {
+ Document sortValue = new Document();
+ for(QFilterOrderBy orderBy : filter.getOrderBys())
+ {
+ String fieldName;
+ if(orderBy instanceof QFilterOrderByAggregate orderByAggregate)
+ {
+ Aggregate aggregate = orderByAggregate.getAggregate();
+ fieldName = aggregate.getFieldName() + "_" + aggregate.getOperator().toString().toLowerCase();
+ }
+ else if(orderBy instanceof QFilterOrderByGroupBy orderByGroupBy)
+ {
+ fieldName = "_id." + getFieldBackendName(table.getField(orderByGroupBy.getGroupBy().getFieldName()));
+ }
+ else
+ {
+ ///////////////////////////////////////////////////
+ // does this happen? should it be "_id." if so? //
+ ///////////////////////////////////////////////////
+ fieldName = getFieldBackendName(table.getField(orderBy.getFieldName()));
+ }
+
+ sortValue.append(fieldName, orderBy.getIsAscending() ? 1 : -1);
+ }
+
+ bsonList.add(new Document("$sort", sortValue));
+ }
+
+ ////////////////////////////////////////////////////////
+ // todo - system property to control (like print-sql) //
+ ////////////////////////////////////////////////////////
+ // LOG.debug(bsonList.toString());
+
+ ///////////////////////////
+ // execute the aggregate //
+ ///////////////////////////
+ AggregateIterable aggregates = collection.aggregate(mongoClientContainer.getMongoSession(), bsonList);
+
+ List results = new ArrayList<>();
+ aggregateOutput.setResults(results);
+
+ /////////////////////
+ // process results //
+ /////////////////////
+ for(Document document : aggregates)
+ {
+ AggregateResult result = new AggregateResult();
+ results.add(result);
+
+ ////////////////////////////////////////////////////////////////
+ // get group by values (if there are any) out of the document //
+ ////////////////////////////////////////////////////////////////
+ for(GroupBy groupBy : CollectionUtils.nonNullList(aggregateInput.getGroupBys()))
+ {
+ Document idDocument = (Document) document.get("_id");
+ Object value = idDocument.get(groupBy.getFieldName());
+ result.withGroupByValue(groupBy, ValueUtils.getValueAsFieldType(groupBy.getType(), value));
+ }
+
+ //////////////////////////////////////////
+ // get aggregate values out of document //
+ //////////////////////////////////////////
+ for(Aggregate aggregate : aggregateInput.getAggregates())
+ {
+ QFieldMetaData field = table.getField(aggregate.getFieldName());
+ QFieldType fieldType = aggregate.getFieldType();
+ if(fieldType == null)
+ {
+ fieldType = field.getType();
+ }
+ if(fieldType.equals(QFieldType.INTEGER) && (aggregate.getOperator().equals(AggregateOperator.AVG)))
+ {
+ fieldType = QFieldType.DECIMAL;
+ }
+
+ Object value = document.get(aggregate.getFieldName() + "_" + aggregate.getOperator().toString().toLowerCase());
+ result.withAggregateValue(aggregate, ValueUtils.getValueAsFieldType(fieldType, value));
+ }
+ }
+
+ return (aggregateOutput);
+ }
+ catch(Exception e)
+ {
+ /*
+ if(actionTimeoutHelper != null && actionTimeoutHelper.getDidTimeout())
+ {
+ setCountStatFirstResultTime();
+ throw (new QUserFacingException("Aggregate timed out."));
+ }
+
+ if(isCancelled)
+ {
+ throw (new QUserFacingException("Aggregate was cancelled."));
+ }
+ */
+
+ LOG.warn("Error executing aggregate", e);
+ throw new QException("Error executing aggregate", e);
+ }
+ finally
+
+ {
+ if(mongoClientContainer != null)
+ {
+ mongoClientContainer.closeIfNeeded();
+ }
+ }
+ }
+
+}
diff --git a/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBCountAction.java b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBCountAction.java
new file mode 100644
index 00000000..277977a7
--- /dev/null
+++ b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBCountAction.java
@@ -0,0 +1,119 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.module.mongodb.actions;
+
+
+import java.util.List;
+import com.kingsrook.qqq.backend.core.actions.interfaces.CountInterface;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.module.mongodb.MongoDBBackendModule;
+import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBBackendMetaData;
+import com.mongodb.client.AggregateIterable;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.MongoDatabase;
+import com.mongodb.client.model.Accumulators;
+import com.mongodb.client.model.Aggregates;
+import org.bson.Document;
+import org.bson.conversions.Bson;
+
+
+/*******************************************************************************
+ **
+ *******************************************************************************/
+public class MongoDBCountAction extends AbstractMongoDBAction implements CountInterface
+{
+ private static final QLogger LOG = QLogger.getLogger(MongoDBBackendModule.class);
+
+ // todo? private ActionTimeoutHelper actionTimeoutHelper;
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public CountOutput execute(CountInput countInput) throws QException
+ {
+ MongoClientContainer mongoClientContainer = null;
+
+ try
+ {
+ CountOutput countOutput = new CountOutput();
+ QTableMetaData table = countInput.getTable();
+ String backendTableName = getBackendTableName(table);
+ MongoDBBackendMetaData backend = (MongoDBBackendMetaData) countInput.getBackend();
+
+ mongoClientContainer = openClient(backend, null); // todo - count input has no transaction!?
+ MongoDatabase database = mongoClientContainer.getMongoClient().getDatabase(backend.getDatabaseName());
+ MongoCollection collection = database.getCollection(backendTableName);
+
+ QQueryFilter filter = countInput.getFilter();
+ Bson searchQuery = makeSearchQueryDocument(table, filter);
+
+ List bsonList = List.of(
+ Aggregates.match(searchQuery),
+ Aggregates.group("_id", Accumulators.sum("count", 1)));
+
+ ////////////////////////////////////////////////////////
+ // todo - system property to control (like print-sql) //
+ ////////////////////////////////////////////////////////
+ // LOG.debug(bsonList.toString());
+
+ AggregateIterable aggregate = collection.aggregate(mongoClientContainer.getMongoSession(), bsonList);
+
+ Document document = aggregate.first();
+ countOutput.setCount(document == null ? 0 : document.get("count", Integer.class));
+
+ return (countOutput);
+ }
+ catch(Exception e)
+ {
+ /*
+ if(actionTimeoutHelper != null && actionTimeoutHelper.getDidTimeout())
+ {
+ setCountStatFirstResultTime();
+ throw (new QUserFacingException("Count timed out."));
+ }
+
+ if(isCancelled)
+ {
+ throw (new QUserFacingException("Count was cancelled."));
+ }
+ */
+
+ LOG.warn("Error executing count", e);
+ throw new QException("Error executing count", e);
+ }
+ finally
+ {
+ if(mongoClientContainer != null)
+ {
+ mongoClientContainer.closeIfNeeded();
+ }
+ }
+ }
+
+}
diff --git a/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBDeleteAction.java b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBDeleteAction.java
new file mode 100644
index 00000000..54806601
--- /dev/null
+++ b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBDeleteAction.java
@@ -0,0 +1,129 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.module.mongodb.actions;
+
+
+import com.kingsrook.qqq.backend.core.actions.interfaces.DeleteInterface;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
+import com.kingsrook.qqq.backend.core.utils.ValueUtils;
+import com.kingsrook.qqq.backend.module.mongodb.MongoDBBackendModule;
+import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBBackendMetaData;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.MongoDatabase;
+import com.mongodb.client.model.Filters;
+import com.mongodb.client.result.DeleteResult;
+import org.bson.Document;
+import org.bson.conversions.Bson;
+import org.bson.types.ObjectId;
+import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
+
+
+/*******************************************************************************
+ **
+ *******************************************************************************/
+public class MongoDBDeleteAction extends AbstractMongoDBAction implements DeleteInterface
+{
+ private static final QLogger LOG = QLogger.getLogger(MongoDBBackendModule.class);
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ @Override
+ public boolean supportsQueryFilterInput()
+ {
+ return (true);
+ }
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public DeleteOutput execute(DeleteInput deleteInput) throws QException
+ {
+ MongoClientContainer mongoClientContainer = null;
+
+ try
+ {
+ DeleteOutput deleteOutput = new DeleteOutput();
+ QTableMetaData table = deleteInput.getTable();
+ String backendTableName = getBackendTableName(table);
+ MongoDBBackendMetaData backend = (MongoDBBackendMetaData) deleteInput.getBackend();
+
+ mongoClientContainer = openClient(backend, deleteInput.getTransaction());
+ MongoDatabase database = mongoClientContainer.getMongoClient().getDatabase(backend.getDatabaseName());
+ MongoCollection collection = database.getCollection(backendTableName);
+
+ QQueryFilter queryFilter = deleteInput.getQueryFilter();
+ Bson searchQuery;
+ if(CollectionUtils.nullSafeHasContents(deleteInput.getPrimaryKeys()))
+ {
+ searchQuery = Filters.in("_id", deleteInput.getPrimaryKeys().stream().map(id -> new ObjectId(ValueUtils.getValueAsString(id))).toList());
+ }
+ else if(queryFilter != null && queryFilter.hasAnyCriteria())
+ {
+ QQueryFilter filter = queryFilter;
+ searchQuery = makeSearchQueryDocument(table, filter);
+ }
+ else
+ {
+ LOG.info("Missing both primary keys and a search filter in delete request - exiting with noop", logPair("tableName", table.getName()));
+ return (deleteOutput);
+ }
+
+ ////////////////////////////////////////////////////////
+ // todo - system property to control (like print-sql) //
+ ////////////////////////////////////////////////////////
+ // LOG.debug(searchQuery);
+
+ DeleteResult deleteResult = collection.deleteMany(mongoClientContainer.getMongoSession(), searchQuery);
+ deleteOutput.setDeletedRecordCount((int) deleteResult.getDeletedCount());
+
+ //////////////////////////////////////////////////////////////////////////
+ // todo any way to get records with errors or warnings for deleteOutput //
+ //////////////////////////////////////////////////////////////////////////
+
+ return (deleteOutput);
+ }
+ catch(Exception e)
+ {
+ LOG.warn("Error executing delete", e);
+ throw new QException("Error executing delete", e);
+ }
+ finally
+ {
+ if(mongoClientContainer != null)
+ {
+ mongoClientContainer.closeIfNeeded();
+ }
+ }
+ }
+
+}
diff --git a/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBInsertAction.java b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBInsertAction.java
new file mode 100644
index 00000000..fe89411f
--- /dev/null
+++ b/qqq-backend-module-mongodb/src/main/java/com/kingsrook/qqq/backend/module/mongodb/actions/MongoDBInsertAction.java
@@ -0,0 +1,266 @@
+/*
+ * QQQ - Low-code Application Framework for Engineers.
+ * Copyright (C) 2021-2022. Kingsrook, LLC
+ * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
+ * contact@kingsrook.com
+ * https://github.com/Kingsrook/
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package com.kingsrook.qqq.backend.module.mongodb.actions;
+
+
+import java.util.ArrayList;
+import java.util.List;
+import com.kingsrook.qqq.backend.core.actions.interfaces.InsertInterface;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
+import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
+import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
+import com.kingsrook.qqq.backend.module.mongodb.model.metadata.MongoDBBackendMetaData;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.MongoDatabase;
+import com.mongodb.client.result.InsertManyResult;
+import org.bson.BsonValue;
+import org.bson.Document;
+
+
+/*******************************************************************************
+ **
+ *******************************************************************************/
+public class MongoDBInsertAction extends AbstractMongoDBAction implements InsertInterface
+{
+ private static final QLogger LOG = QLogger.getLogger(MongoDBInsertAction.class);
+
+
+
+ /*******************************************************************************
+ **
+ *******************************************************************************/
+ public InsertOutput execute(InsertInput insertInput) throws QException
+ {
+ MongoClientContainer mongoClientContainer = null;
+ InsertOutput rs = new InsertOutput();
+ List outputRecords = new ArrayList<>();
+ rs.setRecords(outputRecords);
+
+ try
+ {
+ QTableMetaData table = insertInput.getTable();
+ String backendTableName = getBackendTableName(table);
+ MongoDBBackendMetaData backend = (MongoDBBackendMetaData) insertInput.getBackend();
+
+ mongoClientContainer = openClient(backend, insertInput.getTransaction());
+ MongoDatabase database = mongoClientContainer.getMongoClient().getDatabase(backend.getDatabaseName());
+ MongoCollection collection = database.getCollection(backendTableName);
+
+ //////////////////////////
+ // todo - transaction?! //
+ //////////////////////////
+
+ ///////////////////////////////////////////////////////////////////////////
+ // page over input record list (assuming some size of batch is too big?) //
+ ///////////////////////////////////////////////////////////////////////////
+ for(List page : CollectionUtils.getPages(insertInput.getRecords(), getPageSize()))
+ {
+ //////////////////////////////////////////////////////////////////
+ // build list of documents from records w/o errors in this page //
+ //////////////////////////////////////////////////////////////////
+ List documentList = new ArrayList<>();
+ for(QRecord record : page)
+ {
+ if(CollectionUtils.nullSafeHasContents(record.getErrors()))
+ {
+ continue;
+ }
+ documentList.add(recordToDocument(table, record));
+ }
+
+ /////////////////////////////////////
+ // skip pages that were all errors //
+ /////////////////////////////////////
+ if(documentList.isEmpty())
+ {
+ continue;
+ }
+
+ ////////////////////////////////////////////////////////
+ // todo - system property to control (like print-sql) //
+ ////////////////////////////////////////////////////////
+ // LOG.debug(documentList);
+
+ ///////////////////////////////////////////////
+ // actually do the insert //
+ // todo - how are errors returned by mongo?? //
+ ///////////////////////////////////////////////
+ InsertManyResult insertManyResult = collection.insertMany(mongoClientContainer.getMongoSession(), documentList);
+
+ /////////////////////////////////
+ // put ids on inserted records //
+ /////////////////////////////////
+ int index = 0;
+ for(QRecord record : page)
+ {
+ QRecord outputRecord = new QRecord(record);
+ rs.addRecord(outputRecord);
+
+ if(CollectionUtils.nullSafeIsEmpty(record.getErrors()))
+ {
+ BsonValue insertedId = insertManyResult.getInsertedIds().get(index++);
+ String idString = insertedId.asObjectId().getValue().toString();
+ outputRecord.setValue(table.getPrimaryKeyField(), idString);
+ }
+ }
+ }
+ }
+ catch(Exception e)
+ {
+ throw new QException("Error executing insert: " + e.getMessage(), e);
+ }
+ finally
+ {
+ if(mongoClientContainer != null)
+ {
+ mongoClientContainer.closeIfNeeded();
+ }
+ }
+
+ return (rs);
+
+ /*
+ try
+ {
+ List insertableFields = table.getFields().values().stream()
+ .filter(field -> !field.getName().equals("id")) // todo - intent here is to avoid non-insertable fields.
+ .toList();
+
+ String columns = insertableFields.stream()
+ .map(f -> "`" + getColumnName(f) + "`")
+ .collect(Collectors.joining(", "));
+ String questionMarks = insertableFields.stream()
+ .map(x -> "?")
+ .collect(Collectors.joining(", "));
+
+ List outputRecords = new ArrayList<>();
+ rs.setRecords(outputRecords);
+
+ Connection connection;
+ boolean needToCloseConnection = false;
+ if(insertInput.getTransaction() != null && insertInput.getTransaction() instanceof RDBMSTransaction rdbmsTransaction)
+ {
+ connection = rdbmsTransaction.getConnection();
+ }
+ else
+ {
+ connection = getConnection(insertInput);
+ needToCloseConnection = true;
+ }
+
+ try
+ {
+ for(List page : CollectionUtils.getPages(insertInput.getRecords(), QueryManager.PAGE_SIZE))
+ {
+ String tableName = escapeIdentifier(getTableName(table));
+ StringBuilder sql = new StringBuilder("INSERT INTO ").append(tableName).append("(").append(columns).append(") VALUES");
+ List