Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for entity updates #664

Merged
merged 5 commits into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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]
lognaturel marked this conversation as resolved.
Show resolved Hide resolved

dataset_name = get_validated_dataset_name(entity_row)
entity_id = entity_row["entity_id"] if "entity_id" in entity_row else None
lognaturel marked this conversation as resolved.
Show resolved Hide resolved
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):
lognaturel marked this conversation as resolved.
Show resolved Hide resolved
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
81 changes: 60 additions & 21 deletions pyxform/entities/entity_declaration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,43 +8,82 @@ class EntityDeclaration(SurveyElement):
def xml_instance(self, **kwargs):
lognaturel marked this conversation as resolved.
Show resolved Hide resolved
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):
return node("entity", node("label"), **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),
lindsay-stevens marked this conversation as resolved.
Show resolved Hide resolved
],
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"]',
lognaturel marked this conversation as resolved.
Show resolved Hide resolved
],
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"]',
lindsay-stevens marked this conversation as resolved.
Show resolved Hide resolved
],
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"]'
],
)
Loading