Skip to content

Commit 69d8e24

Browse files
committed
add expressionType for all 34 fields
1 parent 9b50fcd commit 69d8e24

File tree

3 files changed

+152
-124
lines changed

3 files changed

+152
-124
lines changed

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

Lines changed: 120 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import datetime
22
import re
33
import uuid
4-
from typing import Optional
4+
from typing import Decimal, Optional, Union
55

66
from common.validator.constants.enums import MESSAGES, ExceptionLevels, MessageLabel
77
from common.validator.error_report.record_error import ErrorReport, RecordError
@@ -26,14 +26,22 @@ def validate_expression(
2626
self, expression_type: str, expression_rule: str, field_name: str, field_value: str, row: dict
2727
) -> ErrorReport:
2828
match expression_type:
29-
case "DATETIME":
30-
return self._validate_datetime(expression_rule, field_name, field_value, row)
3129
case "STRING":
32-
return self._validate_for_string_values(expression_rule, field_name, field_value, row)
30+
return self.validation_for_string_values(expression_rule, field_name, field_value, row)
3331
case "LIST":
34-
return self._validate_for_list_values(expression_rule, field_name, field_value, row)
32+
return self.validation_for_list(expression_rule, field_name, field_value, row)
3533
case "DATE":
36-
return self.validate_for_date(expression_rule, field_name, field_value, row)
34+
return self.validation_for_date(expression_rule, field_name, field_value, row)
35+
case "DATETIME":
36+
return self.validation_for_date_time(expression_rule, field_name, field_value, row)
37+
case "POSITIVEINTEGER":
38+
return self.validation_for_positive_integer(expression_rule, field_name, field_value, row)
39+
case "UNIQUELIST":
40+
return self.validation_for_unique_list(expression_rule, field_name, field_value, row)
41+
case "BOOLEAN":
42+
return self._validate_boolean(expression_rule, field_name, field_value, row)
43+
case "INTDECIMAL":
44+
return self.validation_for_integer_or_decimal(expression_rule, field_name, field_value, row)
3745
case "UUID":
3846
return self._validate_uuid(expression_rule, field_name, field_value, row)
3947
case "INT":
@@ -86,22 +94,7 @@ def validate_expression(
8694
return "Schema expression not found! Check your expression type : " + expression_type
8795

8896
# ISO 8601 date/datetime validate (currently date-only)
89-
def _validate_datetime(self, _expression_rule, field_name, field_value, row) -> ErrorReport:
90-
try:
91-
# Current behavior expects date-only; datetime raises and is handled below
92-
datetime.date.fromisoformat(field_value)
93-
except RecordError as e:
94-
code = e.code if e.code is not None else ExceptionLevels.RECORD_CHECK_FAILED
95-
message = e.message if e.message is not None else MESSAGES[ExceptionLevels.RECORD_CHECK_FAILED]
96-
if e.details is not None:
97-
details = e.details
98-
return ErrorReport(code, message, row, field_name, details, self.summarise)
99-
except Exception as e:
100-
if self.report_unexpected_exception:
101-
message = MESSAGES[ExceptionLevels.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, e)
102-
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, row, field_name, "", self.summarise)
103-
104-
def validate_for_date(self, _expression_rule, field_name, field_value, row, future_date_allowed: bool = False):
97+
def validation_for_date(self, _expression_rule, field_name, field_value, row, future_date_allowed: bool = False):
10598
"""
10699
Apply pre-validation to a date field to ensure that it is a string (JSON dates must be
107100
written as strings) containing a valid date in the format "YYYY-MM-DD"
@@ -133,53 +126,51 @@ def _validate_uuid(self, _expression_rule: str, field_name: str, field_value: st
133126
message = MESSAGES[ExceptionLevels.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, e)
134127
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, row, field_name, "", self.summarise)
135128

136-
# Integer Validate
137-
def _validate_integer(self, expression_rule: str, field_name: str, field_value: str, row: dict) -> ErrorReport:
138-
try:
139-
int(field_value)
140-
if expression_rule:
141-
check_value = int(expression_rule)
142-
if int(field_value) != check_value:
143-
raise RecordError(
144-
ExceptionLevels.RECORD_CHECK_FAILED,
145-
"Value integer check failed",
146-
MessageLabel.VALUE_MISMATCH_MSG
147-
+ MessageLabel.EXPECTED_LABEL
148-
+ expression_rule
149-
+ " "
150-
+ MessageLabel.FOUND_LABEL
151-
+ field_value,
152-
)
153-
except RecordError as e:
154-
code = e.code if e.code is not None else ExceptionLevels.RECORD_CHECK_FAILED
155-
message = e.message if e.message is not None else MESSAGES[ExceptionLevels.RECORD_CHECK_FAILED]
156-
if e.details is not None:
157-
details = e.details
158-
return ErrorReport(code, message, row, field_name, details, self.summarise)
159-
except Exception as e:
160-
if self.report_unexpected_exception:
161-
message = MESSAGES[ExceptionLevels.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, e)
162-
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, row, field_name, "", self.summarise)
129+
def validation_for_positive_integer(field_value: int, field_name: str, max_value: int = None):
130+
"""
131+
Apply pre-validation to an integer field to ensure that it is a positive integer,
132+
which does not exceed the maximum allowed value (if applicable)
133+
"""
134+
# This check uses type() instead of isinstance() because bool is a subclass of int.
135+
if type(field_value) is not int: # pylint: disable=unidiomatic-typecheck
136+
raise TypeError(f"{field_name} must be a positive integer")
163137

164-
# Length Validate
165-
def _validate_length(self, expression_rule: str, field_name: str, field_value: str, row: dict) -> ErrorReport:
166-
try:
167-
str_len = len(field_value)
168-
check_length = int(expression_rule)
169-
if str_len > check_length:
170-
raise RecordError(
171-
ExceptionLevels.RECORD_CHECK_FAILED, "Value length check failed", "Value is longer than expected"
138+
if field_value <= 0:
139+
raise ValueError(f"{field_name} must be a positive integer")
140+
141+
if max_value:
142+
if field_value > max_value:
143+
raise ValueError(f"{field_name} must be an integer in the range 1 to {max_value}")
144+
145+
def validation_for_integer_or_decimal(field_value: Union[int, Decimal], field_location: str):
146+
"""
147+
Apply pre-validation to a decimal field to ensure that it is an integer or decimal,
148+
which does not exceed the maximum allowed number of decimal places (if applicable)
149+
"""
150+
if not (
151+
# This check uses type() instead of isinstance() because bool is a subclass of int.
152+
type(field_value) is int # pylint: disable=unidiomatic-typecheck
153+
or type(field_value) is Decimal # pylint: disable=unidiomatic-typecheck
154+
):
155+
raise TypeError(f"{field_location} must be a number")
156+
157+
def validation_for_unique_list(
158+
list_to_check: list,
159+
unique_value_in_list: str,
160+
field_location: str,
161+
):
162+
"""
163+
Apply pre-validation to a list of dictionaries to ensure that a specified value in each
164+
dictionary is unique across the list
165+
"""
166+
found = []
167+
for item in list_to_check:
168+
if item[unique_value_in_list] in found:
169+
raise ValueError(
170+
f"{field_location.replace('FIELD_TO_REPLACE', item[unique_value_in_list])}" + " must be unique"
172171
)
173-
except RecordError as e:
174-
code = e.code if e.code is not None else ExceptionLevels.RECORD_CHECK_FAILED
175-
message = e.message if e.message is not None else MESSAGES[ExceptionLevels.RECORD_CHECK_FAILED]
176-
if e.details is not None:
177-
details = e.details
178-
return ErrorReport(code, message, row, field_name, details, self.summarise)
179-
except Exception as e:
180-
if self.report_unexpected_exception:
181-
message = MESSAGES[ExceptionLevels.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, e)
182-
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, row, field_name, "", self.summarise)
172+
173+
found.append(item[unique_value_in_list])
183174

184175
# Regex Validate
185176
def _validate_regex(self, expression_rule: str, field_name: str, field_value: str, row: dict) -> ErrorReport:
@@ -202,32 +193,12 @@ def _validate_regex(self, expression_rule: str, field_name: str, field_value: st
202193
message = MESSAGES[ExceptionLevels.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, e)
203194
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, row, field_name, "", self.summarise)
204195

205-
# Equal Validate
206-
def _validate_equal(self, expression_rule: str, field_name: str, field_value: str, row: dict) -> ErrorReport:
207-
try:
208-
if field_value != expression_rule:
209-
raise RecordError(
210-
ExceptionLevels.RECORD_CHECK_FAILED,
211-
"Value equals check failed",
212-
MessageLabel.VALUE_MISMATCH_MSG
213-
+ MessageLabel.EXPECTED_LABEL
214-
+ expression_rule
215-
+ " "
216-
+ MessageLabel.FOUND_LABEL
217-
+ field_value,
218-
)
219-
except RecordError as e:
220-
code = e.code if e.code is not None else ExceptionLevels.RECORD_CHECK_FAILED
221-
message = e.message if e.message is not None else MESSAGES[ExceptionLevels.RECORD_CHECK_FAILED]
222-
if e.details is not None:
223-
details = e.details
224-
return ErrorReport(code, message, row, field_name, details, self.summarise)
225-
except Exception as e:
226-
if self.report_unexpected_exception:
227-
message = MESSAGES[ExceptionLevels.UNEXPECTED_EXCEPTION] % (e.__class__.__name__, e)
228-
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, row, field_name, "", self.summarise)
196+
def validation_for_boolean(field_value: str, field_name: str):
197+
"""Apply pre-validation to a boolean field to ensure that it is a boolean"""
198+
if not isinstance(field_value, bool):
199+
raise TypeError(f"{field_name} must be a boolean")
229200

230-
def for_list(self, expression_rule: str, field_name: str, field_value: list, row: dict):
201+
def validation_for_list(self, expression_rule: str, field_name: str, field_value: list, row: dict):
231202
"""
232203
Apply validation to a list field to ensure it is a non-empty list which meets the length requirements and
233204
requirements, if applicable, for each list element to be a non-empty string or non-empty dictionary
@@ -263,6 +234,62 @@ def for_list(self, expression_rule: str, field_name: str, field_value: list, row
263234
if len(element) == 0:
264235
raise ValueError(f"{field_name} must be an array of non-empty objects")
265236

237+
def validation_for_date_time(field_value: str, field_location: str, strict_timezone: bool = True):
238+
"""
239+
Apply pre-validation to a datetime field to ensure that it is a string (JSON dates must be written as strings)
240+
containing a valid datetime. Note that partial dates are valid for FHIR, but are not allowed for this API.
241+
Valid formats are any of the following:
242+
* 'YYYY-MM-DD' - Full date only
243+
* 'YYYY-MM-DDThh:mm:ss%z' - Full date, time without milliseconds, timezone
244+
* 'YYYY-MM-DDThh:mm:ss.f%z' - Full date, time with milliseconds (any level of precision), timezone
245+
"""
246+
247+
if not isinstance(field_value, str):
248+
raise TypeError(f"{field_location} must be a string")
249+
250+
error_message = (
251+
f"{field_location} must be a valid datetime in one of the following formats:"
252+
"- 'YYYY-MM-DD' — Full date only"
253+
"- 'YYYY-MM-DDThh:mm:ss%z' — Full date and time with timezone (e.g. +00:00 or +01:00)"
254+
"- 'YYYY-MM-DDThh:mm:ss.f%z' — Full date and time with milliseconds and timezone"
255+
"- Date must not be in the future."
256+
)
257+
if strict_timezone:
258+
error_message += (
259+
"Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n"
260+
f"Note that partial dates are not allowed for {field_location} in this service.\n"
261+
)
262+
263+
allowed_suffixes = {
264+
"+00:00",
265+
"+01:00",
266+
"+0000",
267+
"+0100",
268+
}
269+
270+
# List of accepted strict formats
271+
formats = [
272+
"%Y-%m-%d",
273+
"%Y-%m-%dT%H:%M:%S%z",
274+
"%Y-%m-%dT%H:%M:%S.%f%z",
275+
]
276+
277+
for fmt in formats:
278+
try:
279+
fhir_date = datetime.strptime(field_value, fmt)
280+
# Enforce future-date rule using central checker after successful parse
281+
if check_if_future_date(fhir_date):
282+
raise ValueError(f"{field_location} must not be in the future")
283+
# After successful parse, enforce timezone and future-date rules
284+
if strict_timezone and fhir_date.tzinfo is not None:
285+
if not any(field_value.endswith(suffix) for suffix in allowed_suffixes):
286+
raise ValueError(error_message)
287+
return fhir_date.isoformat()
288+
except ValueError:
289+
continue
290+
291+
raise ValueError(error_message)
292+
266293
# Not Equal Validate
267294
def _validate_not_equal(self, expression_rule: str, field_name: str, field_value: str, row: dict) -> ErrorReport:
268295
try:
@@ -471,7 +498,7 @@ def _validate_empty(self, _expression_rule: str, field_name: str, field_value: s
471498
return ErrorReport(ExceptionLevels.UNEXPECTED_EXCEPTION, message, row, field_name, "", self.summarise)
472499

473500
# String Pre-Validation
474-
def _validate_for_string_values(
501+
def validation_for_string_values(
475502
self, _expression_rule: str, field_name: str, field_value: str, row: dict
476503
) -> ErrorReport:
477504
"""

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def _validate_expression(
5656

5757
try:
5858
expression_values = data_parser.extract_field_values(expression_fieldname)
59+
print(f"Validating Expression ID {expression_fieldname} with values: {expression_values}")
5960
except Exception as e:
6061
message = f"Data get values Unexpected exception [{e.__class__.__name__}]: {e}"
6162
error_record = ErrorReport(code=ExceptionLevels.PARSING_ERROR, message=message)

0 commit comments

Comments
 (0)