@openmrs/esm-report-builder 0.1.1-pre.92 → 0.1.1-pre.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/128.css +1 -0
- package/dist/128.css.map +1 -0
- package/dist/128.js +338 -0
- package/dist/128.js.map +1 -0
- package/dist/1480.js +1 -0
- package/dist/1480.js.map +1 -0
- package/dist/1646.js +1 -0
- package/dist/1646.js.map +1 -0
- package/dist/1869.js +1 -0
- package/dist/1869.js.map +1 -0
- package/dist/1877.js +1 -0
- package/dist/1877.js.map +1 -0
- package/dist/2131.js +1 -0
- package/dist/2131.js.map +1 -0
- package/dist/2317.js +1 -0
- package/dist/2317.js.map +1 -0
- package/dist/2408.js +1 -0
- package/dist/2408.js.map +1 -0
- package/dist/2416.js +1 -0
- package/dist/2416.js.map +1 -0
- package/dist/282.js +1 -0
- package/dist/282.js.map +1 -0
- package/dist/2881.js +1 -0
- package/dist/2881.js.map +1 -0
- package/dist/2997.js +1 -0
- package/dist/2997.js.map +1 -0
- package/dist/3378.js +1 -0
- package/dist/3378.js.map +1 -0
- package/dist/3963.js +1 -0
- package/dist/3963.js.map +1 -0
- package/dist/4106.js +1 -0
- package/dist/4106.js.map +1 -0
- package/dist/4111.js +1 -0
- package/dist/4111.js.map +1 -0
- package/dist/4171.css +1 -0
- package/dist/4171.css.map +1 -0
- package/dist/4171.js +43 -0
- package/dist/4171.js.map +1 -0
- package/dist/434.js +1 -0
- package/dist/434.js.map +1 -0
- package/dist/4348.js +1 -0
- package/dist/4348.js.map +1 -0
- package/dist/4383.js +1 -0
- package/dist/4383.js.map +1 -0
- package/dist/4658.js +1 -0
- package/dist/4658.js.map +1 -0
- package/dist/4668.js +1 -0
- package/dist/4668.js.map +1 -0
- package/dist/4928.js +1 -0
- package/dist/4928.js.map +1 -0
- package/dist/5098.js +1 -0
- package/dist/5098.js.map +1 -0
- package/dist/5117.js +1 -0
- package/dist/5117.js.map +1 -0
- package/dist/5132.js +1 -0
- package/dist/5132.js.map +1 -0
- package/dist/5145.js +1 -0
- package/dist/5145.js.map +1 -0
- package/dist/550.js +1 -0
- package/dist/550.js.map +1 -0
- package/dist/5503.js +1 -0
- package/dist/5503.js.map +1 -0
- package/dist/556.js +1 -0
- package/dist/556.js.map +1 -0
- package/dist/5644.js +1 -0
- package/dist/5644.js.map +1 -0
- package/dist/5940.js +1 -0
- package/dist/5940.js.map +1 -0
- package/dist/6047.js +1 -0
- package/dist/6047.js.map +1 -0
- package/dist/6237.js +1 -0
- package/dist/6237.js.map +1 -0
- package/dist/6247.js +1 -0
- package/dist/6247.js.map +1 -0
- package/dist/6371.js +1 -0
- package/dist/6371.js.map +1 -0
- package/dist/6377.js +1 -0
- package/dist/6377.js.map +1 -0
- package/dist/6444.js +1 -0
- package/dist/6444.js.map +1 -0
- package/dist/6508.js +1 -0
- package/dist/6508.js.map +1 -0
- package/dist/6652.js +1 -0
- package/dist/6652.js.map +1 -0
- package/dist/6724.js +1 -0
- package/dist/6724.js.map +1 -0
- package/dist/6867.js +19 -0
- package/dist/6867.js.map +1 -0
- package/dist/6904.js +1 -0
- package/dist/6904.js.map +1 -0
- package/dist/7045.js +1 -0
- package/dist/7045.js.map +1 -0
- package/dist/7175.js +1 -0
- package/dist/7175.js.map +1 -0
- package/dist/7182.js +1 -0
- package/dist/7182.js.map +1 -0
- package/dist/7475.js +1 -0
- package/dist/7475.js.map +1 -0
- package/dist/7742.js +1 -0
- package/dist/7742.js.map +1 -0
- package/dist/7912.js +1 -0
- package/dist/7912.js.map +1 -0
- package/dist/8251.js +1 -0
- package/dist/8251.js.map +1 -0
- package/dist/8358.js +1 -0
- package/dist/8358.js.map +1 -0
- package/dist/8359.js +1 -0
- package/dist/8359.js.map +1 -0
- package/dist/8695.js +1 -0
- package/dist/8695.js.map +1 -0
- package/dist/903.js +1 -0
- package/dist/903.js.map +1 -0
- package/dist/9072.js +1 -0
- package/dist/9072.js.map +1 -0
- package/dist/9806.js +1 -0
- package/dist/9806.js.map +1 -0
- package/dist/997.js +1 -0
- package/dist/997.js.map +1 -0
- package/dist/openmrs-esm-report-builder.js +3 -1
- package/dist/openmrs-esm-report-builder.js.buildmanifest.json +1099 -196
- package/dist/openmrs-esm-report-builder.js.map +1 -1
- package/dist/routes.json +1 -1
- package/package.json +21 -34
- package/rspack.config.js +2 -0
- package/src/__debug__/all-designs.test.tsx +7 -4
- package/src/components/data-visualizer/data-visualizer.scss +0 -1
- package/src/components/indicators/utils/composite-indicator-sql.utils.test.ts +1 -1
- package/src/components/indicators/utils/custom-indicator-interpreter.test.ts +320 -320
- package/src/components/indicators/utils/population-sql.compiler.test.ts +950 -964
- package/src/components/linelist/design/panels/column-category-selector.scss +7 -12
- package/src/components/linelist/design/panels/query-config-panel.scss +7 -8
- package/src/components/security/access-denied-page.component.tsx +45 -50
- package/src/components/security/privilege-route.component.test.tsx +59 -62
- package/src/components/security/privilege-route.component.tsx +3 -3
- package/src/constants/privileges.test.ts +1 -0
- package/src/features/dashboards/utils/dashboard-config.util.test.ts +37 -6
- package/src/globals.d.ts +0 -5
- package/src/hooks/use-report-builder-privileges.test.tsx +69 -75
- package/src/routes.json +3 -0
- package/src/utils/api-error.utils.test.ts +14 -18
- package/src/utils/etl-browser/etl-browser.util.test.ts +125 -119
- package/src/utils/etl-task/etl-task-card.util.test.ts +3 -2
- package/tools/i18next-parser.config.js +0 -0
- package/tools/setup-tests.ts +1 -1
- package/tools/update-openmrs-deps.mjs +42 -0
- package/translations/am.json +65 -0
- package/translations/ar.json +65 -0
- package/translations/ar_SY.json +65 -0
- package/translations/bn.json +65 -0
- package/translations/cs.json +65 -0
- package/translations/de.json +65 -0
- package/translations/en_US.json +65 -0
- package/translations/es.json +65 -0
- package/translations/es_MX.json +65 -0
- package/translations/fr.json +65 -0
- package/translations/he.json +65 -0
- package/translations/hi.json +65 -0
- package/translations/hi_IN.json +65 -0
- package/translations/id.json +65 -0
- package/translations/it.json +65 -0
- package/translations/ka.json +65 -0
- package/translations/km.json +65 -0
- package/translations/ku.json +65 -0
- package/translations/ky.json +65 -0
- package/translations/lg.json +65 -0
- package/translations/ne.json +65 -0
- package/translations/pl.json +65 -0
- package/translations/pt.json +65 -0
- package/translations/pt_BR.json +65 -0
- package/translations/qu.json +65 -0
- package/translations/ro_RO.json +65 -0
- package/translations/ru_RU.json +65 -0
- package/translations/si.json +65 -0
- package/translations/sq.json +65 -0
- package/translations/sw.json +65 -0
- package/translations/sw_KE.json +65 -0
- package/translations/tr.json +65 -0
- package/translations/tr_TR.json +65 -0
- package/translations/uk.json +65 -0
- package/translations/uz.json +65 -0
- package/translations/uz@Latn.json +65 -0
- package/translations/uz_UZ.json +65 -0
- package/translations/vi.json +65 -0
- package/translations/zh.json +65 -0
- package/translations/zh_CN.json +65 -0
- package/translations/zh_TW.json +65 -0
- package/tsconfig.json +9 -28
- package/vitest.config.mts +42 -0
- package/dist/162.js +0 -1
- package/dist/162.js.map +0 -1
- package/dist/184.js +0 -2
- package/dist/184.js.LICENSE.txt +0 -14
- package/dist/184.js.map +0 -1
- package/dist/242.js +0 -2
- package/dist/242.js.LICENSE.txt +0 -21
- package/dist/242.js.map +0 -1
- package/dist/253.js +0 -1
- package/dist/253.js.map +0 -1
- package/dist/300.js +0 -1
- package/dist/311.js +0 -1
- package/dist/311.js.map +0 -1
- package/dist/469.js +0 -2
- package/dist/469.js.LICENSE.txt +0 -45
- package/dist/469.js.map +0 -1
- package/dist/478.js +0 -2
- package/dist/478.js.LICENSE.txt +0 -9
- package/dist/478.js.map +0 -1
- package/dist/539.js +0 -1
- package/dist/539.js.map +0 -1
- package/dist/540.js +0 -2
- package/dist/540.js.LICENSE.txt +0 -9
- package/dist/540.js.map +0 -1
- package/dist/669.js +0 -1
- package/dist/669.js.map +0 -1
- package/dist/710.js +0 -1
- package/dist/710.js.map +0 -1
- package/dist/729.js +0 -1
- package/dist/729.js.map +0 -1
- package/dist/838.js +0 -2
- package/dist/838.js.LICENSE.txt +0 -9
- package/dist/838.js.map +0 -1
- package/dist/917.js +0 -1
- package/dist/917.js.map +0 -1
- package/dist/961.js +0 -2
- package/dist/961.js.LICENSE.txt +0 -19
- package/dist/961.js.map +0 -1
- package/dist/main.js +0 -1
- package/dist/main.js.map +0 -1
- package/jest.config.js +0 -28
- package/routes.json +0 -22
- package/tools/scss-stub.js +0 -12
- package/webpack.config.js +0 -30
|
@@ -6,337 +6,337 @@
|
|
|
6
6
|
* https://github.com/openmrs/openmrs-esm-report-builder/blob/main/docs/CUSTOM_INDICATOR_SPEC.md
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { describe, test, expect } from '
|
|
9
|
+
import { describe, test, expect } from 'vitest';
|
|
10
10
|
import { customIndicatorInterpreter } from './custom-indicator-interpreter';
|
|
11
11
|
|
|
12
12
|
describe('CustomIndicatorInterpreter', () => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
describe('extractPopulationSql', () => {
|
|
14
|
+
describe('Simple CUSTOM count', () => {
|
|
15
|
+
test('should extract population from COUNT DISTINCT with subquery', () => {
|
|
16
|
+
// The supported pattern is COUNT DISTINCT with a FROM subquery
|
|
17
|
+
// Must use table.column format: COUNT(DISTINCT table.column)
|
|
18
|
+
const input = `SELECT COUNT(DISTINCT t.patient_id) FROM (SELECT patient_id FROM table_a) t`;
|
|
19
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
20
|
+
|
|
21
|
+
expect(result.success).toBe(true);
|
|
22
|
+
expect(result.sql).toMatch(/SELECT\s+DISTINCT\s+t\.patient_id\s+AS\s+patient_id/i);
|
|
23
|
+
expect(result.sql).toContain('FROM table_a');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
describe('Aliased CUSTOM count', () => {
|
|
28
|
+
test('should extract population from aliased COUNT DISTINCT', () => {
|
|
29
|
+
const input = `SELECT COUNT(DISTINCT a.client_id)
|
|
30
30
|
FROM (
|
|
31
31
|
SELECT client_id FROM some_table
|
|
32
32
|
) a
|
|
33
33
|
INNER JOIN other_table o ON a.client_id = o.id`;
|
|
34
|
-
|
|
34
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
expect(result.success).toBe(true);
|
|
37
|
+
expect(result.sql).toMatch(/SELECT\s+DISTINCT\s+a\.client_id\s+AS\s+patient_id/i);
|
|
38
|
+
expect(result.sql).toContain('INNER JOIN other_table');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
describe('Complex TX_RTT indicator', () => {
|
|
43
|
+
test('should preserve business logic for TX_RTT CD4 < 200', () => {
|
|
44
|
+
// Simplified version to test basic COUNT DISTINCT to SELECT DISTINCT conversion
|
|
45
|
+
// TODO: Improve regex to handle AS keywords in column lists
|
|
46
|
+
const input = `SELECT COUNT(DISTINCT a.client_id) FROM (SELECT client_id FROM mamba_fact_encounter_hiv_art_card WHERE encounter_date <= :endDate GROUP BY client_id) a WHERE a.some_column <= 28`;
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
expect(result.success).toBe(true);
|
|
51
|
+
expect(result.sql).toMatch(/SELECT\s+DISTINCT\s+a\.client_id\s+AS\s+patient_id/i);
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
// Verify business logic is preserved
|
|
54
|
+
expect(result.sql).toContain('WHERE encounter_date <= :endDate');
|
|
55
|
+
expect(result.sql).toContain('GROUP BY client_id');
|
|
56
|
+
expect(result.sql).toContain('WHERE a.some_column <= 28');
|
|
57
|
+
});
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
test('should preserve TIMESTAMPDIFF expressions exactly', () => {
|
|
60
|
+
const input = `SELECT COUNT(DISTINCT a.client_id)
|
|
61
61
|
FROM (
|
|
62
62
|
SELECT client_id, TIMESTAMPDIFF(DAY, MAX(return_visit_date), DATE_SUB(:endDate, INTERVAL 3 MONTH)) AS ltfp_days
|
|
63
63
|
FROM mamba_fact_encounter_hiv_art_card
|
|
64
64
|
GROUP BY client_id
|
|
65
65
|
) a`;
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
expect(result.success).toBe(true);
|
|
70
|
+
// DATE_SUB should be preserved exactly
|
|
71
|
+
expect(result.sql).toContain('DATE_SUB(:endDate, INTERVAL 3 MONTH)');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
describe('CD4 criteria', () => {
|
|
76
|
+
test('should preserve CD4 < 200', () => {
|
|
77
|
+
const input = `SELECT COUNT(DISTINCT a.client_id)
|
|
78
78
|
FROM (
|
|
79
79
|
SELECT client_id FROM mamba_fact_encounter_hiv_art_card
|
|
80
80
|
WHERE cd4 < 200
|
|
81
81
|
) a`;
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
expect(result.success).toBe(true);
|
|
86
|
+
expect(result.sql).toContain('WHERE cd4 < 200');
|
|
87
|
+
});
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
test('should preserve CD4 >= 200', () => {
|
|
90
|
+
const input = `SELECT COUNT(DISTINCT a.client_id)
|
|
91
91
|
FROM (
|
|
92
92
|
SELECT client_id FROM mamba_fact_encounter_hiv_art_card
|
|
93
93
|
WHERE cd4 >= 200
|
|
94
94
|
) a`;
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
expect(result.success).toBe(true);
|
|
99
|
+
expect(result.sql).toContain('WHERE cd4 >= 200');
|
|
100
|
+
});
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
test('should preserve CD4 IS NULL', () => {
|
|
103
|
+
const input = `SELECT COUNT(DISTINCT a.client_id)
|
|
104
104
|
FROM (
|
|
105
105
|
SELECT client_id FROM mamba_fact_encounter_hiv_art_card
|
|
106
106
|
WHERE cd4 IS NULL
|
|
107
107
|
) a`;
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
expect(result.success).toBe(true);
|
|
112
|
+
expect(result.sql).toContain('WHERE cd4 IS NULL');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
116
|
+
describe('Patient ID normalization', () => {
|
|
117
|
+
test('should normalize client_id to patient_id', () => {
|
|
118
|
+
// Use a format that matches Pattern 1
|
|
119
|
+
const input = `SELECT COUNT(DISTINCT a.client_id) FROM (SELECT client_id FROM table_a) a`;
|
|
120
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
expect(result.success).toBe(true);
|
|
123
|
+
expect(result.sql).toMatch(/AS\s+patient_id/i);
|
|
124
|
+
});
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
test('should detect patient_id column from inner query', () => {
|
|
127
|
+
const input = `SELECT COUNT(DISTINCT a.client_id)
|
|
128
128
|
FROM (
|
|
129
129
|
SELECT patient_id FROM some_table GROUP BY patient_id
|
|
130
130
|
) a`;
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
133
133
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
134
|
+
expect(result.success).toBe(true);
|
|
135
|
+
// The interpreter normalizes to patient_id in the output SQL
|
|
136
|
+
// but tracks the source column (client_id from the COUNT expression)
|
|
137
|
+
expect(result.patientIdColumn).toBe('client_id');
|
|
138
|
+
expect(result.sql).toContain('AS patient_id');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
describe('Already aggregated age/gender CUSTOM SQL', () => {
|
|
143
|
+
test('should extract population from age_group disaggregated query', () => {
|
|
144
|
+
const input = `SELECT mda.datim_agegroup AS age_group, mdp.gender AS sex, COUNT(DISTINCT a.client_id) AS value
|
|
145
145
|
FROM (SELECT client_id FROM table_a) a
|
|
146
146
|
GROUP BY age_group, sex`;
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
149
149
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
150
|
+
expect(result.success).toBe(true);
|
|
151
|
+
// Result should be patient-level, not already aggregated
|
|
152
|
+
expect(result.sql).not.toContain('age_group');
|
|
153
|
+
expect(result.sql).not.toContain('COUNT(DISTINCT');
|
|
154
|
+
expect(result.sql).toMatch(/SELECT\s+DISTINCT/i);
|
|
155
|
+
});
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
test('should handle hardcoded age_group values like PWIDS', () => {
|
|
158
|
+
const input = `SELECT 'PWIDS' AS age_group, 'F' AS sex, COUNT(DISTINCT a.client_id) AS value
|
|
159
159
|
FROM (SELECT client_id FROM table_a) a`;
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
161
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
162
|
+
|
|
163
|
+
// This SQL doesn't match our standard patterns because it has:
|
|
164
|
+
// 1. Hardcoded values for age_group ('PWIDS')
|
|
165
|
+
// 2. Hardcoded value for sex ('F')
|
|
166
|
+
// 3. No GROUP BY age_group, sex at the end (Pattern 0 requirement)
|
|
167
|
+
//
|
|
168
|
+
// The interpreter should either:
|
|
169
|
+
// 1. Extract the population from the subquery
|
|
170
|
+
// 2. Fail gracefully with a meaningful error
|
|
171
|
+
if (result.success) {
|
|
172
|
+
// If extraction succeeds, verify the population is clean
|
|
173
|
+
expect(result.sql).toMatch(/SELECT\s+DISTINCT/i);
|
|
174
|
+
// The result should not have the hardcoded age_group/sex values
|
|
175
|
+
expect(result.sql).not.toMatch(/SELECT\s+'PWIDS'\s+AS\s+age_group/i);
|
|
176
|
+
} else {
|
|
177
|
+
// If extraction fails, verify we have a meaningful error
|
|
178
|
+
const hasError = result.warnings?.some((w) => w.includes('CUSTOM_'));
|
|
179
|
+
expect(hasError).toBe(true);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
describe('Invalid extraction', () => {
|
|
185
|
+
test('should reject malformed generated identifiers', () => {
|
|
186
|
+
// This simulates the bug where ltfp_days (a calculated column) was used as a table alias
|
|
187
|
+
const input = `SELECT COUNT(DISTINCT ltfp_days.client_id)
|
|
188
188
|
FROM ...`;
|
|
189
189
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
190
|
+
// The pattern should not match because ltfp_days.client_id is not valid
|
|
191
|
+
// (ltfp_days is a column alias, not a table)
|
|
192
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
193
|
+
|
|
194
|
+
// Should fail gracefully or not produce invalid SQL
|
|
195
|
+
if (!result.success) {
|
|
196
|
+
// Check that at least one warning contains an error code
|
|
197
|
+
const hasError = result.warnings?.some((w) => w.includes('CUSTOM_'));
|
|
198
|
+
expect(hasError).toBe(true);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('should handle empty SQL', () => {
|
|
203
|
+
const result = customIndicatorInterpreter.extractPopulationSql('');
|
|
204
|
+
|
|
205
|
+
expect(result.success).toBe(false);
|
|
206
|
+
// Check that at least one warning contains the error code
|
|
207
|
+
const hasMissingError = result.warnings?.some((w) => w.includes('CUSTOM_POPULATION_MISSING'));
|
|
208
|
+
expect(hasMissingError).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('should handle simple COUNT query without clear structure', () => {
|
|
212
|
+
const input = 'SELECT COUNT(*) FROM table_a';
|
|
213
|
+
|
|
214
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
215
|
+
|
|
216
|
+
expect(result.success).toBe(false);
|
|
217
|
+
// Check that at least one warning contains the error code
|
|
218
|
+
const hasAggregatedError = result.warnings?.some((w) => w.includes('CUSTOM_POPULATION_AGGREGATED'));
|
|
219
|
+
expect(hasAggregatedError).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
222
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
describe('TX-RTT style disaggregated queries', () => {
|
|
224
|
+
test('should extract population from TX-RTT style already-disaggregated query', () => {
|
|
225
|
+
// This is the actual TX-RTT pattern with age_group, sex, and COUNT(DISTINCT)
|
|
226
|
+
const input = `SELECT mda.datim_agegroup AS age_group, mdp.gender AS sex, COUNT(DISTINCT a.client_id) AS value
|
|
227
227
|
FROM (SELECT client_id, TIMESTAMPDIFF(DAY, MAX(return_visit_date), :endDate) AS ltfp_days FROM mamba_fact_encounter_hiv_art_card WHERE encounter_date <= :endDate GROUP BY client_id) a
|
|
228
228
|
INNER JOIN mamba_fact_patients_latest_patient_demographics mdp ON a.client_id = mdp.patient_id
|
|
229
229
|
WHERE a.ltfp_days <= 28
|
|
230
230
|
GROUP BY age_group, sex`;
|
|
231
231
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
});
|
|
232
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
233
|
+
|
|
234
|
+
// For now, this should either succeed with proper extraction or fail gracefully
|
|
235
|
+
// The TX-RTT pattern is complex and may need additional refinement
|
|
236
|
+
if (result.success) {
|
|
237
|
+
// If extraction succeeds, verify the structure
|
|
238
|
+
expect(result.sql).toMatch(/SELECT\s+DISTINCT/i);
|
|
239
|
+
expect(result.sql).not.toContain('COUNT(DISTINCT');
|
|
240
|
+
} else {
|
|
241
|
+
// If extraction fails, verify we have a meaningful error
|
|
242
|
+
const hasError = result.warnings?.some((w) => w.includes('CUSTOM_'));
|
|
243
|
+
expect(hasError).toBe(true);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
247
246
|
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('applyDisaggregation', () => {
|
|
250
|
+
describe('Validation', () => {
|
|
251
|
+
test('should reject empty population SQL', () => {
|
|
252
|
+
const result = customIndicatorInterpreter.applyDisaggregation('', {
|
|
253
|
+
ageCategoryCode: 'MOH_105_OPD_DIAG',
|
|
254
|
+
genders: ['F', 'M'],
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(result).toContain('CUSTOM_POPULATION_EMPTY');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('should reject COUNT aggregate instead of population', () => {
|
|
261
|
+
const result = customIndicatorInterpreter.applyDisaggregation('SELECT COUNT(*) FROM table_a', {
|
|
262
|
+
ageCategoryCode: 'MOH_105_OPD_DIAG',
|
|
263
|
+
genders: ['F', 'M'],
|
|
264
|
+
});
|
|
248
265
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
expect(result).toContain('CUSTOM_POPULATION_EMPTY');
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
test('should reject COUNT aggregate instead of population', () => {
|
|
261
|
-
const result = customIndicatorInterpreter.applyDisaggregation(
|
|
262
|
-
'SELECT COUNT(*) FROM table_a',
|
|
263
|
-
{ ageCategoryCode: 'MOH_105_OPD_DIAG', genders: ['F', 'M'] }
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
expect(result).toContain('CUSTOM_POPULATION_AGGREGATED');
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
test('should reject SQL without patient_id column', () => {
|
|
270
|
-
const result = customIndicatorInterpreter.applyDisaggregation(
|
|
271
|
-
'SELECT DISTINCT some_column FROM table_a',
|
|
272
|
-
{ ageCategoryCode: 'MOH_105_OPD_DIAG', genders: ['F', 'M'] }
|
|
273
|
-
);
|
|
274
|
-
|
|
275
|
-
expect(result).toContain('CUSTOM_PATIENT_ID_MISSING');
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
test('should reject already disaggregated SQL with age_group', () => {
|
|
279
|
-
// Create SQL that has both patient_id and age_group with AS
|
|
280
|
-
// to test that age_group validation comes first
|
|
281
|
-
const result = customIndicatorInterpreter.applyDisaggregation(
|
|
282
|
-
'SELECT DISTINCT mda.code AS age_group, a.client_id AS patient_id FROM table_a a',
|
|
283
|
-
{ ageCategoryCode: 'MOH_105_OPD_DIAG', genders: ['F', 'M'] }
|
|
284
|
-
);
|
|
285
|
-
|
|
286
|
-
expect(result).toContain('CUSTOM_ALREADY_DISAGGREGATED');
|
|
287
|
-
});
|
|
266
|
+
expect(result).toContain('CUSTOM_POPULATION_AGGREGATED');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('should reject SQL without patient_id column', () => {
|
|
270
|
+
const result = customIndicatorInterpreter.applyDisaggregation('SELECT DISTINCT some_column FROM table_a', {
|
|
271
|
+
ageCategoryCode: 'MOH_105_OPD_DIAG',
|
|
272
|
+
genders: ['F', 'M'],
|
|
288
273
|
});
|
|
289
274
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
275
|
+
expect(result).toContain('CUSTOM_PATIENT_ID_MISSING');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('should reject already disaggregated SQL with age_group', () => {
|
|
279
|
+
// Create SQL that has both patient_id and age_group with AS
|
|
280
|
+
// to test that age_group validation comes first
|
|
281
|
+
const result = customIndicatorInterpreter.applyDisaggregation(
|
|
282
|
+
'SELECT DISTINCT mda.code AS age_group, a.client_id AS patient_id FROM table_a a',
|
|
283
|
+
{ ageCategoryCode: 'MOH_105_OPD_DIAG', genders: ['F', 'M'] },
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
expect(result).toContain('CUSTOM_ALREADY_DISAGGREGATED');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('Valid disaggregation', () => {
|
|
291
|
+
test('should generate canonical disaggregation SQL', () => {
|
|
292
|
+
const populationSql = `SELECT DISTINCT a.client_id AS patient_id
|
|
293
293
|
FROM (
|
|
294
294
|
SELECT client_id FROM mamba_fact_encounter_hiv_art_card
|
|
295
295
|
) a
|
|
296
296
|
WHERE a.some_column = 'value'`;
|
|
297
297
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
298
|
+
const result = customIndicatorInterpreter.applyDisaggregation(populationSql, {
|
|
299
|
+
ageCategoryCode: 'MOH_105_OPD_DIAG',
|
|
300
|
+
genders: ['F', 'M'],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Should generate the canonical structure
|
|
304
|
+
expect(result).toContain('WITH base_pop AS (');
|
|
305
|
+
expect(result).toContain('ag AS (');
|
|
306
|
+
expect(result).toContain('ag.age_group_id');
|
|
307
|
+
expect(result).toContain('ag.label');
|
|
308
|
+
expect(result).toContain('ag.min_age_days');
|
|
309
|
+
expect(result).toContain('ag.max_age_days');
|
|
310
|
+
expect(result).toContain("ac.code = 'MOH_105_OPD_DIAG'");
|
|
311
|
+
expect(result).toContain('COUNT(DISTINCT base_pop.patient_id) AS value');
|
|
312
|
+
expect(result).toMatch(/FROM\s+base_pop/);
|
|
313
|
+
expect(result).toMatch(/JOIN\s+mamba_fact_patients_latest_patient_demographics\s+mdp/);
|
|
314
|
+
expect(result).toContain('ON mdp.patient_id = base_pop.patient_id');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('should use custom patient ID column from config', () => {
|
|
318
|
+
const populationSql = `SELECT DISTINCT a.client_id AS patient_id
|
|
319
319
|
FROM table_a a`;
|
|
320
320
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
321
|
+
const result = customIndicatorInterpreter.applyDisaggregation(
|
|
322
|
+
populationSql,
|
|
323
|
+
{ ageCategoryCode: 'MOH_105_OPD_DIAG', genders: ['F', 'M'] },
|
|
324
|
+
{
|
|
325
|
+
version: 1,
|
|
326
|
+
patientIdColumn: 'client_id',
|
|
327
|
+
populationQuery: { extractFrom: 'configJson' },
|
|
328
|
+
supportsRedisaggregation: true,
|
|
329
|
+
redisaggregationStrategy: 'population-extraction',
|
|
330
|
+
themeIndependent: true,
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
// Should use the custom patient ID column
|
|
335
|
+
expect(result).toContain('ON mdp.client_id = base_pop.client_id');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('should preserve population business logic in base_pop', () => {
|
|
339
|
+
const populationSql = `SELECT DISTINCT a.client_id AS patient_id
|
|
340
340
|
FROM (
|
|
341
341
|
SELECT
|
|
342
342
|
client_id,
|
|
@@ -348,40 +348,40 @@ FROM (
|
|
|
348
348
|
LEFT JOIN person p ON p.person_id = a.client_id
|
|
349
349
|
WHERE a.ltfp_days <= 28 AND p.person_id IS NULL`;
|
|
350
350
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
);
|
|
355
|
-
|
|
356
|
-
// All business logic should be preserved in base_pop
|
|
357
|
-
expect(result).toContain('TIMESTAMPDIFF(DAY, MAX(return_visit_date)');
|
|
358
|
-
expect(result).toContain('WHERE encounter_date <= :endDate');
|
|
359
|
-
expect(result).toContain('LEFT JOIN person p');
|
|
360
|
-
expect(result).toContain('WHERE a.ltfp_days <= 28');
|
|
361
|
-
});
|
|
351
|
+
const result = customIndicatorInterpreter.applyDisaggregation(populationSql, {
|
|
352
|
+
ageCategoryCode: 'MOH_105_OPD_DIAG',
|
|
353
|
+
genders: ['F', 'M'],
|
|
362
354
|
});
|
|
363
355
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
356
|
+
// All business logic should be preserved in base_pop
|
|
357
|
+
expect(result).toContain('TIMESTAMPDIFF(DAY, MAX(return_visit_date)');
|
|
358
|
+
expect(result).toContain('WHERE encounter_date <= :endDate');
|
|
359
|
+
expect(result).toContain('LEFT JOIN person p');
|
|
360
|
+
expect(result).toContain('WHERE a.ltfp_days <= 28');
|
|
361
|
+
});
|
|
362
|
+
});
|
|
367
363
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
);
|
|
364
|
+
describe('Trailing semicolon handling', () => {
|
|
365
|
+
test('should remove trailing semicolon from population SQL', () => {
|
|
366
|
+
const populationSql = `SELECT DISTINCT client_id AS patient_id FROM table_a;`;
|
|
372
367
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
expect(result).toMatch(/ORDER BY ag\.sort_order, g\.gender;?$/);
|
|
377
|
-
});
|
|
368
|
+
const result = customIndicatorInterpreter.applyDisaggregation(populationSql, {
|
|
369
|
+
ageCategoryCode: 'MOH_105_OPD_DIAG',
|
|
370
|
+
genders: ['F', 'M'],
|
|
378
371
|
});
|
|
379
372
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
373
|
+
// The base_pop CTE should not have a semicolon inside
|
|
374
|
+
expect(result).toMatch(/WITH base_pop AS \(\s+SELECT DISTINCT/);
|
|
375
|
+
// But the final query should have one
|
|
376
|
+
expect(result).toMatch(/ORDER BY ag\.sort_order, g\.gender;?$/);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe('Nested subquery handling (balanced parenthesis matching)', () => {
|
|
381
|
+
test('should handle nested subqueries in inner query (TX_RTT CD4 >= 200 pattern)', () => {
|
|
382
|
+
// This is the actual bug we're fixing - nested subqueries within the inner query
|
|
383
|
+
// The inner query has LEFT JOIN with subqueries, DATE_SUB with INTERVAL, etc.
|
|
384
|
+
const input = `SELECT COUNT(DISTINCT a.client_id)
|
|
385
385
|
FROM (
|
|
386
386
|
SELECT client_id, TIMESTAMPDIFF(DAY, MAX(return_visit_date), :endDate) ltfp_days
|
|
387
387
|
FROM mamba_fact_encounter_hiv_art_card
|
|
@@ -411,28 +411,28 @@ WHERE a.ltfp_days <= 28 AND p.person_id IS NULL`;
|
|
|
411
411
|
WHERE a.ltfp_days <= 28
|
|
412
412
|
) filter_client ON a.client_id = filter_client.client_id`;
|
|
413
413
|
|
|
414
|
-
|
|
414
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
415
415
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
416
|
+
// With balanced parenthesis matching, this should now succeed
|
|
417
|
+
expect(result.success).toBe(true);
|
|
418
|
+
expect(result.sql).toMatch(/SELECT\s+DISTINCT\s+a\.client_id\s+AS\s+patient_id/i);
|
|
419
419
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
420
|
+
// Verify the complete inner query is preserved
|
|
421
|
+
expect(result.sql).toContain('TIMESTAMPDIFF(DAY, MAX(return_visit_date), :endDate)');
|
|
422
|
+
expect(result.sql).toContain('WHERE encounter_date <= :endDate');
|
|
423
423
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
424
|
+
// Verify nested subqueries are preserved
|
|
425
|
+
expect(result.sql).toContain('SELECT * FROM person p WHERE p.dead = 1');
|
|
426
|
+
expect(result.sql).toContain('SELECT mf_to.client_id');
|
|
427
|
+
expect(result.sql).toContain('SELECT DISTINCT a.client_id');
|
|
428
428
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
429
|
+
// Verify the most deeply nested subquery with DATE_SUB is preserved
|
|
430
|
+
expect(result.sql).toContain('DATE_SUB(:endDate, INTERVAL 3 MONTH)');
|
|
431
|
+
});
|
|
432
432
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
433
|
+
test('should handle deeply nested subqueries with INTERVAL expressions', () => {
|
|
434
|
+
// Test with multiple levels of nesting and INTERVAL expressions
|
|
435
|
+
const input = `SELECT COUNT(DISTINCT base.client_id)
|
|
436
436
|
FROM (
|
|
437
437
|
SELECT DISTINCT a.client_id
|
|
438
438
|
FROM (
|
|
@@ -448,20 +448,20 @@ WHERE a.ltfp_days <= 28 AND p.person_id IS NULL`;
|
|
|
448
448
|
) lab ON a.client_id = lab.client_id
|
|
449
449
|
) base`;
|
|
450
450
|
|
|
451
|
-
|
|
451
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
452
452
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
453
|
+
expect(result.success).toBe(true);
|
|
454
|
+
// Verify all levels of nesting are preserved
|
|
455
|
+
expect(result.sql).toContain('DATE_SUB(:endDate, INTERVAL 3 MONTH)');
|
|
456
|
+
expect(result.sql).toContain('mamba_fact_encounter_hiv_art_card');
|
|
457
|
+
expect(result.sql).toContain('mamba_fact_lab_results');
|
|
458
|
+
});
|
|
459
459
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
460
|
+
test('should handle TX_ML pattern with quoted parameters and extra column', () => {
|
|
461
|
+
// This is the TX_ML pattern with:
|
|
462
|
+
// 1. Quoted parameters like ':endDate' instead of :endDate
|
|
463
|
+
// 2. Extra column before COUNT: SELECT 'PWIDS', COUNT(DISTINCT a.client_id)
|
|
464
|
+
const input = `SELECT 'PWIDS', COUNT(DISTINCT a.client_id)
|
|
465
465
|
FROM (SELECT a.client_id, return_date
|
|
466
466
|
FROM (SELECT client_id, MAX(return_visit_date) return_date
|
|
467
467
|
FROM mamba_fact_encounter_hiv_art_card
|
|
@@ -476,18 +476,18 @@ FROM (SELECT a.client_id, return_date
|
|
|
476
476
|
WHERE special_category = 'Current drug user') special_category
|
|
477
477
|
ON a.client_id = special_category.client_id`;
|
|
478
478
|
|
|
479
|
-
|
|
479
|
+
const result = customIndicatorInterpreter.extractPopulationSql(input);
|
|
480
480
|
|
|
481
|
-
|
|
482
|
-
|
|
481
|
+
expect(result.success).toBe(true);
|
|
482
|
+
expect(result.sql).toMatch(/SELECT\s+DISTINCT\s+a\.client_id\s+AS\s+patient_id/i);
|
|
483
483
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
484
|
+
// Verify quoted parameters are converted to unquoted
|
|
485
|
+
expect(result.sql).toMatch(/:endDate/);
|
|
486
|
+
expect(result.sql).not.toMatch(/':endDate'/);
|
|
487
487
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
});
|
|
488
|
+
// Verify the special_category filter is preserved
|
|
489
|
+
expect(result.sql).toContain("special_category = 'Current drug user'");
|
|
490
|
+
});
|
|
492
491
|
});
|
|
492
|
+
});
|
|
493
493
|
});
|