Skip to content

Commit 32e2313

Browse files
committed
schema rules and test on 30 fhir fields
1 parent 9e18d90 commit 32e2313

File tree

6 files changed

+450
-71
lines changed

6 files changed

+450
-71
lines changed

lambdas/shared/src/common/validator/constants/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ class Constants:
2727
"Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n"
2828
f"Note that partial dates are not allowed for {field_name} in this service.\n"
2929
)
30+
MAXIMUM_DOSE_NUMBER_VALUE = 9

lambdas/shared/src/common/validator/expression_checker.py

Lines changed: 52 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -80,36 +80,57 @@ def validation_for_date(self, _expression_rule, field_name, field_value) -> Erro
8080
message = MESSAGES[ExceptionLevels.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, e)
8181
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, None, field_name)
8282

83-
def validation_for_positive_integer(self, _expression_rule, field_name, field_value, row):
83+
def validation_for_positive_integer(self, expression_rule, field_name, field_value) -> ErrorReport:
84+
rules = expression_rule_per_field(expression_rule) if expression_rule else {}
85+
max_value = rules.get("max_value", None)
8486
"""
8587
Apply pre-validation to an integer field to ensure that it is a positive integer,
8688
which does not exceed the maximum allowed value (if applicable)
8789
"""
88-
max_value: int = None
89-
# This check uses type() instead of isinstance() because bool is a subclass of int.
90-
if type(field_value) is not int: # pylint: disable=unidiomatic-typecheck
91-
raise TypeError(f"{field_name} must be a positive integer")
90+
try:
91+
# This check uses type() instead of isinstance() because bool is a subclass of int.
92+
if type(field_value) is not int: # pylint: disable=unidiomatic-typecheck
93+
raise TypeError(f"{field_name} must be a positive integer")
9294

93-
if field_value <= 0:
94-
raise ValueError(f"{field_name} must be a positive integer")
95+
if field_value <= 0:
96+
raise ValueError(f"{field_name} must be a positive integer")
9597

96-
if max_value:
97-
if field_value > max_value:
98-
raise ValueError(f"{field_name} must be an integer in the range 1 to {max_value}")
98+
if max_value:
99+
if field_value > max_value:
100+
raise ValueError(f"{field_name} must be an integer in the range 1 to {max_value}")
101+
except (TypeError, ValueError) as e:
102+
code = ExceptionLevels.RECORD_CHECK_FAILED
103+
message = MESSAGES[ExceptionLevels.RECORD_CHECK_FAILED]
104+
details = str(e)
105+
return ErrorReport(code, message, None, field_name, details)
106+
except Exception as e:
107+
if self.report_unexpected_exception:
108+
message = MESSAGES[ExceptionLevels.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, e)
109+
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, None, field_name)
99110

100111
def validation_for_integer_or_decimal(
101-
self, _expression_rule, field_value: Union[int, Decimal], field_name: str, row: dict
102-
):
112+
self, _expression_rule, field_name: str, field_value: Union[int, Decimal]
113+
) -> ErrorReport:
103114
"""
104115
Apply pre-validation to a decimal field to ensure that it is an integer or decimal,
105116
which does not exceed the maximum allowed number of decimal places (if applicable)
106117
"""
107-
if not (
108-
# This check uses type() instead of isinstance() because bool is a subclass of int.
109-
type(field_value) is int # pylint: disable=unidiomatic-typecheck
110-
or type(field_value) is Decimal # pylint: disable=unidiomatic-typecheck
111-
):
112-
raise TypeError(f"{field_name} must be a number")
118+
try:
119+
if not (
120+
# This check uses type() instead of isinstance() because bool is a subclass of int.
121+
type(field_value) is int # pylint: disable=unidiomatic-typecheck
122+
or type(field_value) is Decimal # pylint: disable=unidiomatic-typecheck
123+
):
124+
raise TypeError(f"{field_name} must be a number")
125+
except (TypeError, ValueError) as e:
126+
code = ExceptionLevels.RECORD_CHECK_FAILED
127+
message = MESSAGES[ExceptionLevels.RECORD_CHECK_FAILED]
128+
details = str(e)
129+
return ErrorReport(code, message, None, field_name, details)
130+
except Exception as e:
131+
if self.report_unexpected_exception:
132+
message = MESSAGES[ExceptionLevels.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, e)
133+
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, None, field_name)
113134

114135
def validation_for_unique_list(
115136
list_to_check: list,
@@ -150,10 +171,20 @@ def _validate_regex(self, expression_rule: str, field_name: str, field_value: st
150171
message = MESSAGES[ExceptionLevels.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, e)
151172
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, row, field_name, "", self.summarise)
152173

153-
def validation_for_boolean(self, expression_rule: str, field_name: str, field_value: str, row: dict):
174+
def validation_for_boolean(self, expression_rule: str, field_name: str, field_value: str) -> ErrorReport:
154175
"""Apply pre-validation to a boolean field to ensure that it is a boolean"""
155-
if not isinstance(field_value, bool):
156-
raise TypeError(f"{field_name} must be a boolean")
176+
try:
177+
if not isinstance(field_value, bool):
178+
raise TypeError(f"{field_name} must be a boolean")
179+
except (TypeError, ValueError) as e:
180+
code = ExceptionLevels.RECORD_CHECK_FAILED
181+
message = MESSAGES[ExceptionLevels.RECORD_CHECK_FAILED]
182+
details = str(e)
183+
return ErrorReport(code, message, None, field_name, details)
184+
except Exception as e:
185+
if self.report_unexpected_exception:
186+
message = MESSAGES[ExceptionLevels.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, e)
187+
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, None, field_name, "")
157188

158189
def validation_for_list(self, expression_rule: str, field_name: str, field_value: list):
159190
"""
@@ -407,27 +438,6 @@ def _validate_not_empty(self, _expression_rule: str, field_name: str, field_valu
407438
message = MESSAGES[ExceptionLevels.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, e)
408439
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, None, field_name)
409440

410-
# Positive Validate
411-
def _validate_positive(self, _expression_rule: str, field_name: str, field_value: str, row: dict) -> ErrorReport:
412-
try:
413-
value = float(field_value)
414-
if value < 0:
415-
raise RecordError(
416-
ExceptionLevels.RECORD_CHECK_FAILED,
417-
"Value is not positive failure",
418-
"Value is not positive as expected, data- " + field_value,
419-
)
420-
except RecordError as e:
421-
code = e.code if e.code is not None else ExceptionLevels.RECORD_CHECK_FAILED
422-
message = e.message if e.message is not None else MESSAGES[ExceptionLevels.RECORD_CHECK_FAILED]
423-
if e.details is not None:
424-
details = e.details
425-
return ErrorReport(code, message, None, field_name, details, self.summarise)
426-
except Exception as e:
427-
if self.report_unexpected_exception:
428-
message = MESSAGES[ExceptionLevels.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, e)
429-
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, row, field_name, "", self.summarise)
430-
431441
# NHSNumber Validate
432442
def _validate_nhs_number(self, _expression_rule: str, field_name: str, field_value: str, row: dict) -> ErrorReport:
433443
try:

lambdas/shared/src/common/validator/expression_rule.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,7 @@ def expression_rule_per_field(expression_type: str) -> dict:
1717
return {"predefined_values": Constants.GENDERS}
1818
case "DATETIME":
1919
return {"strict_time_zone": True}
20+
case "DOSE_NUMBER":
21+
return {"max_value": Constants.MAXIMUM_DOSE_NUMBER_VALUE}
2022
case _:
2123
raise ValueError(f"Expression rule not found for type: {expression_type}")

lambdas/shared/tests/test_common/validator/test_expression_checker.py

Lines changed: 195 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import unittest
2+
from decimal import Decimal
23

34
from common.validator.error_report.record_error import ErrorReport
45
from common.validator.expression_checker import ExpressionChecker
@@ -163,6 +164,199 @@ def test_gender_string_rule_valid_and_invalid(self):
163164
# Invalid values should error
164165
self.assertIsInstance(checker.validate_expression("STRING", "GENDER", field_path, "M"), ErrorReport)
165166

167+
# BOOLEAN with PRIMARY_SOURCE
168+
def test_primary_source_boolean_valid_and_invalid(self):
169+
checker = self.make_checker()
170+
field_path = "primarySource"
171+
# Valid: boolean True
172+
self.assertIsNone(checker.validate_expression("BOOLEAN", "", field_path, True))
173+
# Invalid: non-boolean should raise TypeError per implementation
174+
self.assertIsInstance(checker.validate_expression("BOOLEAN", "", field_path, "true"), ErrorReport)
175+
176+
# STRING with VACCINATION_PROCEDURE_CODE
177+
def test_vaccination_procedure_code_string_valid_and_invalid(self):
178+
checker = self.make_checker()
179+
field_path = "extension|0|valueCodeableConcept|coding|0|code"
180+
# Valid: non-empty string
181+
self.assertIsNone(checker.validate_expression("STRING", "", field_path, "123456"))
182+
# Invalid: empty string
183+
self.assertIsInstance(checker.validate_expression("STRING", "", field_path, ""), ErrorReport)
184+
# Invalid: non-string value
185+
self.assertIsInstance(checker.validate_expression("STRING", "", field_path, 123456), ErrorReport)
186+
187+
# STRING with VACCINATION_PROCEDURE_TERM
188+
def test_vaccination_procedure_term_string_valid_and_invalid(self):
189+
checker = self.make_checker()
190+
field_path = "extension|0|valueCodeableConcept|coding|0|display"
191+
# Valid: non-empty string
192+
self.assertIsNone(checker.validate_expression("STRING", "", field_path, "COVID-19 vaccination"))
193+
# Invalid: empty string
194+
self.assertIsInstance(checker.validate_expression("STRING", "", field_path, ""), ErrorReport)
195+
# Invalid: non-string value
196+
self.assertIsInstance(checker.validate_expression("STRING", "", field_path, 999), ErrorReport)
197+
198+
# POSITIVEINTEGER with DOSE_SEQUENCE
199+
def test_dose_sequence_positiveinteger_valid_and_invalid(self):
200+
checker = self.make_checker()
201+
field_path = "protocolApplied|0|doseNumberPositiveInt"
202+
# Valid: positive integer
203+
self.assertIsNone(checker.validate_expression("POSITIVEINTEGER", "", field_path, 2))
204+
# Invalid: zero -> ValueError
205+
self.assertIsInstance(checker.validate_expression("POSITIVEINTEGER", "", field_path, 0), ErrorReport)
206+
# Invalid: negative -> ValueError
207+
self.assertIsInstance(checker.validate_expression("POSITIVEINTEGER", "", field_path, -1), ErrorReport)
208+
# Invalid: non-int -> TypeError
209+
self.assertIsInstance(checker.validate_expression("POSITIVEINTEGER", "", field_path, "2"), ErrorReport)
210+
211+
# STRING with VACCINE_PRODUCT_CODE
212+
def test_vaccine_product_code_string_valid_and_invalid(self):
213+
checker = self.make_checker()
214+
field_path = "vaccineCode|coding|#:http://snomed.info/sct|code"
215+
# Valid: non-empty string
216+
self.assertIsNone(checker.validate_expression("STRING", "", field_path, "1119349007"))
217+
# Invalid: empty
218+
self.assertIsInstance(checker.validate_expression("STRING", "", field_path, ""), ErrorReport)
219+
# Invalid: non-string
220+
self.assertIsInstance(checker.validate_expression("STRING", "", field_path, 1119349007), ErrorReport)
221+
222+
# STRING with VACCINE_PRODUCT_TERM
223+
def test_vaccine_product_term_string_valid_and_invalid(self):
224+
checker = self.make_checker()
225+
field_path = "vaccineCode|coding|#:http://snomed.info/sct|display"
226+
# Valid: non-empty string (spaces allowed by default)
227+
self.assertIsNone(checker.validate_expression("STRING", "", field_path, "COVID-19 mRNA vaccine"))
228+
# Invalid: empty
229+
self.assertIsInstance(checker.validate_expression("STRING", "", field_path, ""), ErrorReport)
230+
# Invalid: non-string
231+
self.assertIsInstance(checker.validate_expression("STRING", "", field_path, 12345), ErrorReport)
232+
233+
# STRING with VACCINE_MANUFACTURER
234+
def test_vaccine_manufacturer_string_valid_and_invalid(self):
235+
checker = self.make_checker()
236+
field_path = "manufacturer|display"
237+
# Valid: non-empty string
238+
self.assertIsNone(checker.validate_expression("STRING", "", field_path, "Pfizer"))
239+
# Invalid: empty
240+
self.assertIsInstance(checker.validate_expression("STRING", "", field_path, ""), ErrorReport)
241+
# Invalid: non-string
242+
self.assertIsInstance(checker.validate_expression("STRING", "", field_path, 101), ErrorReport)
243+
244+
# STRING with SITE_OF_VACCINATION_CODE
245+
def test_site_of_vaccination_code_string_valid_and_invalid(self):
246+
checker = self.make_checker()
247+
field_path = "site|coding|#:http://snomed.info/sct|code"
248+
self.assertIsNone(
249+
checker.validate_expression("STRING", "", field_path, "123456"),
250+
msg=f"fieldPath={field_path}",
251+
)
252+
self.assertIsInstance(
253+
checker.validate_expression("STRING", "", field_path, ""),
254+
ErrorReport,
255+
msg=f"fieldPath={field_path}",
256+
)
257+
self.assertIsInstance(
258+
checker.validate_expression("STRING", "", field_path, 123456),
259+
ErrorReport,
260+
msg=f"fieldPath={field_path}",
261+
)
262+
263+
# STRING with SITE_OF_VACCINATION_TERM
264+
def test_site_of_vaccination_term_string_valid_and_invalid(self):
265+
checker = self.make_checker()
266+
field_path = "site|coding|#:http://snomed.info/sct|display"
267+
self.assertIsNone(
268+
checker.validate_expression("STRING", "", field_path, "Left deltoid"),
269+
msg=f"fieldPath={field_path}",
270+
)
271+
self.assertIsInstance(
272+
checker.validate_expression("STRING", "", field_path, ""),
273+
ErrorReport,
274+
msg=f"fieldPath={field_path}",
275+
)
276+
self.assertIsInstance(
277+
checker.validate_expression("STRING", "", field_path, 999),
278+
ErrorReport,
279+
msg=f"fieldPath={field_path}",
280+
)
281+
282+
# STRING with ROUTE_OF_VACCINATION_CODE
283+
def test_route_of_vaccination_code_string_valid_and_invalid(self):
284+
checker = self.make_checker()
285+
field_path = "route|coding|#:http://snomed.info/sct|code"
286+
self.assertIsNone(
287+
checker.validate_expression("STRING", "", field_path, "1234"),
288+
msg=f"fieldPath={field_path}",
289+
)
290+
self.assertIsInstance(
291+
checker.validate_expression("STRING", "", field_path, ""),
292+
ErrorReport,
293+
msg=f"fieldPath={field_path}",
294+
)
295+
self.assertIsInstance(
296+
checker.validate_expression("STRING", "", field_path, 1234),
297+
ErrorReport,
298+
msg=f"fieldPath={field_path}",
299+
)
300+
301+
# STRING with ROUTE_OF_VACCINATION_TERM
302+
def test_route_of_vaccination_term_string_valid_and_invalid(self):
303+
checker = self.make_checker()
304+
field_path = "route|coding|#:http://snomed.info/sct|display"
305+
self.assertIsNone(
306+
checker.validate_expression("STRING", "", field_path, "Intramuscular"),
307+
msg=f"fieldPath={field_path}",
308+
)
309+
self.assertIsInstance(
310+
checker.validate_expression("STRING", "", field_path, ""),
311+
ErrorReport,
312+
msg=f"fieldPath={field_path}",
313+
)
314+
self.assertIsInstance(
315+
checker.validate_expression("STRING", "", field_path, 12),
316+
ErrorReport,
317+
msg=f"fieldPath={field_path}",
318+
)
319+
320+
# INTDECIMAL with DOSE_AMOUNT
321+
def test_dose_amount_intdecimal_valid_and_invalid(self):
322+
checker = self.make_checker()
323+
field_path = "doseQuantity|value"
324+
# Valid: int
325+
self.assertIsNone(
326+
checker.validate_expression("INTDECIMAL", "", field_path, 1),
327+
msg=f"fieldPath={field_path}",
328+
)
329+
# Valid: Decimal
330+
self.assertIsNone(
331+
checker.validate_expression("INTDECIMAL", "", field_path, Decimal("0.5")),
332+
msg=f"fieldPath={field_path}",
333+
)
334+
# Invalid: string
335+
self.assertIsInstance(
336+
checker.validate_expression("INTDECIMAL", "", field_path, "0.5"),
337+
ErrorReport,
338+
msg=f"fieldPath={field_path}",
339+
)
340+
341+
# STRING with DOSE_UNIT_CODE
342+
def test_dose_unit_code_string_valid_and_invalid(self):
343+
checker = self.make_checker()
344+
field_path = "doseQuantity|code"
345+
self.assertIsNone(
346+
checker.validate_expression("STRING", "", field_path, "ml"),
347+
msg=f"fieldPath={field_path}",
348+
)
349+
self.assertIsInstance(
350+
checker.validate_expression("STRING", "", field_path, ""),
351+
ErrorReport,
352+
msg=f"fieldPath={field_path}",
353+
)
354+
self.assertIsInstance(
355+
checker.validate_expression("STRING", "", field_path, 1),
356+
ErrorReport,
357+
msg=f"fieldPath={field_path}",
358+
)
359+
166360
# LIST with PERFORMING_PROFESSIONAL_FORENAME (empty rule -> non-empty list)
167361
def test_practitioner_forename_list_valid_and_invalid(self):
168362
checker = self.make_checker()
@@ -211,31 +405,7 @@ def test_postcode_string_rule_valid_and_invalid(self):
211405
ErrorReport,
212406
)
213407
# Empty should also fail
214-
self.assertIsInstance(
215-
checker.validate_expression("STRING", "", field_path, ""),
216-
ErrorReport,
217-
)
218-
219-
220-
# def test_boolean_valid_and_invalid(self):
221-
# checker = self.make_checker()
222-
# self.assertIsNone(checker.validate_expression("BOOLEAN", "", "bool_field", True, 1))
223-
# self.assertIsInstance(checker.validate_expression("BOOLEAN", "", "bool_field", "true", 1), ErrorReport)
224-
225-
# # POSITIVEINTEGER
226-
# def test_positive_integer_valid_and_invalid(self):
227-
# checker = self.make_checker()
228-
# self.assertIsNone(checker.validate_expression("POSITIVEINTEGER", "", "pos_field", "2", 1))
229-
# self.assertIsInstance(checker.validate_expression("POSITIVEINTEGER", "", "pos_field", "0", 1), ErrorReport)
230-
# self.assertIsInstance(checker.validate_expression("POSITIVEINTEGER", "", "pos_field", "-5", 1), ErrorReport)
231-
# self.assertIsInstance(checker.validate_expression("POSITIVEINTEGER", "", "pos_field", "abc", 1), ErrorReport)
232-
233-
# # INTDECIMAL
234-
# def test_intdecimal_valid_and_invalid(self):
235-
# checker = self.make_checker()
236-
# self.assertIsNone(checker.validate_expression("INTDECIMAL", "", "num_field", "1.23", 1))
237-
# self.assertIsNone(checker.validate_expression("INTDECIMAL", "", "num_field", 3, 1))
238-
# self.assertIsInstance(checker.validate_expression("INTDECIMAL", "", "num_field", "abc", 1), ErrorReport)
408+
self.assertIsInstance(checker.validate_expression("STRING", "", field_path, ""), ErrorReport)
239409

240410

241411
# class DummyParserEx:

0 commit comments

Comments
 (0)