Skip to content

Commit a93472c

Browse files
perf: DataEntry metadata endpoint with fewer n+1s [DHIS2-21757](2.42) (#24454)
* perf: DataEntry metadata with fewer n+1s (2.42) * add comment and remove component type * format and remove unnecessary new query count class
1 parent e8d9886 commit a93472c

14 files changed

Lines changed: 542 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
@@ -42,6 +42,18 @@ public interface CategoryOptionComboStore extends IdentifiableObjectStore<Catego
4242
CategoryOptionCombo getCategoryOptionCombo(
4343
CategoryCombo categoryCombo, Set<CategoryOption> categoryOptions);
4444

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

4759
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
@@ -400,6 +400,18 @@ CategoryOptionCombo getCategoryOptionCombo(
400400
*/
401401
List<CategoryOptionCombo> getAllCategoryOptionCombos();
402402

403+
/**
404+
* Returns the {@link CategoryOptionCombo}s of the given {@link CategoryCombo}s with their
405+
* category options eagerly fetched in a single query. Used to prime the session before
406+
* serialising category option combos, avoiding the per-combo N+1 select on the {@code
407+
* categoryoptioncombos_categoryoptions} join table.
408+
*
409+
* @param categoryCombos the category combos whose option combos to load.
410+
* @return the option combos with their category options initialised.
411+
*/
412+
List<CategoryOptionCombo> getCategoryOptionCombosWithCategoryOptions(
413+
Collection<CategoryCombo> categoryCombos);
414+
403415
/**
404416
* Generates and persists a default Category, CategoryOption, CategoryCombo and
405417
* CategoryOptionCombo.

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
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;
@@ -86,6 +87,19 @@ public interface DataSetService extends DataSetDataIntegrityProvider {
8687
*/
8788
DataSet getDataSet(String uid);
8889

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

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
@@ -50,4 +50,20 @@ public interface DataSetStore
5050
List<DataSet> getDataSetsByDataEntryForm(DataEntryForm dataEntryForm);
5151

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

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
@@ -497,6 +497,13 @@ public CategoryOptionCombo getCategoryOptionCombo(
497497
return categoryOptionComboStore.getCategoryOptionCombo(categoryCombo, categoryOptions);
498498
}
499499

500+
@Override
501+
@Transactional(readOnly = true)
502+
public List<CategoryOptionCombo> getCategoryOptionCombosWithCategoryOptions(
503+
Collection<CategoryCombo> categoryCombos) {
504+
return categoryOptionComboStore.getCategoryOptionCombosWithCategoryOptions(categoryCombos);
505+
}
506+
500507
@Override
501508
@Transactional(readOnly = true)
502509
public List<CategoryOptionCombo> getAllCategoryOptionCombos() {

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,25 @@ public CategoryOptionCombo getCategoryOptionCombo(
102102
return categoryOptionCombo;
103103
}
104104

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

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ public DataSet getDataSet(String uid) {
106106
return dataSetStore.getByUid(uid);
107107
}
108108

109+
@Override
110+
@Transactional(readOnly = true)
111+
public List<DataElement> getDataElementsByDataSet(Collection<DataSet> dataSets) {
112+
return dataSetStore.getDataElementsByDataSet(dataSets);
113+
}
114+
109115
@Override
110116
@Transactional(readOnly = true)
111117
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
@@ -126,4 +126,23 @@ public List<DataSetElement> getDataSetElementsByDataElement(
126126
public List<DataSet> getDataSetsNotAssignedToOrganisationUnits() {
127127
return getQuery("from DataSet ds where size(ds.sources) = 0").list();
128128
}
129+
130+
@Override
131+
public List<DataElement> getDataElementsByDataSet(Collection<DataSet> dataSets) {
132+
if (dataSets == null || dataSets.isEmpty()) {
133+
return List.of();
134+
}
135+
136+
return getQuery(
137+
"""
138+
select distinct de from DataSetElement dse
139+
join dse.dataElement de
140+
left join fetch de.dataSetElements
141+
left join fetch de.categoryCombo
142+
where dse.dataSet in :dataSets
143+
""",
144+
DataElement.class)
145+
.setParameter("dataSets", dataSets)
146+
.list();
147+
}
129148
}

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)