Skip to content

Commit 1f725c0

Browse files
committed
FINERACT-2713: Bind untyped report parameters numerically so Postgres cascades work
- Since FINERACT-2624 switched report-parameter substitution from string interpolation to JDBC bind variables, a placeholder that appears in a report's SQL but is not one of that report's own declared parameters binds as a String. On PostgreSQL, comparing a bigint column to a bound character varying raises "operator does not exist: bigint = character varying" and the query fails with HTTP 403; MySQL and MariaDB coerce the types silently, which is why CI does not see it. - The stock loanOfficerIdSelectAll option lookup is the clearest case. Its SQL filters "... and o.id = ${officeId}", where officeId is supplied by the parent parameter, so ReadReportingServiceImpl.getSQLtoRun loads the format types of loanOfficerIdSelectAll, finds no entry for officeId, and castParamValue falls through to returning the raw String. Every report whose Loan Officer dropdown cascades off office is therefore empty on a PostgreSQL deployment. - When no format type is declared, infer a numeric bind for a plain integer value so strict engines compare correctly. The inference is deliberately narrow: the value must match -?(0|[1-9]\d*), so currency codes, free text and identifiers carrying leading zeros such as 000123 keep their String binding and are not mangled into numbers. Declared NUMBER, INTEGER and DATE types are untouched. - Add ReadReportingServiceImplTest, which captures the values bound for a report whose SQL cascades on ${officeId}: an untyped "1" must arrive as a Long, while "USD" and "000123" must stay Strings and a declared number type must keep working. The first case fails on the unfixed code with "expected: java.lang.Long<1> but was: java.lang.String<1>"; the other three assert the narrowness of the inference and hold either way by design. Signed-off-by: oluexpert99 <farooq@techservicehub.io>
1 parent 2f652a8 commit 1f725c0

2 files changed

Lines changed: 151 additions & 0 deletions

File tree

fineract-provider/src/main/java/org/apache/fineract/infrastructure/dataqueries/service/ReadReportingServiceImpl.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ public class ReadReportingServiceImpl implements ReadReportingService {
8787
/** Matches any {@code ${placeholderName}} token in a report SQL template. */
8888
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{([^}]+)\\}");
8989

90+
/** A plain integer with no leading zeros, so identifiers like {@code 000123} stay strings. */
91+
private static final Pattern UNTYPED_INTEGER = Pattern.compile("-?(0|[1-9]\\d*)");
92+
9093
private final JdbcTemplate jdbcTemplate;
9194
private final PlatformSecurityContext context;
9295
private final GenericDataService genericDataService;
@@ -162,6 +165,18 @@ private Object castParamValue(String value, String formatType) {
162165
if ("DATE".equalsIgnoreCase(formatType)) {
163166
return java.sql.Date.valueOf(value);
164167
}
168+
// No declared format type — typically an office/product-cascaded lookup parameter
169+
// substituted into another report's SQL (e.g. ${officeId} inside loanOfficerIdSelectAll).
170+
// Bind a plain integer as a number so strict engines compare correctly; Postgres, unlike
171+
// MySQL, rejects "bigint = varchar". Everything else (currency codes, ids with leading
172+
// zeros, free text) stays a string.
173+
if ((formatType == null || formatType.isBlank()) && UNTYPED_INTEGER.matcher(value).matches()) {
174+
try {
175+
return Long.parseLong(value);
176+
} catch (NumberFormatException e) {
177+
return value;
178+
}
179+
}
165180
return value;
166181
}
167182

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.fineract.infrastructure.dataqueries.service;
20+
21+
import static org.junit.jupiter.api.Assertions.assertEquals;
22+
import static org.mockito.ArgumentMatchers.any;
23+
import static org.mockito.ArgumentMatchers.anyString;
24+
import static org.mockito.ArgumentMatchers.eq;
25+
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
26+
import static org.mockito.Mockito.mock;
27+
import static org.mockito.Mockito.verify;
28+
import static org.mockito.Mockito.when;
29+
30+
import java.util.Map;
31+
import org.apache.fineract.infrastructure.core.config.FineractProperties;
32+
import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator;
33+
import org.apache.fineract.infrastructure.dataqueries.data.GenericResultsetData;
34+
import org.apache.fineract.infrastructure.report.service.ReportParameterTypeResolver;
35+
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
36+
import org.apache.fineract.infrastructure.security.service.SqlInjectionPreventerService;
37+
import org.apache.fineract.useradministration.domain.AppUser;
38+
import org.junit.jupiter.api.BeforeEach;
39+
import org.junit.jupiter.api.Test;
40+
import org.mockito.ArgumentCaptor;
41+
import org.springframework.jdbc.core.JdbcTemplate;
42+
import org.springframework.jdbc.support.rowset.SqlRowSet;
43+
44+
/**
45+
* Binding of report parameters that have no declared format type — the cascaded-lookup case behind FINERACT-2713.
46+
*/
47+
class ReadReportingServiceImplTest {
48+
49+
private static final String REPORT_NAME = "loanOfficerIdSelectAll";
50+
// A cascaded lookup: ${officeId} comes from the PARENT parameter, so this report does not declare it.
51+
private static final String REPORT_SQL = "select lo.id, lo.display_name as name from m_staff lo "
52+
+ "join m_office o on o.id = lo.office_id where lo.is_loan_officer = true and o.id = ${officeId}";
53+
54+
private JdbcTemplate jdbcTemplate;
55+
private GenericDataService genericDataService;
56+
private ReportParameterTypeResolver reportParameterTypeResolver;
57+
private ReadReportingServiceImpl service;
58+
59+
@BeforeEach
60+
void setUp() {
61+
jdbcTemplate = mock(JdbcTemplate.class);
62+
genericDataService = mock(GenericDataService.class);
63+
reportParameterTypeResolver = mock(ReportParameterTypeResolver.class);
64+
PlatformSecurityContext context = mock(PlatformSecurityContext.class, RETURNS_DEEP_STUBS);
65+
DatabaseSpecificSQLGenerator sqlGenerator = mock(DatabaseSpecificSQLGenerator.class);
66+
SqlInjectionPreventerService sqlInjectionPreventerService = mock(SqlInjectionPreventerService.class);
67+
FineractProperties fineractProperties = mock(FineractProperties.class, RETURNS_DEEP_STUBS);
68+
69+
AppUser user = mock(AppUser.class, RETURNS_DEEP_STUBS);
70+
when(user.getId()).thenReturn(1L);
71+
when(user.getOffice().getHierarchy()).thenReturn(".");
72+
when(context.authenticatedUser()).thenReturn(user);
73+
74+
when(sqlGenerator.currentBusinessDate()).thenReturn("'2024-01-01'");
75+
when(sqlGenerator.currentTenantDateTime()).thenReturn("'2024-01-01 00:00:00'");
76+
when(sqlInjectionPreventerService.encodeSql(anyString())).thenAnswer(call -> call.getArgument(0));
77+
78+
// the report SQL lookup
79+
SqlRowSet rowSet = mock(SqlRowSet.class);
80+
when(rowSet.next()).thenReturn(true);
81+
when(rowSet.getString("the_sql")).thenReturn(REPORT_SQL);
82+
when(jdbcTemplate.queryForRowSet(anyString(), eq(REPORT_NAME))).thenReturn(rowSet);
83+
84+
// pass SQL through untouched so the assertion is about the bound values, not the rewriting
85+
when(genericDataService.wrapSQL(anyString())).thenAnswer(call -> call.getArgument(0));
86+
when(genericDataService.replace(anyString(), anyString(), anyString())).thenAnswer(call -> call.getArgument(0));
87+
when(genericDataService.fillGenericResultSet(anyString(), any())).thenReturn(mock(GenericResultsetData.class));
88+
89+
service = new ReadReportingServiceImpl(jdbcTemplate, context, genericDataService, sqlInjectionPreventerService, sqlGenerator,
90+
fineractProperties, reportParameterTypeResolver);
91+
}
92+
93+
private Object[] boundParamsFor(Map<String, String> queryParams) {
94+
service.retrieveGenericResultset(REPORT_NAME, "report", queryParams);
95+
ArgumentCaptor<Object[]> captor = ArgumentCaptor.forClass(Object[].class);
96+
verify(genericDataService).fillGenericResultSet(anyString(), captor.capture());
97+
return captor.getValue();
98+
}
99+
100+
// FINERACT-2713: a cascaded parameter is absent from the running report's own format-type map, so it used to bind
101+
// as a String. PostgreSQL rejects "bigint = character varying" (MySQL silently coerces it), leaving the dependent
102+
// dropdown empty. A plain integer with no declared type must bind numerically.
103+
@Test
104+
void untypedIntegerParameterBindsAsNumber() {
105+
when(reportParameterTypeResolver.loadParamFormatTypes(REPORT_NAME)).thenReturn(Map.of()); // officeId not declared
106+
107+
Object[] bound = boundParamsFor(Map.of("officeId", "1"));
108+
109+
assertEquals(1, bound.length);
110+
assertEquals(Long.valueOf(1L), bound[0], "an untyped plain integer must bind as a number, not a String");
111+
}
112+
113+
// The inference must stay narrow: anything that is not a plain integer keeps its String binding, so currency
114+
// codes and identifiers carrying leading zeros are not silently mangled into numbers.
115+
@Test
116+
void untypedNonIntegerParameterStaysAString() {
117+
when(reportParameterTypeResolver.loadParamFormatTypes(REPORT_NAME)).thenReturn(Map.of());
118+
119+
assertEquals("USD", boundParamsFor(Map.of("officeId", "USD"))[0]);
120+
}
121+
122+
@Test
123+
void untypedIntegerWithLeadingZerosStaysAString() {
124+
when(reportParameterTypeResolver.loadParamFormatTypes(REPORT_NAME)).thenReturn(Map.of());
125+
126+
assertEquals("000123", boundParamsFor(Map.of("officeId", "000123"))[0], "leading zeros are significant in identifiers");
127+
}
128+
129+
// A declared type still wins — this path is untouched by the fix.
130+
@Test
131+
void declaredNumberParameterStillBindsAsNumber() {
132+
when(reportParameterTypeResolver.loadParamFormatTypes(REPORT_NAME)).thenReturn(Map.of("officeId", "number"));
133+
134+
assertEquals(Long.valueOf(7L), boundParamsFor(Map.of("officeId", "7"))[0]);
135+
}
136+
}

0 commit comments

Comments
 (0)