Skip to content

Commit c8c7a85

Browse files
committed
feat: specify backend interface and add reference implmentations
1 parent 882dd2a commit c8c7a85

8 files changed

Lines changed: 432 additions & 67 deletions

File tree

src/oold/backend/document_store.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import json
2+
import sqlite3
3+
from pathlib import Path
4+
from typing import Dict, List, Optional, Union
5+
6+
from oold.backend.interface import Backend, StoreResult
7+
8+
9+
class SimpleDictDocumentStore(Backend):
10+
_store: Optional[Dict[str, dict]] = None
11+
12+
def __init__(self):
13+
self._store = {}
14+
15+
def resolve_iris(self, iris: List[str]) -> Dict[str, Dict]:
16+
jsonld_dicts = {}
17+
for iri in iris:
18+
jsonld_dicts[iri] = self._store.get(iri, None)
19+
return jsonld_dicts
20+
21+
def store_jsonld_dicts(self, jsonld_dicts: Dict[str, Dict]) -> StoreResult:
22+
for iri, jsonld_dict in jsonld_dicts.items():
23+
self._store[iri] = jsonld_dict
24+
return StoreResult(success=True)
25+
26+
def query():
27+
pass
28+
29+
30+
class SqliteDocumentStore(Backend):
31+
db_path: Union[Path, str]
32+
persist_connection: bool = False
33+
_conn: Optional[sqlite3.Connection] = None
34+
35+
def __init__(self, **kwargs):
36+
super().__init__(**kwargs)
37+
38+
if self.db_path == ":memory:":
39+
self.persist_connection = True
40+
self._conn = sqlite3.connect(self.db_path)
41+
42+
# create table 'entities' if not exists
43+
conn = self._conn if self.persist_connection else sqlite3.connect(self.db_path)
44+
c = conn.cursor()
45+
c.execute(
46+
"""
47+
CREATE TABLE IF NOT EXISTS entities (
48+
id TEXT PRIMARY KEY,
49+
data JSONB
50+
)
51+
"""
52+
)
53+
conn.commit()
54+
if not self.persist_connection:
55+
conn.close()
56+
57+
def close(self):
58+
"""Close the persistent connection if it exists."""
59+
if self._conn is not None:
60+
self._conn.close()
61+
self._conn = None
62+
63+
def resolve_iris(self, iris: List[str]) -> Dict[str, Dict]:
64+
jsonld_dicts = {}
65+
conn = self._conn if self.persist_connection else sqlite3.connect(self.db_path)
66+
c = conn.cursor()
67+
c.execute(
68+
"""
69+
SELECT id, data FROM entities WHERE id IN ({})
70+
""".format(
71+
",".join("?" for _ in iris)
72+
),
73+
iris,
74+
)
75+
rows = c.fetchall()
76+
for iri, data in rows:
77+
jsonld_dicts[iri] = json.loads(data)
78+
if not self.persist_connection:
79+
conn.close()
80+
return jsonld_dicts
81+
82+
def store_jsonld_dicts(self, jsonld_dicts: Dict[str, Dict]) -> StoreResult:
83+
conn = self._conn if self.persist_connection else sqlite3.connect(self.db_path)
84+
c = conn.cursor()
85+
c.executemany(
86+
"""
87+
INSERT OR REPLACE INTO entities (id, data) VALUES (?, ?)
88+
""",
89+
[
90+
(iri, json.dumps(jsonld_dict))
91+
for iri, jsonld_dict in jsonld_dicts.items()
92+
],
93+
)
94+
conn.commit()
95+
if not self.persist_connection:
96+
conn.close()
97+
return StoreResult(success=True)
98+
99+
def query():
100+
raise NotImplementedError()

src/oold/backend/interface.py

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class Resolver(BaseModel):
3535
model_cls: Optional[Type[GenericLinkedBaseModel]] = None
3636

3737
@abstractmethod
38-
def resolve_iri(self, iri) -> Dict:
38+
def resolve_iris(self, iris: List[str]) -> Dict[str, Dict]:
3939
pass
4040

4141
def resolve(self, request: ResolveParam):
@@ -47,11 +47,15 @@ def resolve(self, request: ResolveParam):
4747
if model_cls is None:
4848
raise ValueError("No model_cls provided in request or resolver")
4949

50+
jsonld_dicts = self.resolve_iris(request.iris)
5051
nodes = {}
51-
for iri in request.iris:
52-
# nodes[iri] = self.resolve_iri(iri)
53-
jsonld_dict = self.resolve_iri(iri)
54-
nodes[iri] = model_cls.from_jsonld(jsonld_dict)
52+
for iri, jsonld_dict in jsonld_dicts.items():
53+
if jsonld_dict is None:
54+
nodes[iri] = None
55+
else:
56+
node = model_cls.from_jsonld(jsonld_dict)
57+
nodes[iri] = node
58+
5559
return ResolveResult(nodes=nodes)
5660

5761

@@ -65,7 +69,71 @@ def set_resolver(param: SetResolverParam) -> None:
6569

6670
def get_resolver(param: GetResolverParam) -> GetResolverResult:
6771
# ToDo: Handle prefixes (ex:) as well as full IRIs (http://example.com/)
72+
# ToDo: Handle list of IRIs with mixed domains
6873
iri = param.iri.split(":")[0]
6974
if iri not in _resolvers:
7075
raise ValueError(f"No resolvers found for {iri}")
7176
return GetResolverResult(resolver=_resolvers[iri])
77+
78+
79+
class SetBackendParam(BaseModel):
80+
iri: str
81+
backend: "Backend"
82+
83+
84+
class GetBackendParam(BaseModel):
85+
iri: str
86+
87+
88+
class GetBackendResult(BaseModel):
89+
backend: "Backend"
90+
91+
92+
class StoreParam(BaseModel):
93+
model_config = {
94+
"arbitrary_types_allowed": True,
95+
}
96+
nodes: Dict[str, Union[None, GenericLinkedBaseModel]]
97+
98+
99+
class StoreResult(BaseModel):
100+
success: bool
101+
102+
103+
class Query(BaseModel):
104+
pass
105+
106+
107+
class Backend(Resolver):
108+
def store(self, param: StoreParam) -> StoreResult:
109+
jsonld_dicts = {}
110+
for iri, node in param.nodes.items():
111+
if node is None:
112+
jsonld_dicts[iri] = None
113+
else:
114+
jsonld_dicts[iri] = node.to_jsonld()
115+
return self.store_jsonld_dicts(jsonld_dicts)
116+
117+
@abstractmethod
118+
def store_jsonld_dicts(self, jsonld_dicts: Dict[str, Dict]) -> StoreResult:
119+
pass
120+
121+
@abstractmethod
122+
def query(self, query: Query) -> ResolveResult:
123+
"""Query the backend and return a ResolveResult."""
124+
pass
125+
126+
127+
global _backends
128+
_backends = {}
129+
130+
131+
def set_backend(param: SetBackendParam) -> None:
132+
_backends[param.iri] = param.backend
133+
134+
135+
def get_backend(param: GetBackendParam) -> GetBackendResult:
136+
iri = param.iri.split(":")[0]
137+
if iri not in _backends:
138+
raise ValueError(f"No backends found for {iri}")
139+
return GetBackendResult(backend=_backends[iri])

src/oold/backend/sparql.py

Lines changed: 83 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import json
2-
from typing import Dict, Optional
2+
from typing import Dict, List, Optional
33

44
from pydantic import ConfigDict
55
from rdflib import Graph
66
from SPARQLWrapper import JSONLD, SPARQLWrapper
77

8-
from oold.backend.interface import Resolver
8+
from oold.backend.interface import Backend, Resolver, StoreResult
99

1010

1111
class LocalSparqlResolver(Resolver):
@@ -18,30 +18,60 @@ def __init__(self, **kwargs):
1818
if self.graph is None:
1919
self.graph = Graph()
2020

21-
def resolve_iri(self, iri) -> Dict:
21+
def resolve_iris(self, iris: List[str]) -> Dict[str, Dict]:
2222
# sparql query to get a node by IRI with all its properties
2323
# using CONSTRUCT to get the full node
2424
# format the result as json-ld
25-
iri_filter = f"FILTER (?s = {iri})"
26-
# check if the iri is a full IRI or a prefix
27-
if iri.startswith("http"):
28-
iri_filter = f"FILTER (?s = <{iri}>)"
29-
qres = self.graph.query(
30-
"""
31-
PREFIX ex: <https://example.com/>
32-
CONSTRUCT {
33-
?s ?p ?o .
34-
}
35-
WHERE {
36-
?s ?p ?o .
37-
{{{iri_filter}}}
38-
}
39-
""".replace(
25+
jsonld_dicts = {}
26+
for iri in iris:
27+
iri_filter = f"FILTER (?s = {iri})"
28+
# check if the iri is a full IRI or a prefix
29+
if iri.startswith("http"):
30+
iri_filter = f"FILTER (?s = <{iri}>)"
31+
qres = self.graph.query(
32+
"""
33+
PREFIX ex: <https://example.com/>
34+
CONSTRUCT {
35+
?s ?p ?o .
36+
}
37+
WHERE {
38+
?s ?p ?o .
39+
{{{iri_filter}}}
40+
}
41+
""".replace(
42+
"{{{iri_filter}}}", iri_filter
43+
)
44+
)
45+
jsonld_dict = json.loads(qres.serialize(format="json-ld"))[0]
46+
jsonld_dicts[iri] = jsonld_dict
47+
return jsonld_dicts
48+
49+
50+
class LocalSparqlBackend(LocalSparqlResolver, Backend):
51+
def store_jsonld_dicts(self, jsonld_dicts: Dict[str, Dict]) -> StoreResult:
52+
# delete all triples with the given iris as subject
53+
for iri in jsonld_dicts.keys():
54+
iri_filter = f"{iri}"
55+
# check if the iri is a full IRI or a prefix
56+
if iri.startswith("http"):
57+
iri_filter = f"<{iri}>"
58+
query = """
59+
PREFIX ex: <https://example.com/>
60+
DELETE WHERE {
61+
{{{iri_filter}}} ?p ?o .
62+
}
63+
""".replace(
4064
"{{{iri_filter}}}", iri_filter
4165
)
42-
)
43-
jsonld_dict = json.loads(qres.serialize(format="json-ld"))[0]
44-
return jsonld_dict
66+
self.graph.update(query)
67+
# convert jsonld_dict to rdflib triples and add to graph
68+
g = Graph()
69+
g.parse(data=json.dumps(jsonld_dicts[iri]), format="json-ld")
70+
self.graph += g
71+
return StoreResult(success=True)
72+
73+
def query():
74+
raise NotImplementedError()
4575

4676

4777
class WikiDataSparqlResolver(Resolver):
@@ -54,36 +84,39 @@ def __init__(self, **kwargs):
5484

5585
self._sparql = SPARQLWrapper(self.endpoint)
5686

57-
def resolve_iri(self, iri):
87+
def resolve_iri(self, iris: List[str]) -> Dict[str, Dict]:
5888
# sparql query to get a node by IRI with all its properties
5989
# using CONSTRUCT to get the full node
6090
# format the result as json-ld
61-
iri_filter = f"FILTER (?s = {iri})"
62-
# check if the iri is a full IRI or a prefix
63-
if iri.startswith("http"):
64-
iri_filter = f"FILTER (?s = <{iri}>)"
65-
self._sparql.setQuery(
66-
"""
67-
PREFIX ex: <https://example.com/>
68-
PREFIX Item: <http://www.wikidata.org/entity/>
69-
CONSTRUCT {
70-
?s ?p ?o .
71-
}
72-
WHERE {
73-
?s ?p ?o .
74-
{{{iri_filter}}}
75-
}
76-
""".replace(
77-
"{{{iri_filter}}}", iri_filter
91+
jsonld_dicts = {}
92+
for iri in iris:
93+
iri_filter = f"FILTER (?s = {iri})"
94+
# check if the iri is a full IRI or a prefix
95+
if iri.startswith("http"):
96+
iri_filter = f"FILTER (?s = <{iri}>)"
97+
self._sparql.setQuery(
98+
"""
99+
PREFIX ex: <https://example.com/>
100+
PREFIX Item: <http://www.wikidata.org/entity/>
101+
CONSTRUCT {
102+
?s ?p ?o .
103+
}
104+
WHERE {
105+
?s ?p ?o .
106+
{{{iri_filter}}}
107+
}
108+
""".replace(
109+
"{{{iri_filter}}}", iri_filter
110+
)
78111
)
79-
)
80-
self._sparql.setReturnFormat(JSONLD)
81-
result: Graph = self._sparql.query().convert()
82-
jsonld_dict = json.loads(result.serialize(format="json-ld"))[0]
83-
# replace http://www.wikidata.org/prop/direct/P31 with @type
84-
if "http://www.wikidata.org/prop/direct/P31" in jsonld_dict:
85-
jsonld_dict["@type"] = jsonld_dict.pop(
86-
"http://www.wikidata.org/prop/direct/P31"
87-
)[0]["@id"]
88-
89-
return jsonld_dict
112+
self._sparql.setReturnFormat(JSONLD)
113+
result: Graph = self._sparql.query().convert()
114+
jsonld_dict = json.loads(result.serialize(format="json-ld"))[0]
115+
# replace http://www.wikidata.org/prop/direct/P31 with @type
116+
if "http://www.wikidata.org/prop/direct/P31" in jsonld_dict:
117+
jsonld_dict["@type"] = jsonld_dict.pop(
118+
"http://www.wikidata.org/prop/direct/P31"
119+
)[0]["@id"]
120+
jsonld_dicts[iri] = jsonld_dict
121+
122+
return jsonld_dicts

src/oold/model/__init__.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@
55
from pydantic import BaseModel
66
from typing_extensions import Self
77

8-
from oold.backend.interface import GetResolverParam, ResolveParam, get_resolver
8+
from oold.backend.interface import (
9+
GetBackendParam,
10+
GetResolverParam,
11+
ResolveParam,
12+
StoreParam,
13+
get_backend,
14+
get_resolver,
15+
)
916
from oold.static import GenericLinkedBaseModel, export_jsonld, import_jsonld
1017

1118
# pydantic v2
@@ -325,6 +332,14 @@ def _resolve(iris):
325332
).nodes
326333
return node_dict
327334

335+
def _store(self):
336+
backend = get_backend(GetBackendParam(iri=self.get_iri())).backend
337+
backend.store(StoreParam(nodes={self.get_iri(): self}))
338+
339+
def store_jsonld(self):
340+
"""Store the model instance in a backend matching its IRI."""
341+
self._store()
342+
328343
# pydantic v2
329344
def model_dump_json(
330345
self,

0 commit comments

Comments
 (0)