Skip to content

Commit

Permalink
Add support for entity updates
Browse files Browse the repository at this point in the history
  • Loading branch information
lognaturel committed Nov 4, 2023
1 parent aeedfca commit 7f536bf
Show file tree
Hide file tree
Showing 4 changed files with 278 additions and 38 deletions.
53 changes: 38 additions & 15 deletions pyxform/entities/entities_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,43 @@ def get_entity_declaration(
"Currently, you can only declare a single entity per form. Please make sure your entities sheet only declares one entity."
)

entity = entities_sheet[0]
entity_row = entities_sheet[0]

dataset_name = get_validated_dataset_name(entity_row)
entity_id = entity_row["entity_id"] if "entity_id" in entity_row else None
create_condition = entity_row["create_if"] if "create_if" in entity_row else None
update_condition = entity_row["update_if"] if "update_if" in entity_row else None
entity_label = entity_row["label"] if "label" in entity_row else None

if update_condition and not (entity_id):
raise PyXFormError(
"The entities sheet is missing the entity_id column which is required when updating entities."
)

if entity_id and create_condition and not (update_condition):
raise PyXFormError(
"The entities sheet can't specify an entity creation condition and an entity_id without also including an update condition."
)

if not (entity_id) and not (entity_label):
raise PyXFormError(
"The entities sheet is missing the label column which is required when creating entities."
)

return {
"name": "entity",
"type": "entity",
"parameters": {
"dataset": dataset_name,
"entity_id": entity_id,
"create": create_condition,
"update": update_condition,
"label": entity_label,
},
}


def get_validated_dataset_name(entity):
dataset = entity["dataset"]

if dataset.startswith(constants.ENTITIES_RESERVED_PREFIX):
Expand All @@ -41,20 +77,7 @@ def get_entity_declaration(
f"Invalid entity list name: '{dataset}'. Names must begin with a letter, colon, or underscore. Other characters can include numbers or dashes."
)

if not ("label" in entity):
raise PyXFormError("The entities sheet is missing the required label column.")

creation_condition = entity["create_if"] if "create_if" in entity else "1"

return {
"name": "entity",
"type": "entity",
"parameters": {
"dataset": dataset,
"create": creation_condition,
"label": entity["label"],
},
}
return dataset


def validate_entity_saveto(
Expand Down
82 changes: 61 additions & 21 deletions pyxform/entities/entity_declaration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,43 +8,83 @@ class EntityDeclaration(SurveyElement):
def xml_instance(self, **kwargs):
attributes = {}
attributes["dataset"] = self.get("parameters", {}).get("dataset", "")
attributes["create"] = "1"
attributes["id"] = ""

label_node = node("label")
return node("entity", label_node, **attributes)
entity_id_expression = self.get("parameters", {}).get("entity_id", None)
create_condition = self.get("parameters", {}).get("create", None)
update_condition = self.get("parameters", {}).get("update", None)

if entity_id_expression:
attributes["update"] = "1"
attributes["baseVersion"] = ""

if create_condition or (not (update_condition) and not (entity_id_expression)):
attributes["create"] = "1"

if self.get("parameters", {}).get("label", None) != None:
label_node = node("label")
return node("entity", label_node, **attributes)
else:
return node("entity", **attributes)

def xml_bindings(self):
survey = self.get_root()
entity_id_expression = self.get("parameters", {}).get("entity_id", None)
create_condition = self.get("parameters", {}).get("create", None)
update_condition = self.get("parameters", {}).get("update", None)
label_expression = self.get("parameters", {}).get("label", None)

create_expr = survey.insert_xpaths(
self.get("parameters", {}).get("create", "true()"), context=self
)
create_bind = {
"calculate": create_expr,
"type": "string",
"readonly": "true()",
}
create_node = node("bind", nodeset=self.get_xpath() + "/@create", **create_bind)
bind_nodes = []

if create_condition:
bind_nodes.append(self._get_bind_node(survey, create_condition, "/@create"))

bind_nodes.append(self._get_id_bind_node(survey, entity_id_expression))

if create_condition or not (entity_id_expression):
bind_nodes.append(self._get_id_setvalue_node())

if update_condition:
bind_nodes.append(self._get_bind_node(survey, update_condition, "/@update"))

if entity_id_expression:
dataset_name = self.get("parameters", {}).get("dataset", "")
base_version_expression = f"instance('{dataset_name}')/root/item[name={entity_id_expression}]/__version"
bind_nodes.append(
self._get_bind_node(survey, base_version_expression, "/@baseVersion")
)

if label_expression:
bind_nodes.append(self._get_bind_node(survey, label_expression, "/label"))

return bind_nodes

def _get_id_bind_node(self, survey, entity_id_expression):
id_bind = {"type": "string", "readonly": "true()"}
id_node = node("bind", nodeset=self.get_xpath() + "/@id", **id_bind)

if entity_id_expression:
id_bind["calculate"] = survey.insert_xpaths(
entity_id_expression, context=self
)

return node("bind", nodeset=self.get_xpath() + "/@id", **id_bind)

def _get_id_setvalue_node(self):
id_setvalue_attrs = {
"event": "odk-instance-first-load",
"type": "string",
"readonly": "true()",
"value": "uuid()",
}
id_setvalue = node("setvalue", ref=self.get_xpath() + "/@id", **id_setvalue_attrs)

label_expr = survey.insert_xpaths(
self.get("parameters", {}).get("label", ""), context=self
)
label_bind = {
"calculate": label_expr,
return node("setvalue", ref=self.get_xpath() + "/@id", **id_setvalue_attrs)

def _get_bind_node(self, survey, expression, destination):
expr = survey.insert_xpaths(expression, context=self)
bind_attrs = {
"calculate": expr,
"type": "string",
"readonly": "true()",
}
label_node = node("bind", nodeset=self.get_xpath() + "/label", **label_bind)
return [create_node, id_node, id_setvalue, label_node]

return node("bind", nodeset=self.get_xpath() + destination, **bind_attrs)
9 changes: 7 additions & 2 deletions tests/test_entities.py → tests/test_entities_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from tests.pyxform_test_case import PyxformTestCase


class EntitiesTest(PyxformTestCase):
class EntitiesCreationTest(PyxformTestCase):
def test_basic_entity_creation_building_blocks(self):
self.assertPyxformXform(
name="data",
Expand All @@ -26,6 +26,9 @@ def test_basic_entity_creation_building_blocks(self):
'/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/label" and @type = "string" and @readonly = "true()" and @calculate = "a"]',
'/h:html/h:head/x:model[@entities:entities-version = "2022.1.0"]',
],
xml__xpath_count=[
("/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity/@update", 0),
],
xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'],
)

Expand Down Expand Up @@ -160,7 +163,9 @@ def test_entity_label__required(self):
| | trees | | |
""",
errored=True,
error__contains=["The entities sheet is missing the required label column."],
error__contains=[
"The entities sheet is missing the label column which is required when creating entities."
],
)

def test_entities_namespace__omitted_if_no_entities_sheet(self):
Expand Down
172 changes: 172 additions & 0 deletions tests/test_entities_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# -*- coding: utf-8 -*-
from tests.pyxform_test_case import PyxformTestCase


class EntitiesUpdateTest(PyxformTestCase):
def test_basic_entity_update_building_blocks(self):
self.assertPyxformXform(
name="data",
md="""
| survey | | | |
| | type | name | label |
| | text | id | Tree id |
| | text | a | A |
| entities | | | |
| | dataset | entity_id | |
| | trees | ${id} | |
""",
xml__xpath_match=[
"/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity",
'/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@dataset = "trees"]',
# defaults to always updating if an entity_id is specified
'/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@update = "1"]',
'/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@id = ""]',
'/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@id" and @type = "string" and @readonly = "true()" and @calculate = " /data/id "]',
'/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@baseVersion = ""]',
'/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@baseVersion" and @type = "string" and @readonly = "true()" and @calculate = "instance(\'trees\')/root/item[name= /data/id ]/__version"]',
'/h:html/h:head/x:model[@entities:entities-version = "2022.1.0"]',
],
xml__xpath_count=[
("/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity/x:label", 0),
("/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity/@create", 0),
("/h:html/h:head/x:model/x:setvalue", 0),
],
xml__contains=['xmlns:entities="http://www.opendatakit.org/xforms/entities"'],
)

def test_entity_id_with_creation_condition_only__errors(self):
self.assertPyxformXform(
name="data",
md="""
| survey | | | |
| | type | name | label |
| | text | id | Tree id |
| | text | a | A |
| entities | | | |
| | dataset | entity_id | create_if |
| | trees | ${id} | true() |
""",
errored=True,
error__contains=[
"The entities sheet can't specify an entity creation condition and an entity_id without also including an update condition."
],
)

def test_update_condition_without_entity_id__errors(self):
self.assertPyxformXform(
name="data",
md="""
| survey | | | |
| | type | name | label |
| | text | id | Tree id |
| | text | a | A |
| entities | | | |
| | dataset | update_if | |
| | trees | true() | |
""",
errored=True,
error__contains=[
"The entities sheet is missing the entity_id column which is required when updating entities."
],
)

def test_update_and_create_conditions_without_entity_id__errors(self):
self.assertPyxformXform(
name="data",
md="""
| survey | | | |
| | type | name | label |
| | text | id | Tree id |
| | integer | a | A |
| entities | | | |
| | dataset | update_if | create_if |
| | trees | ${id} != ''| ${id} = '' |
""",
errored=True,
error__contains=[
"The entities sheet is missing the entity_id column which is required when updating entities."
],
)

def test_create_if_with_entity_id_in_entities_sheet__puts_expression_on_bind(self):
self.assertPyxformXform(
name="data",
md="""
| survey | | | |
| | type | name | label |
| | text | id | Tree id |
| | text | a | A |
| entities | | | |
| | dataset | update_if | entity_id |
| | trees | string-length(a) > 3 | ${id} |
""",
xml__xpath_match=[
'/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@update" and @calculate = "string-length(a) > 3"]',
'/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@update = "1"]',
'/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@id" and @type = "string" and @readonly = "true()" and @calculate = " /data/id "]',
'/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@baseVersion = ""]',
'/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@baseVersion" and @type = "string" and @readonly = "true()" and @calculate = "instance(\'trees\')/root/item[name= /data/id ]/__version"]',
],
xml__xpath_count=[("/h:html/h:head/x:model/x:setvalue", 0)],
)

def test_update_and_create_conditions_with_entity_id__puts_both_in_bind_calculations(
self,
):
self.assertPyxformXform(
name="data",
md="""
| survey | | | | |
| | type | name | label | |
| | text | id | Tree id | |
| | integer | a | A | |
| entities | | | | |
| | dataset | update_if | create_if | entity_id |
| | trees | id != '' | id = '' | ${id} |
""",
xml__xpath_match=[
'/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@update" and @calculate = "id != \'\'"]',
'/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@update = "1"]',
'/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@create" and @calculate = "id = \'\'"]',
'/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@create = "1"]',
'/h:html/h:head/x:model/x:setvalue[@event = "odk-instance-first-load" and @type = "string" and @ref = "/data/meta/entity/@id" and @value = "uuid()"]',
'/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@id" and @type = "string" and @readonly = "true()" and @calculate = " /data/id "]',
'/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity[@baseVersion = ""]',
'/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/@baseVersion" and @type = "string" and @readonly = "true()" and @calculate = "instance(\'trees\')/root/item[name= /data/id ]/__version"]',
],
)

def test_entity_id_and_label__updates_label(self):
self.assertPyxformXform(
name="data",
md="""
| survey | | | |
| | type | name | label |
| | text | id | Tree id |
| | text | a | A |
| entities | | | |
| | dataset | entity_id | label |
| | trees | ${id} | a |
""",
xml__xpath_match=[
"/h:html/h:head/x:model/x:instance/x:data/x:meta/x:entity/x:label",
'/h:html/h:head/x:model/x:bind[@nodeset = "/data/meta/entity/label" and @type = "string" and @readonly = "true()" and @calculate = "a"]',
],
)

def test_save_to_with_entity_id__puts_save_tos_on_bind(self):
self.assertPyxformXform(
name="data",
md="""
| survey | | | | |
| | type | name | label | save_to |
| | text | id | Tree id | |
| | text | a | A | foo |
| entities | | | | |
| | dataset | entity_id | | |
| | trees | ${id} | | |
""",
xml__xpath_match=[
'/h:html/h:head/x:model/x:bind[@nodeset = "/data/a" and @entities:saveto = "foo"]'
],
)

0 comments on commit 7f536bf

Please sign in to comment.