Skip to content

Commit 0cea25c

Browse files
perf: DataEntry metadata with fewer n+1s [DHIS2-21757] (#24509)
1 parent c4aa911 commit 0cea25c

14 files changed

Lines changed: 539 additions & 1 deletion

File tree

dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryOptionComboStore.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@ public interface CategoryOptionComboStore extends IdentifiableObjectStore<Catego
4444
CategoryOptionCombo getCategoryOptionCombo(
4545
CategoryCombo categoryCombo, Set<CategoryOption> categoryOptions);
4646

47+
/**
48+
* Returns the {@link CategoryOptionCombo}s of the given {@link CategoryCombo}s with their {@link
49+
* CategoryOptionCombo#getCategoryOptions()} eagerly fetched in a single query. Used to prime the
50+
* session before serialising category option combos, avoiding the per-combo N+1 select on the
51+
* {@code categoryoptioncombos_categoryoptions} join table.
52+
*
53+
* @param categoryCombos the category combos whose option combos to load.
54+
* @return the option combos with their category options initialised.
55+
*/
56+
List<CategoryOptionCombo> getCategoryOptionCombosWithCategoryOptions(
57+
Collection<CategoryCombo> categoryCombos);
58+
4759
void updateNames();
4860

4961
void deleteNoRollBack(CategoryOptionCombo categoryOptionCombo);

dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryService.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,18 @@ CategoryOptionCombo getCategoryOptionCombo(
412412
*/
413413
List<CategoryOptionCombo> getAllCategoryOptionCombos();
414414

415+
/**
416+
* Returns the {@link CategoryOptionCombo}s of the given {@link CategoryCombo}s with their
417+
* category options eagerly fetched in a single query. Used to prime the session before
418+
* serialising category option combos, avoiding the per-combo N+1 select on the {@code
419+
* categoryoptioncombos_categoryoptions} join table.
420+
*
421+
* @param categoryCombos the category combos whose option combos to load.
422+
* @return the option combos with their category options initialised.
423+
*/
424+
List<CategoryOptionCombo> getCategoryOptionCombosWithCategoryOptions(
425+
Collection<CategoryCombo> categoryCombos);
426+
415427
/**
416428
* Generates and persists a default Category, CategoryOption, CategoryCombo and
417429
* CategoryOptionCombo.

dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataSetService.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@
2929
*/
3030
package org.hisp.dhis.dataset;
3131

32+
import java.util.Collection;
3233
import java.util.Date;
3334
import java.util.List;
3435
import org.apache.commons.collections4.SetValuedMap;
36+
import org.hisp.dhis.dataelement.DataElement;
3537
import org.hisp.dhis.dataentryform.DataEntryForm;
3638
import org.hisp.dhis.organisationunit.OrganisationUnit;
3739
import org.hisp.dhis.period.Period;
@@ -83,6 +85,19 @@ public interface DataSetService extends DataSetDataIntegrityProvider {
8385
*/
8486
DataSet getDataSet(String uid);
8587

88+
/**
89+
* Returns the distinct {@link DataElement}s that are members of the given data sets, loaded in a
90+
* single query (with each data element's {@code dataSetElements} and {@code categoryCombo}
91+
* eagerly fetched) to avoid N+1 selects when iterating {@link DataSet#getDataElements()} and
92+
* {@link DataElement#getCategoryCombos()} across many data sets. Does not apply sharing/ACL
93+
* predicates on the data elements; callers are responsible for authorizing access to the data
94+
* sets.
95+
*
96+
* @param dataSets the data sets.
97+
* @return the distinct data elements of the given data sets.
98+
*/
99+
List<DataElement> getDataElementsByDataSet(Collection<DataSet> dataSets);
100+
86101
/**
87102
* Returns the DataSet with the given UID. Bypasses the ACL system.
88103
*

dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataSetStore.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,22 @@ public interface DataSetStore
5353

5454
List<DataSetElement> getDataSetElementsByDataElement(Collection<DataElement> dataElements);
5555

56+
/**
57+
* Returns the distinct {@link DataElement}s that are members of the given data sets (via {@link
58+
* DataSetElement}), loaded in a single query. Each data element's {@link
59+
* DataElement#getDataSetElements()} collection and its {@link DataElement#getCategoryCombo()} are
60+
* eagerly fetched as well, so that {@link DataSet#getDataElements()} and {@link
61+
* DataElement#getCategoryCombos()} can be evaluated without triggering per-element (or
62+
* per-category-combo) N+1 selects.
63+
*
64+
* <p>This method does <b>not</b> apply sharing/ACL predicates on the data elements. Callers are
65+
* responsible for authorizing access to the data sets.
66+
*
67+
* @param dataSets the data sets.
68+
* @return the distinct data elements of the given data sets.
69+
*/
70+
List<DataElement> getDataElementsByDataSet(Collection<DataSet> dataSets);
71+
5672
/** Get all DatSets that have any of the CategoryCombos. */
5773
List<DataSet> getByCategoryCombo(List<CategoryCombo> sources);
5874

dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/DefaultCategoryService.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,13 @@ public CategoryOptionCombo getCategoryOptionCombo(
514514
return categoryOptionComboStore.getCategoryOptionCombo(categoryCombo, categoryOptions);
515515
}
516516

517+
@Override
518+
@Transactional(readOnly = true)
519+
public List<CategoryOptionCombo> getCategoryOptionCombosWithCategoryOptions(
520+
Collection<CategoryCombo> categoryCombos) {
521+
return categoryOptionComboStore.getCategoryOptionCombosWithCategoryOptions(categoryCombos);
522+
}
523+
517524
@Override
518525
@Transactional(readOnly = true)
519526
public List<CategoryOptionCombo> getAllCategoryOptionCombos() {

dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/hibernate/HibernateCategoryOptionComboStore.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,26 @@ public CategoryOptionCombo getCategoryOptionCombo(
104104
return categoryOptionCombo;
105105
}
106106

107+
@Override
108+
public List<CategoryOptionCombo> getCategoryOptionCombosWithCategoryOptions(
109+
Collection<CategoryCombo> categoryCombos) {
110+
if (categoryCombos == null || categoryCombos.isEmpty()) {
111+
return List.of();
112+
}
113+
114+
String hql =
115+
"""
116+
select distinct coc from CategoryCombo cc
117+
join cc.optionCombos coc
118+
left join fetch coc.categoryOptions
119+
where cc in :categoryCombos
120+
""";
121+
122+
return getQuery(hql, CategoryOptionCombo.class)
123+
.setParameter("categoryCombos", categoryCombos)
124+
.list();
125+
}
126+
107127
@Override
108128
public void updateNames() {
109129
List<CategoryOptionCombo> categoryOptionCombos =

dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataset/DefaultDataSetService.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import lombok.RequiredArgsConstructor;
4242
import org.apache.commons.collections4.SetValuedMap;
4343
import org.hisp.dhis.association.jdbc.JdbcOrgUnitAssociationsStore;
44+
import org.hisp.dhis.dataelement.DataElement;
4445
import org.hisp.dhis.dataentryform.DataEntryForm;
4546
import org.hisp.dhis.organisationunit.OrganisationUnit;
4647
import org.hisp.dhis.period.Period;
@@ -99,6 +100,12 @@ public DataSet getDataSet(String uid) {
99100
return dataSetStore.getByUid(uid);
100101
}
101102

103+
@Override
104+
@Transactional(readOnly = true)
105+
public List<DataElement> getDataElementsByDataSet(Collection<DataSet> dataSets) {
106+
return dataSetStore.getDataElementsByDataSet(dataSets);
107+
}
108+
102109
@Override
103110
@Transactional(readOnly = true)
104111
public DataSet getDataSetNoAcl(String uid) {

dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataset/hibernate/HibernateDataSetStore.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,25 @@ public List<DataSetElement> getDataSetElementsByDataElement(
127127
.list();
128128
}
129129

130+
@Override
131+
public List<DataElement> getDataElementsByDataSet(Collection<DataSet> dataSets) {
132+
if (dataSets == null || dataSets.isEmpty()) {
133+
return List.of();
134+
}
135+
136+
@Language("hql")
137+
String hql =
138+
"""
139+
select distinct de from DataSetElement dse
140+
join dse.dataElement de
141+
left join fetch de.dataSetElements
142+
left join fetch de.categoryCombo
143+
where dse.dataSet in :dataSets
144+
""";
145+
146+
return getQuery(hql, DataElement.class).setParameter("dataSets", dataSets).list();
147+
}
148+
130149
@Override
131150
public List<DataSet> getByCategoryCombo(List<CategoryCombo> categoryCombos) {
132151
if (categoryCombos == null || categoryCombos.isEmpty()) return List.of();

dhis-2/dhis-services/dhis-service-dxf2/src/main/java/org/hisp/dhis/dxf2/metadata/DefaultDataSetMetadataExportService.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,18 @@ public ObjectNode getDataSetMetadata() {
144144
dataSetService.getDataSetOrganisationUnitsAssociations();
145145

146146
List<DataSet> dataSets = idObjectManager.getDataWriteAll(DataSet.class);
147-
List<DataElement> dataElements = sortById(flatMapToSet(dataSets, DataSet::getDataElements));
147+
List<DataElement> dataElements =
148+
sortById(new HashSet<>(dataSetService.getDataElementsByDataSet(dataSets)));
148149
List<Indicator> indicators = sortById(flatMapToSet(dataSets, DataSet::getIndicators));
149150
List<CategoryCombo> dataElementCategoryCombos =
150151
sortById(flatMapToSet(dataElements, DataElement::getCategoryCombos));
151152
List<CategoryCombo> dataSetCategoryCombos =
152153
sortById(mapToSet(dataSets, DataSet::getCategoryCombo));
154+
155+
// Preload the category options of the data-element category-option-combos in a single query, so
156+
// field-filter serialisation of categoryOptionCombos[...,categoryOptions~pluck[id]] does not
157+
// trigger a per-combo N+1 (categoryoptioncombos_categoryoptions).
158+
categoryService.getCategoryOptionCombosWithCategoryOptions(dataElementCategoryCombos);
153159
List<Category> dataElementCategories =
154160
sortById(flatMapToSet(dataElementCategoryCombos, CategoryCombo::getCategories));
155161
List<Category> dataSetCategories =

dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/config/QueryCountDataSourceProxy.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,17 @@
2929
*/
3030
package org.hisp.dhis.test.config;
3131

32+
import java.util.ArrayList;
33+
import java.util.Collections;
34+
import java.util.List;
35+
import java.util.Locale;
3236
import javax.annotation.Nonnull;
3337
import javax.sql.DataSource;
38+
import net.ttddyy.dsproxy.ExecutionInfo;
39+
import net.ttddyy.dsproxy.QueryInfo;
3440
import net.ttddyy.dsproxy.listener.ChainListener;
3541
import net.ttddyy.dsproxy.listener.DataSourceQueryCountListener;
42+
import net.ttddyy.dsproxy.listener.QueryExecutionListener;
3643
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder;
3744
import org.springframework.beans.BeansException;
3845
import org.springframework.beans.factory.config.BeanPostProcessor;
@@ -57,10 +64,37 @@
5764
* <pre>{@code
5865
* assertDeleteCount(1);
5966
* }</pre>
67+
*
68+
* <p>In addition to the aggregate counts fed to {@code QueryCountHolder}, the executed SQL text is
69+
* captured so a test can assert how many statements match a specific pattern (e.g. a join table)
70+
* rather than just the total by type:
71+
*
72+
* <pre>{@code
73+
* QueryCountDataSourceProxy.clearCapturedSql();
74+
* // ... run the code under test ...
75+
* assertTrue(QueryCountDataSourceProxy.countCapturedSqlMatching("some_join_table") <= 1);
76+
* }</pre>
6077
*/
6178
@Component
6279
public class QueryCountDataSourceProxy implements BeanPostProcessor {
6380

81+
private static final List<String> CAPTURED_SQL = Collections.synchronizedList(new ArrayList<>());
82+
83+
/** Clears the captured SQL. Call before the code under test. */
84+
public static void clearCapturedSql() {
85+
CAPTURED_SQL.clear();
86+
}
87+
88+
/** Number of executed statements whose SQL contains the given (case-insensitive) pattern. */
89+
public static long countCapturedSqlMatching(String pattern) {
90+
String needle = pattern.toLowerCase(Locale.ROOT);
91+
synchronized (CAPTURED_SQL) {
92+
return CAPTURED_SQL.stream()
93+
.filter(sql -> sql.toLowerCase(Locale.ROOT).contains(needle))
94+
.count();
95+
}
96+
}
97+
6498
@Override
6599
public Object postProcessBeforeInitialization(@Nonnull Object bean, @Nonnull String beanName) {
66100
return bean;
@@ -72,6 +106,19 @@ public Object postProcessAfterInitialization(@Nonnull Object bean, @Nonnull Stri
72106
if (bean instanceof DataSource originalDataSource && beanName.equals("actualDataSource")) {
73107
ChainListener listener = new ChainListener();
74108
listener.addListener(new DataSourceQueryCountListener());
109+
listener.addListener(
110+
new QueryExecutionListener() {
111+
@Override
112+
public void beforeQuery(ExecutionInfo execInfo, List<QueryInfo> queryInfoList) {
113+
// No-op: SQL is recorded only after execution (afterQuery), so statements that error
114+
// out or never run are not counted.
115+
}
116+
117+
@Override
118+
public void afterQuery(ExecutionInfo execInfo, List<QueryInfo> queryInfoList) {
119+
queryInfoList.forEach(q -> CAPTURED_SQL.add(q.getQuery()));
120+
}
121+
});
75122
return ProxyDataSourceBuilder.create(originalDataSource)
76123
.name("query-count-datasource-proxy")
77124
.listener(listener)

0 commit comments

Comments
 (0)