diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/serialization/DatatableCommandFromApiJsonDeserializer.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/serialization/DatatableCommandFromApiJsonDeserializer.java index 4e300bda4ca..51e74d6e2c8 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/serialization/DatatableCommandFromApiJsonDeserializer.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/serialization/DatatableCommandFromApiJsonDeserializer.java @@ -65,6 +65,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.data.ApiParameterError; @@ -132,10 +133,11 @@ public void validateForCreate(final String json) { baseDataValidator.reset().parameter(API_PARAM_APPTABLE_NAME).value(apptableName).notBlank().notExceedingLengthOf(50) .isOneOfTheseStringValues(EntityTables.getEntityNames()); - EntityTables entityTable = EntityTables.fromEntityName(apptableName); - validateEntitySubType(baseDataValidator, element, entityTable); + final Optional entityTable = Optional.ofNullable(EntityTables.fromEntityName(apptableName)); + entityTable.ifPresent(et -> validateEntitySubType(baseDataValidator, element, et)); - final String fkColumnName = entityTable == null ? null : entityTable.getForeignKeyColumnNameOnDatatable(); + final Object[] reservedColumnNames = entityTable.map(et -> new Object[] { TABLE_FIELD_ID, et.getForeignKeyColumnNameOnDatatable() }) + .orElseGet(() -> new Object[] { TABLE_FIELD_ID }); final Boolean multiRow = this.fromApiJsonHelper.extractBooleanNamed(API_PARAM_MULTIROW, element); baseDataValidator.reset().parameter(API_PARAM_MULTIROW).value(multiRow).ignoreIfNull().notBlank().isOneOfTheseValues(true, false); @@ -148,8 +150,8 @@ public void validateForCreate(final String json) { this.fromApiJsonHelper.checkForUnsupportedParameters(column.getAsJsonObject(), SUPPORTED_PARAMETERS_FOR_CREATE_COLUMNS); final String name = this.fromApiJsonHelper.extractStringNamed(API_FIELD_NAME, column); - baseDataValidator.reset().parameter(API_FIELD_NAME).value(name).notBlank() - .isNotOneOfTheseValues(TABLE_FIELD_ID, fkColumnName).matchesRegularExpression(DATATABLE_COLUMN_NAME_REGEX_PATTERN); + baseDataValidator.reset().parameter(API_FIELD_NAME).value(name).notBlank().isNotOneOfTheseValues(reservedColumnNames) + .matchesRegularExpression(DATATABLE_COLUMN_NAME_REGEX_PATTERN); validateType(baseDataValidator, column); @@ -194,10 +196,11 @@ public void validateForUpdate(final String json) { baseDataValidator.reset().parameter(API_PARAM_APPTABLE_NAME).value(apptableName).ignoreIfNull().notBlank() .isOneOfTheseStringValues(EntityTables.getEntityNames()); - EntityTables entityTable = EntityTables.fromEntityName(apptableName); - validateEntitySubType(baseDataValidator, element, entityTable); + final Optional entityTable = Optional.ofNullable(EntityTables.fromEntityName(apptableName)); + entityTable.ifPresent(et -> validateEntitySubType(baseDataValidator, element, et)); - final String fkColumnName = entityTable.getForeignKeyColumnNameOnDatatable(); + final Object[] reservedColumnNames = entityTable.map(et -> new Object[] { TABLE_FIELD_ID, et.getForeignKeyColumnNameOnDatatable() }) + .orElseGet(() -> new Object[] { TABLE_FIELD_ID }); final JsonArray changeColumns = this.fromApiJsonHelper.extractJsonArrayNamed(API_PARAM_CHANGECOLUMNS, element); baseDataValidator.reset().parameter(API_PARAM_CHANGECOLUMNS).value(changeColumns).ignoreIfNull().jsonArrayNotEmpty(); @@ -207,12 +210,12 @@ public void validateForUpdate(final String json) { this.fromApiJsonHelper.checkForUnsupportedParameters(column.getAsJsonObject(), SUPPORTED_PARAMETERS_FOR_CHANGE_COLUMNS); final String name = this.fromApiJsonHelper.extractStringNamed(API_FIELD_NAME, column); - baseDataValidator.reset().parameter(API_FIELD_NAME).value(name).notBlank() - .isNotOneOfTheseValues(TABLE_FIELD_ID, fkColumnName).matchesRegularExpression(DATATABLE_COLUMN_NAME_REGEX_PATTERN); + baseDataValidator.reset().parameter(API_FIELD_NAME).value(name).notBlank().isNotOneOfTheseValues(reservedColumnNames) + .matchesRegularExpression(DATATABLE_COLUMN_NAME_REGEX_PATTERN); final String newName = this.fromApiJsonHelper.extractStringNamed(API_FIELD_NEWNAME, column); baseDataValidator.reset().parameter(API_FIELD_NEWNAME).value(newName).ignoreIfNull().notBlank().notExceedingLengthOf(50) - .isNotOneOfTheseValues(TABLE_FIELD_ID, fkColumnName).matchesRegularExpression(DATATABLE_COLUMN_NAME_REGEX_PATTERN); + .isNotOneOfTheseValues(reservedColumnNames).matchesRegularExpression(DATATABLE_COLUMN_NAME_REGEX_PATTERN); if (this.fromApiJsonHelper.parameterExists(API_FIELD_LENGTH, column)) { final String lengthStr = this.fromApiJsonHelper.extractStringNamed(API_FIELD_LENGTH, column); @@ -262,8 +265,8 @@ public void validateForUpdate(final String json) { this.fromApiJsonHelper.checkForUnsupportedParameters(column.getAsJsonObject(), SUPPORTED_PARAMETERS_FOR_ADD_COLUMNS); final String name = this.fromApiJsonHelper.extractStringNamed(API_FIELD_NAME, column); - baseDataValidator.reset().parameter(API_FIELD_NAME).value(name).notBlank() - .isNotOneOfTheseValues(TABLE_FIELD_ID, fkColumnName).matchesRegularExpression(DATATABLE_COLUMN_NAME_REGEX_PATTERN); + baseDataValidator.reset().parameter(API_FIELD_NAME).value(name).notBlank().isNotOneOfTheseValues(reservedColumnNames) + .matchesRegularExpression(DATATABLE_COLUMN_NAME_REGEX_PATTERN); validateType(baseDataValidator, column); @@ -292,8 +295,8 @@ public void validateForUpdate(final String json) { this.fromApiJsonHelper.checkForUnsupportedParameters(column.getAsJsonObject(), SUPPORTED_PARAMETERS_FOR_DROP_COLUMNS); final String name = this.fromApiJsonHelper.extractStringNamed(API_FIELD_NAME, column); - baseDataValidator.reset().parameter(API_FIELD_NAME).value(name).notBlank() - .isNotOneOfTheseValues(TABLE_FIELD_ID, fkColumnName).matchesRegularExpression(DATATABLE_COLUMN_NAME_REGEX_PATTERN); + baseDataValidator.reset().parameter(API_FIELD_NAME).value(name).notBlank().isNotOneOfTheseValues(reservedColumnNames) + .matchesRegularExpression(DATATABLE_COLUMN_NAME_REGEX_PATTERN); } } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/data/EntityTables.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/data/EntityTables.java index 3062ebdf2ad..3632e470413 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/data/EntityTables.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/dataqueries/data/EntityTables.java @@ -47,6 +47,8 @@ public enum EntityTables { SAVINGS("m_savings_account", "savings_account_id", "id", CREATE, APPROVE, ACTIVATE, WITHDRAWN, REJECTED, CLOSE), // SAVINGS_TRANSACTION("m_savings_account_transaction", "savings_transaction_id", "id"), // SHARE_PRODUCT("m_share_product", "share_product_id", "id"), // + WC_LOAN_PRODUCT("m_wc_loan_product", "wc_product_loan_id", "id"), // + WC_LOAN("m_wc_loan", "wc_loan_id", "id"), // ; static final EntityTables[] ENTITY_VALUES = values(); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/datatable/DatatableEntityType.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/datatable/DatatableEntityType.java index 81acddfb251..bf7d6167c3c 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/datatable/DatatableEntityType.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/datatable/DatatableEntityType.java @@ -26,7 +26,11 @@ @Getter public enum DatatableEntityType { - LOAN("m_loan"); + LOAN("m_loan"), // + LOAN_PRODUCT("m_product_loan"), // + WC_LOAN("m_wc_loan"), // + WC_LOAN_PRODUCT("m_wc_loan_product"), // + ; private final String referencedTableName; diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/datatable/DatatablesStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/datatable/DatatablesStepDef.java index e329f2294ac..51d765ad005 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/datatable/DatatablesStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/datatable/DatatablesStepDef.java @@ -19,142 +19,119 @@ package org.apache.fineract.test.stepdef.datatable; import static java.util.function.Function.identity; +import static org.apache.fineract.client.feign.util.FeignCalls.fail; import static org.apache.fineract.client.feign.util.FeignCalls.ok; import static org.assertj.core.api.Assertions.assertThat; import io.cucumber.datatable.DataTable; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; +import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.BooleanUtils; import org.apache.fineract.client.feign.FeignException; import org.apache.fineract.client.feign.FineractFeignClient; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; import org.apache.fineract.client.models.GetDataTablesResponse; import org.apache.fineract.client.models.PostColumnHeaderData; +import org.apache.fineract.client.models.PostDataTablesAppTableIdResponse; import org.apache.fineract.client.models.PostDataTablesRequest; import org.apache.fineract.client.models.PostDataTablesResponse; +import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsResponse; +import org.apache.fineract.client.models.PostWorkingCapitalLoansResponse; +import org.apache.fineract.client.models.PutDataTablesRequest; +import org.apache.fineract.client.models.PutDataTablesRequestAddColumns; +import org.apache.fineract.client.models.PutDataTablesRequestChangeColumns; +import org.apache.fineract.client.models.PutDataTablesRequestDropColumns; import org.apache.fineract.client.models.ResultsetColumnHeaderData; import org.apache.fineract.test.data.datatable.DatatableColumnType; import org.apache.fineract.test.data.datatable.DatatableEntityType; import org.apache.fineract.test.data.datatable.DatatableNameGenerator; import org.apache.fineract.test.stepdef.AbstractStepDef; -import org.springframework.beans.factory.annotation.Autowired; +import org.apache.fineract.test.support.TestContextKey; +@RequiredArgsConstructor public class DatatablesStepDef extends AbstractStepDef { public static final String CREATE_DATATABLE_RESULT_KEY = "CreateDatatableResult"; public static final String DATATABLE_NAME = "DatatableId"; public static final String DATATABLE_QUERY_RESPONSE = "DatatableQueryResponse"; + public static final String DATATABLE_ENTRY_ID = "DatatableEntryId"; - @Autowired - private FineractFeignClient fineractClient; - - @Autowired - private DatatableNameGenerator datatableNameGenerator; + private final FineractFeignClient fineractClient; + private final DatatableNameGenerator datatableNameGenerator; @When("A datatable for {string} is created") - public void whenDatatableCreated(String entityTypeStr) { - DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); - List columns = createRandomDatatableColumnsRequest(); - PostDataTablesRequest request = createDatatableRequest(entityType, columns); + public void whenDatatableCreated(final String entityTypeStr) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final List columns = createRandomDatatableColumnsRequest(); + final PostDataTablesRequest request = createDatatableRequest(entityType, columns); - PostDataTablesResponse response = ok(() -> fineractClient.dataTables().createDatatable(request, Map.of())); + final PostDataTablesResponse response = ok(() -> fineractClient.dataTables().createDatatable(request, Map.of())); testContext().set(CREATE_DATATABLE_RESULT_KEY, response); testContext().set(DATATABLE_NAME, response.getResourceIdentifier()); } @When("A datatable for {string} is created with the following extra columns:") - public void whenDatatableCreatedWithFollowingExtraColumns(String entityTypeStr, DataTable dataTable) { - DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); - List> rows = dataTable.asLists(); - List> rowsWithoutHeader = rows.subList(1, rows.size()); - List columns = createDatatableColumnsRequest(rowsWithoutHeader); - PostDataTablesRequest request = createDatatableRequest(entityType, columns); + public void whenDatatableCreatedWithFollowingExtraColumns(final String entityTypeStr, final DataTable dataTable) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final List> rows = dataTable.asLists(); + final List> rowsWithoutHeader = rows.subList(1, rows.size()); + final List columns = createDatatableColumnsRequest(rowsWithoutHeader); + final PostDataTablesRequest request = createDatatableRequest(entityType, columns); - PostDataTablesResponse response = ok(() -> fineractClient.dataTables().createDatatable(request, Map.of())); + final PostDataTablesResponse response = ok(() -> fineractClient.dataTables().createDatatable(request, Map.of())); testContext().set(CREATE_DATATABLE_RESULT_KEY, response); testContext().set(DATATABLE_NAME, response.getResourceIdentifier()); } - private List createDatatableColumnsRequest(List> rowsWithoutHeader) { - return rowsWithoutHeader.stream().map(row -> { - String columnName = row.get(0); - DatatableColumnType columnType = DatatableColumnType.fromTypeString(row.get(1)); - long columnLength = Long.parseLong(row.get(2)); - boolean unique = BooleanUtils.toBoolean(row.get(3)); - boolean indexed = BooleanUtils.toBoolean(row.get(4)); - - PostColumnHeaderData postColumnHeaderData = new PostColumnHeaderData(); - postColumnHeaderData.setName(columnName); - postColumnHeaderData.setType(columnType.getTypeString()); - postColumnHeaderData.setLength(columnLength); - postColumnHeaderData.setUnique(unique); - postColumnHeaderData.setIndexed(indexed); - return postColumnHeaderData; - }).collect(Collectors.toList()); - } - @When("A multirow datatable for {string} is created") - public void whenMultirowDatatableCreated(String entityTypeStr) { - DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); - List columns = createRandomDatatableColumnsRequest(); - PostDataTablesRequest request = createDatatableRequest(entityType, columns, true); + public void whenMultirowDatatableCreated(final String entityTypeStr) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final List columns = createRandomDatatableColumnsRequest(); + final PostDataTablesRequest request = createDatatableRequest(entityType, columns, true); - PostDataTablesResponse response = ok(() -> fineractClient.dataTables().createDatatable(request, Map.of())); + final PostDataTablesResponse response = ok(() -> fineractClient.dataTables().createDatatable(request, Map.of())); testContext().set(CREATE_DATATABLE_RESULT_KEY, response); testContext().set(DATATABLE_NAME, response.getResourceIdentifier()); } - private List createRandomDatatableColumnsRequest() { - PostColumnHeaderData columnDef = new PostColumnHeaderData(); - columnDef.setName("col"); - columnDef.setType(DatatableColumnType.NUMBER.getTypeString()); - columnDef.setMandatory(false); - columnDef.setLength(10L); - columnDef.setCode(""); - columnDef.setUnique(false); - columnDef.setIndexed(false); - return List.of(columnDef); - } - - private PostDataTablesRequest createDatatableRequest(DatatableEntityType entityType, List columns) { - return createDatatableRequest(entityType, columns, false); - } - - private PostDataTablesRequest createDatatableRequest(DatatableEntityType entityType, List columns, - boolean multiRow) { - PostDataTablesRequest request = new PostDataTablesRequest(); - String datatableName = datatableNameGenerator.generate(entityType); - request.setDatatableName(datatableName); - request.setApptableName(entityType.getReferencedTableName()); - request.setMultiRow(multiRow); - request.setColumns(columns); - return request; + @Then("A datatable for {string} with column {string} is rejected with HTTP {int}") + public void thenCreateDatatableWithReservedColumnRejected(final String entityTypeStr, final String columnName, + final int expectedStatus) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final PostDataTablesRequest request = createDatatableRequest(entityType, List.of(numberColumn(columnName))); + final CallFailedRuntimeException ex = fail(() -> fineractClient.dataTables().createDatatable(request, Map.of())); + assertRejected(ex, expectedStatus, "creating datatable with reserved column [%s] for %s".formatted(columnName, entityType)); } @Then("The following column definitions match:") - public void thenColumnsMatch(DataTable dataTable) { - String datatableName = testContext().get(DATATABLE_NAME); - GetDataTablesResponse response = ok(() -> fineractClient.dataTables().getDatatable(datatableName, Map.of())); + public void thenColumnsMatch(final DataTable dataTable) { + final String datatableName = currentDatatable(); + final GetDataTablesResponse response = ok(() -> fineractClient.dataTables().getDatatable(datatableName, Map.of())); - Map columnMap = response.getColumnHeaderData().stream() + final Map columnMap = response.getColumnHeaderData().stream() .collect(Collectors.toMap(ResultsetColumnHeaderData::getColumnName, identity())); - List> rows = dataTable.asLists(); - List> rowsWithoutHeader = rows.subList(1, rows.size()); + final List> rows = dataTable.asLists(); + final List> rowsWithoutHeader = rows.subList(1, rows.size()); - for (List row : rowsWithoutHeader) { - String columnName = row.get(0); - boolean primaryKey = BooleanUtils.toBoolean(row.get(1)); - boolean unique = BooleanUtils.toBoolean(row.get(2)); - boolean indexed = BooleanUtils.toBoolean(row.get(3)); + for (final List row : rowsWithoutHeader) { + final String columnName = row.get(0); + final boolean primaryKey = BooleanUtils.toBoolean(row.get(1)); + final boolean unique = BooleanUtils.toBoolean(row.get(2)); + final boolean indexed = BooleanUtils.toBoolean(row.get(3)); - ResultsetColumnHeaderData columnMetadata = columnMap.get(columnName); + final ResultsetColumnHeaderData columnMetadata = columnMap.get(columnName); assertThat(columnMetadata).withFailMessage("Column [%s] not found on datatable", columnName).isNotNull(); assertThat(columnMetadata.getIsColumnPrimaryKey()) @@ -166,10 +143,32 @@ public void thenColumnsMatch(DataTable dataTable) { } } + @Then("The datatable contains columns:") + public void thenDatatableContainsColumns(final DataTable dataTable) { + final String datatableName = currentDatatable(); + final GetDataTablesResponse response = ok(() -> fineractClient.dataTables().getDatatable(datatableName, Map.of())); + final List actualColumns = response.getColumnHeaderData().stream().map(ResultsetColumnHeaderData::getColumnName).toList(); + final List expected = readColumnNames(dataTable); + assertThat(actualColumns) + .withFailMessage("Expected datatable [%s] to contain columns %s but had %s", datatableName, expected, actualColumns) + .containsAll(expected); + } + + @Then("The datatable does not contain columns:") + public void thenDatatableDoesNotContainColumns(final DataTable dataTable) { + final String datatableName = currentDatatable(); + final GetDataTablesResponse response = ok(() -> fineractClient.dataTables().getDatatable(datatableName, Map.of())); + final List actualColumns = response.getColumnHeaderData().stream().map(ResultsetColumnHeaderData::getColumnName).toList(); + final List absent = readColumnNames(dataTable); + assertThat(actualColumns) + .withFailMessage("Expected datatable [%s] to NOT contain columns %s but had %s", datatableName, absent, actualColumns) + .doesNotContainAnyElementsOf(absent); + } + @When("The client calls the query endpoint for the created datatable with {string} column filter, and {string} value filter") - public void thenColum23nsMatch(String columnFilter, String valueFilter) { + public void whenQueryEndpointCalled(final String columnFilter, final String valueFilter) { try { - fineractClient.dataTables().queryValues(testContext().get(DATATABLE_NAME), + fineractClient.dataTables().queryValues(currentDatatable(), Map.of("columnFilter", columnFilter, "valueFilter", valueFilter, "resultColumns", columnFilter)); } catch (FeignException e) { testContext().set(DATATABLE_QUERY_RESPONSE, e); @@ -177,15 +176,388 @@ public void thenColum23nsMatch(String columnFilter, String valueFilter) { } @Then("The status of the HTTP response should be {int}") - public void thenStatusCodeMatch(int statusCode) { - FeignException exception = testContext().get(DATATABLE_QUERY_RESPONSE); + public void thenStatusCodeMatch(final int statusCode) { + final FeignException exception = testContext().get(DATATABLE_QUERY_RESPONSE); assertThat(exception.status()).isEqualTo(statusCode); } @Then("The response body should contain the following message: {string}") - public void thenColumnsMatch(String json) { - FeignException exception = testContext().get(DATATABLE_QUERY_RESPONSE); - String jsonResponse = exception.responseBodyAsString(); + public void thenResponseBodyContains(final String json) { + final FeignException exception = testContext().get(DATATABLE_QUERY_RESPONSE); + final String jsonResponse = exception.responseBodyAsString(); assertThat(jsonResponse).contains(json); } + + @Then("Listing datatables with apptable {string} includes the created datatable") + public void thenListingByApptableIncludesCreated(final String apptable) { + final String datatableName = currentDatatable(); + final List response = ok(() -> fineractClient.dataTables().getDatatables(apptable, Map.of())); + assertThat(response).extracting(GetDataTablesResponse::getRegisteredTableName) + .withFailMessage("Expected datatable [%s] to be listed under apptable [%s] but it was not", datatableName, apptable) + .contains(datatableName); + } + + @Then("Listing datatables with apptable {string} excludes the created datatable") + public void thenListingByApptableExcludesCreated(final String apptable) { + final String datatableName = currentDatatable(); + final List response = ok(() -> fineractClient.dataTables().getDatatables(apptable, Map.of())); + assertThat(response).extracting(GetDataTablesResponse::getRegisteredTableName) + .withFailMessage("Datatable [%s] unexpectedly visible under apptable [%s]", datatableName, apptable) + .doesNotContain(datatableName); + } + + @When("The datatable is deregistered") + public void whenDatatableDeregistered() { + final String datatableName = currentDatatable(); + ok(() -> fineractClient.dataTables().deregisterDatatable(datatableName, Map.of())); + } + + @When("The datatable is registered against apptable {string}") + public void whenDatatableRegisteredAgainstApptable(final String apptable) { + final String datatableName = currentDatatable(); + ok(() -> fineractClient.dataTables().registerDatatable(datatableName, apptable, Map.of(), Map.of())); + } + + @When("The datatable is deleted") + public void whenDatatableDeleted() { + final String datatableName = currentDatatable(); + ok(() -> fineractClient.dataTables().deleteDatatable(datatableName, Map.of())); + } + + @When("Column {string} of type {string} is added to the datatable") + public void whenColumnAdded(final String columnName, final String columnType) { + final String datatableName = currentDatatable(); + ok(() -> fineractClient.dataTables().updateDatatable(datatableName, addColumnRequest(columnName, columnType), Map.of())); + } + + @When("Column {string} is renamed to {string} on the datatable") + public void whenColumnRenamed(final String oldName, final String newName) { + final String datatableName = currentDatatable(); + ok(() -> fineractClient.dataTables().updateDatatable(datatableName, renameColumnRequest(oldName, newName), Map.of())); + } + + @When("Column {string} is dropped from the datatable") + public void whenColumnDropped(final String columnName) { + final String datatableName = currentDatatable(); + ok(() -> fineractClient.dataTables().updateDatatable(datatableName, dropColumnRequest(columnName), Map.of())); + } + + @Then("Adding column {string} of type {string} to the datatable is rejected with HTTP {int}") + public void thenAddColumnRejected(final String columnName, final String columnType, final int expectedStatus) { + final String datatableName = currentDatatable(); + final CallFailedRuntimeException ex = fail( + () -> fineractClient.dataTables().updateDatatable(datatableName, addColumnRequest(columnName, columnType), Map.of())); + assertRejected(ex, expectedStatus, "adding reserved column [%s] on [%s]".formatted(columnName, datatableName)); + } + + @Then("Renaming column {string} to {string} on the datatable is rejected with HTTP {int}") + public void thenRenameColumnRejected(final String oldName, final String newName, final int expectedStatus) { + final String datatableName = currentDatatable(); + final CallFailedRuntimeException ex = fail( + () -> fineractClient.dataTables().updateDatatable(datatableName, renameColumnRequest(oldName, newName), Map.of())); + assertRejected(ex, expectedStatus, "renaming [%s] to reserved [%s] on [%s]".formatted(oldName, newName, datatableName)); + } + + @Then("Dropping column {string} from the datatable is rejected with HTTP {int}") + public void thenDropColumnRejected(final String columnName, final int expectedStatus) { + final String datatableName = currentDatatable(); + final CallFailedRuntimeException ex = fail( + () -> fineractClient.dataTables().updateDatatable(datatableName, dropColumnRequest(columnName), Map.of())); + assertRejected(ex, expectedStatus, "dropping reserved column [%s] on [%s]".formatted(columnName, datatableName)); + } + + @When("A datatable entry is created for {string} with value {string} in column {string}") + public void whenEntryCreatedForEntity(final String entityTypeStr, final String value, final String columnName) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final String datatableName = currentDatatable(); + final Long entityId = resolveEntityId(entityType); + final Map body = buildEntryBody(columnName, value); + final PostDataTablesAppTableIdResponse response = ok( + () -> fineractClient.dataTables().createDatatableEntry(datatableName, entityId, body, Map.of())); + testContext().set(DATATABLE_ENTRY_ID, response.getResourceId()); + } + + @Then("A second datatable entry for {string} with value {string} in column {string} is rejected") + public void thenSecondEntryRejected(final String entityTypeStr, final String value, final String columnName) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final String datatableName = currentDatatable(); + final Long entityId = resolveEntityId(entityType); + final Map body = buildEntryBody(columnName, value); + try { + fineractClient.dataTables().createDatatableEntry(datatableName, entityId, body, Map.of()); + throw new AssertionError( + "Expected second entry on single-row datatable [" + datatableName + "] to be rejected, but it succeeded"); + } catch (final FeignException e) { + assertThat(e.status()).withFailMessage("Expected client/server error for second single-row entry, but got HTTP %d", e.status()) + .isGreaterThanOrEqualTo(400); + } + } + + @Then("Fetching the datatable entry for {string} returns value {string} in column {string}") + public void thenEntryHasValue(final String entityTypeStr, final String expectedValue, final String columnName) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final List> rows = fetchEntryRows(entityType); + assertThat(rows).withFailMessage("No datatable rows found for entity %s", entityType).isNotEmpty(); + final Object actual = rows.getFirst().get(columnName); + final Object expected = parseValue(expectedValue); + assertThat(valuesMatch(actual, expected)).withFailMessage("Column [%s] expected [%s] but was [%s]", columnName, expected, actual) + .isTrue(); + } + + @When("The datatable entry for {string} is updated with value {string} in column {string}") + public void whenEntryUpdated(final String entityTypeStr, final String value, final String columnName) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final String datatableName = currentDatatable(); + final Long entityId = resolveEntityId(entityType); + final Map body = buildEntryBody(columnName, value); + ok(() -> fineractClient.dataTables().updateDatatableEntryOnetoOne(datatableName, entityId, body, Map.of())); + } + + @When("The datatable entry for {string} is deleted") + public void whenEntryDeleted(final String entityTypeStr) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final String datatableName = currentDatatable(); + final Long entityId = resolveEntityId(entityType); + ok(() -> fineractClient.dataTables().deleteDatatableEntries(datatableName, entityId, Map.of())); + } + + @Then("Fetching the datatable entry for {string} returns empty result") + public void thenEntryEmpty(final String entityTypeStr) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final List> rows = fetchEntryRows(entityType); + assertThat(rows).isEmpty(); + } + + @When("A multirow datatable entry is created for {string} with value {string} in column {string}") + public void whenMultirowEntryCreated(final String entityTypeStr, final String value, final String columnName) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final String datatableName = currentDatatable(); + final Long entityId = resolveEntityId(entityType); + final Map body = buildEntryBody(columnName, value); + final PostDataTablesAppTableIdResponse response = ok( + () -> fineractClient.dataTables().createDatatableEntry(datatableName, entityId, body, Map.of())); + testContext().set(DATATABLE_ENTRY_ID, response.getResourceId()); + } + + @Then("Fetching multirow datatable entries for {string} returns value {string} in column {string}") + public void thenMultirowEntryHasValue(final String entityTypeStr, final String expectedValue, final String columnName) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final List> rows = fetchEntryRows(entityType); + assertThat(rows).withFailMessage("No datatable rows found for entity %s", entityType).isNotEmpty(); + final Object expected = parseValue(expectedValue); + assertThat(rows).anyMatch(row -> valuesMatch(row.get(columnName), expected)); + } + + @When("The multirow datatable entry for {string} is updated with value {string} in column {string} by entry id") + public void whenMultirowEntryUpdatedById(final String entityTypeStr, final String value, final String columnName) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final String datatableName = currentDatatable(); + final Long entityId = resolveEntityId(entityType); + final Long entryId = testContext().get(DATATABLE_ENTRY_ID); + final Map body = buildEntryBody(columnName, value); + ok(() -> fineractClient.dataTables().updateDatatableEntryOneToMany(datatableName, entityId, entryId, body, Map.of())); + } + + @When("The multirow datatable entry for {string} is deleted by entry id") + public void whenMultirowEntryDeletedById(final String entityTypeStr) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final String datatableName = currentDatatable(); + final Long entityId = resolveEntityId(entityType); + final Long entryId = testContext().get(DATATABLE_ENTRY_ID); + ok(() -> fineractClient.dataTables().deleteDatatableEntry(datatableName, entityId, entryId, Map.of())); + } + + @Then("Fetching the multirow datatable entry by id for {string} returns value {string} in column {string}") + public void thenMultirowEntryByIdHasValue(final String entityTypeStr, final String expectedValue, final String columnName) { + final DatatableEntityType entityType = DatatableEntityType.fromString(entityTypeStr); + final String datatableName = currentDatatable(); + final Long entityId = resolveEntityId(entityType); + final Long entryId = testContext().get(DATATABLE_ENTRY_ID); + final Object response = ok( + () -> fineractClient.dataTables().getDatatableManyEntry(datatableName, entityId, entryId, null, false, Map.of())); + final List> rows = toRowList(response); + assertThat(rows).withFailMessage("No datatable row found for entity %s entry %d", entityType, entryId).isNotEmpty(); + final Object expected = parseValue(expectedValue); + assertThat(rows).anyMatch(row -> valuesMatch(row.get(columnName), expected)); + } + + private String currentDatatable() { + return testContext().get(DATATABLE_NAME); + } + + private void assertRejected(final CallFailedRuntimeException ex, final int expectedStatus, final String operation) { + assertThat(ex.getStatus()).withFailMessage("Expected HTTP %d when %s but got HTTP %d", expectedStatus, operation, ex.getStatus()) + .isEqualTo(expectedStatus); + assertThat(ex.getDeveloperMessage()) + .withFailMessage("Server should return a non-blank developer message when %s, but was blank", operation).isNotBlank(); + } + + private List readColumnNames(final DataTable dataTable) { + return dataTable.asLists().stream().skip(1).map(r -> r.get(0)).toList(); + } + + private List createRandomDatatableColumnsRequest() { + return List.of(numberColumn("col")); + } + + private List createDatatableColumnsRequest(final List> rowsWithoutHeader) { + return rowsWithoutHeader.stream().map(row -> { + final String columnName = row.get(0); + final DatatableColumnType columnType = DatatableColumnType.fromTypeString(row.get(1)); + final long columnLength = Long.parseLong(row.get(2)); + final boolean unique = BooleanUtils.toBoolean(row.get(3)); + final boolean indexed = BooleanUtils.toBoolean(row.get(4)); + + final PostColumnHeaderData postColumnHeaderData = new PostColumnHeaderData(); + postColumnHeaderData.setName(columnName); + postColumnHeaderData.setType(columnType.getTypeString()); + postColumnHeaderData.setLength(columnLength); + postColumnHeaderData.setUnique(unique); + postColumnHeaderData.setIndexed(indexed); + return postColumnHeaderData; + }).collect(Collectors.toList()); + } + + private PostColumnHeaderData numberColumn(final String name) { + final PostColumnHeaderData column = new PostColumnHeaderData(); + column.setName(name); + column.setType(DatatableColumnType.NUMBER.getTypeString()); + column.setMandatory(false); + column.setLength(10L); + column.setCode(""); + column.setUnique(false); + column.setIndexed(false); + return column; + } + + private PostDataTablesRequest createDatatableRequest(final DatatableEntityType entityType, final List columns) { + return createDatatableRequest(entityType, columns, false); + } + + private PostDataTablesRequest createDatatableRequest(final DatatableEntityType entityType, final List columns, + final boolean multiRow) { + final PostDataTablesRequest request = new PostDataTablesRequest(); + final String datatableName = datatableNameGenerator.generate(entityType); + request.setDatatableName(datatableName); + request.setApptableName(entityType.getReferencedTableName()); + request.setMultiRow(multiRow); + request.setColumns(columns); + return request; + } + + private PutDataTablesRequest addColumnRequest(final String name, final String type) { + final PutDataTablesRequestAddColumns add = new PutDataTablesRequestAddColumns(); + add.setName(name); + add.setType(type); + add.setMandatory(false); + add.setUnique(false); + add.setIndexed(false); + final PutDataTablesRequest request = new PutDataTablesRequest(); + request.setAddColumns(List.of(add)); + return request; + } + + private PutDataTablesRequest renameColumnRequest(final String oldName, final String newName) { + final PutDataTablesRequestChangeColumns change = new PutDataTablesRequestChangeColumns(); + change.setName(oldName); + change.setNewName(newName); + final PutDataTablesRequest request = new PutDataTablesRequest(); + request.setChangeColumns(List.of(change)); + return request; + } + + private PutDataTablesRequest dropColumnRequest(final String name) { + final PutDataTablesRequestDropColumns drop = new PutDataTablesRequestDropColumns(); + drop.setName(name); + final PutDataTablesRequest request = new PutDataTablesRequest(); + request.setDropColumns(List.of(drop)); + return request; + } + + private Map buildEntryBody(final String columnName, final String value) { + return Map.of("locale", "en", columnName, parseValue(value)); + } + + private Object parseValue(final String value) { + if ("true".equalsIgnoreCase(value)) { + return Boolean.TRUE; + } + if ("false".equalsIgnoreCase(value)) { + return Boolean.FALSE; + } + try { + final long parsed = Long.parseLong(value); + return parsed >= Integer.MIN_VALUE && parsed <= Integer.MAX_VALUE ? (int) parsed : parsed; + } catch (final NumberFormatException ignored) { + // not an integer, fall through + } + try { + return new BigDecimal(value); + } catch (final NumberFormatException ignored) { + return value; + } + } + + private boolean valuesMatch(final Object actual, final Object expected) { + if (Objects.equals(actual, expected)) { + return true; + } + if (actual instanceof Number actualNum && expected instanceof Number expectedNum) { + return new BigDecimal(actualNum.toString()).compareTo(new BigDecimal(expectedNum.toString())) == 0; + } + return false; + } + + private List> fetchEntryRows(final DatatableEntityType entityType) { + final String datatableName = currentDatatable(); + final Long entityId = resolveEntityId(entityType); + final Object response = ok(() -> fineractClient.dataTables().getDatatableEntries(datatableName, entityId, (String) null, Map.of())); + return toRowList(response); + } + + private List> toRowList(final Object response) { + if (response instanceof List list) { + final List> rows = new ArrayList<>(); + for (final Object item : list) { + if (item instanceof Map map) { + rows.add(map); + } + } + return rows; + } + if (response instanceof Map map && !map.isEmpty()) { + return List.of(map); + } + return List.of(); + } + + private Long resolveEntityId(final DatatableEntityType entityType) { + return switch (entityType) { + case WC_LOAN_PRODUCT -> resolveWcLoanProductId(); + case WC_LOAN -> resolveWcLoanId(); + case LOAN, LOAN_PRODUCT -> + throw new UnsupportedOperationException("Entry steps are not implemented for " + entityType + " entity"); + }; + } + + private Long resolveWcLoanProductId() { + final PostWorkingCapitalLoanProductsResponse response = Optional + .ofNullable( + testContext().get(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE)) + .orElseGet(() -> testContext().get(TestContextKey.DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE_WCLP)); + assertThat(response) + .withFailMessage( + "No WC loan product found in test context. " + "Use 'Admin creates a new Working Capital Loan Product' step first.") + .isNotNull(); + return response.getResourceId(); + } + + private Long resolveWcLoanId() { + final PostWorkingCapitalLoansResponse response = testContext().get(TestContextKey.WORKING_CAPITAL_LOAN_CREATE_RESPONSE); + assertThat(response) + .withFailMessage( + "No WC loan found in test context. Use 'Admin creates a working capital loan with the following data' step first.") + .isNotNull(); + return response.getLoanId(); + } } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java index 9056210cbfe..e37465dc065 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java @@ -131,6 +131,7 @@ public void createWorkingCapitalLoanUsingCreatedProduct(final DataTable table) { final PostWorkingCapitalLoansResponse response = ok( () -> fineractClient.workingCapitalLoans().submitWorkingCapitalLoanApplication(loansRequest)); testContext().set(TestContextKey.LOAN_CREATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_CREATE_RESPONSE, response); log.info("Working Capital Loan created with dynamic product ID: {}, Loan ID: {}", loanProductId, response.getLoanId()); } @@ -903,6 +904,7 @@ private void createWorkingCapitalLoanAccount(final List loanData) { final PostWorkingCapitalLoansResponse response = ok( () -> fineractClient.workingCapitalLoans().submitWorkingCapitalLoanApplication(loansRequest)); testContext().set(TestContextKey.LOAN_CREATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_CREATE_RESPONSE, response); trackLoanIdIfEnabled(response.getLoanId()); log.info("Working Capital Loan created with ID: {}", response.getLoanId()); } @@ -1201,6 +1203,7 @@ private void submitLoanAndStore(final PostWorkingCapitalLoansRequest request) { final PostWorkingCapitalLoansResponse response = ok( () -> fineractClient.workingCapitalLoans().submitWorkingCapitalLoanApplication(request)); testContext().set(TestContextKey.LOAN_CREATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_CREATE_RESPONSE, response); log.info("Working Capital Loan created, loan ID: {}", response.getLoanId()); } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index 32c7ea568df..9a297d12ef7 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -28,6 +28,7 @@ public abstract class TestContextKey { public static final String CLIENT_CREATE_SECOND_CLIENT_RESPONSE = "clientCreateSecondClientResponse"; public static final String LOAN_CREATE_REQUEST = "loanCreateRequest"; public static final String LOAN_CREATE_RESPONSE = "loanCreateResponse"; + public static final String WORKING_CAPITAL_LOAN_CREATE_RESPONSE = "workingCapitalLoanCreateResponse"; public static final String LOAN_CREATE_SECOND_LOAN_RESPONSE = "loanCreateSecondLoanResponse"; public static final String LOAN_MODIFY_RESPONSE = "loanModifyResponse"; public static final String LOAN_DELETE_RESPONSE = "loanDeleteResponse"; diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDatatables.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDatatables.feature new file mode 100644 index 00000000000..b77c8d1d543 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDatatables.feature @@ -0,0 +1,276 @@ +@WorkingCapitalDatatablesFeature +Feature: WorkingCapitalDatatables + + # Datatable schema - foreign key column per entity and strategy + @TestRailId:C76716 + Scenario: Single-row datatable for WC Loan exposes wc_loan_id as unique primary key + When A datatable for "WC_Loan" is created + Then The following column definitions match: + | Name | Primary key | Unique | Indexed | + | wc_loan_id | true | true | true | + + @TestRailId:C76717 + Scenario: Multi-row datatable for WC Loan exposes wc_loan_id as indexed foreign key + When A multirow datatable for "WC_Loan" is created + Then The following column definitions match: + | Name | Primary key | Unique | Indexed | + | wc_loan_id | false | false | true | + + @TestRailId:C76718 + Scenario: Single-row datatable for WC Loan Product exposes wc_product_loan_id as unique primary key + When A datatable for "WC_Loan_Product" is created + Then The following column definitions match: + | Name | Primary key | Unique | Indexed | + | wc_product_loan_id | true | true | true | + + @TestRailId:C76719 + Scenario: Multi-row datatable for WC Loan Product exposes wc_product_loan_id as indexed foreign key + When A multirow datatable for "WC_Loan_Product" is created + Then The following column definitions match: + | Name | Primary key | Unique | Indexed | + | wc_product_loan_id | false | false | true | + + # Datatable visibility is scoped to its registered entity + @TestRailId:C76720 + Scenario: Datatable registered for WC Loan is listed under m_wc_loan but not under m_loan + When A datatable for "WC_Loan" is created + Then Listing datatables with apptable "m_wc_loan" includes the created datatable + And Listing datatables with apptable "m_loan" excludes the created datatable + + @TestRailId:C76721 + Scenario: Datatable registered for WC Loan Product is listed under m_wc_loan_product but not under m_product_loan + When A datatable for "WC_Loan_Product" is created + Then Listing datatables with apptable "m_wc_loan_product" includes the created datatable + And Listing datatables with apptable "m_product_loan" excludes the created datatable + + # Entry CRUD - WC Loan Product + @TestRailId:C76722 + Scenario: Single-row datatable entry for WC Loan Product supports create, read, update, delete + When Admin creates a new Working Capital Loan Product + And A datatable for "WC_Loan_Product" is created with the following extra columns: + | Name | Type | Length | Unique | Indexed | + | test_nr | number | 10 | false | false | + And A datatable entry is created for "WC_Loan_Product" with value "42" in column "test_nr" + Then Fetching the datatable entry for "WC_Loan_Product" returns value "42" in column "test_nr" + When The datatable entry for "WC_Loan_Product" is updated with value "99" in column "test_nr" + Then Fetching the datatable entry for "WC_Loan_Product" returns value "99" in column "test_nr" + When The datatable entry for "WC_Loan_Product" is deleted + Then Fetching the datatable entry for "WC_Loan_Product" returns empty result + + @TestRailId:C76723 + Scenario: Multi-row datatable entries for WC Loan Product support create, read, update, delete + When Admin creates a new Working Capital Loan Product + And A multirow datatable for "WC_Loan_Product" is created + And A multirow datatable entry is created for "WC_Loan_Product" with value "10" in column "col" + Then Fetching multirow datatable entries for "WC_Loan_Product" returns value "10" in column "col" + When The multirow datatable entry for "WC_Loan_Product" is updated with value "77" in column "col" by entry id + Then Fetching multirow datatable entries for "WC_Loan_Product" returns value "77" in column "col" + When The multirow datatable entry for "WC_Loan_Product" is deleted by entry id + Then Fetching the datatable entry for "WC_Loan_Product" returns empty result + + # Entry CRUD - WC Loan + @TestRailId:C76724 + Scenario: Single-row datatable entry for WC Loan supports create, read, update, delete + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And A datatable for "WC_Loan" is created with the following extra columns: + | Name | Type | Length | Unique | Indexed | + | test_nr | number | 10 | false | false | + And A datatable entry is created for "WC_Loan" with value "42" in column "test_nr" + Then Fetching the datatable entry for "WC_Loan" returns value "42" in column "test_nr" + When The datatable entry for "WC_Loan" is updated with value "99" in column "test_nr" + Then Fetching the datatable entry for "WC_Loan" returns value "99" in column "test_nr" + When The datatable entry for "WC_Loan" is deleted + Then Fetching the datatable entry for "WC_Loan" returns empty result + + @TestRailId:C76725 + Scenario: Multi-row datatable entries for WC Loan support create, read, update, delete + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And A multirow datatable for "WC_Loan" is created + And A multirow datatable entry is created for "WC_Loan" with value "10" in column "col" + Then Fetching multirow datatable entries for "WC_Loan" returns value "10" in column "col" + When The multirow datatable entry for "WC_Loan" is updated with value "77" in column "col" by entry id + Then Fetching multirow datatable entries for "WC_Loan" returns value "77" in column "col" + When The multirow datatable entry for "WC_Loan" is deleted by entry id + Then Fetching the datatable entry for "WC_Loan" returns empty result + + # Single-row strategy - only one entry per parent row + @TestRailId:C76726 + Scenario: Single-row datatable for WC Loan rejects a second entry for the same loan + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And A datatable for "WC_Loan" is created with the following extra columns: + | Name | Type | Length | Unique | Indexed | + | test_nr | number | 10 | false | false | + And A datatable entry is created for "WC_Loan" with value "1" in column "test_nr" + Then A second datatable entry for "WC_Loan" with value "2" in column "test_nr" is rejected + + @TestRailId:C76727 + Scenario: Single-row datatable for WC Loan Product rejects a second entry for the same product + When Admin creates a new Working Capital Loan Product + And A datatable for "WC_Loan_Product" is created with the following extra columns: + | Name | Type | Length | Unique | Indexed | + | test_nr | number | 10 | false | false | + And A datatable entry is created for "WC_Loan_Product" with value "1" in column "test_nr" + Then A second datatable entry for "WC_Loan_Product" with value "2" in column "test_nr" is rejected + + @TestRailId:C76728 + Scenario: Deregistered WC Loan datatable is hidden from listings, and reappears when re-registered + When A datatable for "WC_Loan" is created + And The datatable is deregistered + Then Listing datatables with apptable "m_wc_loan" excludes the created datatable + When The datatable is registered against apptable "m_wc_loan" + Then Listing datatables with apptable "m_wc_loan" includes the created datatable + And Listing datatables with apptable "m_loan" excludes the created datatable + + @TestRailId:C76729 + Scenario: Deregistered WC Loan Product datatable is hidden from listings, and reappears when re-registered + When A datatable for "WC_Loan_Product" is created + And The datatable is deregistered + Then Listing datatables with apptable "m_wc_loan_product" excludes the created datatable + When The datatable is registered against apptable "m_wc_loan_product" + Then Listing datatables with apptable "m_wc_loan_product" includes the created datatable + And Listing datatables with apptable "m_product_loan" excludes the created datatable + + @TestRailId:C76730 + Scenario: Adding, renaming and dropping columns on a WC Loan datatable + When A datatable for "WC_Loan" is created with the following extra columns: + | Name | Type | Length | Unique | Indexed | + | note | number | 10 | false | false | + And Column "extra_num" of type "number" is added to the datatable + Then The datatable contains columns: + | Name | + | note | + | extra_num | + When Column "note" is renamed to "note_v2" on the datatable + Then The datatable contains columns: + | Name | + | note_v2 | + And The datatable does not contain columns: + | Name | + | note | + When Column "extra_num" is dropped from the datatable + Then The datatable does not contain columns: + | Name | + | extra_num | + + @TestRailId:C76731 + Scenario: Adding, renaming and dropping columns on a WC Loan Product datatable + When A datatable for "WC_Loan_Product" is created with the following extra columns: + | Name | Type | Length | Unique | Indexed | + | note | number | 10 | false | false | + And Column "extra_num" of type "number" is added to the datatable + Then The datatable contains columns: + | Name | + | extra_num | + When Column "note" is renamed to "note_v2" on the datatable + Then The datatable contains columns: + | Name | + | note_v2 | + When Column "extra_num" is dropped from the datatable + Then The datatable does not contain columns: + | Name | + | extra_num | + + @TestRailId:C76732 + Scenario: Deleting a WC Loan datatable removes it from all listings + When A datatable for "WC_Loan" is created + And The datatable is deleted + Then Listing datatables with apptable "m_wc_loan" excludes the created datatable + + @TestRailId:C76733 + Scenario: Deleting a WC Loan Product datatable removes it from all listings + When A datatable for "WC_Loan_Product" is created + And The datatable is deleted + Then Listing datatables with apptable "m_wc_loan_product" excludes the created datatable + + @TestRailId:C76734 + Scenario Outline: Creating a WC Loan datatable with reserved column "" is rejected + Then A datatable for "WC_Loan" with column "" is rejected with HTTP + + Examples: + | column | status | + | id | 400 | + | wc_loan_id | 400 | + + @TestRailId:C76735 + Scenario Outline: Creating a WC Loan Product datatable with reserved column "" is rejected + Then A datatable for "WC_Loan_Product" with column "" is rejected with HTTP + + Examples: + | column | status | + | id | 400 | + | wc_product_loan_id | 400 | + + @TestRailId:C76736 + # NOTE: `id` returns HTTP 400 from the API validator; `wc_loan_id` returns HTTP 403 because the deserializer + # skips the FK reserved-name check on PUT (apptableName is not part of the PUT body) and the rejection falls + # through to the DB layer (PlatformDataIntegrityException). Confluence implies a uniform 400 path; flip the + # expected status back to 400 once the server-side inconsistency is fixed. + Scenario Outline: Schema-mutating operations reject reserved column names on a WC Loan datatable + When A datatable for "WC_Loan" is created with the following extra columns: + | Name | Type | Length | Unique | Indexed | + | note | number | 10 | false | false | + Then Adding column "" of type "number" to the datatable is rejected with HTTP + And Renaming column "note" to "" on the datatable is rejected with HTTP + And Dropping column "" from the datatable is rejected with HTTP + + Examples: + | column | status | + | id | 400 | + | wc_loan_id | 403 | + + @TestRailId:C76737 + # NOTE: see C76736 — same server-side inconsistency for the FK column on PUT. + Scenario Outline: Schema-mutating operations reject reserved column names on a WC Loan Product datatable + When A datatable for "WC_Loan_Product" is created with the following extra columns: + | Name | Type | Length | Unique | Indexed | + | note | number | 10 | false | false | + Then Adding column "" of type "number" to the datatable is rejected with HTTP + And Renaming column "note" to "" on the datatable is rejected with HTTP + And Dropping column "" from the datatable is rejected with HTTP + + Examples: + | column | status | + | id | 400 | + | wc_product_loan_id | 403 | + + @TestRailId:C76738 + Scenario: Datatable registered for Term Loan is not listed under m_wc_loan + When A datatable for "LOAN" is created + Then Listing datatables with apptable "m_loan" includes the created datatable + And Listing datatables with apptable "m_wc_loan" excludes the created datatable + + @TestRailId:C76739 + Scenario: Datatable registered for Term Loan Product is not listed under m_wc_loan_product + When A datatable for "LOAN_PRODUCT" is created + Then Listing datatables with apptable "m_product_loan" includes the created datatable + And Listing datatables with apptable "m_wc_loan_product" excludes the created datatable + + @TestRailId:C76740 + Scenario: Fetching a specific multi-row datatable entry by id returns that row for WC Loan Product + When Admin creates a new Working Capital Loan Product + And A multirow datatable for "WC_Loan_Product" is created + And A multirow datatable entry is created for "WC_Loan_Product" with value "55" in column "col" + Then Fetching the multirow datatable entry by id for "WC_Loan_Product" returns value "55" in column "col" + + @TestRailId:C76741 + Scenario: Fetching a specific multi-row datatable entry by id returns that row for WC Loan + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And A multirow datatable for "WC_Loan" is created + And A multirow datatable entry is created for "WC_Loan" with value "55" in column "col" + Then Fetching the multirow datatable entry by id for "WC_Loan" returns value "55" in column "col" diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/api/DatatablesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/api/DatatablesApiResource.java index d70d36245e1..2873aece16e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/api/DatatablesApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/api/DatatablesApiResource.java @@ -41,7 +41,6 @@ import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.UriInfo; -import java.util.HashMap; import java.util.List; import lombok.RequiredArgsConstructor; import org.apache.fineract.commands.domain.CommandWrapper; @@ -64,9 +63,15 @@ @Path("/v1/datatables") @Component -@Tag(name = "Data Tables", description = "The datatables API allows you to plug-in your own tables (MySql) that have a relationship to a Apache Fineract core table. For example, you might want to add some extra client fields and record information about each of the clients' family members. Via the API you can create, read, update and delete entries for each 'plugged-in' table. The API checks for permission and for 'data scoping' (only data within the users' office hierarchy can be managed by the user).\n" - + "\n" - + "The Apache Fineract Reference App uses a JQuery plug-in called stretchydatatables (which in turn uses this datatables resource) to provide a pretty flexible CRUD (Create, Read, Update, Delete) User Interface.") +@Tag(name = "Data Tables", description = """ + The datatables API allows you to plug-in your own tables (MySql) that have a relationship to a Apache Fineract \ + core table. For example, you might want to add some extra client fields and record information about each of \ + the clients' family members. Via the API you can create, read, update and delete entries for each 'plugged-in' \ + table. The API checks for permission and for 'data scoping' (only data within the users' office hierarchy can \ + be managed by the user). + + The Apache Fineract Reference App uses a JQuery plug-in called stretchydatatables (which in turn uses this \ + datatables resource) to provide a pretty flexible CRUD (Create, Read, Update, Delete) User Interface.""") @RequiredArgsConstructor public class DatatablesApiResource { @@ -79,9 +84,19 @@ public class DatatablesApiResource { @GET @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "List Data Tables", description = "Lists registered data tables and the Apache Fineract Core application table they are registered to.\n" - + "\n" + "ARGUMENTS\n" + "\n" + "apptable - optional" + "\n" + "The Apache Fineract core application table.\n" + "\n" - + "Example Requests:\n" + "\n" + "datatables?apptable=m_client\n" + "\n" + "\n" + "datatables") + @Operation(summary = "List Data Tables", description = """ + Lists registered data tables and the Apache Fineract Core application table they are registered to. + + ARGUMENTS + + apptable - optional + The Apache Fineract core application table. + + Example Requests: + + datatables?apptable=m_client + + datatables""") @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = DatatablesApiResourceSwagger.GetDataTablesResponse.class)))) public String getDatatables(@QueryParam("apptable") @Parameter(description = "apptable") final String apptable, @Context final UriInfo uriInfo) { @@ -94,23 +109,45 @@ public String getDatatables(@QueryParam("apptable") @Parameter(description = "ap @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Create Data Table", description = "Create a new data table and registers it with the Apache Fineract Core application table.\n" - + "\n" + "Field Descriptions\n" + "\n" + "Mandatory - datatableName : \n" + "\nThe name of the Data Table.\n" + "\n" - + "Mandatory - apptableName\n" + "\n" + "Application table name. Must be one of the following:\n" + "\n" + "m_client\n" + "\n" - + "m_group" + "\n" + "\n" + "m_loan" + "\n" + "\n" + "m_office" + "\n" + "\n" + "m_saving_account" + "\n" + "\n" - + "m_product_loan" + "\n" + "\n" + "m_savings_product" + "\n" + "\n" + "Mandatory - columns " + "\n" - + "An array of columns in the new Data Table." + "\n" + "\n" + "Optional - multiRow" + "\n" + "\n" - + "Allows to create multiple entries in the Data Table. Optional, defaults to false. If this property is not provided Data Table will allow only one entry." - + "\n" + "\n" + "Field Descriptions - columns" + "\n" + "\n" + "Mandatory - name" + "\n" + "\n" - + "Name of the created column. Can contain only alphanumeric characters, underscores and spaces, but cannot start with a number. Cannot start or end with an underscore or space." - + "\n" + "\n" + "Mandatory - type" + "\n" + "\n" + "Column type. Must be one of the following:" + "\n" + "\n" + "Boolean" + "\n" - + "\n" + "Date" + "\n" + "\n" + "DateTime" + "\n" + "\n" + "Decimal" + "\n" + "\n" + "Dropdown" + "\n" + "\n" + "\n" + "Number" - + "\n" + "\n" + "String" + "\n" + "\n" + "Text" + "\n" + "\n" + "Mandatory [type = Dropdown] - code" + "\n" + "\n" - + "Used in Code description fields. Column name becomes: code_cd_name. Mandatory if using type Dropdown, otherwise an error is returned." - + "\n" + "\n" + "Optional - mandatory" + "\n" + "\n" - + "Determines whether this column must have a value in every entry. Optional, defaults to false." + "\n" + "\n" - + "Mandatory [type = String] - length" + "\n" + "\n" - + "Length of the text field. Mandatory if type String is used, otherwise an error is returned.") + @Operation(summary = "Create Data Table", description = """ + Create a new data table and registers it with the Apache Fineract Core application table. + + Field Descriptions + + Mandatory - datatableName : + The name of the Data Table. + + Mandatory - apptableName + Application table name. Must be one of the following: + m_client, m_group, m_loan, m_office, m_saving_account, m_product_loan, \ + m_savings_product, m_wc_loan_product, m_wc_loan + + Mandatory - columns + An array of columns in the new Data Table. + + Optional - multiRow + Allows to create multiple entries in the Data Table. Optional, defaults to false. \ + If this property is not provided Data Table will allow only one entry. + + Field Descriptions - columns + + Mandatory - name + Name of the created column. Can contain only alphanumeric characters, underscores and spaces, \ + but cannot start with a number. Cannot start or end with an underscore or space. + + Mandatory - type + Column type. Must be one of the following: + Boolean, Date, DateTime, Decimal, Dropdown, Number, String, Text + + Mandatory [type = Dropdown] - code + Used in Code description fields. Column name becomes: code_cd_name. \ + Mandatory if using type Dropdown, otherwise an error is returned. + + Optional - mandatory + Determines whether this column must have a value in every entry. Optional, defaults to false. + + Mandatory [type = String] - length + Length of the text field. Mandatory if type String is used, otherwise an error is returned.""") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = DatatablesApiResourceSwagger.PostDataTablesRequest.class))) @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = DatatablesApiResourceSwagger.PostDataTablesResponse.class))) public String createDatatable(@Parameter(hidden = true) final String apiRequestBodyAsJson) { @@ -156,7 +193,10 @@ public String deleteDatatable(@PathParam("datatableName") @Parameter(description @Path("register/{datatable}/{apptable}") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Register Data Table", description = "Registers a data table with the Apache Fineract Core application table. This allows the data table to be maintained through the API. In case the datatable is a PPI (survey table), a parameter category should be pass along with the request. The API currently support one category (200)") + @Operation(summary = "Register Data Table", description = """ + Registers a data table with the Apache Fineract Core application table. This allows the data table to be \ + maintained through the API. In case the datatable is a PPI (survey table), a parameter category should be \ + pass along with the request. The API currently support one category (200)""") @RequestBody(content = @Content(schema = @Schema(implementation = DatatablesApiResourceSwagger.PostDataTablesRegisterDatatableAppTable.class))) @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = DatatablesApiResourceSwagger.PutDataTablesResponse.class))) public String registerDatatable(@PathParam("datatable") @Parameter(description = "datatable") final String datatable, @@ -229,14 +269,25 @@ public String advancedQuery(@PathParam("datatable") @Parameter(description = "da @GET @Path("{datatable}/{apptableId}") @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Retrieve Entry(s) from Data Table", description = "Gets the entry (if it exists) for data tables that are one to one with the application table. \n" - + "Gets the entries (if they exist) for data tables that are one to many with the application table.\n" + "\n" - + "Note: The 'fields' parameter is not available for datatables.\n" + "\n" + "ARGUMENTS\n" - + "orderoptional Specifies the order in which data is returned.genericResultSetoptional, defaults to false If 'true' an optimised JSON format is returned suitable for tabular display of data. This format is used by the default data tables UI functionality.\n" - + "Example Requests:\n" + "\n" + "datatables/extra_client_details/1\n" + "\n" + "\n" - + "datatables/extra_family_details/1?order=`Date of Birth` desc\n" + "\n" + "\n" - + "datatables/extra_client_details/1?genericResultSet=true") - @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = HashMap.class))) + @Operation(operationId = "getDatatableEntries", summary = "Retrieve Entry(s) from Data Table", description = """ + Gets the entry (if it exists) for data tables that are one to one with the application table. \ + Gets the entries (if they exist) for data tables that are one to many with the application table. + + Note: The 'fields' parameter is not available for datatables. + + ARGUMENTS + orderoptional Specifies the order in which data is returned.\ + genericResultSetoptional, defaults to false If 'true' an optimised JSON format is returned suitable for \ + tabular display of data. This format is used by the default data tables UI functionality. + + Example Requests: + + datatables/extra_client_details/1 + + datatables/extra_family_details/1?order=`Date of Birth` desc + + datatables/extra_client_details/1?genericResultSet=true""") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = Object.class))) public String getDatatable(@PathParam("datatable") @Parameter(description = "datatable") final String datatable, @PathParam("apptableId") @Parameter(description = "apptableId") final Long apptableId, @QueryParam("order") @Parameter(description = "order") final String order, @Context final UriInfo uriInfo) { @@ -246,7 +297,7 @@ public String getDatatable(@PathParam("datatable") @Parameter(description = "dat final GenericResultsetData results = this.datatableReadService.retrieveDataTableGenericResultSet(datatable, apptableId, order, null); - String json = ""; + String json; final boolean genericResultSet = ApiParameterHelper.genericResultSet(uriInfo.getQueryParameters()); if (genericResultSet) { @@ -261,9 +312,13 @@ public String getDatatable(@PathParam("datatable") @Parameter(description = "dat @GET @Path("{datatable}/{apptableId}/{datatableId}") @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve Entry from Data Table (One to Many)", description = """ + Gets the entry (if it exists) for data tables that are one-to-many with the application table, by the \ + datatable entry id. Returns the stored row as a JSON object keyed by column name.""") + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = Object.class))) public String getDatatableManyEntry(@PathParam("datatable") final String datatable, @PathParam("apptableId") final Long apptableId, @PathParam("datatableId") final Long datatableId, @QueryParam("order") final String order, - @DefaultValue("false") @QueryParam("genericResultSet") @Parameter(in = ParameterIn.QUERY, name = "genericResultSet", description = "Optional flag to format the response", required = false) final boolean genericResultSet, + @DefaultValue("false") @QueryParam("genericResultSet") @Parameter(in = ParameterIn.QUERY, name = "genericResultSet", description = "Optional flag to format the response") final boolean genericResultSet, @Context final UriInfo uriInfo) { this.context.authenticatedUser().validateHasDatatableReadPermission(datatable); @@ -271,7 +326,7 @@ public String getDatatableManyEntry(@PathParam("datatable") final String datatab final GenericResultsetData results = this.datatableReadService.retrieveDataTableGenericResultSet(datatable, apptableId, order, datatableId); - String json = ""; + String json; if (genericResultSet) { json = toApiJsonSerializer.serialize(results); } else { @@ -285,9 +340,13 @@ public String getDatatableManyEntry(@PathParam("datatable") final String datatab @Path("{datatable}/{apptableId}") @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Create Entry in Data Table", description = "Adds a row to the data table.\n" + "\n" - + "Note that the default datatable UI functionality converts any field name containing spaces to underscores when using the API. This means the field name \"Business Description\" is considered the same as \"Business_Description\". So you shouldn't have both \"versions\" in any data table.") - @RequestBody(required = true, content = @Content(schema = @Schema(implementation = String.class)), description = "{\n \"BusinessDescription\": \"Livestock sales\",\n \"Comment\": \"First comment made\",\n \"Education_cv\": \"Primary\",\n \"Gender_cd\": 6,\n \"HighestRatePaid\": 8.5,\n \"NextVisit\": \"01 October 2012\",\n \"YearsinBusiness\": 5,\n \"dateFormat\": \"dd MMMM yyyy\",\n \"locale\": \"en\"\n}") + @Operation(summary = "Create Entry in Data Table", description = """ + Adds a row to the data table. + + Note that the default datatable UI functionality converts any field name containing spaces to underscores \ + when using the API. This means the field name "Business Description" is considered the same as \ + "Business_Description". So you shouldn't have both "versions" in any data table.""") + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = Object.class)), description = "{\n \"BusinessDescription\": \"Livestock sales\",\n \"Comment\": \"First comment made\",\n \"Education_cv\": \"Primary\",\n \"Gender_cd\": 6,\n \"HighestRatePaid\": 8.5,\n \"NextVisit\": \"01 October 2012\",\n \"YearsinBusiness\": 5,\n \"dateFormat\": \"dd MMMM yyyy\",\n \"locale\": \"en\"\n}") @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = DatatablesApiResourceSwagger.PostDataTablesAppTableIdResponse.class))) public String createDatatableEntry(@PathParam("datatable") @Parameter(description = "datatable") final String datatable, @PathParam("apptableId") @Parameter(description = "apptableId") final Long apptableId, @@ -308,7 +367,7 @@ public String createDatatableEntry(@PathParam("datatable") @Parameter(descriptio @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Update Entry in Data Table (One to One)", description = "Updates the row (if it exists) of the data table.") - @RequestBody(required = true, content = @Content(schema = @Schema(implementation = String.class))) + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = Object.class))) @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = DatatablesApiResourceSwagger.PutDataTablesAppTableIdResponse.class))) public String updateDatatableEntryOnetoOne(@PathParam("datatable") @Parameter(description = "datatable") final String datatable, @PathParam("apptableId") @Parameter(description = "apptableId") final Long apptableId, @@ -329,7 +388,7 @@ public String updateDatatableEntryOnetoOne(@PathParam("datatable") @Parameter(de @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) @Operation(summary = "Update Entry in Data Table (One to Many)", description = "Updates the row (if it exists) of the data table.") - @RequestBody(required = true, content = @Content(schema = @Schema(implementation = String.class))) + @RequestBody(required = true, content = @Content(schema = @Schema(implementation = Object.class))) @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = DatatablesApiResourceSwagger.PutDataTablesAppTableIdDatatableIdResponse.class))) public String updateDatatableEntryOneToMany(@PathParam("datatable") @Parameter(description = "datatable") final String datatable, @PathParam("apptableId") @Parameter(description = "apptableId") final Long apptableId, @@ -349,8 +408,9 @@ public String updateDatatableEntryOneToMany(@PathParam("datatable") @Parameter(d @DELETE @Path("{datatable}/{apptableId}") @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Delete Entry(s) in Data Table", description = "Deletes the entry (if it exists) for data tables that are one-to-one with the application table. \n" - + "Deletes the entries (if they exist) for data tables that are one-to-many with the application table.") + @Operation(summary = "Delete Entry(s) in Data Table", description = """ + Deletes the entry (if it exists) for data tables that are one-to-one with the application table. \ + Deletes the entries (if they exist) for data tables that are one-to-many with the application table.""") @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = DatatablesApiResourceSwagger.DeleteDataTablesDatatableAppTableIdResponse.class))) public String deleteDatatableEntries(@PathParam("datatable") @Parameter(description = "datatable") final String datatable, @PathParam("apptableId") @Parameter(description = "apptableId") final Long apptableId) { @@ -367,8 +427,8 @@ public String deleteDatatableEntries(@PathParam("datatable") @Parameter(descript @DELETE @Path("{datatable}/{apptableId}/{datatableId}") @Produces({ MediaType.APPLICATION_JSON }) - @Operation(summary = "Delete Entry in Datatable (One to Many)", description = "Deletes the entry (if it exists) for data tables that are one to many with the application table.\n" - + "\n") + @Operation(summary = "Delete Entry in Datatable (One to Many)", description = """ + Deletes the entry (if it exists) for data tables that are one to many with the application table.""") @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = DatatablesApiResourceSwagger.DeleteDataTablesDatatableAppTableIdDatatableIdResponse.class))) public String deleteDatatableEntry(@PathParam("datatable") @Parameter(description = "datatable", example = "{}") final String datatable, @PathParam("apptableId") @Parameter(description = "apptableId") final Long apptableId, 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 55bcb25c92c..6577513d3e4 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 @@ -206,13 +206,17 @@ public String dataScopedSQL(@NonNull EntityTables entityTable, final Long appTab 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 -> { + case WC_LOAN -> { + params.add(hierarchyPattern); + params.add(appTableId); + yield "select o.id as officeId, null as groupId, l.client_id as clientId, null as savingsId, l.id as loanId, null as transactionId, null as entityId from m_wc_loan l " + + getClientOfficeJoinCondition("l") + " where l.id = ?"; + } + case LOAN_PRODUCT, SAVINGS_PRODUCT, SHARE_PRODUCT, WC_LOAN_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"); }; }