rsrc_centric_layout.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. import logging
  2. from collections import defaultdict
  3. from itertools import chain
  4. from string import Template
  5. import arrow
  6. from rdflib import Dataset, Graph, Literal, URIRef, plugin
  7. from rdflib.namespace import RDF
  8. from rdflib.query import ResultException
  9. from rdflib.resource import Resource
  10. from rdflib.store import Store
  11. from lakesuperior.dictionaries.namespaces import ns_collection as nsc
  12. from lakesuperior.dictionaries.namespaces import ns_mgr as nsm
  13. from lakesuperior.dictionaries.srv_mgd_terms import srv_mgd_subjects, \
  14. srv_mgd_predicates, srv_mgd_types
  15. from lakesuperior.exceptions import (InvalidResourceError,
  16. ResourceNotExistsError, TombstoneError, PathSegmentError)
  17. from lakesuperior.env import env
  18. from lakesuperior.store.ldp_rs.lmdb_store import TxnManager
  19. META_GR_URI = nsc['fcsystem']['meta']
  20. HIST_GR_URI = nsc['fcsystem']['histmeta']
  21. PTREE_GR_URI = nsc['fcsystem']['pairtree']
  22. VERS_CONT_LABEL = 'fcr:versions'
  23. Lmdb = plugin.register('Lmdb', Store,
  24. 'lakesuperior.store.ldp_rs.lmdb_store', 'LmdbStore')
  25. logger = logging.getLogger(__name__)
  26. class RsrcCentricLayout:
  27. """
  28. This class exposes an interface to build graph store layouts. It also
  29. provides the basics of the triplestore connection.
  30. Some store layouts are provided. New ones aimed at specific uses
  31. and optimizations of the repository may be developed by extending this
  32. class and implementing all its abstract methods.
  33. A layout is implemented via application configuration. However, once
  34. contents are ingested in a repository, changing a layout will most likely
  35. require a migration.
  36. The custom layout must be in the lakesuperior.store.rdf
  37. package and the class implementing the layout must be called
  38. `StoreLayout`. The module name is the one defined in the app
  39. configuration.
  40. E.g. if the configuration indicates `simple_layout` the application will
  41. look for
  42. `lakesuperior.store.rdf.simple_layout.SimpleLayout`.
  43. """
  44. _graph_uids = ('fcadmin', 'fcmain', 'fcstruct')
  45. # @TODO Move to a config file?
  46. attr_map = {
  47. nsc['fcadmin']: {
  48. # List of server-managed predicates. Triples bearing one of these
  49. # predicates will go in the metadata graph.
  50. 'p': {
  51. nsc['ebucore'].hasMimeType,
  52. nsc['fcrepo'].created,
  53. nsc['fcrepo'].createdBy,
  54. nsc['fcrepo'].hasParent,
  55. nsc['fcrepo'].hasVersion,
  56. nsc['fcrepo'].lastModified,
  57. nsc['fcrepo'].lastModifiedBy,
  58. nsc['fcsystem'].tombstone,
  59. # The following 3 are set by the user but still in this group
  60. # for convenience.
  61. nsc['ldp'].membershipResource,
  62. nsc['ldp'].hasMemberRelation,
  63. nsc['ldp'].insertedContentRelation,
  64. nsc['iana'].describedBy,
  65. nsc['premis'].hasMessageDigest,
  66. nsc['premis'].hasSize,
  67. },
  68. # List of metadata RDF types. Triples bearing one of these types in
  69. # the object will go in the metadata graph.
  70. 't': {
  71. nsc['fcrepo'].Binary,
  72. nsc['fcrepo'].Container,
  73. nsc['fcrepo'].Pairtree,
  74. nsc['fcrepo'].Resource,
  75. nsc['fcsystem'].Tombstone,
  76. nsc['ldp'].BasicContainer,
  77. nsc['ldp'].Container,
  78. nsc['ldp'].DirectContainer,
  79. nsc['ldp'].IndirectContainer,
  80. nsc['ldp'].NonRDFSource,
  81. nsc['ldp'].RDFSource,
  82. nsc['ldp'].Resource,
  83. },
  84. },
  85. nsc['fcstruct']: {
  86. # These are placed in a separate graph for optimization purposes.
  87. 'p': {
  88. nsc['ldp'].contains,
  89. nsc['pcdm'].hasMember,
  90. }
  91. },
  92. }
  93. # RDF types of graphs by prefix.
  94. graph_ns_types = {
  95. nsc['fcadmin']: nsc['fcsystem'].AdminGraph,
  96. nsc['fcmain']: nsc['fcsystem'].UserProvidedGraph,
  97. nsc['fcstruct']: nsc['fcsystem'].StructureGraph,
  98. }
  99. ## MAGIC METHODS ##
  100. def __init__(self, config):
  101. """Initialize the graph store and a layout.
  102. NOTE: `rdflib.Dataset` requires a RDF 1.1 compliant store with support
  103. for Graph Store HTTP protocol
  104. (https://www.w3.org/TR/sparql11-http-rdf-update/). Blazegraph supports
  105. this only in the (currently unreleased) 2.2 branch. It works with Jena,
  106. which is currently the reference implementation.
  107. """
  108. self.config = config
  109. self.store = plugin.get('Lmdb', Store)(config['location'])
  110. self.ds = Dataset(self.store, default_union=True)
  111. self.ds.namespace_manager = nsm
  112. @property
  113. def attr_routes(self):
  114. """
  115. This is a map that allows specific triples to go to certain graphs.
  116. It is a machine-friendly version of the static attribute `attr_map`
  117. which is formatted for human readability and to avoid repetition.
  118. The attributes not mapped here (usually user-provided triples with no
  119. special meaning to the application) go to the `fcmain:` graph.
  120. The output of this is a dict with a similar structure::
  121. {
  122. 'p': {
  123. <Predicate P1>: <destination graph G1>,
  124. <Predicate P2>: <destination graph G1>,
  125. <Predicate P3>: <destination graph G1>,
  126. <Predicate P4>: <destination graph G2>,
  127. [...]
  128. },
  129. 't': {
  130. <RDF Type T1>: <destination graph G1>,
  131. <RDF Type T2>: <destination graph G3>,
  132. [...]
  133. }
  134. }
  135. """
  136. if not hasattr(self, '_attr_routes'):
  137. self._attr_routes = {'p': {}, 't': {}}
  138. for dest in self.attr_map.keys():
  139. for term_k, terms in self.attr_map[dest].items():
  140. self._attr_routes[term_k].update(
  141. {term: dest for term in terms})
  142. return self._attr_routes
  143. def bootstrap(self):
  144. """
  145. Delete all graphs and insert the basic triples.
  146. """
  147. logger.info('Deleting all data from the graph store.')
  148. store = self.ds.store
  149. if getattr(store, 'is_txn_open', False):
  150. store.rollback()
  151. store.destroy(store.path)
  152. logger.info('Initializing the graph store with system data.')
  153. store.open()
  154. with TxnManager(store, True):
  155. with open('data/bootstrap/rsrc_centric_layout.sparql', 'r') as f:
  156. data = Template(f.read())
  157. self.ds.update(data.substitute(timestamp=arrow.utcnow()))
  158. def get_raw(self, uri, ctx=None):
  159. """
  160. Get a raw graph of a non-LDP resource.
  161. The graph is queried across all contexts or within a specific one.
  162. :param rdflib.term.URIRef s: URI of the subject.
  163. :param rdflib.term.URIRef ctx: URI of the optional context. If None,
  164. all named graphs are queried.
  165. :rtype: rdflib.Graph
  166. """
  167. return self.store.triples((nsc['fcres'][uid], None, None), ctx)
  168. def count_rsrc(self):
  169. """
  170. Return a count of first-class resources, subdivided in "live" and
  171. historic snapshots.
  172. """
  173. with TxnManager(self.ds.store) as txn:
  174. main = set(
  175. self.ds.graph(META_GR_URI)[ : nsc['foaf'].primaryTopic : ])
  176. hist = set(
  177. self.ds.graph(HIST_GR_URI)[ : nsc['foaf'].primaryTopic : ])
  178. return {'main': len(main), 'hist': len(hist)}
  179. def raw_query(self, qry_str):
  180. """
  181. Perform a straight query to the graph store.
  182. """
  183. return self.ds.query(qry_str)
  184. def extract_imr(
  185. self, uid, ver_uid=None, strict=True, incl_inbound=False,
  186. incl_children=True, embed_children=False, **kwargs):
  187. """
  188. See base_rdf_layout.extract_imr.
  189. """
  190. if ver_uid:
  191. uid = self.snapshot_uid(uid, ver_uid)
  192. graphs = {pfx[uid] for pfx in self.graph_ns_types.keys()}
  193. # Exclude children: remove containment graphs.
  194. if not incl_children:
  195. graphs.remove(nsc['fcstruct'][uid])
  196. rsrc_graphs = [
  197. self.ds.graph(gr)
  198. for gr in graphs]
  199. resultset = set(chain.from_iterable(rsrc_graphs))
  200. gr = Graph()
  201. gr += resultset
  202. # Include inbound relationships.
  203. if incl_inbound and len(gr):
  204. gr += self.get_inbound_rel(nsc['fcres'][uid])
  205. #logger.debug('Found resource: {}'.format(
  206. # gr.serialize(format='turtle').decode('utf-8')))
  207. rsrc = Resource(gr, nsc['fcres'][uid])
  208. if strict:
  209. self._check_rsrc_status(rsrc)
  210. return rsrc
  211. def ask_rsrc_exists(self, uid):
  212. """
  213. See base_rdf_layout.ask_rsrc_exists.
  214. """
  215. logger.debug('Checking if resource exists: {}'.format(uid))
  216. meta_gr = self.ds.graph(nsc['fcadmin'][uid])
  217. return bool(
  218. meta_gr[nsc['fcres'][uid] : RDF.type : nsc['fcrepo'].Resource])
  219. def get_metadata(self, uid, ver_uid=None, strict=True):
  220. """
  221. This is an optimized query to get only the administrative metadata.
  222. """
  223. logger.debug('Getting metadata for: {}'.format(uid))
  224. if ver_uid:
  225. uid = self.snapshot_uid(uid, ver_uid)
  226. gr = self.ds.graph(nsc['fcadmin'][uid]) | Graph()
  227. uri = nsc['fcres'][uid]
  228. rsrc = Resource(gr, uri)
  229. if strict:
  230. self._check_rsrc_status(rsrc)
  231. return rsrc
  232. def get_user_data(self, uid):
  233. """
  234. Get all the user-provided data.
  235. :param string uid: Resource UID.
  236. """
  237. # *TODO* This only works as long as there is only one user-provided
  238. # graph. If multiple user-provided graphs will be supported, this
  239. # should use another query to get all of them.
  240. userdata_gr = self.ds.graph(nsc['fcmain'][uid])
  241. return userdata_gr | Graph()
  242. def get_version_info(self, uid, strict=True):
  243. """
  244. Get all metadata about a resource's versions.
  245. """
  246. # **Note:** This pretty much bends the ontology—it replaces the graph URI
  247. # with the subject URI. But the concepts of data and metadata in Fedora
  248. # are quite fluid anyways...
  249. # WIP—Is it worth to replace SPARQL here?
  250. #versions = self.ds.graph(nsc['fcadmin'][uid]).triples(
  251. # (nsc['fcres'][uid], nsc['fcrepo'].hasVersion, None))
  252. #for version in versions:
  253. # version_meta = self.ds.graph(HIST_GRAPH_URI).triples(
  254. qry = """
  255. CONSTRUCT {
  256. ?s fcrepo:hasVersion ?v .
  257. ?v ?p ?o .
  258. } {
  259. GRAPH ?ag {
  260. ?s fcrepo:hasVersion ?v .
  261. }
  262. GRAPH ?hg {
  263. ?vm foaf:primaryTopic ?v .
  264. ?vm ?p ?o .
  265. FILTER (?o != ?v)
  266. }
  267. }"""
  268. gr = self._parse_construct(qry, init_bindings={
  269. 'ag': nsc['fcadmin'][uid],
  270. 'hg': HIST_GR_URI,
  271. 's': nsc['fcres'][uid]})
  272. rsrc = Resource(gr, nsc['fcres'][uid])
  273. # TODO Should return a graph.
  274. if strict:
  275. self._check_rsrc_status(rsrc)
  276. return rsrc
  277. def get_inbound_rel(self, subj_uri, full_triple=True):
  278. """
  279. Query inbound relationships for a subject.
  280. This can be a list of either complete triples, or of subjects referring
  281. to the given URI. It excludes historic version snapshots.
  282. :param rdflib.URIRef subj_uri: Subject URI.
  283. :param boolean full_triple: Whether to return the full triples found
  284. or only the subjects. By default, full triples are returned.
  285. :rtype: Iterator(tuple(rdflib.term.Identifier) or rdflib.URIRef)
  286. :return: Inbound triples or subjects.
  287. """
  288. # Only return non-historic graphs.
  289. meta_gr = self.ds.graph(META_GR_URI)
  290. ptopic_uri = nsc['foaf'].primaryTopic
  291. yield from (
  292. (match[:3] if full_triple else match[0])
  293. for match in self.ds.quads((None, None, subj_uri, None))
  294. if set(meta_gr[ : ptopic_uri : match[0]])
  295. )
  296. def get_descendants(self, uid, recurse=True):
  297. """
  298. Get descendants (recursive children) of a resource.
  299. :param str uid: Resource UID.
  300. :rtype: Iterator(rdflib.URIRef)
  301. :return: Subjects of descendant resources.
  302. """
  303. ds = self.ds
  304. subj_uri = nsc['fcres'][uid]
  305. ctx_uri = nsc['fcstruct'][uid]
  306. def _recurse(dset, s, p, c):
  307. new_dset = set(ds.graph(c)[s : p])
  308. for ss in new_dset:
  309. dset.add(ss)
  310. cc = URIRef(ss.replace(nsc['fcres'], nsc['fcstruct']))
  311. if set(ds.graph(cc)[ss : p]):
  312. _recurse(dset, ss, p, cc)
  313. return dset
  314. return (
  315. _recurse(set(), subj_uri, nsc['ldp'].contains, ctx_uri)
  316. if recurse
  317. else ds.graph(ctx_uri)[subj_uri : nsc['ldp'].contains : ])
  318. def patch_rsrc(self, uid, qry):
  319. """
  320. Patch a resource with SPARQL-Update statements.
  321. The statement(s) is/are executed on the user-provided graph only
  322. to ensure that the scope is limited to the resource.
  323. :param str uid: UID of the resource to be patched.
  324. :param dict qry: Parsed and translated query, or query string.
  325. """
  326. # Add meta graph for user-defined triples. This may not be used but
  327. # it's simple and harmless to add here.
  328. self.ds.graph(META_GR_URI).add(
  329. (nsc['fcmain'][uid], nsc['foaf'].primaryTopic,
  330. nsc['fcres'][uid]))
  331. gr = self.ds.graph(nsc['fcmain'][uid])
  332. logger.debug('Updating graph {} with statements: {}'.format(
  333. nsc['fcmain'][uid], qry))
  334. return gr.update(qry)
  335. def forget_rsrc(self, uid, inbound=True, children=True):
  336. """
  337. Completely delete a resource and (optionally) its children and inbound
  338. references.
  339. NOTE: inbound references in historic versions are not affected.
  340. """
  341. # Localize variables to be used in loops.
  342. uri = nsc['fcres'][uid]
  343. topic_uri = nsc['foaf'].primaryTopic
  344. uid_fn = self.uri_to_uid
  345. # remove children.
  346. if children:
  347. logger.debug('Purging children for /{}'.format(uid))
  348. for rsrc_uri in self.get_descendants(uid, False):
  349. self.forget_rsrc(uid_fn(rsrc_uri), inbound, False)
  350. # Remove structure graph.
  351. self.ds.remove_graph(nsc['fcstruct'][uid])
  352. # Remove inbound references.
  353. if inbound:
  354. for ibs in self.get_inbound_rel(uri):
  355. self.ds.remove(ibs)
  356. # Remove versions.
  357. for ver_uri in self.ds.graph(nsc['fcadmin'][uid])[
  358. uri : nsc['fcrepo'].hasVersion : None]:
  359. self.delete_rsrc(uid_fn(ver_uri), True)
  360. # Remove resource itself.
  361. self.delete_rsrc(uid)
  362. def truncate_rsrc(self, uid):
  363. """
  364. Remove all user-provided data from a resource and only leave admin and
  365. structure data.
  366. """
  367. userdata = set(self.get_user_data(uid))
  368. return self.modify_rsrc(uid, remove_trp=userdata)
  369. def modify_rsrc(self, uid, remove_trp=set(), add_trp=set()):
  370. """
  371. Modify triples about a subject.
  372. This method adds and removes triple sets from specific graphs,
  373. indicated by the term router. It also adds metadata about the changed
  374. graphs.
  375. """
  376. remove_routes = defaultdict(set)
  377. add_routes = defaultdict(set)
  378. historic = VERS_CONT_LABEL in uid
  379. graph_types = set() # Graphs that need RDF type metadata added.
  380. # Create add and remove sets for each graph.
  381. for t in remove_trp:
  382. map_graph = self._map_graph_uri(t, uid)
  383. target_gr_uri = map_graph[0]
  384. remove_routes[target_gr_uri].add(t)
  385. graph_types.add(map_graph)
  386. for t in add_trp:
  387. map_graph = self._map_graph_uri(t, uid)
  388. target_gr_uri = map_graph[0]
  389. add_routes[target_gr_uri].add(t)
  390. graph_types.add(map_graph)
  391. # Decide if metadata go into historic or current graph.
  392. meta_gr_uri = HIST_GR_URI if historic else META_GR_URI
  393. meta_gr = self.ds.graph(meta_gr_uri)
  394. # Remove and add triple sets from each graph.
  395. for gr_uri, trp in remove_routes.items():
  396. gr = self.ds.graph(gr_uri)
  397. gr -= trp
  398. for gr_uri, trp in add_routes.items():
  399. gr = self.ds.graph(gr_uri)
  400. gr += trp
  401. # Add metadata.
  402. meta_gr.set(
  403. (gr_uri, nsc['foaf'].primaryTopic, nsc['fcres'][uid]))
  404. ts = getattr(env, 'timestamp_term', Literal(arrow.utcnow()))
  405. meta_gr.set((gr_uri, nsc['fcrepo'].created, ts))
  406. if historic:
  407. # @FIXME Ugly reverse engineering.
  408. ver_uid = uid.split(VERS_CONT_LABEL)[1].lstrip('/')
  409. meta_gr.set((
  410. gr_uri, nsc['fcrepo'].hasVersionLabel, Literal(ver_uid)))
  411. # *TODO* More provenance metadata can be added here.
  412. # Add graph RDF types.
  413. for gr_uri, gr_type in graph_types:
  414. meta_gr.add((gr_uri, RDF.type, gr_type))
  415. def delete_rsrc(self, uid, historic=False):
  416. """
  417. Delete all aspect graphs of an individual resource.
  418. :param uid: Resource UID.
  419. :param bool historic: Whether the UID is of a historic version.
  420. """
  421. meta_gr_uri = HIST_GR_URI if historic else META_GR_URI
  422. for gr_uri in self.ds.graph(meta_gr_uri)[
  423. : nsc['foaf'].primaryTopic : nsc['fcres'][uid]]:
  424. self.ds.remove_context(gr_uri)
  425. self.ds.graph(meta_gr_uri).remove((gr_uri, None, None))
  426. def snapshot_uid(self, uid, ver_uid):
  427. """
  428. Create a versioned UID string from a main UID and a version UID.
  429. """
  430. if VERS_CONT_LABEL in uid:
  431. raise InvalidResourceError(uid,
  432. 'Resource \'{}\' is already a version.')
  433. return '{}/{}/{}'.format(uid, VERS_CONT_LABEL, ver_uid)
  434. def uri_to_uid(self, uri):
  435. """
  436. Convert an internal URI to a UID.
  437. """
  438. return str(uri).replace(nsc['fcres'], '')
  439. def find_refint_violations(self):
  440. """
  441. Find all referential integrity violations.
  442. This method looks for dangling relationships within a repository by
  443. checking the objects of each triple; if the object is an in-repo
  444. resource reference, and no resource with that URI results to be in the
  445. repo, that triple is reported.
  446. :rtype: set
  447. :return: Triples referencing a repository URI that is not a resource.
  448. """
  449. for obj in self.store.all_terms('o'):
  450. if (
  451. isinstance(obj, URIRef)
  452. and str(obj).startswith(nsc['fcres'])
  453. and not self.ask_rsrc_exists(self.uri_to_uid(obj))):
  454. print('Object not found: {}'.format(obj))
  455. for trp in self.store.triples((None, None, obj)):
  456. yield trp
  457. ## PROTECTED MEMBERS ##
  458. def _check_rsrc_status(self, rsrc):
  459. """
  460. Check if a resource is not existing or if it is a tombstone.
  461. """
  462. uid = self.uri_to_uid(rsrc.identifier)
  463. if not len(rsrc.graph):
  464. raise ResourceNotExistsError(uid)
  465. # Check if resource is a tombstone.
  466. if rsrc[RDF.type : nsc['fcsystem'].Tombstone]:
  467. raise TombstoneError(
  468. uid, rsrc.value(nsc['fcrepo'].created))
  469. elif rsrc.value(nsc['fcsystem'].tombstone):
  470. raise TombstoneError(
  471. self.uri_to_uid(
  472. rsrc.value(nsc['fcsystem'].tombstone).identifier),
  473. rsrc.value(nsc['fcrepo'].created))
  474. def _parse_construct(self, qry, init_bindings={}):
  475. """
  476. Parse a CONSTRUCT query.
  477. :rtype: rdflib.Graph
  478. """
  479. try:
  480. qres = self.ds.query(qry, initBindings=init_bindings)
  481. except ResultException:
  482. # RDFlib bug: https://github.com/RDFLib/rdflib/issues/775
  483. return Graph()
  484. else:
  485. return qres.graph
  486. def _map_graph_uri(self, t, uid):
  487. """
  488. Map a triple to a namespace prefix corresponding to a graph.
  489. :rtype: tuple
  490. :return: 2-tuple with a graph URI and an associated RDF type.
  491. """
  492. if t[1] in self.attr_routes['p'].keys():
  493. pfx = self.attr_routes['p'][t[1]]
  494. elif t[1] == RDF.type and t[2] in self.attr_routes['t'].keys():
  495. pfx = self.attr_routes['t'][t[2]]
  496. else:
  497. pfx = nsc['fcmain']
  498. return (pfx[uid], self.graph_ns_types[pfx])