diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/GenericDataService.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/GenericDataService.java index 44a24f1f6..c0dffeb49 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/GenericDataService.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/GenericDataService.java @@ -31,6 +31,8 @@ public interface GenericDataService { List fillResultsetRowData(String sql, List columnHeaders); + List fillResultsetRowData(String sql, List columnHeaders, Object... args); + String generateJsonFromGenericResultsetData(GenericResultsetData grs); String replace(String str, String pattern, String replace); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableReadServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableReadServiceImpl.java index b7ee53911..bb6b9c521 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableReadServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableReadServiceImpl.java @@ -263,8 +263,8 @@ public class DatatableReadServiceImpl implements DatatableReadService { @Override public Long countDatatableEntries(final String datatableName, final Long appTableId, String foreignKeyColumn) { final String sqlString = "SELECT COUNT(" + sqlGenerator.escape(foreignKeyColumn) + ") FROM " + sqlGenerator.escape(datatableName) - + " WHERE " + sqlGenerator.escape(foreignKeyColumn) + " = " + appTableId; - return this.jdbcTemplate.queryForObject(sqlString, Long.class); // NOSONAR + + " WHERE " + sqlGenerator.escape(foreignKeyColumn) + " = ?"; + return this.jdbcTemplate.queryForObject(sqlString, Long.class, appTableId); // NOSONAR } @Override diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableUtil.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableUtil.java index c19e3f93c..7c913f437 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableUtil.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableUtil.java @@ -23,6 +23,7 @@ import static org.apache.fineract.infrastructure.dataqueries.api.DataTableApiCon import static org.apache.fineract.infrastructure.dataqueries.api.DataTableApiConstant.TABLE_FIELD_ID; import static org.apache.fineract.infrastructure.dataqueries.api.DataTableApiConstant.TABLE_REGISTERED_TABLE; +import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -112,9 +113,10 @@ public class DatatableUtil { } public CommandProcessingResult checkMainResourceExistsWithinScope(@NonNull EntityTables entityTable, final Long appTableId) { - final String sql = dataScopedSQL(entityTable, appTableId); + final List params = new ArrayList<>(); + final String sql = dataScopedSQL(entityTable, appTableId, params); log.debug("data scoped sql: {}", sql); - final SqlRowSet rs = this.jdbcTemplate.queryForRowSet(sql); + final SqlRowSet rs = this.jdbcTemplate.queryForRowSet(sql, params.toArray()); if (!rs.next()) { throw new DatatableNotFoundException(entityTable, appTableId); @@ -140,60 +142,88 @@ public class DatatableUtil { .build(); } - public String dataScopedSQL(@NonNull EntityTables entityTable, final Long appTableId) { + public String dataScopedSQL(@NonNull EntityTables entityTable, final Long appTableId, final List params) { // unfortunately have to, one way or another, be able to restrict data to the users office hierarchy. Here, a // few key tables are done. But if additional fields are needed on other tables the same pattern applies final AppUser currentUser = this.context.authenticatedUser(); String officeHierarchy = currentUser.getOffice().getHierarchy(); + String hierarchyPattern = officeHierarchy + "%"; // m_loan and m_savings_account are connected to an m_office through either an m_client or an m_group If both it // means it relates to an m_client that is in a group (still an m_client account) return switch (entityTable) { - case LOAN -> "select distinct x.* from ( " - + "(select o.id as officeId, l.group_id as groupId, l.client_id as clientId, null as savingsId, l.id as loanId, null as transactionId, null as entityId from m_loan l " - + getClientOfficeJoinCondition(officeHierarchy, "l") + " where l.id = " + appTableId + ")" + " union all " - + "(select o.id as officeId, l.group_id as groupId, l.client_id as clientId, null as savingsId, l.id as loanId, null as transactionId, null as entityId from m_loan l " - + getGroupOfficeJoinCondition(officeHierarchy, "l") + " where l.id = " + appTableId + ")" + " ) as x"; - case SAVINGS -> "select distinct x.* from ( " - + "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, null as transactionId, null as entityId " - + "from m_savings_account s " + getClientOfficeJoinCondition(officeHierarchy, "s") + " where s.id = " + appTableId + ")" - + " union all " - + "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, null as transactionId, null as entityId " - + "from m_savings_account s " + getGroupOfficeJoinCondition(officeHierarchy, "s") + " where s.id = " + appTableId + ")" - + " ) as x"; - case SAVINGS_TRANSACTION -> "select distinct x.* from ( " - + "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, t.id as transactionId, null as entityId " - + "from m_savings_account_transaction t join m_savings_account s on t.savings_account_id = s.id " - + getClientOfficeJoinCondition(officeHierarchy, "s") + " where t.id = " + appTableId + ")" + " union all " - + "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, t.id as transactionId, null as entityId " - + "from m_savings_account_transaction t join m_savings_account s on t.savings_account_id = s.id " - + getGroupOfficeJoinCondition(officeHierarchy, "s") + " where t.id = " + appTableId + ")" + " ) as x"; - case CLIENT -> - "select o.id as officeId, null as groupId, c.id as clientId, null as savingsId, null as loanId, null as transactionId, null as entityId from m_client c " - + getOfficeJoinCondition(officeHierarchy, "c") + " where c.id = " + appTableId; - case GROUP, CENTER -> - "select o.id as officeId, g.id as groupId, null as clientId, null as savingsId, null as loanId, null as transactionId, null as entityId from m_group g " - + getOfficeJoinCondition(officeHierarchy, "g") + " where g.id = " + appTableId; - case OFFICE -> - "select o.id as officeId, null as groupId, null as clientId, null as savingsId, null as loanId, null as transactionId, null as entityId from m_office o " - + "where o.hierarchy like '" + officeHierarchy + "%'" + " and o.id = " + appTableId; - case LOAN_PRODUCT, SAVINGS_PRODUCT, SHARE_PRODUCT -> - "select null as officeId, null as groupId, null as clientId, null as savingsId, null as loanId, null as transactionId, p.id as entityId from " - + entityTable.getName() + " as p WHERE p.id = " + appTableId; + case LOAN -> { + params.add(hierarchyPattern); + params.add(appTableId); + params.add(hierarchyPattern); + params.add(appTableId); + yield "select distinct x.* from ( " + + "(select o.id as officeId, l.group_id as groupId, l.client_id as clientId, null as savingsId, l.id as loanId, null as transactionId, null as entityId from m_loan l " + + getClientOfficeJoinCondition("l") + " where l.id = ?)" + " union all " + + "(select o.id as officeId, l.group_id as groupId, l.client_id as clientId, null as savingsId, l.id as loanId, null as transactionId, null as entityId from m_loan l " + + getGroupOfficeJoinCondition("l") + " where l.id = ?)" + " ) as x"; + } + case SAVINGS -> { + params.add(hierarchyPattern); + params.add(appTableId); + params.add(hierarchyPattern); + params.add(appTableId); + yield "select distinct x.* from ( " + + "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, null as transactionId, null as entityId " + + "from m_savings_account s " + getClientOfficeJoinCondition("s") + " where s.id = ?)" + " union all " + + "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, null as transactionId, null as entityId " + + "from m_savings_account s " + getGroupOfficeJoinCondition("s") + " where s.id = ?)" + " ) as x"; + } + case SAVINGS_TRANSACTION -> { + params.add(hierarchyPattern); + params.add(appTableId); + params.add(hierarchyPattern); + params.add(appTableId); + yield "select distinct x.* from ( " + + "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, t.id as transactionId, null as entityId " + + "from m_savings_account_transaction t join m_savings_account s on t.savings_account_id = s.id " + + getClientOfficeJoinCondition("s") + " where t.id = ?)" + " union all " + + "(select o.id as officeId, s.group_id as groupId, s.client_id as clientId, s.id as savingsId, null as loanId, t.id as transactionId, null as entityId " + + "from m_savings_account_transaction t join m_savings_account s on t.savings_account_id = s.id " + + getGroupOfficeJoinCondition("s") + " where t.id = ?)" + " ) as x"; + } + case CLIENT -> { + params.add(hierarchyPattern); + params.add(appTableId); + yield "select o.id as officeId, null as groupId, c.id as clientId, null as savingsId, null as loanId, null as transactionId, null as entityId from m_client c " + + getOfficeJoinCondition("c") + " where c.id = ?"; + } + case GROUP, CENTER -> { + params.add(hierarchyPattern); + params.add(appTableId); + yield "select o.id as officeId, g.id as groupId, null as clientId, null as savingsId, null as loanId, null as transactionId, null as entityId from m_group g " + + getOfficeJoinCondition("g") + " where g.id = ?"; + } + case OFFICE -> { + params.add(hierarchyPattern); + params.add(appTableId); + yield "select o.id as officeId, null as groupId, null as clientId, null as savingsId, null as loanId, null as transactionId, null as entityId from m_office o " + + "where o.hierarchy like ? and o.id = ?"; + } + case LOAN_PRODUCT, SAVINGS_PRODUCT, SHARE_PRODUCT -> { + params.add(appTableId); + yield "select null as officeId, null as groupId, null as clientId, null as savingsId, null as loanId, null as transactionId, p.id as entityId from " + + entityTable.getName() + " as p WHERE p.id = ?"; + } default -> throw new PlatformDataIntegrityException("error.msg.invalid.dataScopeCriteria", "Application Table: " + entityTable.getName() + " not catered for in data Scoping"); }; } - public String getOfficeJoinCondition(String officeHierarchy, String joinTableAlias) { - return " join m_office o on o.id = " + joinTableAlias + ".office_id and o.hierarchy like '" + officeHierarchy + "%' "; + public String getOfficeJoinCondition(String joinTableAlias) { + return " join m_office o on o.id = " + joinTableAlias + ".office_id and o.hierarchy like ? "; } - public String getGroupOfficeJoinCondition(String officeHierarchy, String appTableAlias) { - return " join m_group g on g.id = " + appTableAlias + ".group_id " + getOfficeJoinCondition(officeHierarchy, "g"); + public String getGroupOfficeJoinCondition(String appTableAlias) { + return " join m_group g on g.id = " + appTableAlias + ".group_id " + getOfficeJoinCondition("g"); } - public String getClientOfficeJoinCondition(String officeHierarchy, String appTableAlias) { - return " join m_client c on c.id = " + appTableAlias + ".client_id " + getOfficeJoinCondition(officeHierarchy, "c"); + public String getClientOfficeJoinCondition(String appTableAlias) { + return " join m_client c on c.id = " + appTableAlias + ".client_id " + getOfficeJoinCondition("c"); } public GenericResultsetData retrieveDataTableGenericResultSet(final EntityTables entityTable, final String dataTableName, @@ -201,20 +231,21 @@ public class DatatableUtil { final List columnHeaders = genericDataService.fillResultsetColumnHeaders(dataTableName); final boolean multiRow = isMultirowDatatable(columnHeaders); - String whereClause = getFKField(entityTable) + " = " + appTableId; - sqlValidator.validate(whereClause); - String sql = "select * from " + sqlGenerator.escape(dataTableName) + " where " + whereClause; + final List params = new ArrayList<>(); + params.add(appTableId); + String sql = "select * from " + sqlGenerator.escape(dataTableName) + " where " + getFKField(entityTable) + " = ?"; // id only used for reading a specific entry that belongs to appTableId (in a one to many datatable) if (multiRow && id != null) { - sql = sql + " and " + TABLE_FIELD_ID + " = " + id; + sql = sql + " and " + TABLE_FIELD_ID + " = ?"; + params.add(id); } if (StringUtils.isNotBlank(order)) { columnValidator.validateSqlInjection(sql, order); sql = sql + " order by " + order; } - final List result = genericDataService.fillResultsetRowData(sql, columnHeaders); + final List result = genericDataService.fillResultsetRowData(sql, columnHeaders, params.toArray()); return new GenericResultsetData(columnHeaders, result); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableWriteServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableWriteServiceImpl.java index ae4c0db88..5c07474ea 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableWriteServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableWriteServiceImpl.java @@ -152,9 +152,7 @@ public class DatatableWriteServiceImpl implements DatatableWriteService { @Override public void registerDatatable(final String dataTableName, final String entityName, final String entitySubType) { Integer category = DataTableApiConstant.CATEGORY_DEFAULT; - - final String permissionSql = this.getPermissionSql(dataTableName); - this.registerDataTable(entityName, dataTableName, entitySubType, category, permissionSql); + this.registerDataTable(entityName, dataTableName, entitySubType, category); } @Transactional @@ -167,13 +165,13 @@ public class DatatableWriteServiceImpl implements DatatableWriteService { Integer category = this.getCategory(command); this.dataTableValidator.validateDataTableRegistration(command.json()); - final String permissionSql = this.getPermissionSql(dataTableName); - this.registerDataTable(applicationTableName, dataTableName, entitySubType, category, permissionSql); + this.registerDataTable(applicationTableName, dataTableName, entitySubType, category); } @Transactional @Override public void registerDatatable(final JsonCommand command, final String permissionSql) { + // permissionSql parameter is kept for interface compatibility but ignored in favor of parameterized queries final String applicationTableName = datatableReadService.getTableName(command.getUrl()); final String dataTableName = datatableReadService.getDataTableName(command.getUrl()); final String entitySubType = command.stringValueOfParameterNamed(ENTITY_SUB_TYPE); @@ -182,30 +180,23 @@ public class DatatableWriteServiceImpl implements DatatableWriteService { this.dataTableValidator.validateDataTableRegistration(command.json()); - this.registerDataTable(applicationTableName, dataTableName, entitySubType, category, permissionSql); + this.registerDataTable(applicationTableName, dataTableName, entitySubType, category); } @Transactional @Override public void deregisterDatatable(final String datatable) { datatableUtil.validateDatatableRegistered(datatable); - final String permissionList = "('CREATE_" + datatable + "', 'CREATE_" + datatable + "_CHECKER', 'READ_" + datatable + "', 'UPDATE_" - + datatable + "', 'UPDATE_" + datatable + "_CHECKER', 'DELETE_" + datatable + "', 'DELETE_" + datatable + "_CHECKER')"; + final Object[] permissionCodes = { "CREATE_" + datatable, "CREATE_" + datatable + "_CHECKER", "READ_" + datatable, + "UPDATE_" + datatable, "UPDATE_" + datatable + "_CHECKER", "DELETE_" + datatable, "DELETE_" + datatable + "_CHECKER" }; + final String placeholders = "(?, ?, ?, ?, ?, ?, ?)"; - final String deleteRolePermissionsSql = "delete from m_role_permission where m_role_permission.permission_id in (select id from m_permission where code in " - + permissionList + ")"; - - final String deletePermissionsSql = "delete from m_permission where code in " + permissionList; - final String deleteRegisteredDatatableSql = "delete from x_registered_table where registered_table_name = '" + datatable + "'"; - final String deleteFromConfigurationSql = "delete from c_configuration where name ='" + datatable + "'"; - - String[] sqlArray = new String[4]; - sqlArray[0] = deleteRolePermissionsSql; - sqlArray[1] = deletePermissionsSql; - sqlArray[2] = deleteRegisteredDatatableSql; - sqlArray[3] = deleteFromConfigurationSql; - - this.jdbcTemplate.batchUpdate(sqlArray); // NOSONAR + this.jdbcTemplate + .update("DELETE FROM m_role_permission WHERE m_role_permission.permission_id IN (SELECT id FROM m_permission WHERE code IN " + + placeholders + ")", permissionCodes); + this.jdbcTemplate.update("DELETE FROM m_permission WHERE code IN " + placeholders, permissionCodes); + this.jdbcTemplate.update("DELETE FROM x_registered_table WHERE registered_table_name = ?", datatable); + this.jdbcTemplate.update("DELETE FROM c_configuration WHERE name = ?", datatable); } @Transactional @@ -605,8 +596,8 @@ public class DatatableWriteServiceImpl implements DatatableWriteService { return deleteDatatableEntries(dataTableName, appTableId, datatableId, command); } - private void registerDataTable(final String entityName, final String dataTableName, final String entitySubType, final Integer category, - final String permissionsSql) { + private void registerDataTable(final String entityName, final String dataTableName, final String entitySubType, + final Integer category) { datatableUtil.resolveEntity(entityName); datatableUtil.validateDatatableName(dataTableName); validateDataTableExists(dataTableName); @@ -622,7 +613,7 @@ public class DatatableWriteServiceImpl implements DatatableWriteService { try { this.namedParameterJdbcTemplate.update(registerDatatableSql, paramMap); - this.jdbcTemplate.update(permissionsSql); + this.registerPermissions(dataTableName, true); // add the registered table to the config if it is a ppi if (category.equals(DataTableApiConstant.CATEGORY_PPI)) { @@ -638,24 +629,21 @@ public class DatatableWriteServiceImpl implements DatatableWriteService { } } - private String getPermissionSql(final String dataTableName) { - final String createPermission = "'CREATE_" + dataTableName + "'"; - final String createPermissionChecker = "'CREATE_" + dataTableName + "_CHECKER'"; - final String readPermission = "'READ_" + dataTableName + "'"; - final String updatePermission = "'UPDATE_" + dataTableName + "'"; - final String updatePermissionChecker = "'UPDATE_" + dataTableName + "_CHECKER'"; - final String deletePermission = "'DELETE_" + dataTableName + "'"; - final String deletePermissionChecker = "'DELETE_" + dataTableName + "_CHECKER'"; + private void registerPermissions(final String dataTableName, final boolean canMakerChecker) { final List escapedColumns = Stream.of("grouping", "code", "action_name", "entity_name", "can_maker_checker") .map(sqlGenerator::escape).toList(); final String columns = String.join(", ", escapedColumns); - - return "insert into m_permission (" + columns + ") values " + "('datatable', " + createPermission + ", 'CREATE', '" + dataTableName - + "', true)," + "('datatable', " + createPermissionChecker + ", 'CREATE', '" + dataTableName + "', false)," - + "('datatable', " + readPermission + ", 'READ', '" + dataTableName + "', false)," + "('datatable', " + updatePermission - + ", 'UPDATE', '" + dataTableName + "', true)," + "('datatable', " + updatePermissionChecker + ", 'UPDATE', '" - + dataTableName + "', false)," + "('datatable', " + deletePermission + ", 'DELETE', '" + dataTableName + "', true)," - + "('datatable', " + deletePermissionChecker + ", 'DELETE', '" + dataTableName + "', false)"; + final String sql = "INSERT INTO m_permission (" + columns + ") VALUES ('datatable', ?, ?, ?, ?)"; + final Object[][] permissions = { { "CREATE_" + dataTableName, "CREATE", dataTableName, canMakerChecker }, + { "CREATE_" + dataTableName + "_CHECKER", "CREATE", dataTableName, false }, + { "READ_" + dataTableName, "READ", dataTableName, false }, + { "UPDATE_" + dataTableName, "UPDATE", dataTableName, canMakerChecker }, + { "UPDATE_" + dataTableName + "_CHECKER", "UPDATE", dataTableName, false }, + { "DELETE_" + dataTableName, "DELETE", dataTableName, canMakerChecker }, + { "DELETE_" + dataTableName + "_CHECKER", "DELETE", dataTableName, false }, }; + for (Object[] params : permissions) { + this.jdbcTemplate.update(sql, params); + } } private Integer getCategory(final JsonCommand command) { @@ -897,9 +885,9 @@ public class DatatableWriteServiceImpl implements DatatableWriteService { String schemaSql = databaseTypeResolver.isMySQL() ? "i.TABLE_SCHEMA = SCHEMA()" : "i.table_catalog = current_catalog AND i.table_schema = current_schema"; String findFKSql = "SELECT count(*) FROM information_schema.TABLE_CONSTRAINTS i" + " WHERE i.CONSTRAINT_TYPE = 'FOREIGN KEY' AND " - + schemaSql + " AND i.TABLE_NAME = '" + datatableName + "' AND i.CONSTRAINT_NAME = '" + fkName + "' "; + + schemaSql + " AND i.TABLE_NAME = ? AND i.CONSTRAINT_NAME = ?"; - final Integer count = this.jdbcTemplate.queryForObject(findFKSql, Integer.class); // NOSONAR + final Integer count = this.jdbcTemplate.queryForObject(findFKSql, Integer.class, datatableName, fkName); // NOSONAR if (count != null && count > 0) { codeMappings.add(datatableAlias + "_" + name); constrainBuilder.append(", DROP FOREIGN KEY ").append(sqlGenerator.escape(fkName)).append(" "); @@ -908,36 +896,28 @@ public class DatatableWriteServiceImpl implements DatatableWriteService { private void registerColumnCodeMapping(final Map codeMappings) { if (codeMappings != null && !codeMappings.isEmpty()) { - final String[] addSqlList = new String[codeMappings.size()]; - int i = 0; + final String sql = "INSERT INTO x_table_column_code_mappings (column_alias_name, code_id) VALUES (?, ?)"; for (final Map.Entry mapEntry : codeMappings.entrySet()) { - addSqlList[i++] = "insert into x_table_column_code_mappings (column_alias_name, code_id) values ('" + mapEntry.getKey() - + "'," + mapEntry.getValue() + ");"; + this.jdbcTemplate.update(sql, mapEntry.getKey(), mapEntry.getValue()); } - - this.jdbcTemplate.batchUpdate(addSqlList); } } private void deleteColumnCodeMapping(final List columnNames) { if (columnNames != null && !columnNames.isEmpty()) { - final String[] deleteSqlList = new String[columnNames.size()]; - int i = 0; + final String sql = "DELETE FROM x_table_column_code_mappings WHERE column_alias_name = ?"; for (final String columnName : columnNames) { - deleteSqlList[i++] = "DELETE FROM x_table_column_code_mappings WHERE column_alias_name='" + columnName + "';"; + this.jdbcTemplate.update(sql, columnName); } - - this.jdbcTemplate.batchUpdate(deleteSqlList); } } private int getCodeIdForColumn(final String dataTableNameAlias, final String name) { - final StringBuilder checkColumnCodeMapping = new StringBuilder(); - checkColumnCodeMapping.append("select ccm.code_id from x_table_column_code_mappings ccm where ccm.column_alias_name='") - .append(dataTableNameAlias).append("_").append(name).append("'"); + final String sql = "SELECT ccm.code_id FROM x_table_column_code_mappings ccm WHERE ccm.column_alias_name = ?"; + final String columnAliasName = dataTableNameAlias + "_" + name; Integer codeId = 0; try { - codeId = this.jdbcTemplate.queryForObject(checkColumnCodeMapping.toString(), Integer.class); + codeId = this.jdbcTemplate.queryForObject(sql, Integer.class, columnAliasName); } catch (final EmptyResultDataAccessException e) { log.warn("Error occurred.", e); } @@ -1327,10 +1307,9 @@ public class DatatableWriteServiceImpl implements DatatableWriteService { whereColumn = TABLE_FIELD_ID; whereValue = datatableId; } - String sql = "DELETE FROM " + sqlGenerator.escape(dataTableName) + " WHERE " + sqlGenerator.escape(whereColumn) + " = " - + whereValue; + String sql = "DELETE FROM " + sqlGenerator.escape(dataTableName) + " WHERE " + sqlGenerator.escape(whereColumn) + " = ?"; - this.jdbcTemplate.update(sql); // NOSONAR + this.jdbcTemplate.update(sql, whereValue); // NOSONAR final Map dataParams = null; final DatatableEntryDetails datatableEntryDetails = new DatatableEntryDetails(dataTableName, entityTable, datatableId, appTableId, dataParams); @@ -1351,8 +1330,8 @@ public class DatatableWriteServiceImpl implements DatatableWriteService { private boolean isDatatableAttachedToEntityDatatableCheck(final String datatableName) { String sql = "SELECT COUNT(edc.x_registered_table_name) FROM x_registered_table xrt" + " JOIN m_entity_datatable_check edc ON edc.x_registered_table_name = xrt.registered_table_name" - + " WHERE edc.x_registered_table_name = '" + datatableName + "'"; - final Long count = this.jdbcTemplate.queryForObject(sql, Long.class); // NOSONAR + + " WHERE edc.x_registered_table_name = ?"; + final Long count = this.jdbcTemplate.queryForObject(sql, Long.class, datatableName); // NOSONAR return count != null && count > 0; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/GenericDataServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/GenericDataServiceImpl.java index 5e06d9fd8..9849ec0b1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/GenericDataServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/GenericDataServiceImpl.java @@ -141,6 +141,13 @@ public class GenericDataServiceImpl implements GenericDataService { return fillResultsetRowData(rs, columnHeaders); } + @NonNull + @Override + public List fillResultsetRowData(final String sql, List columnHeaders, Object... args) { + final SqlRowSet rs = jdbcTemplate.queryForRowSet(sql, args); + return fillResultsetRowData(rs, columnHeaders); + } + @NonNull private static List fillResultsetRowData(SqlRowSet rs, List columnHeaders) { final SqlRowSetMetaData rsmd = rs.getMetaData(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/survey/service/WriteSurveyServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/survey/service/WriteSurveyServiceImpl.java index 8b6135f32..a8552f3b8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/survey/service/WriteSurveyServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/survey/service/WriteSurveyServiceImpl.java @@ -18,13 +18,9 @@ */ package org.apache.fineract.infrastructure.survey.service; -import java.util.List; -import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; -import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; -import org.apache.fineract.infrastructure.dataqueries.service.DatatableReadService; import org.apache.fineract.infrastructure.dataqueries.service.DatatableWriteService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,39 +32,14 @@ import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor public class WriteSurveyServiceImpl implements WriteSurveyService { - private final DatatableReadService datatableReadService; private final DatatableWriteService datatableWriteService; - private final DatabaseSpecificSQLGenerator sqlGenerator; @Override @Transactional public CommandProcessingResult registerSurvey(JsonCommand command) { - final String dataTableName = datatableReadService.getDataTableName(command.getUrl()); - final String permissionSql = this.getPermissionSql(dataTableName); - datatableWriteService.registerDatatable(command, permissionSql); + datatableWriteService.registerDatatable(command); return CommandProcessingResult.commandOnlyResult(command.commandId()); - - } - - private String getPermissionSql(final String dataTableName) { - final String createPermission = "'CREATE_" + dataTableName + "'"; - final String createPermissionChecker = "'CREATE_" + dataTableName + "_CHECKER'"; - final String readPermission = "'READ_" + dataTableName + "'"; - final String updatePermission = "'UPDATE_" + dataTableName + "'"; - final String updatePermissionChecker = "'UPDATE_" + dataTableName + "_CHECKER'"; - final String deletePermission = "'DELETE_" + dataTableName + "'"; - final String deletePermissionChecker = "'DELETE_" + dataTableName + "_CHECKER'"; - final List escapedColumns = Stream.of("grouping", "code", "action_name", "entity_name", "can_maker_checker") - .map(sqlGenerator::escape).toList(); - final String columns = String.join(", ", escapedColumns); - - return "insert into m_permission (" + columns + ") values " + "('datatable', " + createPermission + ", 'CREATE', '" + dataTableName - + "', false)," + "('datatable', " + createPermissionChecker + ", 'CREATE', '" + dataTableName + "', false)," - + "('datatable', " + readPermission + ", 'READ', '" + dataTableName + "', false)," + "('datatable', " + updatePermission - + ", 'UPDATE', '" + dataTableName + "', false)," + "('datatable', " + updatePermissionChecker + ", 'UPDATE', '" - + dataTableName + "', false)," + "('datatable', " + deletePermission + ", 'DELETE', '" + dataTableName + "', false)," - + "('datatable', " + deletePermissionChecker + ", 'DELETE', '" + dataTableName + "', false)"; } @Transactional diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java index 822a08cab..618f77f25 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java @@ -134,6 +134,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead final PaginationParameters paginationParameters) { this.context.authenticatedUser(); + this.paginationParametersDataValidator.validateParameterValues(paginationParameters, supportedOrderByValues, + depositAccountType.resourceName()); + final DepositAccountMapper depositAccountMapper = this.getDepositAccountMapper(depositAccountType); if (depositAccountMapper == null) { return null; diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableReadServiceImplCountTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableReadServiceImplCountTest.java new file mode 100644 index 000000000..9fc9a359a --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableReadServiceImplCountTest.java @@ -0,0 +1,91 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.dataqueries.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.infrastructure.dataqueries.data.DataTableValidator; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.infrastructure.security.service.SqlValidator; +import org.apache.fineract.portfolio.search.service.SearchUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; + +@ExtendWith(MockitoExtension.class) +class DatatableReadServiceImplCountTest { + + private static final Long APP_TABLE_ID = 42L; + private static final String DATATABLE_NAME = "dt_loan_test"; + private static final String FK_COLUMN = "loan_id"; + + @Mock + private JdbcTemplate jdbcTemplate; + @Mock + private DatabaseSpecificSQLGenerator sqlGenerator; + @Mock + private PlatformSecurityContext context; + @Mock + private GenericDataService genericDataService; + @Mock + private DataTableValidator dataTableValidator; + @Mock + private SqlValidator sqlValidator; + @Mock + private SearchUtil searchUtil; + @Mock + private DatatableUtil datatableUtil; + + private DatatableReadServiceImpl underTest; + + @BeforeEach + void setUp() { + underTest = new DatatableReadServiceImpl(jdbcTemplate, sqlGenerator, context, genericDataService, dataTableValidator, sqlValidator, + searchUtil, datatableUtil); + } + + @Test + void testCountDatatableEntriesUsesParameterizedQuery() { + when(sqlGenerator.escape(anyString())).thenAnswer(invocation -> "\"" + invocation.getArgument(0) + "\""); + when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), eq(APP_TABLE_ID))).thenReturn(3L); + + Long count = underTest.countDatatableEntries(DATATABLE_NAME, APP_TABLE_ID, FK_COLUMN); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcTemplate).queryForObject(sqlCaptor.capture(), eq(Long.class), eq(APP_TABLE_ID)); + + String sql = sqlCaptor.getValue(); + assertTrue(sql.contains("= ?"), "SQL should use ? placeholder for appTableId"); + assertFalse(sql.contains("= " + APP_TABLE_ID), "SQL should not contain concatenated appTableId"); + assertTrue(sql.contains("\"" + FK_COLUMN + "\""), "SQL should contain escaped FK column name"); + assertTrue(sql.contains("\"" + DATATABLE_NAME + "\""), "SQL should contain escaped datatable name"); + assertEquals(3L, count, "Should return the count from the query"); + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableUtilTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableUtilTest.java new file mode 100644 index 000000000..635e73cd2 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableUtilTest.java @@ -0,0 +1,235 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.dataqueries.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.infrastructure.core.service.database.DatabaseType; +import org.apache.fineract.infrastructure.dataqueries.data.EntityTables; +import org.apache.fineract.infrastructure.dataqueries.data.ResultsetColumnHeaderData; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.infrastructure.security.service.SqlValidator; +import org.apache.fineract.infrastructure.security.utils.ColumnValidator; +import org.apache.fineract.organisation.office.domain.Office; +import org.apache.fineract.portfolio.search.service.SearchUtil; +import org.apache.fineract.useradministration.domain.AppUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.rowset.SqlRowSet; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DatatableUtilTest { + + private static final Long APP_TABLE_ID = 42L; + private static final String OFFICE_HIERARCHY = ".1.2."; + + @Mock + private SearchUtil searchUtil; + @Mock + private JdbcTemplate jdbcTemplate; + @Mock + private SqlValidator sqlValidator; + @Mock + private PlatformSecurityContext context; + @Mock + private GenericDataService genericDataService; + @Mock + private DatabaseSpecificSQLGenerator sqlGenerator; + @Mock + private ColumnValidator columnValidator; + + private DatatableUtil underTest; + + @BeforeEach + void setUp() { + underTest = new DatatableUtil(searchUtil, jdbcTemplate, sqlValidator, context, genericDataService, sqlGenerator, columnValidator); + setupSecurityContext(); + } + + private void setupSecurityContext() { + AppUser currentUser = Mockito.mock(AppUser.class); + Office office = Mockito.mock(Office.class); + when(context.authenticatedUser()).thenReturn(currentUser); + when(currentUser.getOffice()).thenReturn(office); + when(office.getHierarchy()).thenReturn(OFFICE_HIERARCHY); + } + + @Test + void testDataScopedSqlLoanUsesParameterizedQuery() { + List params = new ArrayList<>(); + String sql = underTest.dataScopedSQL(EntityTables.LOAN, APP_TABLE_ID, params); + + assertEquals(4, params.size(), "LOAN case should have 4 parameters"); + assertTrue(sql.contains("where l.id = ?"), "SQL should use ? placeholder for loan id"); + assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId"); + assertFalse(sql.contains("'" + OFFICE_HIERARCHY), "SQL should not contain concatenated officeHierarchy"); + assertEquals(OFFICE_HIERARCHY + "%", params.get(0), "First param should be hierarchy pattern"); + assertEquals(APP_TABLE_ID, params.get(1), "Second param should be appTableId"); + assertEquals(OFFICE_HIERARCHY + "%", params.get(2), "Third param should be hierarchy pattern"); + assertEquals(APP_TABLE_ID, params.get(3), "Fourth param should be appTableId"); + } + + @Test + void testDataScopedSqlSavingsUsesParameterizedQuery() { + List params = new ArrayList<>(); + String sql = underTest.dataScopedSQL(EntityTables.SAVINGS, APP_TABLE_ID, params); + + assertEquals(4, params.size(), "SAVINGS case should have 4 parameters"); + assertTrue(sql.contains("where s.id = ?"), "SQL should use ? placeholder for savings id"); + assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId"); + } + + @Test + void testDataScopedSqlSavingsTransactionUsesParameterizedQuery() { + List params = new ArrayList<>(); + String sql = underTest.dataScopedSQL(EntityTables.SAVINGS_TRANSACTION, APP_TABLE_ID, params); + + assertEquals(4, params.size(), "SAVINGS_TRANSACTION case should have 4 parameters"); + assertTrue(sql.contains("where t.id = ?"), "SQL should use ? placeholder for transaction id"); + assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId"); + } + + @Test + void testDataScopedSqlClientUsesParameterizedQuery() { + List params = new ArrayList<>(); + String sql = underTest.dataScopedSQL(EntityTables.CLIENT, APP_TABLE_ID, params); + + assertEquals(2, params.size(), "CLIENT case should have 2 parameters"); + assertTrue(sql.contains("where c.id = ?"), "SQL should use ? placeholder for client id"); + assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId"); + assertEquals(OFFICE_HIERARCHY + "%", params.get(0), "First param should be hierarchy pattern"); + assertEquals(APP_TABLE_ID, params.get(1), "Second param should be appTableId"); + } + + @Test + void testDataScopedSqlGroupUsesParameterizedQuery() { + List params = new ArrayList<>(); + String sql = underTest.dataScopedSQL(EntityTables.GROUP, APP_TABLE_ID, params); + + assertEquals(2, params.size(), "GROUP case should have 2 parameters"); + assertTrue(sql.contains("where g.id = ?"), "SQL should use ? placeholder for group id"); + assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId"); + } + + @Test + void testDataScopedSqlOfficeUsesParameterizedQuery() { + List params = new ArrayList<>(); + String sql = underTest.dataScopedSQL(EntityTables.OFFICE, APP_TABLE_ID, params); + + assertEquals(2, params.size(), "OFFICE case should have 2 parameters"); + assertTrue(sql.contains("o.hierarchy like ?"), "SQL should use ? placeholder for hierarchy LIKE"); + assertTrue(sql.contains("o.id = ?"), "SQL should use ? placeholder for office id"); + assertFalse(sql.contains("'" + OFFICE_HIERARCHY), "SQL should not contain concatenated officeHierarchy"); + assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId"); + } + + @Test + void testDataScopedSqlProductUsesParameterizedQuery() { + List params = new ArrayList<>(); + String sql = underTest.dataScopedSQL(EntityTables.LOAN_PRODUCT, APP_TABLE_ID, params); + + assertEquals(1, params.size(), "PRODUCT case should have 1 parameter"); + assertTrue(sql.contains("p.id = ?"), "SQL should use ? placeholder for product id"); + assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId"); + assertEquals(APP_TABLE_ID, params.get(0), "First param should be appTableId"); + } + + @Test + void testCheckMainResourceExistsWithinScopePassesParamsToQuery() { + SqlRowSet rs = Mockito.mock(SqlRowSet.class); + when(rs.next()).thenReturn(true).thenReturn(false); + when(rs.getObject(anyString())).thenReturn(1L); + when(jdbcTemplate.queryForRowSet(anyString(), any(Object[].class))).thenReturn(rs); + + underTest.checkMainResourceExistsWithinScope(EntityTables.CLIENT, APP_TABLE_ID); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(Object[].class); + verify(jdbcTemplate).queryForRowSet(sqlCaptor.capture(), paramsCaptor.capture()); + + String sql = sqlCaptor.getValue(); + Object[] capturedParams = paramsCaptor.getValue(); + + assertTrue(sql.contains("?"), "SQL should contain ? placeholders"); + assertFalse(sql.contains(APP_TABLE_ID.toString()), "SQL should not contain concatenated appTableId"); + assertTrue(capturedParams.length > 0, "Parameters array should not be empty"); + } + + @Test + void testRetrieveDataTableGenericResultSetUsesParameterizedQuery() { + EntityTables entityTable = EntityTables.LOAN; + String dataTableName = "test_datatable"; + Long id = 5L; + + when(sqlGenerator.escape(anyString())).thenReturn("\"" + dataTableName + "\""); + when(genericDataService.fillResultsetColumnHeaders(anyString())).thenReturn(createMultiRowHeaders()); + when(searchUtil.findFiltered(any(), any())).thenReturn(ResultsetColumnHeaderData.basic("id", "bigint", DatabaseType.MYSQL)); + when(genericDataService.fillResultsetRowData(anyString(), any(), any(Object[].class))).thenReturn(new ArrayList<>()); + + underTest.retrieveDataTableGenericResultSet(entityTable, dataTableName, APP_TABLE_ID, null, id); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(Object[].class); + verify(genericDataService).fillResultsetRowData(sqlCaptor.capture(), any(), paramsCaptor.capture()); + + String sql = sqlCaptor.getValue(); + Object[] capturedParams = paramsCaptor.getValue(); + + assertTrue(sql.contains("= ?"), "SQL should use ? placeholder for foreign key"); + assertTrue(sql.contains("id = ?"), "SQL should use ? placeholder for id"); + assertFalse(sql.contains("= " + APP_TABLE_ID), "SQL should not contain concatenated appTableId"); + assertFalse(sql.contains("= " + id), "SQL should not contain concatenated id"); + assertEquals(2, capturedParams.length, "Should have 2 parameters (appTableId + id)"); + assertEquals(APP_TABLE_ID, capturedParams[0], "First param should be appTableId"); + assertEquals(id, capturedParams[1], "Second param should be id"); + } + + @Test + void testOfficeJoinConditionUsesPlaceholder() { + String joinCondition = underTest.getOfficeJoinCondition("t"); + + assertTrue(joinCondition.contains("o.hierarchy like ?"), "Join condition should use ? for hierarchy"); + assertFalse(joinCondition.contains("like '"), "Join condition should not contain literal quote for hierarchy"); + } + + private List createMultiRowHeaders() { + List headers = new ArrayList<>(); + headers.add(ResultsetColumnHeaderData.basic("id", "bigint", DatabaseType.MYSQL)); + headers.add(ResultsetColumnHeaderData.basic("loan_id", "bigint", DatabaseType.MYSQL)); + return headers; + } +} diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableWriteServiceImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableWriteServiceImplTest.java new file mode 100644 index 000000000..44be56280 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/dataqueries/service/DatatableWriteServiceImplTest.java @@ -0,0 +1,248 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.dataqueries.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.gson.JsonObject; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.fineract.infrastructure.codes.service.CodeReadPlatformService; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.serialization.DatatableCommandFromApiJsonDeserializer; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.infrastructure.core.service.database.DatabaseTypeResolver; +import org.apache.fineract.infrastructure.dataqueries.data.DataTableValidator; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.search.service.SearchUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DatatableWriteServiceImplTest { + + private static final String DATATABLE_NAME = "dt_test_loan"; + + @Mock + private JdbcTemplate jdbcTemplate; + @Mock + private DatabaseTypeResolver databaseTypeResolver; + @Mock + private DatabaseSpecificSQLGenerator sqlGenerator; + @Mock + private PlatformSecurityContext context; + @Mock + private FromJsonHelper fromJsonHelper; + @Mock + private GenericDataService genericDataService; + @Mock + private DatatableCommandFromApiJsonDeserializer fromApiJsonDeserializer; + @Mock + private ConfigurationDomainService configurationDomainService; + @Mock + private CodeReadPlatformService codeReadPlatformService; + @Mock + private DataTableValidator dataTableValidator; + @Mock + private NamedParameterJdbcTemplate namedParameterJdbcTemplate; + @Mock + private DatatableKeywordGenerator datatableKeywordGenerator; + @Mock + private SearchUtil searchUtil; + @Mock + private BusinessEventNotifierService businessEventNotifierService; + @Mock + private DatatableReadService datatableReadService; + @Mock + private DatatableUtil datatableUtil; + + @InjectMocks + private DatatableWriteServiceImpl underTest; + + @Test + void testDeregisterDatatableUsesParameterizedQueries() { + underTest.deregisterDatatable(DATATABLE_NAME); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(Object[].class); + + verify(jdbcTemplate, atLeastOnce()).update(sqlCaptor.capture(), paramsCaptor.capture()); + + List allSql = sqlCaptor.getAllValues(); + List allParams = paramsCaptor.getAllValues(); + + // First call: DELETE FROM m_role_permission WHERE ... IN (?, ?, ?, ?, ?, ?, ?) + assertTrue(allSql.get(0).contains("m_role_permission"), "First DELETE should target m_role_permission"); + assertTrue(allSql.get(0).contains("?"), "SQL should use ? placeholders"); + assertFalse(allSql.get(0).contains("'" + DATATABLE_NAME), "SQL should not contain concatenated datatable name"); + assertEquals(7, allParams.get(0).length, "Should have 7 permission code parameters"); + + // Second call: DELETE FROM m_permission WHERE code IN (?, ?, ?, ?, ?, ?, ?) + assertTrue(allSql.get(1).contains("m_permission"), "Second DELETE should target m_permission"); + assertTrue(allSql.get(1).contains("?"), "SQL should use ? placeholders"); + assertEquals(7, allParams.get(1).length, "Should have 7 permission code parameters"); + + // Third call: DELETE FROM x_registered_table WHERE registered_table_name = ? + assertTrue(allSql.get(2).contains("x_registered_table"), "Third DELETE should target x_registered_table"); + assertTrue(allSql.get(2).contains("= ?"), "SQL should use ? placeholder"); + + // Fourth call: DELETE FROM c_configuration WHERE name = ? + assertTrue(allSql.get(3).contains("c_configuration"), "Fourth DELETE should target c_configuration"); + assertTrue(allSql.get(3).contains("= ?"), "SQL should use ? placeholder"); + } + + @Test + void testDeleteColumnCodeMappingUsesParameterizedQuery() { + List columnNames = new ArrayList<>(); + columnNames.add("test_column_1"); + columnNames.add("test_column_2"); + + ReflectionTestUtils.invokeMethod(underTest, "deleteColumnCodeMapping", columnNames); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcTemplate, atLeastOnce()).update(sqlCaptor.capture(), anyString()); + + String sql = sqlCaptor.getValue(); + assertTrue(sql.contains("DELETE FROM x_table_column_code_mappings"), "Should delete from x_table_column_code_mappings"); + assertTrue(sql.contains("column_alias_name = ?"), "Should use ? placeholder for column_alias_name"); + assertFalse(sql.contains("test_column"), "SQL should not contain concatenated column name"); + } + + @Test + void testGetCodeIdForColumnUsesParameterizedQuery() { + String dataTableAlias = "dt_test"; + String columnName = "status"; + + when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), anyString())).thenReturn(42); + + ReflectionTestUtils.invokeMethod(underTest, "getCodeIdForColumn", dataTableAlias, columnName); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor paramCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcTemplate).queryForObject(sqlCaptor.capture(), eq(Integer.class), paramCaptor.capture()); + + String sql = sqlCaptor.getValue(); + String param = paramCaptor.getValue(); + + assertTrue(sql.contains("column_alias_name = ?"), "SQL should use ? placeholder"); + assertFalse(sql.contains(dataTableAlias + "_" + columnName), "SQL should not contain concatenated alias"); + assertEquals(dataTableAlias + "_" + columnName, param, "Parameter should be the combined alias_name"); + } + + @Test + void testIsDatatableAttachedUsesParameterizedQuery() { + when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), anyString())).thenReturn(0L); + + ReflectionTestUtils.invokeMethod(underTest, "isDatatableAttachedToEntityDatatableCheck", DATATABLE_NAME); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor paramCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcTemplate).queryForObject(sqlCaptor.capture(), eq(Long.class), paramCaptor.capture()); + + String sql = sqlCaptor.getValue(); + assertTrue(sql.contains("x_registered_table_name = ?"), "SQL should use ? placeholder for datatable name"); + assertFalse(sql.contains("'" + DATATABLE_NAME + "'"), "SQL should not contain concatenated datatable name"); + assertEquals(DATATABLE_NAME, paramCaptor.getValue(), "Parameter should be the datatable name"); + } + + @Test + void testParseDatatableColumnForDropUsesParameterizedFKLookup() { + when(databaseTypeResolver.isMySQL()).thenReturn(true); + when(sqlGenerator.escape(anyString())).thenAnswer(invocation -> "\"" + invocation.getArgument(0) + "\""); + when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class), anyString(), anyString())).thenReturn(0); + + JsonObject column = new JsonObject(); + column.addProperty("name", "test_col"); + + StringBuilder sqlBuilder = new StringBuilder(); + StringBuilder constrainBuilder = new StringBuilder(); + List codeMappings = new ArrayList<>(); + + ReflectionTestUtils.invokeMethod(underTest, "parseDatatableColumnForDrop", column, sqlBuilder, DATATABLE_NAME, constrainBuilder, + codeMappings); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor param1Captor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor param2Captor = ArgumentCaptor.forClass(String.class); + verify(jdbcTemplate).queryForObject(sqlCaptor.capture(), eq(Integer.class), param1Captor.capture(), param2Captor.capture()); + + String sql = sqlCaptor.getValue(); + assertTrue(sql.contains("i.TABLE_NAME = ?"), "SQL should use ? placeholder for TABLE_NAME"); + assertTrue(sql.contains("i.CONSTRAINT_NAME = ?"), "SQL should use ? placeholder for CONSTRAINT_NAME"); + assertFalse(sql.contains("'" + DATATABLE_NAME + "'"), "SQL should not contain concatenated table name"); + assertEquals(DATATABLE_NAME, param1Captor.getValue(), "First param should be the table name"); + assertTrue(param2Captor.getValue().startsWith("fk_"), "Second param should be the FK constraint name"); + } + + @Test + void testRegisterPermissionsUsesParameterizedQuery() { + when(sqlGenerator.escape(anyString())).thenAnswer(invocation -> "\"" + invocation.getArgument(0) + "\""); + + ReflectionTestUtils.invokeMethod(underTest, "registerPermissions", DATATABLE_NAME, true); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(Object[].class); + verify(jdbcTemplate, atLeastOnce()).update(sqlCaptor.capture(), paramsCaptor.capture()); + + String sql = sqlCaptor.getValue(); + assertTrue(sql.contains("INSERT INTO m_permission"), "SQL should be an INSERT into m_permission"); + assertTrue(sql.contains("VALUES ('datatable', ?, ?, ?, ?)"), "SQL should use ? placeholders for values"); + assertFalse(sql.contains("'" + DATATABLE_NAME + "'"), "SQL should not contain concatenated datatable name"); + + List allParams = paramsCaptor.getAllValues(); + assertEquals(7, allParams.size(), "Should create 7 permission entries"); + assertEquals("CREATE_" + DATATABLE_NAME, allParams.get(0)[0], "First permission code should be CREATE_"); + } + + @Test + void testRegisterColumnCodeMappingUsesParameterizedQuery() { + Map codeMappings = new HashMap<>(); + codeMappings.put("dt_test_status", 10L); + + ReflectionTestUtils.invokeMethod(underTest, "registerColumnCodeMapping", codeMappings); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(jdbcTemplate).update(sqlCaptor.capture(), eq("dt_test_status"), eq(10L)); + + String sql = sqlCaptor.getValue(); + assertTrue(sql.contains("INSERT INTO x_table_column_code_mappings"), "SQL should insert into code mappings table"); + assertTrue(sql.contains("VALUES (?, ?)"), "SQL should use ? placeholders"); + } +}