|
30 | 30 | package org.hisp.dhis.analytics.event.data; |
31 | 31 |
|
32 | 32 | import static org.hamcrest.CoreMatchers.containsString; |
| 33 | +import static org.hamcrest.CoreMatchers.is; |
33 | 34 | import static org.hamcrest.CoreMatchers.not; |
34 | 35 | import static org.hamcrest.MatcherAssert.assertThat; |
35 | 36 | import static org.hisp.dhis.analytics.DataType.NUMERIC; |
36 | 37 | import static org.hisp.dhis.analytics.QueryKey.NV; |
| 38 | +import static org.hisp.dhis.analytics.table.EventAnalyticsColumnName.EVENT_STATUS_COLUMN_NAME; |
37 | 39 | import static org.hisp.dhis.analytics.table.EventAnalyticsColumnName.OCCURRED_DATE_COLUMN_NAME; |
38 | 40 | import static org.hisp.dhis.analytics.table.EventAnalyticsColumnName.OU_COLUMN_NAME; |
39 | 41 | import static org.hisp.dhis.common.DimensionConstants.OPTION_SEP; |
|
78 | 80 | import org.hisp.dhis.common.QueryFilter; |
79 | 81 | import org.hisp.dhis.common.QueryItem; |
80 | 82 | import org.hisp.dhis.common.QueryOperator; |
| 83 | +import org.hisp.dhis.common.RepeatableStageParams; |
81 | 84 | import org.hisp.dhis.common.ValueType; |
82 | 85 | import org.hisp.dhis.dataelement.DataElement; |
83 | 86 | import org.hisp.dhis.dataelement.DataElementService; |
|
90 | 93 | import org.hisp.dhis.program.Program; |
91 | 94 | import org.hisp.dhis.program.ProgramIndicator; |
92 | 95 | import org.hisp.dhis.program.ProgramIndicatorService; |
| 96 | +import org.hisp.dhis.program.ProgramStage; |
93 | 97 | import org.hisp.dhis.relationship.RelationshipConstraint; |
94 | 98 | import org.hisp.dhis.relationship.RelationshipEntity; |
95 | 99 | import org.hisp.dhis.relationship.RelationshipType; |
@@ -411,6 +415,134 @@ void verifyAggregateEnrollmentStageOrgUnitFilterStaysInFilterCte() { |
411 | 415 | assertThat(baseCteSql, not(containsString("and \"n1rtSHYf6O6\" in ('ImspTQPwCqd')"))); |
412 | 416 | } |
413 | 417 |
|
| 418 | + @Test |
| 419 | + void verifyAggregateEnrollmentUnfilteredEventStatusUsesLatestEventsCte() { |
| 420 | + // When a stage-specific dimension without a filter (e.g. stage.EVENT_STATUS) is requested |
| 421 | + // alongside a filtered stage dimension (stage.EVENT_DATE), the unfiltered dimension must |
| 422 | + // also be projected by the latest_events_<stage> CTE so the outer SELECT can read it from |
| 423 | + // the same "latest event" row. Without this, the resolver emits a reference to |
| 424 | + // uiogg.ev_eventstatus while the CTE doesn't project the column, producing a SQL error. |
| 425 | + String stageUid = programStage.getUid(); |
| 426 | + |
| 427 | + QueryItem dateItem = |
| 428 | + new QueryItem( |
| 429 | + new BaseDimensionalItemObject(OCCURRED_DATE_COLUMN_NAME), |
| 430 | + programA, |
| 431 | + null, |
| 432 | + ValueType.DATE, |
| 433 | + null, |
| 434 | + null); |
| 435 | + dateItem.setProgram(programA); |
| 436 | + dateItem.setProgramStage(programStage); |
| 437 | + dateItem.addFilter(new QueryFilter(QueryOperator.GE, "2026-01-01")); |
| 438 | + dateItem.addFilter(new QueryFilter(QueryOperator.LE, "2026-12-31")); |
| 439 | + |
| 440 | + QueryItem statusItem = |
| 441 | + new QueryItem( |
| 442 | + new BaseDimensionalItemObject(EVENT_STATUS_COLUMN_NAME), |
| 443 | + programA, |
| 444 | + null, |
| 445 | + ValueType.TEXT, |
| 446 | + null, |
| 447 | + null); |
| 448 | + statusItem.setProgram(programA); |
| 449 | + statusItem.setProgramStage(programStage); |
| 450 | + |
| 451 | + EventQueryParams.Builder params = createRequestParamsBuilder(); |
| 452 | + params.addItem(dateItem); |
| 453 | + params.addItem(statusItem); |
| 454 | + params.withEndpointAction(AGGREGATE); |
| 455 | + |
| 456 | + ListGrid grid = new ListGrid(); |
| 457 | + grid.addHeader(new GridHeader("value", "Value", ValueType.NUMBER, false, false)); |
| 458 | + grid.addHeader( |
| 459 | + new GridHeader(stageUid + ".eventdate", "Event date", ValueType.DATE, false, false)); |
| 460 | + grid.addHeader( |
| 461 | + new GridHeader(stageUid + ".eventstatus", "Event status", ValueType.TEXT, false, false)); |
| 462 | + |
| 463 | + subject.getEnrollments(params.build(), grid, 10000); |
| 464 | + verify(jdbcTemplate).queryForRowSet(sql.capture()); |
| 465 | + |
| 466 | + String generatedSql = noEof(sql.getValue()); |
| 467 | + String latestEventsCteSql = |
| 468 | + generatedSql.substring( |
| 469 | + generatedSql.indexOf("latest_events_" + stageUid + " as ("), |
| 470 | + generatedSql.indexOf("enrollment_aggr_base as (")); |
| 471 | + |
| 472 | + // The latest_events CTE must project both the filtered (eventdate) and unfiltered |
| 473 | + // (eventstatus) stage dimensions so the outer SELECT can read them from the same row. |
| 474 | + assertThat(latestEventsCteSql, containsString("ev_occurreddate")); |
| 475 | + assertThat(latestEventsCteSql, containsString("ev_eventstatus")); |
| 476 | + |
| 477 | + // The redundant per-item CTE must not be created when a latest_events_<stage> already exists. |
| 478 | + assertThat(generatedSql, not(containsString(stageUid + "_" + EVENT_STATUS_COLUMN_NAME + "_0"))); |
| 479 | + } |
| 480 | + |
| 481 | + @Test |
| 482 | + void verifyAggregateEnrollmentRepeatableOffsetItemKeepsPerItemCteWhenLatestEventsCteExists() { |
| 483 | + String stageUid = repeatableProgramStage.getUid(); |
| 484 | + String deUid = dataElementA.getUid(); |
| 485 | + |
| 486 | + QueryItem dateItem = createFilteredStageDateItem(repeatableProgramStage); |
| 487 | + QueryItem offsetItem = createRepeatableOffsetDataElementItem(stageUid, -1); |
| 488 | + |
| 489 | + EventQueryParams.Builder params = createRequestParamsBuilder(); |
| 490 | + params.addItem(dateItem); |
| 491 | + params.addItem(offsetItem); |
| 492 | + params.withEndpointAction(AGGREGATE); |
| 493 | + |
| 494 | + ListGrid grid = new ListGrid(); |
| 495 | + grid.addHeader(new GridHeader("value", "Value", ValueType.NUMBER, false, false)); |
| 496 | + grid.addHeader( |
| 497 | + new GridHeader(stageUid + ".eventdate", "Event date", ValueType.DATE, false, false)); |
| 498 | + grid.addHeader( |
| 499 | + new GridHeader(stageUid + "[-1]." + deUid, "Offset value", ValueType.NUMBER, false, false)); |
| 500 | + |
| 501 | + subject.getEnrollments(params.build(), grid, 10000); |
| 502 | + verify(jdbcTemplate).queryForRowSet(sql.capture()); |
| 503 | + |
| 504 | + String generatedSql = noEof(sql.getValue()); |
| 505 | + String latestEventsCteSql = |
| 506 | + generatedSql.substring( |
| 507 | + generatedSql.indexOf("latest_events_" + stageUid + " as ("), |
| 508 | + generatedSql.indexOf("enrollment_aggr_base as (")); |
| 509 | + |
| 510 | + assertThat(latestEventsCteSql, containsString("ev_occurreddate")); |
| 511 | + assertThat(latestEventsCteSql, not(containsString("ev_" + deUid))); |
| 512 | + assertThat(generatedSql, containsString(stageUid + "_" + deUid + "_0 as (")); |
| 513 | + assertThat(generatedSql, containsString("as \"" + stageUid + "[-1]." + deUid + "\"")); |
| 514 | + } |
| 515 | + |
| 516 | + @Test |
| 517 | + void verifyAggregateEnrollmentRepeatableOffsetsAreNotDuplicatedInLatestEventsCte() { |
| 518 | + String stageUid = repeatableProgramStage.getUid(); |
| 519 | + String deUid = dataElementA.getUid(); |
| 520 | + |
| 521 | + EventQueryParams.Builder params = createRequestParamsBuilder(); |
| 522 | + params.addItem(createFilteredStageDateItem(repeatableProgramStage)); |
| 523 | + params.addItem(createRepeatableOffsetDataElementItem(stageUid, -1)); |
| 524 | + params.addItem(createRepeatableOffsetDataElementItem(stageUid, 1)); |
| 525 | + params.withEndpointAction(AGGREGATE); |
| 526 | + |
| 527 | + ListGrid grid = new ListGrid(); |
| 528 | + grid.addHeader(new GridHeader("value", "Value", ValueType.NUMBER, false, false)); |
| 529 | + grid.addHeader( |
| 530 | + new GridHeader(stageUid + ".eventdate", "Event date", ValueType.DATE, false, false)); |
| 531 | + |
| 532 | + subject.getEnrollments(params.build(), grid, 10000); |
| 533 | + verify(jdbcTemplate).queryForRowSet(sql.capture()); |
| 534 | + |
| 535 | + String generatedSql = noEof(sql.getValue()); |
| 536 | + String latestEventsCteSql = |
| 537 | + generatedSql.substring( |
| 538 | + generatedSql.indexOf("latest_events_" + stageUid + " as ("), |
| 539 | + generatedSql.indexOf("enrollment_aggr_base as (")); |
| 540 | + |
| 541 | + assertThat(countOccurrences(latestEventsCteSql, "ev_" + deUid), is(0)); |
| 542 | + assertThat(generatedSql, containsString(stageUid + "_" + deUid + "_0 as (")); |
| 543 | + assertThat(generatedSql, containsString(stageUid + "_" + deUid + "_1 as (")); |
| 544 | + } |
| 545 | + |
414 | 546 | @Test |
415 | 547 | void verifyAggregateEnrollmentIncludesProgramStatusFilterInSql() { |
416 | 548 | EventQueryParams.Builder params = createRequestParamsBuilder(); |
@@ -1109,6 +1241,32 @@ private EventQueryParams createAggregateEnrollmentWithStageDateParams() { |
1109 | 1241 | return params.build(); |
1110 | 1242 | } |
1111 | 1243 |
|
| 1244 | + private QueryItem createFilteredStageDateItem(ProgramStage stage) { |
| 1245 | + QueryItem dateItem = |
| 1246 | + new QueryItem( |
| 1247 | + new BaseDimensionalItemObject(OCCURRED_DATE_COLUMN_NAME), |
| 1248 | + programA, |
| 1249 | + null, |
| 1250 | + ValueType.DATE, |
| 1251 | + null, |
| 1252 | + null); |
| 1253 | + dateItem.setProgram(programA); |
| 1254 | + dateItem.setProgramStage(stage); |
| 1255 | + dateItem.addFilter(new QueryFilter(QueryOperator.GE, "2026-01-01")); |
| 1256 | + dateItem.addFilter(new QueryFilter(QueryOperator.LE, "2026-12-31")); |
| 1257 | + return dateItem; |
| 1258 | + } |
| 1259 | + |
| 1260 | + private QueryItem createRepeatableOffsetDataElementItem(String stageUid, int offset) { |
| 1261 | + QueryItem item = new QueryItem(new BaseDimensionalItemObject(dataElementA.getUid())); |
| 1262 | + item.setProgram(programA); |
| 1263 | + item.setProgramStage(repeatableProgramStage); |
| 1264 | + item.setValueType(ValueType.NUMBER); |
| 1265 | + item.setRepeatableStageParams( |
| 1266 | + RepeatableStageParams.of(offset, stageUid + "[" + offset + "]." + dataElementA.getUid())); |
| 1267 | + return item; |
| 1268 | + } |
| 1269 | + |
1112 | 1270 | private EventQueryParams createAggregateEnrollmentWithEventDateParams() { |
1113 | 1271 | BaseDimensionalItemObject dateItem = new BaseDimensionalItemObject(OCCURRED_DATE_COLUMN_NAME); |
1114 | 1272 | QueryItem queryItem = new QueryItem(dateItem, programA, null, ValueType.DATE, null, null); |
@@ -1203,4 +1361,14 @@ private void testIt( |
1203 | 1361 | private String noEof(String sql) { |
1204 | 1362 | return sql.replaceAll("\\s+", " ").trim(); |
1205 | 1363 | } |
| 1364 | + |
| 1365 | + private int countOccurrences(String value, String search) { |
| 1366 | + int count = 0; |
| 1367 | + int index = 0; |
| 1368 | + while ((index = value.indexOf(search, index)) >= 0) { |
| 1369 | + count++; |
| 1370 | + index += search.length(); |
| 1371 | + } |
| 1372 | + return count; |
| 1373 | + } |
1206 | 1374 | } |
0 commit comments