@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.
Files changed (232) hide show
  1. package/dist/128.css +1 -0
  2. package/dist/128.css.map +1 -0
  3. package/dist/128.js +338 -0
  4. package/dist/128.js.map +1 -0
  5. package/dist/1480.js +1 -0
  6. package/dist/1480.js.map +1 -0
  7. package/dist/1646.js +1 -0
  8. package/dist/1646.js.map +1 -0
  9. package/dist/1869.js +1 -0
  10. package/dist/1869.js.map +1 -0
  11. package/dist/1877.js +1 -0
  12. package/dist/1877.js.map +1 -0
  13. package/dist/2131.js +1 -0
  14. package/dist/2131.js.map +1 -0
  15. package/dist/2317.js +1 -0
  16. package/dist/2317.js.map +1 -0
  17. package/dist/2408.js +1 -0
  18. package/dist/2408.js.map +1 -0
  19. package/dist/2416.js +1 -0
  20. package/dist/2416.js.map +1 -0
  21. package/dist/282.js +1 -0
  22. package/dist/282.js.map +1 -0
  23. package/dist/2881.js +1 -0
  24. package/dist/2881.js.map +1 -0
  25. package/dist/2997.js +1 -0
  26. package/dist/2997.js.map +1 -0
  27. package/dist/3378.js +1 -0
  28. package/dist/3378.js.map +1 -0
  29. package/dist/3963.js +1 -0
  30. package/dist/3963.js.map +1 -0
  31. package/dist/4106.js +1 -0
  32. package/dist/4106.js.map +1 -0
  33. package/dist/4111.js +1 -0
  34. package/dist/4111.js.map +1 -0
  35. package/dist/4171.css +1 -0
  36. package/dist/4171.css.map +1 -0
  37. package/dist/4171.js +43 -0
  38. package/dist/4171.js.map +1 -0
  39. package/dist/434.js +1 -0
  40. package/dist/434.js.map +1 -0
  41. package/dist/4348.js +1 -0
  42. package/dist/4348.js.map +1 -0
  43. package/dist/4383.js +1 -0
  44. package/dist/4383.js.map +1 -0
  45. package/dist/4658.js +1 -0
  46. package/dist/4658.js.map +1 -0
  47. package/dist/4668.js +1 -0
  48. package/dist/4668.js.map +1 -0
  49. package/dist/4928.js +1 -0
  50. package/dist/4928.js.map +1 -0
  51. package/dist/5098.js +1 -0
  52. package/dist/5098.js.map +1 -0
  53. package/dist/5117.js +1 -0
  54. package/dist/5117.js.map +1 -0
  55. package/dist/5132.js +1 -0
  56. package/dist/5132.js.map +1 -0
  57. package/dist/5145.js +1 -0
  58. package/dist/5145.js.map +1 -0
  59. package/dist/550.js +1 -0
  60. package/dist/550.js.map +1 -0
  61. package/dist/5503.js +1 -0
  62. package/dist/5503.js.map +1 -0
  63. package/dist/556.js +1 -0
  64. package/dist/556.js.map +1 -0
  65. package/dist/5644.js +1 -0
  66. package/dist/5644.js.map +1 -0
  67. package/dist/5940.js +1 -0
  68. package/dist/5940.js.map +1 -0
  69. package/dist/6047.js +1 -0
  70. package/dist/6047.js.map +1 -0
  71. package/dist/6237.js +1 -0
  72. package/dist/6237.js.map +1 -0
  73. package/dist/6247.js +1 -0
  74. package/dist/6247.js.map +1 -0
  75. package/dist/6371.js +1 -0
  76. package/dist/6371.js.map +1 -0
  77. package/dist/6377.js +1 -0
  78. package/dist/6377.js.map +1 -0
  79. package/dist/6444.js +1 -0
  80. package/dist/6444.js.map +1 -0
  81. package/dist/6508.js +1 -0
  82. package/dist/6508.js.map +1 -0
  83. package/dist/6652.js +1 -0
  84. package/dist/6652.js.map +1 -0
  85. package/dist/6724.js +1 -0
  86. package/dist/6724.js.map +1 -0
  87. package/dist/6867.js +19 -0
  88. package/dist/6867.js.map +1 -0
  89. package/dist/6904.js +1 -0
  90. package/dist/6904.js.map +1 -0
  91. package/dist/7045.js +1 -0
  92. package/dist/7045.js.map +1 -0
  93. package/dist/7175.js +1 -0
  94. package/dist/7175.js.map +1 -0
  95. package/dist/7182.js +1 -0
  96. package/dist/7182.js.map +1 -0
  97. package/dist/7475.js +1 -0
  98. package/dist/7475.js.map +1 -0
  99. package/dist/7742.js +1 -0
  100. package/dist/7742.js.map +1 -0
  101. package/dist/7912.js +1 -0
  102. package/dist/7912.js.map +1 -0
  103. package/dist/8251.js +1 -0
  104. package/dist/8251.js.map +1 -0
  105. package/dist/8358.js +1 -0
  106. package/dist/8358.js.map +1 -0
  107. package/dist/8359.js +1 -0
  108. package/dist/8359.js.map +1 -0
  109. package/dist/8695.js +1 -0
  110. package/dist/8695.js.map +1 -0
  111. package/dist/903.js +1 -0
  112. package/dist/903.js.map +1 -0
  113. package/dist/9072.js +1 -0
  114. package/dist/9072.js.map +1 -0
  115. package/dist/9806.js +1 -0
  116. package/dist/9806.js.map +1 -0
  117. package/dist/997.js +1 -0
  118. package/dist/997.js.map +1 -0
  119. package/dist/openmrs-esm-report-builder.js +3 -1
  120. package/dist/openmrs-esm-report-builder.js.buildmanifest.json +1099 -196
  121. package/dist/openmrs-esm-report-builder.js.map +1 -1
  122. package/dist/routes.json +1 -1
  123. package/package.json +21 -34
  124. package/rspack.config.js +2 -0
  125. package/src/__debug__/all-designs.test.tsx +7 -4
  126. package/src/components/data-visualizer/data-visualizer.scss +0 -1
  127. package/src/components/indicators/utils/composite-indicator-sql.utils.test.ts +1 -1
  128. package/src/components/indicators/utils/custom-indicator-interpreter.test.ts +320 -320
  129. package/src/components/indicators/utils/population-sql.compiler.test.ts +950 -964
  130. package/src/components/linelist/design/panels/column-category-selector.scss +7 -12
  131. package/src/components/linelist/design/panels/query-config-panel.scss +7 -8
  132. package/src/components/security/access-denied-page.component.tsx +45 -50
  133. package/src/components/security/privilege-route.component.test.tsx +59 -62
  134. package/src/components/security/privilege-route.component.tsx +3 -3
  135. package/src/constants/privileges.test.ts +1 -0
  136. package/src/features/dashboards/utils/dashboard-config.util.test.ts +37 -6
  137. package/src/globals.d.ts +0 -5
  138. package/src/hooks/use-report-builder-privileges.test.tsx +69 -75
  139. package/src/routes.json +3 -0
  140. package/src/utils/api-error.utils.test.ts +14 -18
  141. package/src/utils/etl-browser/etl-browser.util.test.ts +125 -119
  142. package/src/utils/etl-task/etl-task-card.util.test.ts +3 -2
  143. package/tools/i18next-parser.config.js +0 -0
  144. package/tools/setup-tests.ts +1 -1
  145. package/tools/update-openmrs-deps.mjs +42 -0
  146. package/translations/am.json +65 -0
  147. package/translations/ar.json +65 -0
  148. package/translations/ar_SY.json +65 -0
  149. package/translations/bn.json +65 -0
  150. package/translations/cs.json +65 -0
  151. package/translations/de.json +65 -0
  152. package/translations/en_US.json +65 -0
  153. package/translations/es.json +65 -0
  154. package/translations/es_MX.json +65 -0
  155. package/translations/fr.json +65 -0
  156. package/translations/he.json +65 -0
  157. package/translations/hi.json +65 -0
  158. package/translations/hi_IN.json +65 -0
  159. package/translations/id.json +65 -0
  160. package/translations/it.json +65 -0
  161. package/translations/ka.json +65 -0
  162. package/translations/km.json +65 -0
  163. package/translations/ku.json +65 -0
  164. package/translations/ky.json +65 -0
  165. package/translations/lg.json +65 -0
  166. package/translations/ne.json +65 -0
  167. package/translations/pl.json +65 -0
  168. package/translations/pt.json +65 -0
  169. package/translations/pt_BR.json +65 -0
  170. package/translations/qu.json +65 -0
  171. package/translations/ro_RO.json +65 -0
  172. package/translations/ru_RU.json +65 -0
  173. package/translations/si.json +65 -0
  174. package/translations/sq.json +65 -0
  175. package/translations/sw.json +65 -0
  176. package/translations/sw_KE.json +65 -0
  177. package/translations/tr.json +65 -0
  178. package/translations/tr_TR.json +65 -0
  179. package/translations/uk.json +65 -0
  180. package/translations/uz.json +65 -0
  181. package/translations/uz@Latn.json +65 -0
  182. package/translations/uz_UZ.json +65 -0
  183. package/translations/vi.json +65 -0
  184. package/translations/zh.json +65 -0
  185. package/translations/zh_CN.json +65 -0
  186. package/translations/zh_TW.json +65 -0
  187. package/tsconfig.json +9 -28
  188. package/vitest.config.mts +42 -0
  189. package/dist/162.js +0 -1
  190. package/dist/162.js.map +0 -1
  191. package/dist/184.js +0 -2
  192. package/dist/184.js.LICENSE.txt +0 -14
  193. package/dist/184.js.map +0 -1
  194. package/dist/242.js +0 -2
  195. package/dist/242.js.LICENSE.txt +0 -21
  196. package/dist/242.js.map +0 -1
  197. package/dist/253.js +0 -1
  198. package/dist/253.js.map +0 -1
  199. package/dist/300.js +0 -1
  200. package/dist/311.js +0 -1
  201. package/dist/311.js.map +0 -1
  202. package/dist/469.js +0 -2
  203. package/dist/469.js.LICENSE.txt +0 -45
  204. package/dist/469.js.map +0 -1
  205. package/dist/478.js +0 -2
  206. package/dist/478.js.LICENSE.txt +0 -9
  207. package/dist/478.js.map +0 -1
  208. package/dist/539.js +0 -1
  209. package/dist/539.js.map +0 -1
  210. package/dist/540.js +0 -2
  211. package/dist/540.js.LICENSE.txt +0 -9
  212. package/dist/540.js.map +0 -1
  213. package/dist/669.js +0 -1
  214. package/dist/669.js.map +0 -1
  215. package/dist/710.js +0 -1
  216. package/dist/710.js.map +0 -1
  217. package/dist/729.js +0 -1
  218. package/dist/729.js.map +0 -1
  219. package/dist/838.js +0 -2
  220. package/dist/838.js.LICENSE.txt +0 -9
  221. package/dist/838.js.map +0 -1
  222. package/dist/917.js +0 -1
  223. package/dist/917.js.map +0 -1
  224. package/dist/961.js +0 -2
  225. package/dist/961.js.LICENSE.txt +0 -19
  226. package/dist/961.js.map +0 -1
  227. package/dist/main.js +0 -1
  228. package/dist/main.js.map +0 -1
  229. package/jest.config.js +0 -28
  230. package/routes.json +0 -22
  231. package/tools/scss-stub.js +0 -12
  232. 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 '@jest/globals';
9
+ import { describe, test, expect } from 'vitest';
10
10
  import { customIndicatorInterpreter } from './custom-indicator-interpreter';
11
11
 
12
12
  describe('CustomIndicatorInterpreter', () => {
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
- });
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
- describe('Aliased CUSTOM count', () => {
28
- test('should extract population from aliased COUNT DISTINCT', () => {
29
- const input = `SELECT COUNT(DISTINCT a.client_id)
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
- const result = customIndicatorInterpreter.extractPopulationSql(input);
34
+ const result = customIndicatorInterpreter.extractPopulationSql(input);
35
35
 
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
- });
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
- 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`;
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
- const result = customIndicatorInterpreter.extractPopulationSql(input);
48
+ const result = customIndicatorInterpreter.extractPopulationSql(input);
49
49
 
50
- expect(result.success).toBe(true);
51
- expect(result.sql).toMatch(/SELECT\s+DISTINCT\s+a\.client_id\s+AS\s+patient_id/i);
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
- // 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
- });
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
- test('should preserve TIMESTAMPDIFF expressions exactly', () => {
60
- const input = `SELECT COUNT(DISTINCT a.client_id)
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
- const result = customIndicatorInterpreter.extractPopulationSql(input);
67
+ const result = customIndicatorInterpreter.extractPopulationSql(input);
68
68
 
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
- });
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
- describe('CD4 criteria', () => {
76
- test('should preserve CD4 < 200', () => {
77
- const input = `SELECT COUNT(DISTINCT a.client_id)
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
- const result = customIndicatorInterpreter.extractPopulationSql(input);
83
+ const result = customIndicatorInterpreter.extractPopulationSql(input);
84
84
 
85
- expect(result.success).toBe(true);
86
- expect(result.sql).toContain('WHERE cd4 < 200');
87
- });
85
+ expect(result.success).toBe(true);
86
+ expect(result.sql).toContain('WHERE cd4 < 200');
87
+ });
88
88
 
89
- test('should preserve CD4 >= 200', () => {
90
- const input = `SELECT COUNT(DISTINCT a.client_id)
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
- const result = customIndicatorInterpreter.extractPopulationSql(input);
96
+ const result = customIndicatorInterpreter.extractPopulationSql(input);
97
97
 
98
- expect(result.success).toBe(true);
99
- expect(result.sql).toContain('WHERE cd4 >= 200');
100
- });
98
+ expect(result.success).toBe(true);
99
+ expect(result.sql).toContain('WHERE cd4 >= 200');
100
+ });
101
101
 
102
- test('should preserve CD4 IS NULL', () => {
103
- const input = `SELECT COUNT(DISTINCT a.client_id)
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
- const result = customIndicatorInterpreter.extractPopulationSql(input);
109
+ const result = customIndicatorInterpreter.extractPopulationSql(input);
110
110
 
111
- expect(result.success).toBe(true);
112
- expect(result.sql).toContain('WHERE cd4 IS NULL');
113
- });
114
- });
111
+ expect(result.success).toBe(true);
112
+ expect(result.sql).toContain('WHERE cd4 IS NULL');
113
+ });
114
+ });
115
115
 
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);
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
- expect(result.success).toBe(true);
123
- expect(result.sql).toMatch(/AS\s+patient_id/i);
124
- });
122
+ expect(result.success).toBe(true);
123
+ expect(result.sql).toMatch(/AS\s+patient_id/i);
124
+ });
125
125
 
126
- test('should detect patient_id column from inner query', () => {
127
- const input = `SELECT COUNT(DISTINCT a.client_id)
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
- const result = customIndicatorInterpreter.extractPopulationSql(input);
132
+ const result = customIndicatorInterpreter.extractPopulationSql(input);
133
133
 
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
- });
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
- 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
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
- const result = customIndicatorInterpreter.extractPopulationSql(input);
148
+ const result = customIndicatorInterpreter.extractPopulationSql(input);
149
149
 
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
- });
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
- 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
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
- 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
- });
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
- 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)
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
- // 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
- });
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
- 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
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
- 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
- });
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
- describe('applyDisaggregation', () => {
250
- describe('Validation', () => {
251
- test('should reject empty population SQL', () => {
252
- const result = customIndicatorInterpreter.applyDisaggregation(
253
- '',
254
- { ageCategoryCode: 'MOH_105_OPD_DIAG', 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(
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
- describe('Valid disaggregation', () => {
291
- test('should generate canonical disaggregation SQL', () => {
292
- const populationSql = `SELECT DISTINCT a.client_id AS patient_id
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
- const result = customIndicatorInterpreter.applyDisaggregation(
299
- populationSql,
300
- { ageCategoryCode: 'MOH_105_OPD_DIAG', 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
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
- 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
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
- const result = customIndicatorInterpreter.applyDisaggregation(
352
- populationSql,
353
- { ageCategoryCode: 'MOH_105_OPD_DIAG', genders: ['F', 'M'] }
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
- 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;`;
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
- const result = customIndicatorInterpreter.applyDisaggregation(
369
- populationSql,
370
- { ageCategoryCode: 'MOH_105_OPD_DIAG', genders: ['F', 'M'] }
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
- // 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
- });
368
+ const result = customIndicatorInterpreter.applyDisaggregation(populationSql, {
369
+ ageCategoryCode: 'MOH_105_OPD_DIAG',
370
+ genders: ['F', 'M'],
378
371
  });
379
372
 
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)
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
- const result = customIndicatorInterpreter.extractPopulationSql(input);
414
+ const result = customIndicatorInterpreter.extractPopulationSql(input);
415
415
 
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);
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
- // 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');
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
- // 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');
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
- // Verify the most deeply nested subquery with DATE_SUB is preserved
430
- expect(result.sql).toContain('DATE_SUB(:endDate, INTERVAL 3 MONTH)');
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
- 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)
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
- const result = customIndicatorInterpreter.extractPopulationSql(input);
451
+ const result = customIndicatorInterpreter.extractPopulationSql(input);
452
452
 
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
- });
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
- 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)
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
- const result = customIndicatorInterpreter.extractPopulationSql(input);
479
+ const result = customIndicatorInterpreter.extractPopulationSql(input);
480
480
 
481
- expect(result.success).toBe(true);
482
- expect(result.sql).toMatch(/SELECT\s+DISTINCT\s+a\.client_id\s+AS\s+patient_id/i);
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
- // Verify quoted parameters are converted to unquoted
485
- expect(result.sql).toMatch(/:endDate/);
486
- expect(result.sql).not.toMatch(/':endDate'/);
484
+ // Verify quoted parameters are converted to unquoted
485
+ expect(result.sql).toMatch(/:endDate/);
486
+ expect(result.sql).not.toMatch(/':endDate'/);
487
487
 
488
- // Verify the special_category filter is preserved
489
- expect(result.sql).toContain('special_category = \'Current drug user\'');
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
  });