Skip to content

Commit

Permalink
Merge pull request #2 from linkml/docs-and-tests
Browse files Browse the repository at this point in the history
docs and tests
  • Loading branch information
cmungall authored Jan 7, 2022
2 parents f5e8c10 + 55efbf6 commit 3f81888
Show file tree
Hide file tree
Showing 18 changed files with 278 additions and 74 deletions.
11 changes: 9 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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 $@

71 changes: 57 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions linkml_runtime_api/apiroot.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
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):
"""
Base class for runtime API
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
"""


Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion linkml_runtime_api/changer/changer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
41 changes: 31 additions & 10 deletions linkml_runtime_api/generators/apigenerator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -37,28 +37,42 @@ 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:
cns = self._get_top_level_classes(container_class)

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 = {
Expand All @@ -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,
Expand All @@ -82,19 +96,22 @@ def serialize(self, container_class=None):
},
"path": {},
"constraints": {
"range": "Any"
"range": "__Any"
},
"id_value": {},
"target_class": {}
},
"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',
Expand All @@ -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',
Expand Down
14 changes: 9 additions & 5 deletions linkml_runtime_api/query/object_queryengine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -15,14 +15,18 @@

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,
'==': op.eq,
'=': op.eq,
'>=': op.ge,
'>': op.gt,
'regex': re_match,
'like': like}


Expand Down Expand Up @@ -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():
Expand All @@ -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):
Expand Down
Loading

0 comments on commit 3f81888

Please sign in to comment.