Skip to content

Commit a187792

Browse files
add: allow entities to be declared for a repeat
- as much processing as possible in entities_parsing.py - most tests about entity repeat position are in test_create_repeat.py - tests specific to update-related fields in test_update_repeat.py - add more entities xpath helpers
1 parent 7a3d3cd commit a187792

9 files changed

Lines changed: 915 additions & 46 deletions

File tree

pyxform/entities/entities_parsing.py

Lines changed: 139 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,56 @@
22
from typing import Any
33

44
from pyxform import constants as const
5-
from pyxform.errors import PyXFormError
5+
from pyxform.errors import Detail, PyXFormError
66
from pyxform.parsing.expression import is_xml_tag
7+
from pyxform.utils import PYXFORM_REFERENCE_REGEX
78

89
EC = const.EntityColumns
10+
ENTITY001 = Detail(
11+
name="Invalid entity repeat reference",
12+
msg=(
13+
"[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. "
14+
"The 'repeat' column, if specified, must contain only a single reference variable "
15+
"(like '${{q1}}'), and the reference variable must contain a valid name."
16+
),
17+
)
18+
ENTITY002 = Detail(
19+
name="Invalid entity repeat: target not found",
20+
msg=(
21+
"[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. "
22+
"The entity repeat target was not found in the 'survey' sheet."
23+
),
24+
)
25+
ENTITY003 = Detail(
26+
name="Invalid entity repeat: target is not a repeat",
27+
msg=(
28+
"[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. "
29+
"The entity repeat target is not a repeat."
30+
),
31+
)
32+
ENTITY004 = Detail(
33+
name="Invalid entity repeat: target is in a repeat",
34+
msg=(
35+
"[row : 2] On the 'entities' sheet, the 'repeat' value '{value}' is invalid. "
36+
"The entity repeat target is inside a repeat."
37+
),
38+
)
39+
ENTITY005 = Detail(
40+
name="Invalid entity repeat save_to: question in nested repeat",
41+
msg=(
42+
"[row : {row}] On the 'survey' sheet, the 'save_to' value '{value}' is invalid. "
43+
"The entity properties populated with 'save_to' must not be inside of a nested "
44+
"repeat within the entity repeat."
45+
),
46+
)
47+
ENTITY006 = Detail(
48+
name="Invalid entity repeat save_to: question not in entity repeat",
49+
msg=(
50+
"[row : {row}] On the 'survey' sheet, the 'save_to' value '{value}' is invalid. "
51+
"The entity properties populated with 'save_to' must be inside of the entity "
52+
"repeat."
53+
),
54+
)
955

1056

1157
def get_entity_declaration(
@@ -24,6 +70,7 @@ def get_entity_declaration(
2470
create_condition = entity_row.get(EC.CREATE_IF, None)
2571
update_condition = entity_row.get(EC.UPDATE_IF, None)
2672
entity_label = entity_row.get(EC.LABEL, None)
73+
entity_repeat = get_validated_repeat_name(entity_row)
2774

2875
if update_condition and not entity_id:
2976
raise PyXFormError(
@@ -49,6 +96,7 @@ def get_entity_declaration(
4996
EC.CREATE_IF.value: create_condition,
5097
EC.UPDATE_IF.value: update_condition,
5198
EC.LABEL.value: entity_label,
99+
EC.REPEAT.value: entity_repeat,
52100
},
53101
}
54102

@@ -77,11 +125,29 @@ def get_validated_dataset_name(entity):
77125
return dataset
78126

79127

128+
def get_validated_repeat_name(entity) -> str | None:
129+
if EC.REPEAT.value not in entity:
130+
return None
131+
132+
value = entity[EC.REPEAT]
133+
if not (value and len(value) > 3):
134+
raise PyXFormError(ENTITY001.format(value=value))
135+
matches = PYXFORM_REFERENCE_REGEX.fullmatch(value)
136+
if matches is None:
137+
raise PyXFormError(ENTITY001.format(value=value))
138+
else:
139+
match = matches.group(1)
140+
if not is_xml_tag(match):
141+
raise PyXFormError(ENTITY001.format(value=value))
142+
else:
143+
return match
144+
145+
80146
def validate_entity_saveto(
81147
row: dict,
82148
row_number: int,
83-
in_repeat: bool,
84149
entity_declaration: dict[str, Any] | None = None,
150+
stack: Sequence[dict[str, Any]] | None = None,
85151
):
86152
save_to = row.get(const.BIND, {}).get("entities:saveto", "")
87153
if not save_to:
@@ -97,10 +163,24 @@ def validate_entity_saveto(
97163
f"{const.ROW_FORMAT_STRING % row_number} Groups and repeats can't be saved as entity properties."
98164
)
99165

100-
if in_repeat:
101-
raise PyXFormError(
102-
f"{const.ROW_FORMAT_STRING % row_number} Currently, you can't create entities from repeats. You may only specify save_to values for form fields outside of repeats."
103-
)
166+
entity_repeat = entity_declaration[const.PARAMETERS].get(EC.REPEAT, None)
167+
if entity_repeat and stack:
168+
# Error: saveto in nested repeat inside entity repeat.
169+
in_repeat = False
170+
located = False
171+
for i in reversed(stack):
172+
if not i["control_name"] or not i["control_type"]:
173+
break
174+
elif i["control_type"] == const.REPEAT:
175+
if in_repeat:
176+
raise PyXFormError(ENTITY005.format(row=row_number, value=save_to))
177+
elif i["control_name"] == entity_repeat:
178+
located = True
179+
in_repeat = True
180+
181+
# Error: saveto not in entity repeat
182+
if not located:
183+
raise PyXFormError(ENTITY006.format(row=row_number, value=save_to))
104184

105185
error_start = f"{const.ROW_FORMAT_STRING % row_number} Invalid save_to name:"
106186

@@ -134,3 +214,56 @@ def validate_entities_columns(row: dict):
134214
f"pyxform."
135215
)
136216
raise PyXFormError(msg)
217+
218+
219+
def validate_entity_repeat_target(
220+
entity_declaration: dict[str, Any] | None,
221+
stack: Sequence[dict[str, Any]] | None = None,
222+
) -> bool:
223+
"""
224+
Check if the entity repeat target exists, is a repeat, and is a name match.
225+
226+
Raises an error if the control type or name is None (such as for the Survey), or if
227+
the control type is not a repeat.
228+
229+
:param entity_declaration:
230+
:param stack: The control stack from workbook_to_json.
231+
:return:
232+
"""
233+
# Ignore: entity already processed.
234+
if not entity_declaration:
235+
return False
236+
237+
entity_repeat = entity_declaration[const.PARAMETERS].get(EC.REPEAT, None)
238+
239+
# Ignore: no repeat declared for the entity.
240+
if not entity_repeat:
241+
return False
242+
243+
# Error: repeat not found while processing survey sheet.
244+
if not stack:
245+
raise PyXFormError(ENTITY002.format(value=entity_repeat))
246+
247+
control_name = stack[-1]["control_name"]
248+
control_type = stack[-1]["control_type"]
249+
250+
# Ignore: current control is not the target.
251+
if control_name and control_name != entity_repeat:
252+
return False
253+
254+
# Error: target is not a repeat.
255+
if control_type and control_type != const.REPEAT:
256+
raise PyXFormError(ENTITY003.format(value=entity_repeat))
257+
258+
# Error: repeat is in nested repeat.
259+
located = False
260+
for i in reversed(stack):
261+
if not i["control_name"] or not i["control_type"]:
262+
break
263+
elif i["control_type"] == const.REPEAT:
264+
if located:
265+
raise PyXFormError(ENTITY004.format(value=entity_repeat))
266+
elif i["control_name"] == entity_repeat:
267+
located = True
268+
269+
return entity_repeat == control_name

pyxform/question_type_dictionary.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
XForm survey question type mapping dictionary module.
33
"""
44

5+
from collections.abc import Sequence
56
from types import MappingProxyType
7+
from typing import Any
8+
9+
from pyxform import constants as const
610

711
_QUESTION_TYPE_DICT = {
812
"q picture": {
@@ -378,3 +382,14 @@
378382

379383
# Read-only view of the types.
380384
QUESTION_TYPE_DICT = MappingProxyType(_QUESTION_TYPE_DICT)
385+
386+
387+
def get_meta_group(children: Sequence[dict[str, Any]]) -> dict[str, Any]:
388+
if children is None:
389+
children = []
390+
return {
391+
const.NAME: "meta",
392+
const.TYPE: const.GROUP,
393+
const.CONTROL: {"bodyless": True},
394+
const.CHILDREN: children,
395+
}

pyxform/xls2json.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717
)
1818
from pyxform.entities.entities_parsing import (
1919
get_entity_declaration,
20+
validate_entity_repeat_target,
2021
validate_entity_saveto,
2122
)
2223
from pyxform.errors import PyXFormError
2324
from pyxform.parsing.expression import is_pyxform_reference, is_xml_tag
2425
from pyxform.parsing.sheet_headers import dealias_and_group_headers
26+
from pyxform.question_type_dictionary import get_meta_group
2527
from pyxform.utils import (
2628
PYXFORM_REFERENCE_REGEX,
2729
coalesce,
@@ -781,6 +783,15 @@ def workbook_to_json(
781783
if end_control_parse:
782784
parse_dict = end_control_parse.groupdict()
783785
if parse_dict.get("end") and "type" in parse_dict:
786+
if validate_entity_repeat_target(
787+
entity_declaration=entity_declaration,
788+
stack=stack,
789+
):
790+
parent_children_array.append(
791+
get_meta_group(children=[entity_declaration])
792+
)
793+
json_dict[constants.ENTITY_FEATURES] = ["create", "update", "offline"]
794+
entity_declaration = None
784795
control_type = aliases.control[parse_dict["type"]]
785796
if prev_control_type != control_type or len(stack) == 1:
786797
raise PyXFormError(
@@ -817,11 +828,12 @@ def workbook_to_json(
817828
seen_names_lower=child_names_lower,
818829
warnings=warnings,
819830
)
820-
821-
in_repeat = any(
822-
ancestor["control_type"] == constants.REPEAT for ancestor in stack
831+
validate_entity_saveto(
832+
row=row,
833+
row_number=row_number,
834+
entity_declaration=entity_declaration,
835+
stack=stack,
823836
)
824-
validate_entity_saveto(row, row_number, in_repeat, entity_declaration)
825837

826838
# Try to parse question as begin control statement
827839
# (i.e. begin loop/repeat/group):
@@ -961,6 +973,10 @@ def workbook_to_json(
961973
"row_number": row_number,
962974
}
963975
)
976+
validate_entity_repeat_target(
977+
stack=stack,
978+
entity_declaration=entity_declaration,
979+
)
964980
continue
965981

966982
# Assuming a question is anything not processed above as a loop/repeat/group.
@@ -1428,16 +1444,12 @@ def workbook_to_json(
14281444
)
14291445

14301446
if entity_declaration:
1447+
validate_entity_repeat_target(entity_declaration=entity_declaration)
14311448
json_dict[constants.ENTITY_FEATURES] = ["create", "update", "offline"]
14321449
meta_children.append(entity_declaration)
14331450

14341451
if len(meta_children) > 0:
1435-
meta_element = {
1436-
"name": "meta",
1437-
"type": "group",
1438-
"control": {"bodyless": True},
1439-
"children": meta_children,
1440-
}
1452+
meta_element = get_meta_group(children=meta_children)
14411453
survey_children_array = stack[0]["parent_children"]
14421454
survey_children_array.append(meta_element)
14431455

0 commit comments

Comments
 (0)