diff --git a/Makefile b/Makefile index 3719771..4b479f1 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,14 @@ +RUN = pipenv run + test: - pipenv run python -m unittest discover -p 'test_*.py' + $(RUN) python -m unittest discover -p 'test_*.py' prep_tests: tests/model/kitchen_sink_api.yaml %_api.yaml: %.yaml - pipenv run gen-api --container-class Dataset $< > $@.tmp && mv $@.tmp $@ + $(RUN) gen-api-datamodel --container-class Dataset $< > $@.tmp && mv $@.tmp $@ + +# requires dev +%_api.py: %_api.yaml + gen-python --no-mergeimports $< > $@.tmp && mv $@.tmp $@ + diff --git a/README.md b/README.md index 37ae226..8c85631 100644 --- a/README.md +++ b/README.md @@ -4,39 +4,41 @@ An extension to linkml-runtime to provide an API over runtime data objects. Documentation will later be added in the main [linkml](https://linkml.io/linkml/) repo -This provides: +This provides data models for CRUD (Create, Read, Update, Delete) objects, i.e. -* The ability to *query* objects that instantiate linkml classes -* The ability to *change/patch* (apply changes to) objects that instantiate linkml classes +* Representations of *queries* over objects that instantiate linkml classes +* Representations of *changes/patches* that apply changes to objects that instantiate linkml classes Additionally this provides hooks to allow both sets of operations over different *engines* -Current engines: +Current engines implemented here: * in-memory object tree engine * json-patcher engine +Other engines may be implemented elsewhere - e.g. linkml-solr + See [tests/](https://github.com/linkml/linkml-runtime-api/tree/main/tests) for examples -## Changes API +## Changes Datamodel See linkml_runtime_api.changes -The ObjectChanger class provides an engine for applying generic changes to -linkml object trees loaded into memory. - -Changes include: +Change classes include: * AddObject * RemoveObject * Append to a list of values * Rename (i.e. change the value of an identifier) +The ObjectChanger class provides an engine for applying generic changes to +linkml object trees loaded into memory. + Example: ```python # your datamodel here: -from kitchen_sink import Dataset, Person, FamilialRelationship +from personinfo import Dataset, Person, FamilialRelationship # setup - load schema schemaview = SchemaView('kitchen_sink.yaml') @@ -63,10 +65,12 @@ Both operate on in-memory object trees. JsonPatchChanger uses JsonPatch objects In future there will be other datastores -## Query API +## Query Datamodel See linkml_runtime_api.query +The main query class is `FetchQuery` + ObjectQueryEngine provides an engine for executing generic queries on in-memory object trees Example: @@ -97,11 +101,50 @@ Currently there is only one Query api implemntation This operates on in-memory object trees. -In future there will be other datastores implemented (SQL, SPARQL, ...) +In future there will be other datastores implemented (Solr, SQL, SPARQL, ...) + +## Generating a Domain CRUD Datamodel + +The above examples use *generic* CRUD datamodels that can be used with any data models. You can also generate *specific CRUD datamodels* for your main domain datamodel. + +```bash +gen-python-api kitchen_sink.yaml > kitchen_sink_crud.yaml +``` + +This will generate a LinkML database that represents CRUD operations on your schema + +```yaml + PersonQuery: + description: A query object for instances of Person from a database + comments: + - This is an autogenerated class + mixins: LocalQuery + slots: + - constraints + AddPerson: + description: A change action that adds a Person to a database + comments: + - This is an autogenerated class + mixins: LocalChange + slot_usage: + value: + range: Person + inlined: true + RemovePerson: + description: A change action that remoaves a Person to a database + comments: + - This is an autogenerated class + mixins: LocalChange + slot_usage: + value: + range: Person + inlined: true +``` + + -## Generating Domain APIs -The above examples use *generic* APIs that can be used with any data models. You can also generate *specific APIs* for your datamodel. +## Generating Python ``` gen-python-api kitchen_sink.yaml > kitchen_sink_api.py diff --git a/linkml_runtime_api/apiroot.py b/linkml_runtime_api/apiroot.py index da92196..e4b2c41 100644 --- a/linkml_runtime_api/apiroot.py +++ b/linkml_runtime_api/apiroot.py @@ -1,11 +1,23 @@ from abc import ABC from dataclasses import dataclass +from typing import Any from linkml_runtime.utils.schemaview import SchemaView from linkml_runtime.utils.yamlutils import YAMLRoot PATH_EXPRESSION = str +@dataclass +class Database: + """ + Abstraction over different datastores + + Currently only one supported + """ + name: str = None + #document: YAMLRoot = None + data: Any = None + @dataclass class ApiRoot(ABC): """ @@ -13,8 +25,8 @@ class ApiRoot(ABC): This class only contains base methods and cannot be used directly. Instead use: - * Patcher -- for update operations on instances of a LinkML model - * QueryEngine -- for query operations on instances of a LinkML model + * :ref:`changer` -- for update operations on instances of a LinkML model + * :ref:`queryengine` -- for query operations on instances of a LinkML model """ @@ -71,6 +83,9 @@ def _yield_path(self, path: str, element: YAMLRoot) -> YAMLRoot: if len(parts) == 0: yield element return + if parts == ['.']: + yield element + return new_path = '/'.join(parts[1:]) selector = parts[0] new_element = None diff --git a/linkml_runtime_api/changer/changer.py b/linkml_runtime_api/changer/changer.py index 1521185..a1183ca 100644 --- a/linkml_runtime_api/changer/changer.py +++ b/linkml_runtime_api/changer/changer.py @@ -16,6 +16,8 @@ class Changer(ApiRoot): """ Base class for engines that perform changes on elements + Implementing classes must implement :ref:`apply` + Currently the most useful subclasses: * :class:`ObjectChanger` - operate directly on objects @@ -30,7 +32,7 @@ def apply(self, change: Change, element: YAMLRoot) -> ChangeResult: :param element: :return: """ - raise Exception(f'Base class') + raise NotImplementedError(f'{self} must implement this method') def _map_change_object(self, change: YAMLRoot) -> Change: if isinstance(change, Change): diff --git a/linkml_runtime_api/generators/apigenerator.py b/linkml_runtime_api/generators/apigenerator.py index cd00d05..99f653e 100644 --- a/linkml_runtime_api/generators/apigenerator.py +++ b/linkml_runtime_api/generators/apigenerator.py @@ -14,7 +14,7 @@ @dataclass class ApiGenerator(ApiRoot): """ - Generates an API schema given a data schema + Generates an CRUD datamodel from an existing Datamodel For each class MyClass, will generate: @@ -37,7 +37,15 @@ class ApiGenerator(ApiRoot): Instances of these objects can be used as currency in both changers and query engines respectively """ - def serialize(self, container_class=None): + def serialize(self, container_class=None, prefix_uri=None, include_slots_as_params=False): + """ + Generates and serializes the CRUD API as yaml + + :param container_class: + :param prefix_uri: + :param include_slots_as_params: (experimental) + :return: + """ sv = self.schemaview cns = sv.all_classes(imports=False).keys() if container_class != None: @@ -45,20 +53,26 @@ def serialize(self, container_class=None): src = sv.schema name = f'{src.name}_api' + if prefix_uri is None: + prefix_uri = sv.schema.id.rstrip('/') + f"/{name}/" classes = { "LocalChange": { + "mixin": True, "slots": [ "value", "path" ], - "mixin": True }, "LocalQuery": { "mixin": True, "slots": [ "target_class", - "path" - ] + "path", + ], + }, + "__Any": { + "class_uri": "linkml:Any", + "abstract": True } } schema = { @@ -67,7 +81,7 @@ def serialize(self, container_class=None): "name": name, "description": f"API for querying and manipulating objects from the {src.name} schema", "prefixes": { - name: "https://w3id.org/linkml/tests/kitchen_sink_api/", + name: prefix_uri, "linkml": "https://w3id.org/linkml/" }, "default_prefix": name, @@ -82,7 +96,7 @@ def serialize(self, container_class=None): }, "path": {}, "constraints": { - "range": "Any" + "range": "__Any" }, "id_value": {}, "target_class": {} @@ -90,11 +104,14 @@ def serialize(self, container_class=None): "classes": classes } - cmts = ["This is an autogenerated class"] + cmts = ["This is an autogenerated class"] for cn in cns: c = sv.get_class(cn) + if c.class_uri == 'linkml:Any': + continue cn_camel = camelcase(cn) + slot_names = sv.class_slots(cn) if not c.abstract and not c.mixin: classes[f'Add{cn_camel}'] = { "description": f'A change action that adds a {cn_camel} to a database', @@ -118,13 +135,17 @@ def serialize(self, container_class=None): } } } + if include_slots_as_params: + q_slot_names = slot_names + else: + q_slot_names = [] classes[f'{cn_camel}Query'] = { "description": f'A query object for instances of {cn_camel} from a database', "comments": copy(cmts), - "mixins": "LocalChange", + "mixins": "LocalQuery", "slots": [ "constraints" - ], + ] + q_slot_names, } classes[f'{cn_camel}FetchById'] = { "description": f'A query object for fetching an instance of {cn_camel} from a database by id', diff --git a/linkml_runtime_api/query/object_queryengine.py b/linkml_runtime_api/query/object_queryengine.py index d2f15f1..7cfaff2 100644 --- a/linkml_runtime_api/query/object_queryengine.py +++ b/linkml_runtime_api/query/object_queryengine.py @@ -2,7 +2,7 @@ import re import operator as op from dataclasses import dataclass -from typing import Any +from typing import Any, List from linkml_runtime.utils.formatutils import camelcase, underscore @@ -15,7 +15,10 @@ def like(x: Any, y: Any) -> bool: y = str(y).replace('%', '.*') - return re.match(f'^{y}$', str(x)) + return re.match(f'^{y}$', str(x)) is not None + +def re_match(x: Any, y: str) -> bool: + return re.match(y, str(x)) is not None OPMAP = {'<': op.lt, '<=': op.le, @@ -23,6 +26,7 @@ def like(x: Any, y: Any) -> bool: '=': op.eq, '>=': op.ge, '>': op.gt, + 'regex': re_match, 'like': like} @@ -58,7 +62,7 @@ def fetch(self, query: AbstractQuery, element: YAMLRoot): def fetch_by_id(self, query: FetchById, element: YAMLRoot = None): if element is None: - element = self.database.document + element = self.database.data pk = None tc = query.target_class for cn, c in self.schemaview.all_class().items(): @@ -72,9 +76,9 @@ def fetch_by_id(self, query: FetchById, element: YAMLRoot = None): constraints=[c]), element) - def fetch_by_query(self, query: FetchQuery, element: YAMLRoot = None): + def fetch_by_query(self, query: FetchQuery, element: YAMLRoot = None) -> List[YAMLRoot]: if element is None: - element = self.database.document + element = self.database.data path = self._get_path(query) place = self.select_path(path, element) if isinstance(place, list): diff --git a/linkml_runtime_api/query/queryengine.py b/linkml_runtime_api/query/queryengine.py index e23ebf8..84d2dee 100644 --- a/linkml_runtime_api/query/queryengine.py +++ b/linkml_runtime_api/query/queryengine.py @@ -1,11 +1,11 @@ import re import operator as op from dataclasses import dataclass -from typing import Any +from typing import Any, List from linkml_runtime.utils.formatutils import camelcase, underscore -from linkml_runtime_api.apiroot import ApiRoot +from linkml_runtime_api.apiroot import ApiRoot, Database from linkml_runtime_api.query.query_model import FetchQuery, Constraint, MatchConstraint, OrConstraint, AbstractQuery, \ FetchById from linkml_runtime.utils.yamlutils import YAMLRoot @@ -28,20 +28,15 @@ def create_match_constraint(left: str, right: Any, op: str = "==") -> MatchConst else: return MatchConstraint(op=op, left=left, right=right) -@dataclass -class Database: - """ - Abstraction over different datastores - Currently only one supported - """ - document: YAMLRoot = None @dataclass class QueryEngine(ApiRoot): """ Abstract base class for QueryEngine objects for querying over a database + Implementing classes must implement :func: + Here a ref:`Database` can refer to: * in-memory object tree or JSON document * external database (SQL, Solr, Triplestore) -- to be implemented in future @@ -54,6 +49,9 @@ class QueryEngine(ApiRoot): database: Database = None + def fetch_by_query(self, query: FetchQuery, element: YAMLRoot = None) -> List[YAMLRoot]: + raise NotImplementedError(f'{self} must implement this method') + # TODO: avoid repetion with same method def _get_path(self, query: AbstractQuery, strict=True) -> str: if query.path is not None: diff --git a/setup.cfg b/setup.cfg index e9d1da7..4fc1430 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,5 +35,6 @@ packages = [entry_points] console_scripts = gen-api-datamodel = linkml_runtime_api.generators.apigenerator:cli + gen-crud-datamodel = linkml_runtime_api.generators.apigenerator:cli gen-python-api = linkml_runtime_api.generators.pyapigenerator:cli linkml-apply = linkml_runtime_api.changer.jsonpatch_changer diff --git a/tests/input/kitchen_sink_inst_01.yaml b/tests/input/kitchen_sink_inst_01.yaml index 3f40417..9dd85ea 100644 --- a/tests/input/kitchen_sink_inst_01.yaml +++ b/tests/input/kitchen_sink_inst_01.yaml @@ -25,6 +25,28 @@ persons: addresses: - street: 1 foo street city: foo city + - id: P:003 + name: other person + has_employment_history: + # joe's history + - employed_at: ROR:2 + started_at_time: 2019-01-01 + is_current: false + has_familial_relationships: + - related_to: P:003 + type: PARENT_OF + has_medical_history: + - started_at_time: 2019-01-01 + in_location: GEO:1234 + diagnosis: + id: CODE:D0002 + name: cough + procedure: + id: CODE:P0002 + name: cough medicine + addresses: + - street: 1 foo street + city: foo city companies: - id: ROR:1 name: foo diff --git a/tests/model/kitchen_sink.yaml b/tests/model/kitchen_sink.yaml index 062a8bd..f4e4203 100644 --- a/tests/model/kitchen_sink.yaml +++ b/tests/model/kitchen_sink.yaml @@ -348,6 +348,10 @@ slots: metadata: range: Any + aliases: + ceo: + range: Person + enums: FamilialRelationshipType: permissible_values: diff --git a/tests/model/kitchen_sink_api.py b/tests/model/kitchen_sink_api.py index 4a2a0ed..a2a26da 100644 --- a/tests/model/kitchen_sink_api.py +++ b/tests/model/kitchen_sink_api.py @@ -1,5 +1,5 @@ # Auto generated from kitchen_sink_api.yaml by pythongen.py version: 0.9.0 -# Generation date: 2021-09-19 04:09 +# Generation date: 2021-12-26T19:39:45 # Schema: kitchen_sink_api # # id: https://w3id.org/linkml/tests/kitchen_sink_api @@ -22,7 +22,7 @@ from linkml_runtime.utils.enumerations import EnumDefinitionImpl from rdflib import Namespace, URIRef from linkml_runtime.utils.curienamespace import CurieNamespace -from . kitchen_sink import Activity, ActivityId, Any, Company, CompanyId, Person, PersonId +from . kitchen_sink import Activity, ActivityId, Company, CompanyId, Person, PersonId from linkml_runtime.linkml_model.types import String metamodel_version = "1.7.0" @@ -31,7 +31,7 @@ dataclasses._init_fn = dataclasses_init_fn_with_kwargs # Namespaces -KITCHEN_SINK_API = CurieNamespace('kitchen_sink_api', 'https://w3id.org/linkml/tests/kitchen_sink_api/') +KITCHEN_SINK_API = CurieNamespace('kitchen_sink_api', 'https://w3id.org/linkml/tests/kitchen_sink/kitchen_sink_api/') LINKML = CurieNamespace('linkml', 'https://w3id.org/linkml/') DEFAULT_ = KITCHEN_SINK_API @@ -86,6 +86,8 @@ def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): super().__post_init__(**kwargs) +Any = Any + @dataclass class AddPerson(YAMLRoot): """ @@ -149,12 +151,12 @@ class PersonQuery(YAMLRoot): class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.PersonQuery constraints: Optional[Union[dict, Any]] = None - value: Optional[str] = None + target_class: Optional[str] = None path: Optional[str] = None def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): - if self.value is not None and not isinstance(self.value, str): - self.value = str(self.value) + if self.target_class is not None and not isinstance(self.target_class, str): + self.target_class = str(self.target_class) if self.path is not None and not isinstance(self.path, str): self.path = str(self.path) @@ -254,12 +256,12 @@ class CompanyQuery(YAMLRoot): class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.CompanyQuery constraints: Optional[Union[dict, Any]] = None - value: Optional[str] = None + target_class: Optional[str] = None path: Optional[str] = None def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): - if self.value is not None and not isinstance(self.value, str): - self.value = str(self.value) + if self.target_class is not None and not isinstance(self.target_class, str): + self.target_class = str(self.target_class) if self.path is not None and not isinstance(self.path, str): self.path = str(self.path) @@ -359,12 +361,12 @@ class ActivityQuery(YAMLRoot): class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.ActivityQuery constraints: Optional[Union[dict, Any]] = None - value: Optional[str] = None + target_class: Optional[str] = None path: Optional[str] = None def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): - if self.value is not None and not isinstance(self.value, str): - self.value = str(self.value) + if self.target_class is not None and not isinstance(self.target_class, str): + self.target_class = str(self.target_class) if self.path is not None and not isinstance(self.path, str): self.path = str(self.path) @@ -405,4 +407,38 @@ def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): # Slots +class slots: + pass + +slots.value = Slot(uri=KITCHEN_SINK_API.value, name="value", curie=KITCHEN_SINK_API.curie('value'), + model_uri=KITCHEN_SINK_API.value, domain=None, range=Optional[str]) + +slots.path = Slot(uri=KITCHEN_SINK_API.path, name="path", curie=KITCHEN_SINK_API.curie('path'), + model_uri=KITCHEN_SINK_API.path, domain=None, range=Optional[str]) + +slots.constraints = Slot(uri=KITCHEN_SINK_API.constraints, name="constraints", curie=KITCHEN_SINK_API.curie('constraints'), + model_uri=KITCHEN_SINK_API.constraints, domain=None, range=Optional[Union[dict, Any]]) + +slots.id_value = Slot(uri=KITCHEN_SINK_API.id_value, name="id_value", curie=KITCHEN_SINK_API.curie('id_value'), + model_uri=KITCHEN_SINK_API.id_value, domain=None, range=Optional[str]) + +slots.target_class = Slot(uri=KITCHEN_SINK_API.target_class, name="target_class", curie=KITCHEN_SINK_API.curie('target_class'), + model_uri=KITCHEN_SINK_API.target_class, domain=None, range=Optional[str]) + +slots.AddPerson_value = Slot(uri=KITCHEN_SINK_API.value, name="AddPerson_value", curie=KITCHEN_SINK_API.curie('value'), + model_uri=KITCHEN_SINK_API.AddPerson_value, domain=AddPerson, range=Optional[Union[dict, Person]]) + +slots.RemovePerson_value = Slot(uri=KITCHEN_SINK_API.value, name="RemovePerson_value", curie=KITCHEN_SINK_API.curie('value'), + model_uri=KITCHEN_SINK_API.RemovePerson_value, domain=RemovePerson, range=Optional[Union[dict, Person]]) + +slots.AddCompany_value = Slot(uri=KITCHEN_SINK_API.value, name="AddCompany_value", curie=KITCHEN_SINK_API.curie('value'), + model_uri=KITCHEN_SINK_API.AddCompany_value, domain=AddCompany, range=Optional[Union[dict, Company]]) + +slots.RemoveCompany_value = Slot(uri=KITCHEN_SINK_API.value, name="RemoveCompany_value", curie=KITCHEN_SINK_API.curie('value'), + model_uri=KITCHEN_SINK_API.RemoveCompany_value, domain=RemoveCompany, range=Optional[Union[dict, Company]]) + +slots.AddActivity_value = Slot(uri=KITCHEN_SINK_API.value, name="AddActivity_value", curie=KITCHEN_SINK_API.curie('value'), + model_uri=KITCHEN_SINK_API.AddActivity_value, domain=AddActivity, range=Optional[Union[dict, Activity]]) +slots.RemoveActivity_value = Slot(uri=KITCHEN_SINK_API.value, name="RemoveActivity_value", curie=KITCHEN_SINK_API.curie('value'), + model_uri=KITCHEN_SINK_API.RemoveActivity_value, domain=RemoveActivity, range=Optional[Union[dict, Activity]]) diff --git a/tests/model/kitchen_sink_api.yaml b/tests/model/kitchen_sink_api.yaml index 6c3568a..4346c7d 100644 --- a/tests/model/kitchen_sink_api.yaml +++ b/tests/model/kitchen_sink_api.yaml @@ -2,7 +2,7 @@ id: https://w3id.org/linkml/tests/kitchen_sink_api name: kitchen_sink_api description: API for querying and manipulating objects from the kitchen_sink schema prefixes: - kitchen_sink_api: https://w3id.org/linkml/tests/kitchen_sink_api/ + kitchen_sink_api: https://w3id.org/linkml/tests/kitchen_sink/kitchen_sink_api/ linkml: https://w3id.org/linkml/ default_prefix: kitchen_sink_api imports: @@ -14,20 +14,23 @@ slots: inlined: true path: {} constraints: - range: Any + range: __Any id_value: {} target_class: {} classes: LocalChange: + mixin: true slots: - value - path - mixin: true LocalQuery: mixin: true slots: - target_class - path + __Any: + class_uri: linkml:Any + abstract: true AddPerson: description: A change action that adds a Person to a database comments: @@ -50,7 +53,7 @@ classes: description: A query object for instances of Person from a database comments: - This is an autogenerated class - mixins: LocalChange + mixins: LocalQuery slots: - constraints PersonFetchById: @@ -83,7 +86,7 @@ classes: description: A query object for instances of Company from a database comments: - This is an autogenerated class - mixins: LocalChange + mixins: LocalQuery slots: - constraints CompanyFetchById: @@ -116,7 +119,7 @@ classes: description: A query object for instances of Activity from a database comments: - This is an autogenerated class - mixins: LocalChange + mixins: LocalQuery slots: - constraints ActivityFetchById: diff --git a/tests/test_apigenerator.py b/tests/test_apigenerator.py index 31f2b9d..c5612ee 100644 --- a/tests/test_apigenerator.py +++ b/tests/test_apigenerator.py @@ -9,7 +9,7 @@ from linkml_runtime.utils.schemaview import SchemaView from tests.model.kitchen_sink import Person, Dataset, FamilialRelationship from tests.model.kitchen_sink_api import AddPerson -from tests import MODEL_DIR, INPUT_DIR +from tests import MODEL_DIR, INPUT_DIR, OUTPUT_DIR SCHEMA = os.path.join(MODEL_DIR, 'kitchen_sink.yaml') API_SCHEMA = os.path.join(MODEL_DIR, 'kitchen_sink_api_test.yaml') diff --git a/tests/test_changer_common.py b/tests/test_changer_common.py index c5e70e6..962976f 100644 --- a/tests/test_changer_common.py +++ b/tests/test_changer_common.py @@ -38,9 +38,11 @@ class ChangerCommonTests: def _test_all(self): self._test_add() - def test_add(self): + def _test_add(self): """Adds a top level element""" patcher = self.patcher + if patcher is None: + return d = Dataset(persons=[Person('foo', name='foo')]) new_person = Person(id='P1', name='P1') # ADD OBJECT @@ -54,10 +56,11 @@ def test_remove(self): """Removes a top level element""" dataset = yaml_loader.load(DATA, target_class=Dataset) dataset: Dataset + n_persons = len(dataset.persons) change = RemoveObject(value=Person(id='P:002')) r = self.patcher.apply(change, dataset) print(yaml_dumper.dumps(dataset)) - self.assertEqual(len(dataset.persons), 1) + self.assertEqual(len(dataset.persons), n_persons-1) self.assertEqual(dataset.persons[0].id, 'P:001') def test_add_remove(self): @@ -139,6 +142,7 @@ def test_generated_api(self): patcher = self.patcher dataset = yaml_loader.load(DATA, target_class=Dataset) dataset: Dataset + n_persons = len(dataset.persons) frel = FamilialRelationship(related_to='P:001', type='SIBLING_OF') person = Person(id='P:222', name='foo', has_familial_relationships=[frel]) @@ -147,6 +151,10 @@ def test_generated_api(self): patcher.apply(change, dataset) logging.info(dataset) logging.info(yaml_dumper.dumps(dataset)) - assert len(dataset.persons) == 3 - assert dataset.persons[2].id == 'P:222' - assert dataset.persons[2].has_familial_relationships[0].related_to == 'P:001' \ No newline at end of file + assert len(dataset.persons) == n_persons + 1 + ok = False + for p in dataset.persons: + if p.id == 'P:222': + assert p.has_familial_relationships[0].related_to == 'P:001' + ok = True + assert ok \ No newline at end of file diff --git a/tests/test_jsonpatch_changer.py b/tests/test_jsonpatch_changer.py index e006cec..756155e 100644 --- a/tests/test_jsonpatch_changer.py +++ b/tests/test_jsonpatch_changer.py @@ -41,6 +41,8 @@ def setUp(self): view = SchemaView(SCHEMA) self.patcher = JsonPatchChanger(schemaview=view) + def test_add(self): + self._test_add() def test_make_jsonpatch(self): patcher = self.patcher diff --git a/tests/test_object_changer.py b/tests/test_object_changer.py index 1d78087..25a4a25 100644 --- a/tests/test_object_changer.py +++ b/tests/test_object_changer.py @@ -37,6 +37,8 @@ def setUp(self): self.patcher = ObjectChanger(schemaview=view) + def test_add(self): + self._test_add() @unittest.skip def test_set_value(self): @@ -48,11 +50,12 @@ def test_remove_by_identifier(self): view = SchemaView(SCHEMA) patcher = ObjectChanger(schemaview=view) dataset = yaml_loader.load(DATA, target_class=Dataset) + n_persons = len(dataset.persons) dataset: Dataset change = RemoveObject(value=Person(id='P:002')) r = patcher.apply(change, dataset) print(yaml_dumper.dumps(dataset)) - self.assertEqual(len(dataset.persons), 1) + self.assertEqual(len(dataset.persons), n_persons-1) self.assertEqual(dataset.persons[0].id, 'P:001') def test_duplicate_primary_key(self): diff --git a/tests/test_objectqueryengine.py b/tests/test_objectqueryengine.py index 134ba19..c4200c5 100644 --- a/tests/test_objectqueryengine.py +++ b/tests/test_objectqueryengine.py @@ -56,6 +56,34 @@ def test_query(self): right='headache')]) results = qe.fetch(q, dataset) self.assertEqual(results[0].id, 'P:002') + self.assertEqual(len(results), 1) + + q = FetchQuery(target_class=Person.class_name, + constraints=[MatchConstraint(op='like', + left='has_medical_history/*/diagnosis/name', + right='head%')]) + results = qe.fetch(q, dataset) + self.assertEqual(results[0].id, 'P:002') + self.assertEqual(len(results), 1) + last_person = results[0] + + # exemplar query + q = FetchQuery(target_class=Person.class_name, + constraints=[MatchConstraint(op='==', + left='.', + right=last_person)]) + results = qe.fetch(q, dataset) + self.assertEqual(results[0].id, 'P:002') + self.assertEqual(len(results), 1) + + q = FetchQuery(target_class=Person.class_name, + constraints=[MatchConstraint(op='==', + left='has_medical_history', + right=last_person.has_medical_history)]) + results = qe.fetch(q, dataset) + self.assertEqual(results[0].id, 'P:002') + self.assertEqual(len(results), 1) + # union queries def id_constraint(v): @@ -72,6 +100,11 @@ def id_constraint(v): results = qe.fetch(q, dataset) assert len(results) == 1 + q = FetchQuery(target_class=Person.class_name, + constraints=[MatchConstraint(op='regex', left='name', right='^j[aeiou].*')]) + results = qe.fetch(q, dataset) + assert len(results) == 1 + q = FetchQuery(target_class=Person.class_name, constraints=[MatchConstraint(op='like', left='name', right='joe')]) results = qe.fetch(q, dataset) @@ -80,7 +113,7 @@ def id_constraint(v): q = FetchQuery(target_class=Person.class_name, constraints=[MatchConstraint(op='like', left='name', right='%')]) results = qe.fetch(q, dataset) - assert len(results) == 2 + self.assertEqual(3, len(results)) # by ID q = FetchById(id='P:001', @@ -89,16 +122,18 @@ def id_constraint(v): assert len(results) == 1 self.assertEqual(results[0].id, 'P:001') - def test_query_with_domain_change_objects(self): + def test_query_with_domain_crud_model(self): """ Tests generic ObjectQueryEngine using domain-specific change objects + Uses tests.model.kitchen_sink_api + To regenerate this, use gen-crud-datamodel + """ view = SchemaView(SCHEMA) qe = ObjectQueryEngine(schemaview=view) dataset = yaml_loader.load(DATA, target_class=Dataset) - q = PersonQuery(constraints=[MatchConstraint(op='=', left='id', right='P:001')]) logging.info(q) results = qe.fetch(q, dataset) @@ -120,13 +155,13 @@ def test_bespoke_api(self): Tests a bespoke generated domain API Generated API here: tests.model.kitchen_sink_api - note: if you need to change the datamodel, regenerate using gen-oython-api + note: if you need to change the datamodel, regenerate using gen-python-api """ view = SchemaView(SCHEMA) qe = ObjectQueryEngine(schemaview=view) api = KitchenSinkAPI(query_engine=qe) dataset = yaml_loader.load(DATA, target_class=Dataset) - qe.database = Database(document=dataset) + qe.database = Database(data=dataset) person = api.fetch_Person('P:001') logging.info(f'PERSON={person}') self.assertEqual(person.id, 'P:001') diff --git a/tests/test_pythonapigenerator.py b/tests/test_pythonapigenerator.py index 817a1da..9dcda1e 100644 --- a/tests/test_pythonapigenerator.py +++ b/tests/test_pythonapigenerator.py @@ -34,7 +34,7 @@ def test_pyapigen(self): qe = ObjectQueryEngine(schemaview=view) api = mod.KitchenSinkAPI(query_engine=qe) dataset = yaml_loader.load(DATA, target_class=Dataset) - qe.database = Database(document=dataset) + qe.database = Database(data=dataset) person = api.fetch_Person('P:001') #print(f'PERSON={person}') self.assertEqual(person.id, 'P:001')