Browse Source

Merge branch 'master' into development

Stefano Cossu 5 years ago
parent
commit
8d9b863ac9
60 changed files with 3832 additions and 1429 deletions
  1. 16 1
      .gitignore
  2. 3 2
      .gitmodules
  3. 3 1
      .travis.yml
  4. 3 0
      MANIFEST.in
  5. 23 22
      README.rst
  6. 1 1
      docs/api.rst
  7. 4 4
      docs/apidoc/lakesuperior.model.rst
  8. 99 0
      docs/structures.rst
  9. 1 0
      ext/collections-c
  10. 9 8
      lakesuperior/api/admin.py
  11. 40 41
      lakesuperior/api/resource.py
  12. 282 0
      lakesuperior/cy_include/collections.pxd
  13. 20 0
      lakesuperior/cy_include/spookyhash.pxd
  14. 64 51
      lakesuperior/endpoints/ldp.py
  15. 0 0
      lakesuperior/model/__init__.pxd
  16. 29 0
      lakesuperior/model/base.pxd
  17. 11 0
      lakesuperior/model/base.pyx
  18. 30 0
      lakesuperior/model/callbacks.pxd
  19. 54 0
      lakesuperior/model/callbacks.pyx
  20. 11 11
      lakesuperior/model/ldp/ldp_factory.py
  21. 2 2
      lakesuperior/model/ldp/ldp_nr.py
  22. 1 1
      lakesuperior/model/ldp/ldp_rs.py
  23. 57 46
      lakesuperior/model/ldp/ldpr.py
  24. 0 0
      lakesuperior/model/rdf/__init__.pxd
  25. 0 0
      lakesuperior/model/rdf/__init__.py
  26. 37 0
      lakesuperior/model/rdf/graph.pxd
  27. 613 0
      lakesuperior/model/rdf/graph.pyx
  28. 41 0
      lakesuperior/model/rdf/term.pxd
  29. 191 0
      lakesuperior/model/rdf/term.pyx
  30. 19 0
      lakesuperior/model/rdf/triple.pxd
  31. 41 0
      lakesuperior/model/rdf/triple.pyx
  32. 0 0
      lakesuperior/model/structures/__init__.pxd
  33. 0 0
      lakesuperior/model/structures/__init__.py
  34. 34 0
      lakesuperior/model/structures/hash.pxd
  35. 53 0
      lakesuperior/model/structures/hash.pyx
  36. 37 0
      lakesuperior/model/structures/keyset.pxd
  37. 364 0
      lakesuperior/model/structures/keyset.pyx
  38. 2 1
      lakesuperior/store/base_lmdb_store.pxd
  39. 50 5
      lakesuperior/store/base_lmdb_store.pyx
  40. 0 0
      lakesuperior/store/ldp_rs/__init__.pxd
  41. 0 53
      lakesuperior/store/ldp_rs/lmdb_store.py
  42. 45 0
      lakesuperior/store/ldp_rs/lmdb_triplestore.pxd
  43. 106 746
      lakesuperior/store/ldp_rs/lmdb_triplestore.pyx
  44. 47 39
      lakesuperior/store/ldp_rs/rsrc_centric_layout.py
  45. 0 41
      lakesuperior/store/ldp_rs/term.pxd
  46. 0 134
      lakesuperior/store/ldp_rs/term.pyx
  47. 13 0
      lakesuperior/toolbox.py
  48. 112 29
      lakesuperior/util/benchmark.py
  49. 2 1
      requirements_dev.txt
  50. 15 0
      sandbox/NOTES
  51. 10 0
      sandbox/txn_openLogic.txt
  52. 112 46
      setup.py
  53. 850 0
      tests/0_data_structures/test_0_0_graph.py
  54. 121 29
      tests/1_store/test_1_0_lmdb_store.py
  55. 138 106
      tests/2_api/test_2_0_resource_api.py
  56. 11 6
      tests/2_api/test_2_1_admin_api.py
  57. 1 1
      tests/3_endpoints/test_3_0_ldp.py
  58. 4 1
      tests/3_endpoints/test_3_1_admin.py
  59. 0 0
      tests/3_endpoints/test_3_2_query.py
  60. 0 0
      tests/4_ancillary/test_4_0_toolbox.py

+ 16 - 1
.gitignore

@@ -107,9 +107,24 @@ venv.bak/
 .pytest_cache/
 
 # Default Lakesuperior data directories
-/data
+lakesuperior/data/ldprs_store
+lakesuperior/data/ldpnr_store
 
 # Cython business.
+/cython_debug
 /lakesuperior/store/*.c
+/lakesuperior/store/*.html
 /lakesuperior/store/ldp_rs/*.c
+/lakesuperior/store/ldp_rs/*.html
+/lakesuperior/model/*.c
+/lakesuperior/model/*/*.html
+/lakesuperior/model/*/*.c
+/lakesuperior/model/*.html
+/lakesuperior/util/*.c
+/lakesuperior/util/*.html
 !ext/lib
+
+# Vim CTags file.
+tags
+
+!.keep

+ 3 - 2
.gitmodules

@@ -1,11 +1,12 @@
 [submodule "ext/lmdb"]
     path = ext/lmdb
     url = https://github.com/LMDB/lmdb.git
-    branch = stable
 [submodule "ext/tpl"]
     path = ext/tpl
     url = https://github.com/troydhanson/tpl.git
-    branch = stable
 [submodule "ext/spookyhash"]
     path = ext/spookyhash
     url = https://github.com/centaurean/spookyhash.git
+[submodule "ext/collections-c"]
+    path = ext/collections-c
+    url = https://github.com/srdja/Collections-C.git

+ 3 - 1
.travis.yml

@@ -3,12 +3,14 @@ language: python
 matrix:
     include:
     - python: 3.6
+      dist: xenial
+      sudo: true
     - python: 3.7
       dist: xenial
       sudo: true
 
 install:
-  - pip install Cython==0.29
+  - pip install Cython==0.29.6 cymem
   - pip install -e .
 script:
   - python setup.py test

+ 3 - 0
MANIFEST.in

@@ -5,10 +5,13 @@ include ext/lmdb/libraries/liblmdb/mdb.c
 include ext/lmdb/libraries/liblmdb/lmdb.h
 include ext/lmdb/libraries/liblmdb/midl.c
 include ext/lmdb/libraries/liblmdb/midl.h
+include ext/collections-c/src/*.c
+include ext/collections-c/src/include/*.h
 include ext/tpl/src/tpl.c
 include ext/tpl/src/tpl.h
 include ext/spookyhash/src/*.c
 include ext/spookyhash/src/*.h
+
 graft lakesuperior/data/bootstrap
 graft lakesuperior/endpoints/templates
 graft lakesuperior/etc.defaults

+ 23 - 22
README.rst

@@ -3,43 +3,44 @@ Lakesuperior
 
 |build status| |docs| |pypi| |codecov|
 
-Lakesuperior is an alternative `Fedora
-Repository <http://fedorarepository.org>`__ implementation.
+Lakesuperior is a Linked Data repository software. It is capable of storing and
+managing  large volumes of files and their metadata regardless of their
+format, size, ethnicity, gender identity or expression.
 
-Fedora is a mature repository software system historically adopted by
-major cultural heritage institutions. It exposes an
-`LDP <https://www.w3.org/TR/ldp-primer/>`__ endpoint to manage
-any type of binary files and their metadata in Linked Data format.
+Lakesuperior is an alternative `Fedora Repository
+<http://fedorarepository.org>`__ implementation. Fedora is a mature repository
+software system historically adopted by major cultural heritage institutions
+which extends the `Linked Data Platform <https://www.w3.org/TR/ldp-primer/>`__
+protocol.
 
 Guiding Principles
 ------------------
 
-Lakesuperior aims at being an uncomplicated, efficient Fedora 4
-implementation.
+Lakesuperior aims at being a reliable and efficient Fedora 4 implementation.
 
 Its main goals are:
 
 -  **Reliability:** Based on solid technologies with stability in mind.
 -  **Efficiency:** Small memory and CPU footprint, high scalability.
--  **Ease of management:** Tools to perform monitoring and maintenance
-   included.
+-  **Ease of management:** Tools to perform migration, monitoring and
+   maintenance included.
 -  **Simplicity of design:** Straight-forward architecture, robustness
    over features.
 
 Key features
 ------------
 
--  Drop-in replacement for Fedora4
--  Very stable persistence layer based on
-   `LMDB <https://symas.com/lmdb/>`__ and filesystem. Fully
-   ACID-compliant writes guarantee consistency of data.
--  Term-based search and SPARQL Query API + UI
--  No performance penalty for storing many resources under the same
-   container, or having one resource link to many URIs
--  Extensible provenance metadata tracking
--  Multi-modal access: HTTP (REST), command line interface and native Python
-   API.
--  Fits in a pocket: you can carry 50M triples in an 8Gb memory stick.
+- Stores binary files and RDF metadata in one repository.
+- Multi-modal access: REST/LDP, command line and native Python API.
+- (`almost <fcrepo4_deltas>`_) Drop-in replacement for Fedora4
+- Very stable persistence layer based on
+  `LMDB <https://symas.com/lmdb/>`__ and filesystem. Fully
+  ACID-compliant writes guarantee consistency of data.
+- Term-based search and SPARQL Query API + UI
+- No performance penalty for storing many resources under the same
+  container, or having one resource link to many URIs
+- Extensible provenance metadata tracking
+- Fits in a pocket: you can carry 50M triples in an 8Gb memory stick.
 
 Installation & Documentation
 ----------------------------
@@ -50,7 +51,7 @@ With Docker::
     cd lakesuperior
     docker-compose up
 
-With pip (assuming you are familiar with it)::
+With pip (requires a C compiler to be installed)::
 
     pip install lakesuperior
 

+ 1 - 1
docs/api.rst

@@ -10,7 +10,7 @@ The Lakesuperior API modules of most interest for a client are:
 - :mod:`lakesupeiror.api.query`
 - :mod:`lakesuperior.api.admin`
 
-:mod:`lakesuperior.model.ldpr` is used to manipulate resources.
+:mod:`lakesuperior.model.ldp.ldpr` is used to manipulate resources.
 
 The full API docs are listed below.
 

+ 4 - 4
docs/apidoc/lakesuperior.model.rst

@@ -7,7 +7,7 @@ Submodules
 lakesuperior\.model\.ldp\_factory module
 ----------------------------------------
 
-.. automodule:: lakesuperior.model.ldp_factory
+.. automodule:: lakesuperior.model.ldp.ldp_factory
     :members:
     :undoc-members:
     :show-inheritance:
@@ -15,7 +15,7 @@ lakesuperior\.model\.ldp\_factory module
 lakesuperior\.model\.ldp\_nr module
 -----------------------------------
 
-.. automodule:: lakesuperior.model.ldp_nr
+.. automodule:: lakesuperior.model.ldp.ldp_nr
     :members:
     :undoc-members:
     :show-inheritance:
@@ -23,7 +23,7 @@ lakesuperior\.model\.ldp\_nr module
 lakesuperior\.model\.ldp\_rs module
 -----------------------------------
 
-.. automodule:: lakesuperior.model.ldp_rs
+.. automodule:: lakesuperior.model.ldp.ldp_rs
     :members:
     :undoc-members:
     :show-inheritance:
@@ -31,7 +31,7 @@ lakesuperior\.model\.ldp\_rs module
 lakesuperior\.model\.ldpr module
 --------------------------------
 
-.. automodule:: lakesuperior.model.ldpr
+.. automodule:: lakesuperior.model.ldp.ldpr
     :members:
     :undoc-members:
     :show-inheritance:

+ 99 - 0
docs/structures.rst

@@ -0,0 +1,99 @@
+Data Structure Internals
+========================
+
+**(Draft)**
+
+Lakesuperior has its own methods for handling in-memory graphs. These methods
+rely on C data structures and are therefore much faster than Python/RDFLib
+objects.
+
+The graph data model modules are in :py:module:`lakesuperior.model.graph`.
+
+The Graph Data Model
+--------------------
+
+Triples are stored in a C hash set. Each triple is represented by a pointer to
+a ``BufferTriple`` structure stored in a temporary memory pool. This pool is
+tied to the life cycle of the ``SimpleGraph`` object it belongs to.
+
+A triple structure contains three pointers to ``Buffer`` structures, which
+contain a serialized version of a RDF term. These structures are stored in the
+``SimpleGraph`` memory pool as well.
+
+Each ``SimpleGraph`` object has a ``_terms`` property and a ``_triples``
+property. These are C hash sets holding addresses of unique terms and
+triples inserted in the graph. If the same term is entered more than once,
+in any position in any triple, the first one entered is used and is pointed to
+by the triple. This makes the graph data structure very compact.
+
+In summary, the pointers can be represented this way::
+
+   <serialized term data in mem pool (x3)>
+         ^      ^      ^
+         |      |      |
+   <Term structures in mem pool (x3)>
+         ^      ^      ^
+         |      |      |
+   <Term struct addresses in _terms set (x3)>
+         ^      ^      ^
+         |      |      |
+   <Triple structure in mem pool>
+         ^
+         |
+   <address of triple in _triples set>
+
+Let's say we insert the following triples in a ``SimpleGraph``::
+
+   <urn:s:0> <urn:p:0> <urn:o:0>
+   <urn:s:0> <urn:p:1> <urn:o:1>
+   <urn:s:0> <urn:p:1> <urn:o:2>
+   <urn:s:0> <urn:p:0> <urn:o:0>
+
+The memory pool contains the following byte arrays  of raw data, displayed in
+the following list with their relative addresses (simplified to 8-bit
+addresses and fixed-length byte strings for readability)::
+
+   0x00     <urn:s:0>
+   0x09     <urn:p:0>
+   0x12     <urn:o:0>
+
+   0x1b     <urn:s:0>
+   0x24     <urn:p:1>
+   0x2d     <urn:o:1>
+
+   0x36     <urn:s:0>
+   0x3f     <urn:p:1>
+   0x48     <urn:o:2>
+
+   0x51     <urn:s:0>
+   0x5a     <urn:p:0>
+   0x63     <urn:o:0>
+
+However, the ``_terms`` set contains only ``Buffer`` structures pointing to
+unique addresses::
+
+   0x00
+   0x09
+   0x12
+   0x24
+   0x2d
+   0x48
+
+The other terms are just unutilized. They will be deallocated en masse when
+the ``SimpleGraph`` object is garbage collected.
+
+The ``_triples`` set would then contain 3 unique entries pointing to the unique
+term addresses::
+
+   0x00  0x09  0x12
+   0x00  0x24  0x2d
+   0x00  0x24  0x48
+
+(the actual addresses would actually belong to the structures pointing to the
+raw data, but this is just an illustrative example).
+
+The advantage of this approach is that the memory pool is contiguous and
+append-only (until it gets purged), so it's cheap to just add to it, while the
+sets that must maintain uniqueness and are the ones that most operations
+(lookup, adding, removing, slicing, copying, etc.) are done on, contain much
+less data and are therefore faster.

+ 1 - 0
ext/collections-c

@@ -0,0 +1 @@
+Subproject commit 719fd8c229b2fb369e9fc175fc97ad658108438c

+ 9 - 8
lakesuperior/api/admin.py

@@ -77,18 +77,19 @@ def fixity_check(uid):
         resource is not an LDP-NR.
     """
     from lakesuperior.api import resource as rsrc_api
-    from lakesuperior.model.ldp_factory import LDP_NR_TYPE
+    from lakesuperior.model.ldp.ldp_factory import LDP_NR_TYPE
 
     rsrc = rsrc_api.get(uid)
-    if LDP_NR_TYPE not in rsrc.ldp_types:
-        raise IncompatibleLdpTypeError()
+    with env.app_globals.rdf_store.txn_ctx():
+        if LDP_NR_TYPE not in rsrc.ldp_types:
+            raise IncompatibleLdpTypeError()
 
-    ref_digest_term = rsrc.metadata.value(nsc['premis'].hasMessageDigest)
-    ref_digest_parts = ref_digest_term.split(':')
-    ref_cksum = ref_digest_parts[-1]
-    ref_cksum_algo = ref_digest_parts[-2]
+        ref_digest_term = rsrc.metadata.value(nsc['premis'].hasMessageDigest)
+        ref_digest_parts = ref_digest_term.split(':')
+        ref_cksum = ref_digest_parts[-1]
+        ref_cksum_algo = ref_digest_parts[-2]
 
-    calc_cksum = hashlib.new(ref_cksum_algo, rsrc.content.read()).hexdigest()
+        calc_cksum = hashlib.new(ref_cksum_algo, rsrc.content.read()).hexdigest()
 
     if calc_cksum != ref_cksum:
         raise ChecksumValidationError(uid, ref_cksum, calc_cksum)

+ 40 - 41
lakesuperior/api/resource.py

@@ -7,7 +7,7 @@ from threading import Lock, Thread
 
 import arrow
 
-from rdflib import Graph, Literal, URIRef
+from rdflib import Literal
 from rdflib.namespace import XSD
 
 from lakesuperior.config_parser import config
@@ -15,8 +15,7 @@ from lakesuperior.exceptions import (
         InvalidResourceError, ResourceNotExistsError, TombstoneError)
 from lakesuperior import env, thread_env
 from lakesuperior.globals import RES_DELETED, RES_UPDATED
-from lakesuperior.model.ldp_factory import LDP_NR_TYPE, LdpFactory
-from lakesuperior.store.ldp_rs.lmdb_triplestore import SimpleGraph
+from lakesuperior.model.ldp.ldp_factory import LDP_NR_TYPE, LdpFactory
 
 
 logger = logging.getLogger(__name__)
@@ -24,36 +23,36 @@ logger = logging.getLogger(__name__)
 __doc__ = """
 Primary API for resource manipulation.
 
-Quickstart:
-
->>> # First import default configuration and globals—only done once.
->>> import lakesuperior.default_env
->>> from lakesuperior.api import resource
->>> # Get root resource.
->>> rsrc = resource.get('/')
->>> # Dump graph.
->>> set(rsrc.imr)
-{(rdflib.term.URIRef('info:fcres/'),
-  rdflib.term.URIRef('http://purl.org/dc/terms/title'),
-  rdflib.term.Literal('Repository Root')),
- (rdflib.term.URIRef('info:fcres/'),
-  rdflib.term.URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
-  rdflib.term.URIRef('http://fedora.info/definitions/v4/repository#Container')),
- (rdflib.term.URIRef('info:fcres/'),
-  rdflib.term.URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
-  rdflib.term.URIRef('http://fedora.info/definitions/v4/repository#RepositoryRoot')),
- (rdflib.term.URIRef('info:fcres/'),
-  rdflib.term.URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
-  rdflib.term.URIRef('http://fedora.info/definitions/v4/repository#Resource')),
- (rdflib.term.URIRef('info:fcres/'),
-  rdflib.term.URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
-  rdflib.term.URIRef('http://www.w3.org/ns/ldp#BasicContainer')),
- (rdflib.term.URIRef('info:fcres/'),
-  rdflib.term.URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
-  rdflib.term.URIRef('http://www.w3.org/ns/ldp#Container')),
- (rdflib.term.URIRef('info:fcres/'),
-  rdflib.term.URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
-  rdflib.term.URIRef('http://www.w3.org/ns/ldp#RDFSource'))}
+Quickstart::
+
+    >>> # First import default configuration and globals—only done once.
+    >>> import lakesuperior.default_env
+    >>> from lakesuperior.api import resource
+    >>> # Get root resource.
+    >>> rsrc = resource.get('/')
+    >>> # Dump graph.
+    >>> set(rsrc.imr)
+    {(rdflib.term.URIRef('info:fcres/'),
+      rdflib.term.URIRef('http://purl.org/dc/terms/title'),
+      rdflib.term.Literal('Repository Root')),
+     (rdflib.term.URIRef('info:fcres/'),
+      rdflib.term.URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
+      rdflib.term.URIRef('http://fedora.info/definitions/v4/repository#Container')),
+     (rdflib.term.URIRef('info:fcres/'),
+      rdflib.term.URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
+      rdflib.term.URIRef('http://fedora.info/definitions/v4/repository#RepositoryRoot')),
+     (rdflib.term.URIRef('info:fcres/'),
+      rdflib.term.URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
+      rdflib.term.URIRef('http://fedora.info/definitions/v4/repository#Resource')),
+     (rdflib.term.URIRef('info:fcres/'),
+      rdflib.term.URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
+      rdflib.term.URIRef('http://www.w3.org/ns/ldp#BasicContainer')),
+     (rdflib.term.URIRef('info:fcres/'),
+      rdflib.term.URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
+      rdflib.term.URIRef('http://www.w3.org/ns/ldp#Container')),
+     (rdflib.term.URIRef('info:fcres/'),
+      rdflib.term.URIRef('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
+      rdflib.term.URIRef('http://www.w3.org/ns/ldp#RDFSource'))}
 """
 
 def transaction(write=False):
@@ -200,13 +199,13 @@ def create(parent, slug=None, **kwargs):
     :param str parent: UID of the parent resource.
     :param str slug: Tentative path relative to the parent UID.
     :param \*\*kwargs: Other parameters are passed to the
-      :py:meth:`~lakesuperior.model.ldp_factory.LdpFactory.from_provided`
+      :py:meth:`~lakesuperior.model.ldp.ldp_factory.LdpFactory.from_provided`
       method.
 
-    :rtype: tuple(str, lakesuperior.model.ldpr.Ldpr)
+    :rtype: tuple(str, lakesuperior.model.ldp.ldpr.Ldpr)
     :return: A tuple of:
         1. Event type (str): whether the resource was created or updated.
-        2. Resource (lakesuperior.model.ldpr.Ldpr): The new or updated resource.
+        2. Resource (lakesuperior.model.ldp.ldpr.Ldpr): The new or updated resource.
     """
     uid = LdpFactory.mint_uid(parent, slug)
     logger.debug('Minted UID for new resource: {}'.format(uid))
@@ -224,13 +223,13 @@ def create_or_replace(uid, **kwargs):
 
     :param string uid: UID of the resource to be created or updated.
     :param \*\*kwargs: Other parameters are passed to the
-        :py:meth:`~lakesuperior.model.ldp_factory.LdpFactory.from_provided`
+        :py:meth:`~lakesuperior.model.ldp.ldp_factory.LdpFactory.from_provided`
         method.
 
-    :rtype: tuple(str, lakesuperior.model.ldpr.Ldpr)
+    :rtype: tuple(str, lakesuperior.model.ldp.ldpr.Ldpr)
     :return: A tuple of:
         1. Event type (str): whether the resource was created or updated.
-        2. Resource (lakesuperior.model.ldpr.Ldpr): The new or updated resource.
+        2. Resource (lakesuperior.model.ldp.ldpr.Ldpr): The new or updated resource.
     """
     rsrc = LdpFactory.from_provided(uid, **kwargs)
     return rsrc.create_or_replace(), rsrc
@@ -274,8 +273,8 @@ def update_delta(uid, remove_trp, add_trp):
         add, as 3-tuples of RDFLib terms.
     """
     rsrc = LdpFactory.from_stored(uid)
-    remove_trp = rsrc.check_mgd_terms(SimpleGraph(remove_trp))
-    add_trp = rsrc.check_mgd_terms(SimpleGraph(add_trp))
+    remove_trp = rsrc.check_mgd_terms(remove_trp)
+    add_trp = rsrc.check_mgd_terms(add_trp)
 
     return rsrc.modify(RES_UPDATED, remove_trp, add_trp)
 

+ 282 - 0
lakesuperior/cy_include/collections.pxd

@@ -0,0 +1,282 @@
+from libc.stdint cimport uint32_t
+
+ctypedef void* (*mem_alloc_ft)(size_t size)
+ctypedef void* (*mem_calloc_ft)(size_t blocks, size_t size)
+ctypedef void (*mem_free_ft)(void* block)
+ctypedef size_t (*hash_ft)(const void* key, int l, uint32_t seed)
+ctypedef int (*key_compare_ft)(const void* key1, const void* key2)
+
+
+cdef extern from "common.h":
+
+    enum cc_stat:
+        CC_OK
+        CC_ERR_ALLOC
+        CC_ERR_INVALID_CAPACITY
+        CC_ERR_INVALID_RANGE
+        CC_ERR_MAX_CAPACITY
+        CC_ERR_KEY_NOT_FOUND
+        CC_ERR_VALUE_NOT_FOUND
+        CC_ERR_OUT_OF_RANGE
+        CC_ITER_END
+
+    key_compare_ft CC_CMP_STRING
+    key_compare_ft CC_CMP_POINTER
+#
+#    int cc_common_cmp_str(const void* key1, const void* key2)
+#
+#    int cc_common_cmp_ptr(const void* key1, const void* key2)
+
+cdef extern from "array.h":
+
+    ctypedef struct Array:
+        pass
+
+    ctypedef struct ArrayConf:
+        size_t          capacity
+        float           exp_factor
+        mem_alloc_ft  mem_alloc
+        mem_calloc_ft mem_calloc
+        mem_free_ft   mem_free
+
+    ctypedef struct ArrayIter:
+        Array* ar
+        size_t index
+        bint last_removed
+
+#    ctypedef struct ArrayZipIter:
+#        Array* ar1
+#        Array* ar2
+#        size_t index
+#        bint last_removed
+#
+    cc_stat array_new(Array** out)
+
+    cc_stat array_new_conf(ArrayConf* conf, Array** out)
+
+    void array_conf_init(ArrayConf* conf)
+
+    void array_destroy(Array* ar)
+
+#    ctypedef void (*_array_destroy_cb_cb_ft)(void*)
+#
+#    void array_destroy_cb(Array* ar, _array_destroy_cb_cb_ft cb)
+#
+    cc_stat array_add(Array* ar, void* element)
+#
+#    #cc_stat array_add_at(Array* ar, void* element, size_t index)
+#
+#    cc_stat array_replace_at(Array* ar, void* element, size_t index, void** out)
+#
+#    cc_stat array_swap_at(Array* ar, size_t index1, size_t index2)
+#
+#    cc_stat array_remove(Array* ar, void* element, void** out)
+#
+#    cc_stat array_remove_at(Array* ar, size_t index, void** out)
+#
+#    cc_stat array_remove_last(Array* ar, void** out)
+#
+#    void array_remove_all(Array* ar)
+#
+#    void array_remove_all_free(Array* ar)
+#
+#    cc_stat array_get_at(Array* ar, size_t index, void** out)
+#
+#    cc_stat array_get_last(Array* ar, void** out)
+#
+#    cc_stat array_subarray(Array* ar, size_t from_, size_t to, Array** out)
+#
+#    cc_stat array_copy_shallow(Array* ar, Array** out)
+#
+#    ctypedef void* (*_array_copy_deep_cp_ft)(void*)
+#
+#    cc_stat array_copy_deep(Array* ar, _array_copy_deep_cp_ft cp, Array** out)
+#
+#    void array_reverse(Array* ar)
+#
+#    cc_stat array_trim_capacity(Array* ar)
+#
+#    size_t array_contains(Array* ar, void* element)
+#
+#    ctypedef int (*_array_contains_value_cmp_ft)(void*, void*)
+#
+#    size_t array_contains_value(Array* ar, void* element, _array_contains_value_cmp_ft cmp)
+#
+#    size_t array_size(Array* ar)
+#
+#    size_t array_capacity(Array* ar)
+#
+#    cc_stat array_index_of(Array* ar, void* element, size_t* index)
+#
+#    ctypedef int (*_array_sort_cmp_ft)(void*, void*)
+#
+#    void array_sort(Array* ar, _array_sort_cmp_ft cmp)
+#
+#    ctypedef void (*_array_map_fn_ft)(void*)
+#
+#    void array_map(Array* ar, _array_map_fn_ft fn)
+#
+#    ctypedef void (*_array_reduce_fn_ft)(void*, void*, void*)
+#
+#    void array_reduce(Array* ar, _array_reduce_fn_ft fn, void* result)
+#
+#    ctypedef bint (*_array_filter_mut_predicate_ft)(void*)
+#
+#    cc_stat array_filter_mut(Array* ar, _array_filter_mut_predicate_ft predicate)
+#
+#    ctypedef bint (*_array_filter_predicate_ft)(void*)
+#
+#    cc_stat array_filter(Array* ar, _array_filter_predicate_ft predicate, Array** out)
+#
+    void array_iter_init(ArrayIter* iter, Array* ar)
+
+    cc_stat array_iter_next(ArrayIter* iter, void** out)
+#
+#    cc_stat array_iter_remove(ArrayIter* iter, void** out)
+#
+#    cc_stat array_iter_add(ArrayIter* iter, void* element)
+#
+#    cc_stat array_iter_replace(ArrayIter* iter, void* element, void** out)
+#
+#    size_t array_iter_index(ArrayIter* iter)
+#
+#    void array_zip_iter_init(ArrayZipIter* iter, Array* a1, Array* a2)
+#
+#    cc_stat array_zip_iter_next(ArrayZipIter* iter, void** out1, void** out2)
+#
+#    cc_stat array_zip_iter_add(ArrayZipIter* iter, void* e1, void* e2)
+#
+#    cc_stat array_zip_iter_remove(ArrayZipIter* iter, void** out1, void** out2)
+#
+#    cc_stat array_zip_iter_replace(ArrayZipIter* iter, void* e1, void* e2, void** out1, void** out2)
+#
+#    size_t array_zip_iter_index(ArrayZipIter* iter)
+#
+#    void** array_get_buffer(Array* ar)
+
+
+cdef extern from "hashtable.h":
+
+    ctypedef struct TableEntry:
+        void*       key
+        void*       value
+        size_t      hash
+        TableEntry* next
+
+    ctypedef struct HashTable:
+        pass
+
+    ctypedef struct HashTableConf:
+        float               load_factor
+        size_t              initial_capacity
+        int                 key_length
+        uint32_t            hash_seed
+
+        hash_ft           hash
+        key_compare_ft    key_compare
+        mem_alloc_ft  mem_alloc
+        mem_calloc_ft mem_calloc
+        mem_free_ft   mem_free
+
+    ctypedef struct HashTableIter:
+        HashTable* table
+        size_t bucket_index
+        TableEntry* prev_entry
+        TableEntry* next_entry
+
+    hash_ft GENERAL_HASH
+    hash_ft STRING_HASH
+    hash_ft POINTER_HASH
+
+#    size_t get_table_index(HashTable *table, void *key)
+#
+#    void hashtable_conf_init(HashTableConf* conf)
+#
+#    cc_stat hashtable_new(HashTable** out)
+#
+#    cc_stat hashtable_new_conf(HashTableConf* conf, HashTable** out)
+#
+#    void hashtable_destroy(HashTable* table)
+#
+#    cc_stat hashtable_add(HashTable* table, void* key, void* val)
+#
+#    cc_stat hashtable_get(HashTable* table, void* key, void** out)
+#
+#    cc_stat hashtable_remove(HashTable* table, void* key, void** out)
+#
+#    void hashtable_remove_all(HashTable* table)
+#
+#    bint hashtable_contains_key(HashTable* table, void* key)
+#
+#    size_t hashtable_size(HashTable* table)
+#
+#    size_t hashtable_capacity(HashTable* table)
+#
+#    cc_stat hashtable_get_keys(HashTable* table, Array** out)
+#
+#    cc_stat hashtable_get_values(HashTable* table, Array** out)
+#
+    size_t hashtable_hash_string(void* key, int len, uint32_t seed)
+
+    size_t hashtable_hash(void* key, int len, uint32_t seed)
+
+    size_t hashtable_hash_ptr(void* key, int len, uint32_t seed)
+#
+#    ctypedef void (*_hashtable_foreach_key_op_ft)(void*)
+#
+#    void hashtable_foreach_key(HashTable* table, _hashtable_foreach_key_op_ft op)
+#
+#    ctypedef void (*_hashtable_foreach_value_op_ft)(void*)
+#
+#    void hashtable_foreach_value(HashTable* table, _hashtable_foreach_value_op_ft op)
+#
+#    void hashtable_iter_init(HashTableIter* iter, HashTable* table)
+#
+#    cc_stat hashtable_iter_next(HashTableIter* iter, TableEntry** out)
+#
+#    cc_stat hashtable_iter_remove(HashTableIter* iter, void** out)
+
+
+cdef extern from "hashset.h":
+
+    ctypedef struct HashSet:
+        pass
+
+    ctypedef HashTableConf HashSetConf
+
+    ctypedef struct HashSetIter:
+        HashTableIter iter
+
+    void hashset_conf_init(HashSetConf* conf)
+
+    cc_stat hashset_new(HashSet** hs)
+
+    cc_stat hashset_new_conf(HashSetConf* conf, HashSet** hs)
+
+    void hashset_destroy(HashSet* set)
+
+    cc_stat hashset_add(HashSet* set, void* element)
+
+    cc_stat hashset_add_or_get(HashSet* set, void** element)
+
+    cc_stat hashset_remove(HashSet* set, void* element, void** out)
+
+    void hashset_remove_all(HashSet* set)
+
+    bint hashset_contains(HashSet* set, void* element)
+
+    cc_stat hashset_get(HashSet *set, void **element)
+
+    size_t hashset_size(HashSet* set)
+
+    size_t hashset_capacity(HashSet* set)
+
+    ctypedef void (*_hashset_foreach_op_ft)(void*)
+
+    void hashset_foreach(HashSet* set, _hashset_foreach_op_ft op)
+
+    void hashset_iter_init(HashSetIter* iter, HashSet* set)
+
+    cc_stat hashset_iter_next(HashSetIter* iter, void** out)
+
+    cc_stat hashset_iter_remove(HashSetIter* iter, void** out)

+ 20 - 0
lakesuperior/cy_include/spookyhash.pxd

@@ -0,0 +1,20 @@
+from libc.stdint cimport uint32_t, uint64_t
+
+cdef extern from 'spookyhash_api.h':
+
+    ctypedef struct spookyhash_context:
+        pass
+
+    void spookyhash_context_init(
+            spookyhash_context *context, uint64_t seed_1, uint64_t seed_2)
+    void spookyhash_update(
+            spookyhash_context *context, const void *input, size_t input_size)
+    void spookyhash_final(
+            spookyhash_context *context, uint64_t *hash_1, uint64_t *hash_2)
+
+    uint32_t spookyhash_32(const void *input, size_t input_size, uint32_t seed)
+    uint64_t spookyhash_64(const void *input, size_t input_size, uint64_t seed)
+    void spookyhash_128(
+            const void *input, size_t input_size, uint64_t *hash_1,
+            uint64_t *hash_2)
+

+ 64 - 51
lakesuperior/endpoints/ldp.py

@@ -25,10 +25,10 @@ from lakesuperior.exceptions import (
         ServerManagedTermError, InvalidResourceError, SingleSubjectError,
         ResourceExistsError, IncompatibleLdpTypeError)
 from lakesuperior.globals import RES_CREATED
-from lakesuperior.model.ldp_factory import LdpFactory
-from lakesuperior.model.ldp_nr import LdpNr
-from lakesuperior.model.ldp_rs import LdpRs
-from lakesuperior.model.ldpr import Ldpr
+from lakesuperior.model.ldp.ldp_factory import LdpFactory
+from lakesuperior.model.ldp.ldp_nr import LdpNr
+from lakesuperior.model.ldp.ldp_rs import LdpRs
+from lakesuperior.model.ldp.ldpr import Ldpr
 from lakesuperior.toolbox import Toolbox
 
 
@@ -44,6 +44,8 @@ rdf_parsable_mimetypes = {
 }
 """MIMEtypes that can be parsed into RDF."""
 
+store = env.app_globals.rdf_store
+
 rdf_serializable_mimetypes = {
     #mt.name for mt in plugin.plugins()
     #if mt.kind is serializer.Serializer and '/' in mt.name
@@ -166,39 +168,42 @@ def get_resource(uid, out_fmt=None):
 
     rsrc = rsrc_api.get(uid, repr_options)
 
-    if out_fmt is None:
-        rdf_mimetype = _best_rdf_mimetype()
-        out_fmt = (
-                'rdf'
-                if isinstance(rsrc, LdpRs) or rdf_mimetype is not None
-                else 'non_rdf')
-    out_headers.update(_headers_from_metadata(rsrc, out_fmt))
-    uri = g.tbox.uid_to_uri(uid)
-
-    # RDF output.
-    if out_fmt == 'rdf':
-        if locals().get('rdf_mimetype', None) is None:
-            rdf_mimetype = DEFAULT_RDF_MIMETYPE
-        ggr = g.tbox.globalize_graph(rsrc.out_graph)
-        ggr.namespace_manager = nsm
-        return _negotiate_content(
-                ggr, rdf_mimetype, out_headers, uid=uid, uri=uri)
-
-    # Datastream.
-    else:
-        if not getattr(rsrc, 'local_path', False):
-            return ('{} has no binary content.'.format(rsrc.uid), 404)
-
-        logger.debug('Streaming out binary content.')
-        if request.range and request.range.units == 'bytes':
-            # Stream partial response.
-            # This is only true if the header is well-formed. Thanks, Werkzeug.
-            rsp = _parse_range_header(request.range.ranges, rsrc, out_headers)
+    with store.txn_ctx():
+        if out_fmt is None:
+            rdf_mimetype = _best_rdf_mimetype()
+            out_fmt = (
+                    'rdf'
+                    if isinstance(rsrc, LdpRs) or rdf_mimetype is not None
+                    else 'non_rdf')
+        out_headers.update(_headers_from_metadata(rsrc, out_fmt))
+        uri = g.tbox.uid_to_uri(uid)
+
+# RDF output.
+        if out_fmt == 'rdf':
+            if locals().get('rdf_mimetype', None) is None:
+                rdf_mimetype = DEFAULT_RDF_MIMETYPE
+            ggr = g.tbox.globalize_imr(rsrc.out_graph)
+            ggr.namespace_manager = nsm
+            return _negotiate_content(
+                    ggr, rdf_mimetype, out_headers, uid=uid, uri=uri)
+
+# Datastream.
         else:
-            rsp = make_response(send_file(
-                    rsrc.local_path, as_attachment=True,
-                    attachment_filename=rsrc.filename,
-                    mimetype=rsrc.mimetype), 200, out_headers)
+            if not getattr(rsrc, 'local_path', False):
+                return ('{} has no binary content.'.format(rsrc.uid), 404)
+
+            logger.debug('Streaming out binary content.')
+            if request.range and request.range.units == 'bytes':
+                # Stream partial response.
+                # This is only true if the header is well-formed. Thanks, Werkzeug.
+                rsp = _parse_range_header(
+                    request.range.ranges, rsrc, out_headers
+                )
+            else:
+                rsp = make_response(send_file(
+                        rsrc.local_path, as_attachment=True,
+                        attachment_filename=rsrc.filename,
+                        mimetype=rsrc.mimetype), 200, out_headers)
 
         # This seems necessary to prevent Flask from setting an
         # additional ETag.
@@ -217,7 +222,7 @@ def get_version_info(uid):
     """
     rdf_mimetype = _best_rdf_mimetype() or DEFAULT_RDF_MIMETYPE
     try:
-        gr = rsrc_api.get_version_info(uid)
+        imr = rsrc_api.get_version_info(uid)
     except ResourceNotExistsError as e:
         return str(e), 404
     except InvalidResourceError as e:
@@ -225,7 +230,8 @@ def get_version_info(uid):
     except TombstoneError as e:
         return _tombstone_response(e, uid)
     else:
-        return _negotiate_content(g.tbox.globalize_graph(gr), rdf_mimetype)
+        with store.txn_ctx():
+            return _negotiate_content(g.tbox.globalize_imr(imr), rdf_mimetype)
 
 
 @ldp.route('/<path:uid>/fcr:versions/<ver_uid>', methods=['GET'])
@@ -238,7 +244,7 @@ def get_version(uid, ver_uid):
     """
     rdf_mimetype = _best_rdf_mimetype() or DEFAULT_RDF_MIMETYPE
     try:
-        gr = rsrc_api.get_version(uid, ver_uid)
+        imr = rsrc_api.get_version(uid, ver_uid)
     except ResourceNotExistsError as e:
         return str(e), 404
     except InvalidResourceError as e:
@@ -246,7 +252,8 @@ def get_version(uid, ver_uid):
     except TombstoneError as e:
         return _tombstone_response(e, uid)
     else:
-        return _negotiate_content(g.tbox.globalize_graph(gr), rdf_mimetype)
+        with store.txn_ctx():
+            return _negotiate_content(g.tbox.globalize_imr(imr), rdf_mimetype)
 
 
 @ldp.route('/<path:parent_uid>', methods=['POST'], strict_slashes=False)
@@ -290,7 +297,8 @@ def post_resource(parent_uid):
         return str(e), 412
 
     uri = g.tbox.uid_to_uri(rsrc.uid)
-    rsp_headers.update(_headers_from_metadata(rsrc))
+    with store.txn_ctx():
+        rsp_headers.update(_headers_from_metadata(rsrc))
     rsp_headers['Location'] = uri
 
     if mimetype and kwargs.get('rdf_fmt') is None:
@@ -346,7 +354,8 @@ def put_resource(uid):
     except TombstoneError as e:
         return _tombstone_response(e, uid)
 
-    rsp_headers = _headers_from_metadata(rsrc)
+    with store.txn_ctx():
+        rsp_headers = _headers_from_metadata(rsrc)
     rsp_headers['Content-Type'] = 'text/plain; charset=utf-8'
 
     uri = g.tbox.uid_to_uri(uid)
@@ -397,7 +406,8 @@ def patch_resource(uid, is_metadata=False):
     except InvalidResourceError as e:
         return str(e), 415
     else:
-        rsp_headers.update(_headers_from_metadata(rsrc))
+        with store.txn_ctx():
+            rsp_headers.update(_headers_from_metadata(rsrc))
         return '', 204, rsp_headers
 
 
@@ -455,7 +465,7 @@ def tombstone(uid):
     405.
     """
     try:
-        rsrc = rsrc_api.get(uid)
+        rsrc_api.get(uid)
     except TombstoneError as e:
         if request.method == 'DELETE':
             if e.uid == uid:
@@ -668,7 +678,7 @@ def _headers_from_metadata(rsrc, out_fmt='text/turtle'):
     """
     Create a dict of headers from a metadata graph.
 
-    :param lakesuperior.model.ldpr.Ldpr rsrc: Resource to extract metadata
+    :param lakesuperior.model.ldp.ldpr.Ldpr rsrc: Resource to extract metadata
         from.
     """
     rsp_headers = defaultdict(list)
@@ -764,12 +774,14 @@ def _condition_hdr_match(uid, headers, safe=True):
         req_etags = [
                 et.strip('\'" ') for et in headers.get(cond_hdr).split(',')]
 
-        try:
-            rsrc_meta = rsrc_api.get_metadata(uid)
-        except ResourceNotExistsError:
-            rsrc_meta = Imr(nsc['fcres'][uid])
+        with store.txn_ctx():
+            try:
+                rsrc_meta = rsrc_api.get_metadata(uid)
+            except ResourceNotExistsError:
+                rsrc_meta = Graph(uri=nsc['fcres'][uid])
+
+            digest_prop = rsrc_meta.value(nsc['premis'].hasMessageDigest)
 
-        digest_prop = rsrc_meta.value(nsc['premis'].hasMessageDigest)
         if digest_prop:
             etag, _ = _digest_headers(digest_prop)
             if cond_hdr == 'if-match':
@@ -793,7 +805,8 @@ def _condition_hdr_match(uid, headers, safe=True):
                 'if-unmodified-since': False
             }
 
-        lastmod_str = rsrc_meta.value(nsc['fcrepo'].lastModified)
+        with store.txn_ctx():
+            lastmod_str = rsrc_meta.value(nsc['fcrepo'].lastModified)
         lastmod_ts = arrow.get(lastmod_str)
 
         # If date is not in a RFC 5322 format

+ 0 - 0
lakesuperior/model/__init__.pxd


+ 29 - 0
lakesuperior/model/base.pxd

@@ -0,0 +1,29 @@
+cimport lakesuperior.cy_include.cytpl as tpl
+
+ctypedef tpl.tpl_bin Buffer
+
+# NOTE This may change in the future, e.g. if a different key size is to
+# be forced.
+ctypedef size_t Key
+
+ctypedef Key DoubleKey[2]
+ctypedef Key TripleKey[3]
+ctypedef Key QuadKey[4]
+
+cdef enum:
+    KLEN = sizeof(Key)
+    DBL_KLEN = 2 * sizeof(Key)
+    TRP_KLEN = 3 * sizeof(Key)
+    QUAD_KLEN = 4 * sizeof(Key)
+
+    # "NULL" key, a value that is never user-provided. Used to mark special
+    # values (e.g. deleted records).
+    NULL_KEY = 0
+    # Value of first key inserted in an empty term database.
+    FIRST_KEY = 1
+
+cdef bytes buffer_dump(Buffer* buf)
+
+# "NULL" triple, a value that is never user-provided. Used to mark special
+# values (e.g. deleted records).
+cdef TripleKey NULL_TRP = [NULL_KEY, NULL_KEY, NULL_KEY]

+ 11 - 0
lakesuperior/model/base.pyx

@@ -0,0 +1,11 @@
+cdef bytes buffer_dump(const Buffer* buf):
+    """
+    Return a buffer's content as a string.
+
+    :param const Buffer* buf Pointer to a buffer to be read.
+
+    :rtype: str
+    """
+    cdef unsigned char* buf_stream = (<unsigned char*>buf.addr)
+    return buf_stream[:buf.sz]
+

+ 30 - 0
lakesuperior/model/callbacks.pxd

@@ -0,0 +1,30 @@
+from lakesuperior.model.base cimport Key, TripleKey
+
+cdef:
+    bint lookup_sk_cmp_fn(
+        const TripleKey* spok, const Key k1, const Key k2
+    )
+
+    bint lookup_pk_cmp_fn(
+        const TripleKey* spok, const Key k1, const Key k2
+    )
+
+    bint lookup_ok_cmp_fn(
+        const TripleKey* spok, const Key k1, const Key k2
+    )
+
+    bint lookup_skpk_cmp_fn(
+        const TripleKey* spok, const Key k1, const Key k2
+    )
+
+    bint lookup_skok_cmp_fn(
+        const TripleKey* spok, const Key k1, const Key k2
+    )
+
+    bint lookup_pkok_cmp_fn(
+        const TripleKey* spok, const Key k1, const Key k2
+    )
+
+    bint lookup_none_cmp_fn(
+        const TripleKey* spok, const Key k1, const Key k2
+    )

+ 54 - 0
lakesuperior/model/callbacks.pyx

@@ -0,0 +1,54 @@
+from lakesuperior.model.base cimport Key, TripleKey
+
+cdef inline bint lookup_sk_cmp_fn(
+    const TripleKey* spok, const Key k1, const Key k2
+):
+    """ Keyset lookup for S key. """
+    return spok[0][0] == k1
+
+
+cdef inline bint lookup_pk_cmp_fn(
+    const TripleKey* spok, const Key k1, const Key k2
+):
+    """ Keyset lookup for P key. """
+    return spok[0][1] == k1
+
+
+cdef inline bint lookup_ok_cmp_fn(
+    const TripleKey* spok, const Key k1, const Key k2
+):
+    """ Keyset lookup for O key. """
+    return spok[0][2] == k1
+
+
+cdef inline bint lookup_skpk_cmp_fn(
+    const TripleKey* spok, const Key k1, const Key k2
+):
+    """ Keyset lookup for S and P keys. """
+    return spok[0][0] == k1 and spok[0][1] == k2
+
+
+cdef inline bint lookup_skok_cmp_fn(
+    const TripleKey* spok, const Key k1, const Key k2
+):
+    """ Keyset lookup for S and O keys. """
+    return spok[0][0] == k1 and spok[0][2] == k2
+
+
+cdef inline bint lookup_pkok_cmp_fn(
+    const TripleKey* spok, const Key k1, const Key k2
+):
+    """ Keyset lookup for P and O keys. """
+    return spok[0][1] == k1 and spok[0][2] == k2
+
+
+cdef inline bint lookup_none_cmp_fn(
+    const TripleKey* spok, const Key k1, const Key k2
+):
+    """
+    Dummy callback for queries with all parameters unbound.
+
+    This function always returns ``True`` 
+    """
+    return True
+

+ 11 - 11
lakesuperior/model/ldp_factory.py → lakesuperior/model/ldp/ldp_factory.py

@@ -3,20 +3,19 @@ import logging
 from pprint import pformat
 from uuid import uuid4
 
-from rdflib import Graph, parser
 from rdflib.resource import Resource
 from rdflib.namespace import RDF
 
 from lakesuperior import env
-from lakesuperior.model.ldpr import Ldpr
-from lakesuperior.model.ldp_nr import LdpNr
-from lakesuperior.model.ldp_rs import LdpRs, Ldpc, LdpDc, LdpIc
+from lakesuperior.model.ldp.ldpr import Ldpr
+from lakesuperior.model.ldp.ldp_nr import LdpNr
+from lakesuperior.model.ldp.ldp_rs import LdpRs, Ldpc, LdpDc, LdpIc
 from lakesuperior.config_parser import config
 from lakesuperior.dictionaries.namespaces import ns_collection as nsc
 from lakesuperior.exceptions import (
         IncompatibleLdpTypeError, InvalidResourceError, ResourceExistsError,
         ResourceNotExistsError, TombstoneError)
-from lakesuperior.store.ldp_rs.lmdb_triplestore import Imr
+from lakesuperior.model.rdf.graph import Graph, from_rdf
 
 
 LDP_NR_TYPE = nsc['ldp'].NonRDFSource
@@ -37,7 +36,7 @@ class LdpFactory:
             raise InvalidResourceError(uid)
         if rdfly.ask_rsrc_exists(uid):
             raise ResourceExistsError(uid)
-        rsrc = Ldpc(uid, provided_imr=Imr(uri=nsc['fcres'][uid]))
+        rsrc = Ldpc(uid, provided_imr=Graph(uri=nsc['fcres'][uid]))
 
         return rsrc
 
@@ -100,14 +99,15 @@ class LdpFactory:
         """
         uri = nsc['fcres'][uid]
         if rdf_data:
-            data = set(Graph().parse(
-                data=rdf_data, format=rdf_fmt, publicID=nsc['fcres'][uid]))
+            provided_imr = from_rdf(
+                uri=uri, data=rdf_data, format=rdf_fmt,
+                publicID=nsc['fcres'][uid]
+            )
         elif graph:
-            data = set(graph)
+            provided_imr = Graph(uri=uri, data={*graph})
         else:
-            data = set()
+            provided_imr = Graph(uri=uri)
 
-        provided_imr = Imr(uri=uri, data=data)
         #logger.debug('Provided graph: {}'.format(
         #        pformat(set(provided_imr))))
 

+ 2 - 2
lakesuperior/model/ldp_nr.py → lakesuperior/model/ldp/ldp_nr.py

@@ -8,8 +8,8 @@ from rdflib.term import URIRef, Literal, Variable
 
 from lakesuperior import env
 from lakesuperior.dictionaries.namespaces import ns_collection as nsc
-from lakesuperior.model.ldpr import Ldpr
-from lakesuperior.model.ldp_rs import LdpRs
+from lakesuperior.model.ldp.ldpr import Ldpr
+from lakesuperior.model.ldp.ldp_rs import LdpRs
 
 
 nonrdfly = env.app_globals.nonrdfly

+ 1 - 1
lakesuperior/model/ldp_rs.py → lakesuperior/model/ldp/ldp_rs.py

@@ -5,7 +5,7 @@ from rdflib import Graph
 from lakesuperior import env
 from lakesuperior.globals import RES_UPDATED
 from lakesuperior.dictionaries.namespaces import ns_collection as nsc
-from lakesuperior.model.ldpr import Ldpr
+from lakesuperior.model.ldp.ldpr import Ldpr
 
 
 logger = logging.getLogger(__name__)

+ 57 - 46
lakesuperior/model/ldpr.py → lakesuperior/model/ldp/ldpr.py

@@ -12,8 +12,9 @@ from urllib.parse import urldefrag
 from uuid import uuid4
 
 import arrow
+import rdflib
 
-from rdflib import Graph, URIRef, Literal
+from rdflib import URIRef, Literal
 from rdflib.compare import to_isomorphic
 from rdflib.namespace import RDF
 
@@ -26,7 +27,7 @@ from lakesuperior.dictionaries.srv_mgd_terms import (
 from lakesuperior.exceptions import (
     InvalidResourceError, RefIntViolationError, ResourceNotExistsError,
     ServerManagedTermError, TombstoneError)
-from lakesuperior.store.ldp_rs.lmdb_triplestore import SimpleGraph, Imr
+from lakesuperior.model.rdf.graph import Graph
 from lakesuperior.store.ldp_rs.rsrc_centric_layout import VERS_CONT_LABEL
 from lakesuperior.toolbox import Toolbox
 
@@ -47,7 +48,7 @@ class Ldpr(metaclass=ABCMeta):
     **Note**: Even though LdpNr (which is a subclass of Ldpr) handles binary
     files, it still has an RDF representation in the triplestore. Hence, some
     of the RDF-related methods are defined in this class rather than in
-    :class:`~lakesuperior.model.ldp_rs.LdpRs`.
+    :class:`~lakesuperior.model.ldp.ldp_rs.LdpRs`.
 
     **Note:** Only internal facing (``info:fcres``-prefixed) URIs are handled
     in this class. Public-facing URI conversion is handled in the
@@ -233,7 +234,7 @@ class Ldpr(metaclass=ABCMeta):
         :param v: New set of triples to populate the IMR with.
         :type v: set or rdflib.Graph
         """
-        self._imr = Imr(self.uri, data=set(data))
+        self._imr = Graph(uri=self.uri, data=set(data))
 
 
     @imr.deleter
@@ -266,8 +267,8 @@ class Ldpr(metaclass=ABCMeta):
         """
         Set resource metadata.
         """
-        if not isinstance(rsrc, Imr):
-            raise TypeError('Provided metadata is not an Imr object.')
+        if not isinstance(rsrc, Graph):
+            raise TypeError('Provided metadata is not a Graph object.')
         self._metadata = rsrc
 
 
@@ -276,7 +277,7 @@ class Ldpr(metaclass=ABCMeta):
         """
         Retun a graph of the resource's IMR formatted for output.
         """
-        out_gr = Graph(identifier=self.uri)
+        out_trp = set()
 
         for t in self.imr:
             if (
@@ -290,9 +291,9 @@ class Ldpr(metaclass=ABCMeta):
                 self._imr_options.get('incl_srv_mgd', True) or
                 not self._is_trp_managed(t)
             ):
-                out_gr.add(t)
+                out_trp.add(t)
 
-        return out_gr
+        return Graph(uri=self.uri, data=out_trp)
 
 
     @property
@@ -304,7 +305,7 @@ class Ldpr(metaclass=ABCMeta):
             try:
                 self._version_info = rdfly.get_version_info(self.uid)
             except ResourceNotExistsError as e:
-                self._version_info = Imr(uri=self.uri)
+                self._version_info = Graph(uri=self.uri)
 
         return self._version_info
 
@@ -422,8 +423,7 @@ class Ldpr(metaclass=ABCMeta):
 
         remove_trp = {
             (self.uri, pred, None) for pred in self.delete_preds_on_replace}
-        add_trp = set(
-            self.provided_imr | self._containment_rel(create))
+        add_trp = {*self.provided_imr} | self._containment_rel(create)
 
         self.modify(ev_type, remove_trp, add_trp)
 
@@ -462,7 +462,7 @@ class Ldpr(metaclass=ABCMeta):
             }
 
         # Bury descendants.
-        from lakesuperior.model.ldp_factory import LdpFactory
+        from lakesuperior.model.ldp.ldp_factory import LdpFactory
         for desc_uri in rdfly.get_descendants(self.uid):
             try:
                 desc_rsrc = LdpFactory.from_stored(
@@ -513,7 +513,7 @@ class Ldpr(metaclass=ABCMeta):
         self.modify(RES_CREATED, remove_trp, add_trp)
 
         # Resurrect descendants.
-        from lakesuperior.model.ldp_factory import LdpFactory
+        from lakesuperior.model.ldp.ldp_factory import LdpFactory
         descendants = env.app_globals.rdfly.get_descendants(self.uid)
         for desc_uri in descendants:
             LdpFactory.from_stored(
@@ -583,50 +583,54 @@ class Ldpr(metaclass=ABCMeta):
 
         ver_gr = rdfly.get_imr(
             self.uid, ver_uid=ver_uid, incl_children=False)
-        self.provided_imr = Imr(uri=self.uri)
+        self.provided_imr = Graph(uri=self.uri)
 
         for t in ver_gr:
             if not self._is_trp_managed(t):
-                self.provided_imr.add((self.uri, t[1], t[2]))
+                self.provided_imr.add(((self.uri, t[1], t[2]),))
             # @TODO Check individual objects: if they are repo-managed URIs
             # and not existing or tombstones, they are not added.
 
         return self.create_or_replace(create_only=False)
 
 
-    def check_mgd_terms(self, gr):
+    def check_mgd_terms(self, trp):
         """
         Check whether server-managed terms are in a RDF payload.
 
-        :param SimpleGraph gr: The graph to validate.
+        :param SimpleGraph trp: The graph to validate.
         """
-        offending_subjects = gr.terms('s') & srv_mgd_subjects
+        offending_subjects = (
+            {t[0] for t in trp if t[0] is not None} & srv_mgd_subjects
+        )
         if offending_subjects:
             if self.handling == 'strict':
                 raise ServerManagedTermError(offending_subjects, 's')
             else:
-                gr_set = set(gr)
                 for s in offending_subjects:
                     logger.info('Removing offending subj: {}'.format(s))
-                    for t in gr_set:
+                    for t in trp:
                         if t[0] == s:
-                            gr.remove(t)
+                            trp.remove(t)
 
-        offending_predicates = gr.terms('p') & srv_mgd_predicates
+        offending_predicates = (
+            {t[1] for t in trp if t[1] is not None} & srv_mgd_predicates
+        )
         # Allow some predicates if the resource is being created.
         if offending_predicates:
             if self.handling == 'strict':
                 raise ServerManagedTermError(offending_predicates, 'p')
             else:
-                gr_set = set(gr)
                 for p in offending_predicates:
                     logger.info('Removing offending pred: {}'.format(p))
-                    for t in gr_set:
+                    for t in trp:
                         if t[1] == p:
-                            gr.remove(t)
+                            trp.remove(t)
 
-        types = {t[2] for t in gr if t[1] == RDF.type}
-        offending_types = types & srv_mgd_types
+        offending_types = (
+            {t[2] for t in trp if t[1] == RDF.type and t[2] is not None}
+            & srv_mgd_types
+        )
         if not self.is_stored:
             offending_types -= self.smt_allow_on_create
         if offending_types:
@@ -635,11 +639,11 @@ class Ldpr(metaclass=ABCMeta):
             else:
                 for to in offending_types:
                     logger.info('Removing offending type: {}'.format(to))
-                    gr.remove_triples((None, RDF.type, to))
+                    trp.remove_triples((None, RDF.type, to))
 
-        #logger.debug('Sanitized graph: {}'.format(gr.serialize(
+        #logger.debug('Sanitized graph: {}'.format(trp.serialize(
         #    format='turtle').decode('utf-8')))
-        return gr
+        return trp
 
 
     def sparql_delta(self, qry_str):
@@ -672,16 +676,15 @@ class Ldpr(metaclass=ABCMeta):
         qry_str = (
                 re.sub('<#([^>]+)>', '<{}#\\1>'.format(self.uri), qry_str)
                 .replace('<>', '<{}>'.format(self.uri)))
-        pre_gr = self.imr.graph
-        post_rdfgr = Graph(identifier=self.uri)
-        post_rdfgr += pre_gr
+        pre_gr = self.imr.as_rdflib()
+        post_gr = rdflib.Graph(identifier=self.uri)
+        post_gr |= pre_gr
 
-        post_rdfgr.update(qry_str)
-        post_gr = SimpleGraph(data=set(post_rdfgr))
+        post_gr.update(qry_str)
 
         # FIXME Fix and  use SimpleGraph's native subtraction operation.
-        remove_gr = self.check_mgd_terms(SimpleGraph(pre_gr.data - post_gr.data))
-        add_gr = self.check_mgd_terms(SimpleGraph(post_gr.data - pre_gr.data))
+        remove_gr = self.check_mgd_terms(Graph(data=set(pre_gr - post_gr)))
+        add_gr = self.check_mgd_terms(Graph(data=set(post_gr - pre_gr)))
 
         return remove_gr, add_gr
 
@@ -719,6 +722,12 @@ class Ldpr(metaclass=ABCMeta):
         rdfly.modify_rsrc(self.uid, remove_trp, add_trp)
 
         self._clear_cache()
+        # Reload IMR because if we exit the LMDB txn we lose access to stored
+        # memory locations.
+        try:
+            self.imr
+        except:
+            pass
 
         if (
                 ev_type is not None and
@@ -798,7 +807,7 @@ class Ldpr(metaclass=ABCMeta):
             logger.info(
                 'Removing link to non-existent repo resource: {}'
                 .format(obj))
-            self.provided_imr.remove_triples((None, None, obj))
+            self.provided_imr.remove((None, None, obj))
 
 
     def _add_srv_mgd_triples(self, create=False):
@@ -808,8 +817,9 @@ class Ldpr(metaclass=ABCMeta):
         :param create: Whether the resource is being created.
         """
         # Base LDP types.
-        for t in self.base_types:
-            self.provided_imr.add((self.uri, RDF.type, t))
+        self.provided_imr.add(
+            [(self.uri, RDF.type, t) for t in self.base_types]
+        )
 
         # Create and modify timestamp.
         if create:
@@ -856,7 +866,7 @@ class Ldpr(metaclass=ABCMeta):
         a LDP-NR has "children" under ``fcr:versions``) by setting this to
         True.
         """
-        from lakesuperior.model.ldp_factory import LdpFactory
+        from lakesuperior.model.ldp.ldp_factory import LdpFactory
 
         if '/' in self.uid.lstrip('/'):
             # Traverse up the hierarchy to find the parent.
@@ -886,8 +896,9 @@ class Ldpr(metaclass=ABCMeta):
         # Only update parent if the resource is new.
         if create:
             add_gr = Graph()
-            add_gr.add(
-                (nsc['fcres'][parent_uid], nsc['ldp'].contains, self.uri))
+            add_gr.add({
+                (nsc['fcres'][parent_uid], nsc['ldp'].contains, self.uri)
+            })
             parent_rsrc.modify(RES_UPDATED, add_trp=add_gr)
 
         # Direct or indirect container relationship.
@@ -900,7 +911,7 @@ class Ldpr(metaclass=ABCMeta):
 
         :param rdflib.resource.Resouce cont_rsrc:  The container resource.
         """
-        cont_p = cont_rsrc.metadata.terms('p')
+        cont_p = cont_rsrc.metadata.terms_by_type('p')
 
         logger.info('Checking direct or indirect containment.')
         logger.debug('Parent predicates: {}'.format(cont_p))
@@ -908,7 +919,7 @@ class Ldpr(metaclass=ABCMeta):
         add_trp = {(self.uri, nsc['fcrepo'].hasParent, cont_rsrc.uri)}
 
         if self.MBR_RSRC_URI in cont_p and self.MBR_REL_URI in cont_p:
-            from lakesuperior.model.ldp_factory import LdpFactory
+            from lakesuperior.model.ldp.ldp_factory import LdpFactory
 
             s = cont_rsrc.metadata.value(self.MBR_RSRC_URI)
             p = cont_rsrc.metadata.value(self.MBR_REL_URI)

+ 0 - 0
lakesuperior/model/rdf/__init__.pxd


+ 0 - 0
lakesuperior/model/rdf/__init__.py


+ 37 - 0
lakesuperior/model/rdf/graph.pxd

@@ -0,0 +1,37 @@
+from libc.stdint cimport uint32_t, uint64_t
+
+from cymem.cymem cimport Pool
+
+cimport lakesuperior.cy_include.collections as cc
+
+from lakesuperior.model.base cimport Key, TripleKey
+from lakesuperior.model.rdf.triple cimport BufferTriple
+from lakesuperior.model.structures.keyset cimport Keyset
+from lakesuperior.store.ldp_rs cimport lmdb_triplestore
+
+# Callback for an iterator.
+ctypedef void (*lookup_callback_fn_t)(
+    Graph gr, const TripleKey* spok_p, void* ctx
+)
+
+cdef class Graph:
+    cdef:
+        readonly lmdb_triplestore.LmdbTriplestore store
+        public Keyset keys
+        public object uri
+
+        cc.key_compare_ft term_cmp_fn
+        cc.key_compare_ft trp_cmp_fn
+
+        void _match_ptn_callback(
+            self, pattern, Graph gr, lookup_callback_fn_t callback_fn,
+            bint callback_cond=*, void* ctx=*
+        ) except *
+
+    cpdef Graph copy(self, str uri=*)
+    cpdef Graph empty_copy(self, str uri=*)
+    cpdef void set(self, tuple trp) except *
+
+
+cdef:
+    void add_trp_callback(Graph gr, const TripleKey* spok_p, void* ctx)

+ 613 - 0
lakesuperior/model/rdf/graph.pyx

@@ -0,0 +1,613 @@
+import logging
+
+import rdflib
+
+from lakesuperior import env
+
+from cpython.object cimport Py_LT, Py_EQ, Py_GT, Py_LE, Py_NE, Py_GE
+from libc.string cimport memcpy
+from libc.stdlib cimport free
+
+cimport lakesuperior.cy_include.collections as cc
+cimport lakesuperior.model.callbacks as cb
+cimport lakesuperior.model.structures.keyset as kset
+
+from lakesuperior.model.base cimport Key, TripleKey
+from lakesuperior.model.rdf cimport term
+from lakesuperior.model.rdf.triple cimport BufferTriple
+from lakesuperior.model.structures.hash cimport term_hash_seed32
+from lakesuperior.model.structures.keyset cimport Keyset
+
+logger = logging.getLogger(__name__)
+
+
+cdef class Graph:
+    """
+    Fast and simple implementation of a graph.
+
+    Most functions should mimic RDFLib's graph with less overhead. It uses
+    the same funny but functional slicing notation.
+
+    A Graph contains a :py:class:`lakesuperior.model.structures.keyset.Keyset`
+    at its core and is bound to a
+    :py:class:`~lakesuperior.store.ldp_rs.lmdb_triplestore.LmdbTriplestore`.
+    This makes lookups and boolean operations very efficient because all these
+    operations are performed on an array of integers.
+
+    In order to retrieve RDF values from a ``Graph``, the underlying store
+    must be looked up. This can be done in a different transaction than the
+    one used to create or otherwise manipulate the graph.
+
+    Every time a term is looked up or added to even a temporary graph, that
+    term is added to the store and creates a key. This is because in the
+    majority of cases that term is likely to be stored permanently anyway, and
+    it's more efficient to hash it and allocate it immediately. A cleanup
+    function to remove all orphaned terms (not in any triple or context index)
+    can be later devised to compact the database.
+
+    An instance of this class can also be converted to a ``rdflib.Graph``
+    instance.
+    """
+
+    def __cinit__(
+        self, store=None, size_t capacity=0, uri=None, set data=set()
+    ):
+        """
+        Initialize the graph, optionally from Python/RDFlib data.
+
+        When initializing a non-empty Graph, a store transaction must be
+        opened::
+
+            >>> from rdflib import URIRef
+            >>> from lakesuperior import env_setup, env
+            >>> store = env.app_globals.rdf_store
+            >>> # Or alternatively:
+            >>> # from lakesuperior.store.ldp_rs.lmdb_store import LmdbStore
+            >>> # store = LmdbStore('/tmp/test')
+            >>> trp = {(URIRef('urn:s:0'), URIRef('urn:p:0'), URIRef('urn:o:0'))}
+            >>> with store.txn_ctx():
+            >>>     gr = Graph(store, data=trp)
+
+        Similarly, any operation such as adding, changing or looking up triples
+        needs a store transaction.
+
+        Note that, even though any operation may involve adding new terms to
+        the store, a read-only transaction is sufficient. Lakesuperior will
+        open a write transaction automatically only if necessary and only for
+        the time needed to enter the new terms.
+
+        :type store: lakesuperior.store.ldp_rs.lmdb_triplestore.LmdbTriplestore
+        :param store: Triplestore where keys are mapped to terms. By default
+            this is the default application store
+            (``env.app_globals.rdf_store``).
+
+        :param size_t capacity: Initial number of allocated triples.
+
+        :param str uri: If specified, the graph becomes a named graph and can
+            utilize the :py:meth:`value()` method and special slicing notation.
+
+        :param set data: If specified, ``capacity`` is ignored and an initial key
+            set is created from a set of 3-tuples of :py:class:``rdflib.Term``
+            instances.
+        """
+        self.uri = rdflib.URIRef(uri) if uri else None
+
+        self.store = store if store is not None else env.app_globals.rdf_store
+        #logger.debug(f'Assigned store at {self.store.env_path}')
+
+        # Initialize empty data set.
+        if data:
+            # Populate with provided Python set.
+            self.keys = Keyset(len(data))
+            self.add(data)
+        else:
+            self.keys = Keyset(capacity)
+
+
+    ## PROPERTIES ##
+
+    property data:
+        def __get__(self):
+            """
+            Triple data as a Python/RDFlib set.
+
+            :rtype: set
+            """
+            cdef TripleKey spok
+
+            ret = set()
+
+            self.keys.seek()
+            while self.keys.get_next(&spok):
+                ret.add((
+                    self.store.from_key(spok[0]),
+                    self.store.from_key(spok[1]),
+                    self.store.from_key(spok[2])
+                ))
+
+            return ret
+
+
+    property capacity:
+        def __get__(self):
+            """
+            Total capacity of the underlying Keyset, in number of triples.
+            """
+            return self.keys.capacity
+
+
+    property txn_ctx:
+        def __get__(self):
+            """ Expose underlying store's ``txn_ctx``. """
+            return self.store.txn_ctx
+
+
+    ## MAGIC METHODS ##
+
+    def __len__(self):
+        """ Number of triples in the graph. """
+        return self.keys.size()
+
+
+    def __richcmp__(self, other, int op):
+        """ Comparators between ``Graph`` instances. """
+        if op == Py_LT:
+            raise NotImplementedError()
+        elif op == Py_EQ:
+            return len(self ^ other) == 0
+        elif op == Py_GT:
+            raise NotImplementedError()
+        elif op == Py_LE:
+            raise NotImplementedError()
+        elif op == Py_NE:
+            return len(self ^ other) != 0
+        elif op == Py_GE:
+            raise NotImplementedError()
+
+
+    def __repr__(self):
+        """
+        String representation of the graph.
+
+        This includes the subject URI, number of triples contained and the
+        memory address of the instance.
+        """
+        uri_repr = f', uri={self.uri}' if self.uri else ''
+        return (
+            f'<{self.__class__.__module__}.{self.__class__.__qualname__} '
+            f'@0x{id(self):02x} length={len(self)}{uri_repr}>'
+        )
+
+
+    def __str__(self):
+        """ String dump of the graph triples. """
+        return str(self.data)
+
+
+    def __add__(self, other):
+        """ Alias for set-theoretical union. """
+        return self.__or__(other)
+
+
+    def __iadd__(self, other):
+        """ Alias for in-place set-theoretical union. """
+        return self.__ior__(other)
+
+
+    def __sub__(self, other):
+        """ Set-theoretical subtraction. """
+        cdef Graph gr3 = self.empty_copy()
+
+        gr3.keys = kset.subtract(self.keys, other.keys)
+
+        return gr3
+
+
+    def __isub__(self, other):
+        """ In-place set-theoretical subtraction. """
+        self.keys = kset.subtract(self.keys, other.keys)
+
+        return self
+
+    def __and__(self, other):
+        """ Set-theoretical intersection. """
+        cdef Graph gr3 = self.empty_copy()
+
+        gr3.keys = kset.intersect(self.keys, other.keys)
+
+        return gr3
+
+
+    def __iand__(self, other):
+        """ In-place set-theoretical intersection. """
+        self.keys = kset.intersect(self.keys, other.keys)
+
+        return self
+
+
+    def __or__(self, other):
+        """ Set-theoretical union. """
+        cdef Graph gr3 = self.empty_copy()
+
+        gr3.keys = kset.merge(self.keys, other.keys)
+
+        return gr3
+
+
+    def __ior__(self, other):
+        """ In-place set-theoretical union. """
+        self.keys = kset.merge(self.keys, other.keys)
+
+        return self
+
+
+    def __xor__(self, other):
+        """ Set-theoretical exclusive disjunction (XOR). """
+        cdef Graph gr3 = self.empty_copy()
+
+        gr3.keys = kset.xor(self.keys, other.keys)
+
+        return gr3
+
+
+    def __ixor__(self, other):
+        """ In-place set-theoretical exclusive disjunction (XOR). """
+        self.keys = kset.xor(self.keys, other.keys)
+
+        return self
+
+
+    def __contains__(self, trp):
+        """
+        Whether the graph contains a triple.
+
+        :rtype: boolean
+        """
+        cdef TripleKey spok
+
+        spok = [
+            self.store.to_key(trp[0]),
+            self.store.to_key(trp[1]),
+            self.store.to_key(trp[2]),
+        ]
+
+        return self.keys.contains(&spok)
+
+
+    def __iter__(self):
+        """ Graph iterator. It iterates over the set triples. """
+        yield from self.data
+
+
+    # Slicing.
+
+    def __getitem__(self, item):
+        """
+        Slicing function.
+
+        It behaves similarly to `RDFLib graph slicing
+        <https://rdflib.readthedocs.io/en/stable/utilities.html#slicing-graphs>`__
+        """
+        if isinstance(item, slice):
+            s, p, o = item.start, item.stop, item.step
+            return self._slice(s, p, o)
+        elif self.uri and isinstance(item, rdflib.term.Identifier):
+            # If a Node is given, return all values for that predicate.
+            return self._slice(self.uri, item, None)
+        else:
+            raise TypeError(f'Wrong slice format: {item}.')
+
+
+    def __hash__(self):
+        """ TODO Not that great of a hash. """
+        return id(self)
+
+
+    ## BASIC PYTHON-ACCESSIBLE SET OPERATIONS ##
+
+    def value(self, p, strict=False):
+        """
+        Get an individual value.
+
+        :param rdflib.termNode p: Predicate to search for.
+        :param bool strict: If set to ``True`` the method raises an error if
+            more than one value is found. If ``False`` (the default) only
+            the first found result is returned.
+        :rtype: rdflib.term.Node
+        """
+        if not self.uri:
+            raise ValueError('Cannot use `value` on a non-named graph.')
+
+        # TODO use slice.
+        values = {trp[2] for trp in self.lookup((self.uri, p, None))}
+
+        if strict and len(values) > 1:
+            raise RuntimeError('More than one value found for {}, {}.'.format(
+                    self.uri, p))
+
+        for ret in values:
+            return ret
+
+        return None
+
+
+    def terms_by_type(self, type):
+        """
+        Get all terms of a type: subject, predicate or object.
+
+        :param str type: One of ``s``, ``p`` or ``o``.
+        """
+        i = 'spo'.index(type)
+        return {r[i] for r in self.data}
+
+
+    def add(self, triples):
+        """
+        Add triples to the graph.
+
+        This method checks for duplicates.
+
+        :param iterable triples: iterable of 3-tuple triples.
+        """
+        cdef:
+            TripleKey spok
+
+        for s, p, o in triples:
+            #logger.info(f'Adding {s} {p} {o} to store: {self.store}')
+            spok = [
+                self.store.to_key(s),
+                self.store.to_key(p),
+                self.store.to_key(o),
+            ]
+
+            self.keys.add(&spok, True)
+
+
+    def remove(self, pattern):
+        """
+        Remove triples by pattern.
+
+        The pattern used is similar to :py:meth:`LmdbTripleStore.delete`.
+        """
+        # create an empty copy of the current object.
+        new_gr = self.empty_copy()
+
+        # Reverse lookup: only triples not matching the pattern are added to
+        # the new set.
+        self._match_ptn_callback(
+            pattern, new_gr, add_trp_callback, False
+        )
+
+        # Replace the keyset.
+        self.keys = new_gr.keys
+
+
+    ## CYTHON-ACCESSIBLE BASIC METHODS ##
+
+    cpdef Graph copy(self, str uri=None):
+        """
+        Create copy of the graph with a different (or no) URI.
+
+        :param str uri: URI of the new graph. This should be different from
+            the original.
+        """
+        cdef Graph new_gr = Graph(self.store, self.capacity, uri=uri)
+
+        new_gr.keys = self.keys.copy()
+
+        return new_gr
+
+
+    cpdef Graph empty_copy(self, str uri=None):
+        """
+        Create an empty copy with same capacity and store binding.
+
+        :param str uri: URI of the new graph. This should be different from
+            the original.
+        """
+        return Graph(self.store, self.capacity, uri=uri)
+
+
+    cpdef void set(self, tuple trp) except *:
+        """
+        Set a single value for subject and predicate.
+
+        Remove all triples matching ``s`` and ``p`` before adding ``s p o``.
+        """
+        if None in trp:
+            raise ValueError(f'Invalid triple: {trp}')
+        self.remove((trp[0], trp[1], None))
+        self.add((trp,))
+
+
+    def as_rdflib(self):
+        """
+        Return the data set as an RDFLib Graph.
+
+        :rtype: rdflib.Graph
+        """
+        gr = rdflib.Graph(identifier=self.uri)
+        for trp in self.data:
+            gr.add(trp)
+
+        return gr
+
+
+    def _slice(self, s, p, o):
+        """
+        Return terms filtered by other terms.
+
+        This behaves like the rdflib.Graph slicing policy.
+        """
+        #logger.info(f'Slicing: {s} {p} {o}')
+        # If no terms are unbound, check for containment.
+        if s is not None and p is not None and o is not None: # s p o
+            return (s, p, o) in self
+
+        # If some terms are unbound, do a lookup.
+        res = self.lookup((s, p, o))
+        #logger.info(f'Slicing results: {res}')
+        if s is not None:
+            if p is not None: # s p ?
+                return {r[2] for r in res}
+
+            if o is not None: # s ? o
+                return {r[1] for r in res}
+
+            # s ? ?
+            return {(r[1], r[2]) for r in res}
+
+        if p is not None:
+            if o is not None: # ? p o
+                return {r[0] for r in res}
+
+            # ? p ?
+            return {(r[0], r[2]) for r in res}
+
+        if o is not None: # ? ? o
+            return {(r[0], r[1]) for r in res}
+
+        # ? ? ?
+        return res
+
+
+    def lookup(self, pattern):
+        """
+        Look up triples by a pattern.
+
+        This function converts RDFLib terms into the serialized format stored
+        in the graph's internal structure and compares them bytewise.
+
+        Any and all of the lookup terms msy be ``None``.
+
+        :rtype: Graph
+        "return: New Graph instance with matching triples.
+        """
+        cdef:
+            Graph res_gr = self.empty_copy()
+
+        self._match_ptn_callback(pattern, res_gr, add_trp_callback)
+        res_gr.keys.resize()
+
+        return res_gr
+
+
+    cdef void _match_ptn_callback(
+        self, pattern, Graph gr, lookup_callback_fn_t callback_fn,
+        bint callback_cond=True, void* ctx=NULL
+    ) except *:
+        """
+        Execute an arbitrary function on a list of triples matching a pattern.
+
+        The arbitrary function is applied to each triple found in the current
+        graph, and to a discrete graph that can be the current graph itself
+        or a different one.
+
+        :param tuple pattern: A 3-tuple of rdflib terms or None.
+        :param Graph gr: The graph instance to apply the callback function to.
+        :param lookup_callback_fn_t callback_fn: A callback function to be
+            applied to the target graph using the matching triples.
+        :param bint callback_cond: Whether to apply the callback function if
+            a match is found (``True``) or if it is not found (``False``).
+        :param void* ctx: Pointer to an arbitrary object that can be used by
+            the callback function.
+        """
+        cdef:
+            kset.key_cmp_fn_t cmp_fn
+            Key k1, k2, k3
+            TripleKey spok
+
+        s, p, o = pattern
+
+        #logger.info(f'Match Callback pattern: {pattern}')
+
+        self.keys.seek()
+        # Decide comparison logic outside the loop.
+        if all(pattern):
+            if callback_cond:
+                # Shortcut for 3-term match—only if callback_cond is True.
+                spok = [
+                    self.store.to_key(s),
+                    self.store.to_key(p),
+                    self.store.to_key(o),
+                ]
+                if self.keys.contains(&spok):
+                    callback_fn(gr, &spok, ctx)
+            else:
+                # For negative condition (i.e. "apply this function to all keys
+                # except the matching one"), the whole set must be scanned.
+                #logger.info('All terms bound and negative condition.')
+                k1 = self.store.to_key(s)
+                k2 = self.store.to_key(p)
+                k3 = self.store.to_key(o)
+                #logger.info(f'Keys to match: {k1} {k2} {k3}')
+                while self.keys.get_next(&spok):
+                    #logger.info(f'Verifying spok: {spok}')
+                    if k1 != spok[0] or k2 != spok[1] or k3 != spok[2]:
+                        #logger.info(f'Calling function for spok: {spok}')
+                        callback_fn(gr, &spok, ctx)
+            return
+
+        if s is not None:
+            k1 = self.store.to_key(s)
+            if p is not None:
+                k2 = self.store.to_key(p)
+                cmp_fn = cb.lookup_skpk_cmp_fn
+            elif o is not None:
+                k2 = self.store.to_key(o)
+                cmp_fn = cb.lookup_skok_cmp_fn
+            else:
+                cmp_fn = cb.lookup_sk_cmp_fn
+        elif p is not None:
+            k1 = self.store.to_key(p)
+            if o is not None:
+                k2 = self.store.to_key(o)
+                cmp_fn = cb.lookup_pkok_cmp_fn
+            else:
+                cmp_fn = cb.lookup_pk_cmp_fn
+        elif o is not None:
+            k1 = self.store.to_key(o)
+            cmp_fn = cb.lookup_ok_cmp_fn
+        else:
+            cmp_fn = cb.lookup_none_cmp_fn
+
+        # Iterate over serialized triples.
+        while self.keys.get_next(&spok):
+            if cmp_fn(&spok, k1, k2) == callback_cond:
+                callback_fn(gr, &spok, ctx)
+
+
+
+## FACTORY METHODS
+
+def from_rdf(store=None, uri=None, *args, **kwargs):
+    """
+    Create a Graph from a serialized RDF string.
+
+    This factory function takes the same arguments as
+    :py:meth:`rdflib.Graph.parse`.
+
+    :param store: see :py:meth:`Graph.__cinit__`.
+
+    :param uri: see :py:meth:`Graph.__cinit__`.
+
+    :param *args: Positional arguments passed to RDFlib's ``parse``.
+
+    :param *kwargs: Keyword arguments passed to RDFlib's ``parse``.
+
+    :rtype: Graph
+    """
+    gr = rdflib.Graph().parse(*args, **kwargs)
+
+    return Graph(store=store, uri=uri, data={*gr})
+
+
+## LOOKUP CALLBACK FUNCTIONS
+
+cdef inline void add_trp_callback(
+    Graph gr, const TripleKey* spok_p, void* ctx
+):
+    """
+    Add a triple to a graph as a result of a lookup callback.
+    """
+    gr.keys.add(spok_p)

+ 41 - 0
lakesuperior/model/rdf/term.pxd

@@ -0,0 +1,41 @@
+from cymem.cymem cimport Pool
+
+from lakesuperior.model.base cimport Buffer
+
+#cdef extern from "regex.h" nogil:
+#   ctypedef struct regmatch_t:
+#      int rm_so
+#      int rm_eo
+#   ctypedef struct regex_t:
+#      pass
+#   int REG_NOSUB, REG_NOMATCH
+#   int regcomp(regex_t* preg, const char* regex, int cflags)
+#   int regexec(
+#       const regex_t *preg, const char* string, size_t nmatch,
+#       regmatch_t pmatch[], int eflags
+#    )
+#   void regfree(regex_t* preg)
+
+
+ctypedef struct Term:
+    char type
+    char *data
+    char *datatype
+    char *lang
+
+cdef:
+    #int term_new(
+    #    Term* term, char type, char* data, char* datatype=*, char* lang=*
+    #) except -1
+    #regex_t uri_regex
+    # Temporary TPL variable.
+    #char* _pk
+
+    int serialize(const Term *term, Buffer *sterm, Pool pool=*) except -1
+    int deserialize(const Buffer *data, Term *term) except -1
+    int from_rdflib(term_obj, Term *term) except -1
+    int serialize_from_rdflib(term_obj, Buffer *data, Pool pool=*) except -1
+    object deserialize_to_rdflib(const Buffer *data)
+    object to_rdflib(const Term *term)
+    object to_bytes(const Term *term)
+

+ 191 - 0
lakesuperior/model/rdf/term.pyx

@@ -0,0 +1,191 @@
+from uuid import uuid4
+
+from rdflib import URIRef, BNode, Literal
+
+#from cpython.mem cimport PyMem_Malloc, PyMem_Free
+from libc.stdint cimport uint64_t
+from libc.stdlib cimport free
+from libc.string cimport memcpy
+
+from cymem.cymem cimport Pool
+
+from lakesuperior.cy_include cimport cytpl as tpl
+from lakesuperior.model.base cimport Buffer, buffer_dump
+
+
+DEF LSUP_TERM_TYPE_URIREF = 1
+DEF LSUP_TERM_TYPE_BNODE = 2
+DEF LSUP_TERM_TYPE_LITERAL = 3
+DEF LSUP_TERM_PK_FMT = b'csss' # Reflects the Term structure
+DEF LSUP_TERM_STRUCT_PK_FMT = b'S(' + LSUP_TERM_PK_FMT + b')'
+# URI parsing regular expression. Conforms to RFC3986.
+#DEF URI_REGEX_STR = (
+#    b'^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
+#)
+
+#cdef char* ptn = URI_REGEX_STR
+#regcomp(&uri_regex, ptn, REG_NOSUB)
+# Compile with no catch groups.
+# TODO This should be properly cleaned up on application shutdown:
+# regfree(&uri_regex)
+
+#cdef int term_new(
+#    Term* term, char type, char* data, char* datatype=NULL, char* lang=NULL
+#) except -1:
+#    if regexec(&uri_regex, data, 0, NULL, 0) == REG_NOMATCH:
+#        raise ValueError('Not a valid URI.')
+#    term.type = type
+#    term.data = (
+#        data # TODO use C UUID v4 (RFC 4122) generator
+#        if term.type == LSUP_TERM_TYPE_BNODE
+#        else data
+#    )
+#    if term.type == LSUP_TERM_TYPE_LITERAL:
+#        term.datatype = datatype
+#        term.lang = lang
+#
+#    return 0
+
+
+cdef int serialize(const Term *term, Buffer *sterm, Pool pool=None) except -1:
+    """
+    Serialize a Term into a binary buffer.
+
+    The returned result is dynamically allocated in the provided memory pool.
+    """
+    cdef:
+        unsigned char *addr
+        size_t sz
+
+    tpl.tpl_jot(tpl.TPL_MEM, &addr, &sz, LSUP_TERM_STRUCT_PK_FMT, term)
+    if pool is None:
+        sterm.addr = addr
+    else:
+        # addr is within this function scope. Must be copied to the cymem pool.
+        sterm.addr = pool.alloc(sz, 1)
+        if not sterm.addr:
+            raise MemoryError()
+        memcpy(sterm.addr, addr, sz)
+    sterm.sz = sz
+
+
+cdef int deserialize(const Buffer *data, Term *term) except -1:
+    """
+    Return a term from serialized binary data.
+    """
+    #print(f'Deserializing: {buffer_dump(data)}')
+    _pk = tpl.tpl_peek(
+            tpl.TPL_MEM | tpl.TPL_DATAPEEK, data[0].addr, data[0].sz,
+            LSUP_TERM_PK_FMT, &(term[0].type), &(term[0].data),
+            &(term[0].datatype), &(term[0].lang))
+
+    if _pk is NULL:
+        raise MemoryError('Error deserializing term.')
+    else:
+        free(_pk)
+
+
+cdef int from_rdflib(term_obj, Term *term) except -1:
+    """
+    Return a Term struct obtained from a Python/RDFLib term.
+    """
+    _data = str(term_obj).encode()
+    term[0].data = _data
+
+    if isinstance(term_obj, Literal):
+        _datatype = (getattr(term_obj, 'datatype') or '').encode()
+        _lang = (getattr(term_obj, 'language') or '').encode()
+        term[0].type = LSUP_TERM_TYPE_LITERAL
+        term[0].datatype = _datatype
+        term[0].lang = _lang
+    else:
+        term[0].datatype = NULL
+        term[0].lang = NULL
+        if isinstance(term_obj, URIRef):
+            term[0].type = LSUP_TERM_TYPE_URIREF
+        elif isinstance(term_obj, BNode):
+            term[0].type = LSUP_TERM_TYPE_BNODE
+        else:
+            raise ValueError(f'Unsupported term type: {type(term_obj)}')
+
+
+cdef int serialize_from_rdflib(
+        term_obj, Buffer *data, Pool pool=None
+    ) except -1:
+    """
+    Return a Buffer struct from a Python/RDFLib term.
+    """
+
+    cdef:
+        Term _term
+        void *addr
+        size_t sz
+
+    # From RDFlib
+    _data = str(term_obj).encode()
+    _term.data = _data
+
+    if isinstance(term_obj, Literal):
+        _datatype = (getattr(term_obj, 'datatype') or '').encode()
+        _lang = (getattr(term_obj, 'language') or '').encode()
+        _term.type = LSUP_TERM_TYPE_LITERAL
+        _term.datatype = _datatype
+        _term.lang = _lang
+    else:
+        _term.datatype = NULL
+        _term.lang = NULL
+        if isinstance(term_obj, URIRef):
+            _term.type = LSUP_TERM_TYPE_URIREF
+        elif isinstance(term_obj, BNode):
+            _term.type = LSUP_TERM_TYPE_BNODE
+        else:
+            raise ValueError(
+                f'Unsupported term type: {term_obj} {type(term_obj)}'
+            )
+
+    serialize(&_term, data, pool)
+
+
+cdef object to_rdflib(const Term *term):
+    """
+    Return an RDFLib term.
+    """
+    cdef str data = (<bytes>term.data).decode()
+    if term[0].type == LSUP_TERM_TYPE_LITERAL:
+        return Literal(
+            data,
+            datatype=term.datatype if not term.lang else None,
+            lang=term.lang or None
+        )
+    else:
+        if term.type == LSUP_TERM_TYPE_URIREF:
+            return URIRef(data)
+        elif term.type == LSUP_TERM_TYPE_BNODE:
+            return BNode(data)
+        else:
+            raise IOError(f'Unknown term type code: {term[0].type}')
+
+
+cdef object deserialize_to_rdflib(const Buffer *data):
+    """
+    Return a Python/RDFLib term from a serialized Cython term.
+    """
+    cdef Term t
+
+    deserialize(data, &t)
+
+    return to_rdflib(&t)
+
+
+cdef object to_bytes(const Term *term):
+    """
+    Return a Python bytes object of the serialized term.
+    """
+    cdef:
+        Buffer pk_t
+        unsigned char *bytestream
+
+    serialize(term, &pk_t)
+    bytestream = <unsigned char *>pk_t.addr
+
+    return <bytes>(bytestream)[:pk_t.sz]

+ 19 - 0
lakesuperior/model/rdf/triple.pxd

@@ -0,0 +1,19 @@
+#from lakesuperior.cy_include cimport cytpl as tpl
+from lakesuperior.model.base cimport Buffer
+from lakesuperior.model.rdf.term cimport Term
+
+# Triple of Term structs.
+ctypedef struct Triple:
+    Term *s
+    Term *p
+    Term *o
+
+# Triple of serialized terms.
+ctypedef struct BufferTriple:
+    Buffer *s
+    Buffer *p
+    Buffer *o
+
+#cdef:
+#    int serialize(tuple trp, tpl.tpl_bin *data) except -1
+#    tuple deserialize(tpl.tpl_bin data)

+ 41 - 0
lakesuperior/model/rdf/triple.pyx

@@ -0,0 +1,41 @@
+#from lakesuperior.store.ldp_rs cimport term
+#
+#cdef int serialize(tuple trp, tpl.tpl_bin *data) except -1:
+#    """
+#    Serialize a triple expressed as a tuple of RDFlib terms.
+#
+#    :param tuple trp: 3-tuple of RDFlib terms.
+#
+#    :rtype: Triple
+#    """
+#    cdef:
+#        Triple strp
+#        Term *s
+#        Term *p
+#        Term *o
+#
+#    strp.s = s
+#    strp.p = p
+#    strp.o = o
+#
+##    term.serialize(s)
+##    term.serialize(p)
+##    term.serialize(o)
+#
+#    return strp
+#
+#
+#cdef tuple deserialize(Triple strp):
+#    """
+#    Deserialize a ``Triple`` structure into a tuple of terms.
+#
+#    :rtype: tuple
+#    """
+#    pass
+##    s = term.deserialize(strp.s.addr, strp.s.sz)
+##    p = term.deserialize(strp.p.addr, strp.p.sz)
+##    o = term.deserialize(strp.o.addr, strp.o.sz)
+##
+##    return s, p, o
+#
+#

+ 0 - 0
lakesuperior/model/structures/__init__.pxd


+ 0 - 0
lakesuperior/model/structures/__init__.py


+ 34 - 0
lakesuperior/model/structures/hash.pxd

@@ -0,0 +1,34 @@
+from libc.stdint cimport uint32_t, uint64_t
+
+from lakesuperior.model.base cimport Buffer
+
+
+# Seed for computing the term hash.
+#
+# This is a 16-byte string that will be split up into two ``uint64``
+# numbers to make up the ``spookyhash_128`` seeds.
+#
+# TODO This should be made configurable.
+DEF _TERM_HASH_SEED = \
+        b'\x72\x69\x76\x65\x72\x72\x75\x6e\x2c\x20\x70\x61\x73\x74\x20\x45'
+
+cdef enum:
+    HLEN_32 = sizeof(uint32_t)
+    HLEN_64 = sizeof(uint64_t)
+    HLEN_128 = sizeof(uint64_t) * 2
+
+ctypedef uint32_t Hash32
+ctypedef uint64_t Hash64
+ctypedef uint64_t DoubleHash64[2]
+ctypedef unsigned char Hash128[HLEN_128]
+
+cdef:
+    uint32_t term_hash_seed32
+    uint64_t term_hash_seed64_1, term_hash_seed64_2
+    unsigned char TERM_HASH_SEED[16]
+
+    int hash32(const Buffer *message, Hash32 *hash) except -1
+    int hash64(const Buffer *message, Hash64 *hash) except -1
+    int hash128(const Buffer *message, Hash128 *hash) except -1
+
+TERM_HASH_SEED = _TERM_HASH_SEED

+ 53 - 0
lakesuperior/model/structures/hash.pyx

@@ -0,0 +1,53 @@
+from libc.stdint cimport uint32_t, uint64_t
+from libc.string cimport memcpy
+
+from lakesuperior.model.base cimport Buffer
+from lakesuperior.cy_include cimport spookyhash as sph
+
+
+memcpy(&term_hash_seed32, TERM_HASH_SEED, HLEN_32)
+memcpy(&term_hash_seed64_1, TERM_HASH_SEED, HLEN_64)
+memcpy(&term_hash_seed64_2, TERM_HASH_SEED + HLEN_64, HLEN_64)
+
+
+cdef inline int hash32(const Buffer *message, Hash32 *hash) except -1:
+    """
+    Get a 32-bit (unsigned int) hash value of a byte string.
+    """
+    cdef uint32_t seed = term_hash_seed64_1
+
+    hash[0] = sph.spookyhash_32(message[0].addr, message[0].sz, seed)
+
+
+cdef inline int hash64(const Buffer *message, Hash64 *hash) except -1:
+    """
+    Get a 64-bit (unsigned long) hash value of a byte string.
+    """
+    cdef uint64_t seed = term_hash_seed32
+
+    hash[0] = sph.spookyhash_64(message[0].addr, message[0].sz, seed)
+
+
+cdef inline int hash128(const Buffer *message, Hash128 *hash) except -1:
+    """
+    Get the hash value of a byte string with a defined size.
+
+    The hashing algorithm is `SpookyHash
+    <http://burtleburtle.net/bob/hash/spooky.html>`_ which produces 128-bit
+    (16-byte) digests.
+
+    Note that this returns a char array while the smaller functions return
+    numeric types (uint, ulong).
+
+    The initial seeds are determined in the application configuration.
+
+    :rtype: Hash128
+    """
+    cdef:
+        DoubleHash64 seed = [term_hash_seed64_1, term_hash_seed64_2]
+        Hash128 digest
+
+    sph.spookyhash_128(message[0].addr, message[0].sz, seed, seed + 1)
+
+    # This casts the 2 contiguous uint64_t's into a char[16] pointer.
+    hash[0] = <Hash128>seed

+ 37 - 0
lakesuperior/model/structures/keyset.pxd

@@ -0,0 +1,37 @@
+from lakesuperior.model.base cimport (
+    Key, Key, DoubleKey, TripleKey, Buffer
+)
+
+ctypedef bint (*key_cmp_fn_t)(
+    const TripleKey* spok, const Key k1, const Key k2
+)
+
+cdef class Keyset:
+    cdef:
+        TripleKey* data
+        size_t capacity
+        size_t cur # Index cursor used to look up values.
+        size_t free_i # Index of next free slot.
+        float expand_ratio # By how much storage is automatically expanded when
+                           # full. 1 means the size doubles, 0.5 a 50%
+                           # increase. 0 means that storage won't be
+                           # automatically expanded and adding above capacity
+                           # will raise an error.
+
+        void seek(self, size_t idx=*)
+        size_t size(self)
+        size_t tell(self)
+        bint get_next(self, TripleKey* item)
+        void add(self, const TripleKey* val, bint check_dup=*) except *
+        void remove(self, const TripleKey* val) except *
+        bint contains(self, const TripleKey* val) nogil
+        Keyset copy(self)
+        Keyset sparse_copy(self)
+        void resize(self, size_t size=*) except *
+        Keyset lookup(self, const Key sk, const Key pk, const Key ok)
+
+cdef:
+    Keyset merge(Keyset ks1, Keyset ks2)
+    Keyset subtract(Keyset ks1, Keyset ks2)
+    Keyset intersect(Keyset ks1, Keyset ks2)
+    Keyset xor(Keyset ks1, Keyset ks2)

+ 364 - 0
lakesuperior/model/structures/keyset.pyx

@@ -0,0 +1,364 @@
+import logging
+
+from libc.string cimport memcmp, memcpy
+from cpython.mem cimport PyMem_Malloc, PyMem_Realloc, PyMem_Free
+from cython.parallel import prange
+
+cimport lakesuperior.model.callbacks as cb
+
+from lakesuperior.model.base cimport NULL_TRP, TRP_KLEN, TripleKey
+
+
+logger = logging.getLogger(__name__)
+
+
+cdef class Keyset:
+    """
+    Pre-allocated set of ``TripleKey``s.
+
+    The set is not checked for duplicates all the time: e.g., when creating
+    from a single set of triples coming from the store, the duplicate check
+    is turned off for efficiency. When merging with other sets, duplicate
+    checking should be turned on.
+
+    Since this class is based on a contiguous block of memory, it is best to
+    do very little manipulation. Several operations involve copying the whole
+    data block, so e.g. bulk removal and intersection are much more efficient
+    than individual record operations.
+    """
+    def __cinit__(self, size_t capacity=0, expand_ratio=.5):
+        """
+        Initialize and allocate memory for the data set.
+
+        :param size_t capacity: Number of elements to be accounted for.
+        """
+        self.capacity = capacity
+        self.expand_ratio = expand_ratio
+        self.data = <TripleKey*>PyMem_Malloc(self.capacity * TRP_KLEN)
+        if capacity and not self.data:
+            raise MemoryError('Error allocating Keyset data.')
+
+        self.cur = 0
+        self.free_i = 0
+
+
+    def __dealloc__(self):
+        """
+        Free the memory.
+
+        This is called when the Python instance is garbage collected, which
+        makes it handy to safely pass a Keyset instance across functions.
+        """
+        PyMem_Free(self.data)
+
+
+    # Access methods.
+
+    cdef void seek(self, size_t idx=0):
+        """
+        Place the cursor at a certain index, 0 by default.
+        """
+        self.cur = idx
+
+
+    cdef size_t size(self):
+        """
+        Size of the object as the number of occupied data slots.
+
+        Note that this is different from :py:data:`capacity`_, which indicates
+        the number of allocated items in memory.
+        """
+        return self.free_i
+
+
+    cdef size_t tell(self):
+        """
+        Tell the position of the cursor in the keyset.
+        """
+        return self.cur
+
+
+    cdef inline bint get_next(self, TripleKey* val):
+        """
+        Get the current value and advance the cursor by 1.
+
+        :param void *val: Addres of value returned. It is NULL if
+            the end of the buffer was reached.
+
+        :rtype: bint
+        :return: True if a value was found, False if the end of the buffer
+            has been reached.
+        """
+        if self.cur >= self.free_i:
+            return False
+
+        val[0] = self.data[self.cur]
+        self.cur += 1
+
+        return True
+
+
+    cdef void add(self, const TripleKey* val, bint check_dup=False) except *:
+        """
+        Add a triple key to the array.
+        """
+        # Check for deleted triples and optionally duplicates.
+        if val[0] == NULL_TRP or (check_dup and self.contains(val)):
+            return
+
+        if self.free_i >= self.capacity:
+            if self.expand_ratio > 0:
+                # In some edge casees, a very small ratio may round down to a
+                # zero increase, so the baseline increase is 1 element.
+                self.resize(1 + <size_t>(self.capacity * (1 + self.expand_ratio)))
+            else:
+                raise MemoryError('No space left in key set.')
+
+        self.data[self.free_i] = val[0]
+
+        self.free_i += 1
+
+
+    cdef void remove(self, const TripleKey* val) except *:
+        """
+        Remove a triple key.
+
+        This method replaces a triple with NULL_TRP if found. It
+        does not reclaim space. Therefore, if many removal operations are
+        forseen, using :py:meth:`subtract`_ is advised.
+        """
+        cdef:
+            TripleKey stored_val
+
+        self.seek()
+        while self.get_next(&stored_val):
+            #logger.info(f'Looking up for removal: {stored_val}')
+            if memcmp(val, stored_val, TRP_KLEN) == 0:
+                memcpy(&stored_val, NULL_TRP, TRP_KLEN)
+                return
+
+
+    cdef bint contains(self, const TripleKey* val) nogil:
+        """
+        Whether a value exists in the set.
+        """
+        cdef size_t i
+
+        for i in range(self.free_i):
+            # o is least likely to match.
+            if (
+                val[0][2] == self.data[i][2] and
+                val[0][0] == self.data[i][0] and
+                val[0][1] == self.data[i][1]
+            ):
+                return True
+        return False
+
+
+    cdef Keyset copy(self):
+        """
+        Copy a Keyset.
+        """
+        cdef Keyset new_ks = Keyset(
+            self.capacity, expand_ratio=self.expand_ratio
+        )
+        memcpy(new_ks.data, self.data, self.capacity * TRP_KLEN)
+        new_ks.seek()
+        new_ks.free_i = self.free_i
+
+        return new_ks
+
+
+    cdef Keyset sparse_copy(self):
+        """
+        Copy a Keyset and plug holes.
+
+        ``NULL_TRP`` values left from removing triple keys are skipped in the
+        copy and the set is shrunk to its used size.
+        """
+        cdef:
+            TripleKey val
+            Keyset new_ks = Keyset(self.capacity, self.expand_ratio)
+
+        self.seek()
+        while self.get_next(&val):
+            if val != NULL_TRP:
+                new_ks.add(&val)
+
+        new_ks.resize()
+
+        return new_ks
+
+
+    cdef void resize(self, size_t size=0) except *:
+        """
+        Change the array capacity.
+
+        :param size_t size: The new capacity size. If not specified or 0, the
+            array is shrunk to the last used item. The resulting size
+            therefore will always be greater than 0. The only exception
+            to this is if the specified size is 0 and no items have been added
+            to the array, in which case the array will be effectively shrunk
+            to 0.
+        """
+        if not size:
+            size = self.free_i
+
+        tmp = <TripleKey*>PyMem_Realloc(self.data, size * TRP_KLEN)
+
+        if not tmp:
+            raise MemoryError('Could not reallocate Keyset data.')
+
+        self.data = tmp
+        self.capacity = size
+        self.seek()
+
+
+    cdef Keyset lookup(self, const Key sk, const Key pk, const Key ok):
+        """
+        Look up triple keys.
+
+        This works in a similar way that the ``Graph`` and ``LmdbStore``
+        methods work.
+
+        Any and all the terms may be NULL. A NULL term is treated as unbound.
+
+        :param const Key* sk: s key pointer.
+        :param const Key* pk: p key pointer.
+        :param const Key* ok: o key pointer.
+        """
+        cdef:
+            TripleKey spok
+            Keyset ret = Keyset(self.capacity)
+            Key k1, k2
+            key_cmp_fn_t cmp_fn
+
+        if sk and pk and ok: # s p o
+            pass # TODO
+
+        elif sk:
+            k1 = sk
+            if pk: # s p ?
+                k2 = pk
+                cmp_fn = cb.lookup_skpk_cmp_fn
+
+            elif ok: # s ? o
+                k2 = ok
+                cmp_fn = cb.lookup_skok_cmp_fn
+
+            else: # s ? ?
+                cmp_fn = cb.lookup_sk_cmp_fn
+
+        elif pk:
+            k1 = pk
+            if ok: # ? p o
+                k2 = ok
+                cmp_fn = cb.lookup_pkok_cmp_fn
+
+            else: # ? p ?
+                cmp_fn = cb.lookup_pk_cmp_fn
+
+        elif ok: # ? ? o
+            k1 = ok
+            cmp_fn = cb.lookup_ok_cmp_fn
+
+        else: # ? ? ?
+            return self.copy()
+
+        self.seek()
+        while self.get_next(&spok):
+            if cmp_fn(&spok, k1, k2):
+                ret.add(&spok)
+
+        ret.resize()
+
+        return ret
+
+
+
+## Boolean operations.
+
+cdef Keyset merge(Keyset ks1, Keyset ks2):
+    """
+    Create a Keyset by merging an``ks2`` Keyset with the current one.
+
+    :rtype: Keyset
+    """
+    cdef:
+        TripleKey val
+        Keyset ks3 = ks1.copy()
+
+    ks2.seek()
+    while ks2.get_next(&val):
+        ks3.add(&val, True)
+
+    ks3.resize()
+
+    return ks3
+
+
+cdef Keyset subtract(Keyset ks1, Keyset ks2):
+    """
+    Create a Keyset by subtracting an``ks2`` Keyset from the current one.
+
+    :rtype: Keyset
+    """
+    cdef:
+        TripleKey val
+        Keyset ks3 = Keyset(ks1.capacity)
+
+    ks1.seek()
+    while ks1.get_next(&val):
+        if val != NULL_TRP and not ks2.contains(&val):
+            ks3.add(&val)
+
+    ks3.resize()
+
+    return ks3
+
+
+cdef Keyset intersect(Keyset ks1, Keyset ks2):
+    """
+    Create a Keyset by intersection with an``ks2`` Keyset.
+
+    :rtype: Keyset
+    """
+    cdef:
+        TripleKey val
+        Keyset ks3 = Keyset(ks1.capacity)
+
+    ks1.seek()
+    while ks1.get_next(&val):
+        if val != NULL_TRP and ks2.contains(&val):
+            ks3.add(&val)
+
+    ks3.resize()
+
+    return ks3
+
+
+cdef Keyset xor(Keyset ks1, Keyset ks2):
+    """
+    Create a Keyset by disjunction (XOR) with an``ks2`` Keyset.
+
+    :rtype: Keyset
+    """
+    cdef:
+        TripleKey val
+        Keyset ks3 = Keyset(ks1.capacity + ks2.capacity)
+
+    ks1.seek()
+    while ks1.get_next(&val):
+        if val != NULL_TRP and not ks2.contains(&val):
+            ks3.add(&val)
+
+    ks2.seek()
+    while ks2.get_next(&val):
+        if val != NULL_TRP and not ks1.contains(&val):
+            ks3.add(&val)
+
+    ks3.resize()
+
+    return ks3
+
+

+ 2 - 1
lakesuperior/store/base_lmdb_store.pxd

@@ -1,4 +1,4 @@
-cimport lakesuperior.cy_include.cylmdb as lmdb
+from lakesuperior.cy_include cimport cylmdb as lmdb
 
 cdef:
     int rc
@@ -13,6 +13,7 @@ cdef:
 cdef class BaseLmdbStore:
     cdef:
         readonly bint is_txn_open
+        readonly bint is_txn_rw
         public bint _open
         unsigned int _readers
         readonly str env_path

+ 50 - 5
lakesuperior/store/base_lmdb_store.pyx

@@ -27,6 +27,12 @@ cdef void _check(int rc, str message='') except *:
         raise KeyNotFoundError()
     if rc == lmdb.MDB_KEYEXIST:
         raise KeyExistsError()
+    if rc == errno.EINVAL:
+        raise InvalidParamError(
+            'Invalid LMDB parameter error.\n'
+            'Please verify that a transaction is open and valid for the '
+            'current operation.'
+        )
     if rc != lmdb.MDB_SUCCESS:
         out_msg = (
                 message + '\nInternal error ({}): '.format(rc)
@@ -44,6 +50,9 @@ class KeyNotFoundError(LmdbError):
 class KeyExistsError(LmdbError):
     pass
 
+class InvalidParamError(LmdbError):
+    pass
+
 
 
 cdef class BaseLmdbStore:
@@ -335,22 +344,46 @@ cdef class BaseLmdbStore:
         """
         Transaction context manager.
 
+        Open and close a transaction for the duration of the functions in the
+        context. If a transaction has already been opened in the store, a new
+        one is opened only if the current transaction is read-only and the new
+        requested transaction is read-write.
+
+        If a new write transaction is opened, the old one is kept on hold until
+        the new transaction is closed, then restored. All cursors are
+        invalidated and must be restored as well if one needs to reuse them.
+
         :param bool write: Whether a write transaction is to be opened.
 
         :rtype: lmdb.Transaction
         """
+        cdef lmdb.MDB_txn* hold_txn
+
+        will_open = False
+
         if not self.is_open:
             raise LmdbError('Store is not open.')
 
+        # If another transaction is open, only open the new transaction if
+        # the current one is RO and the new one RW.
         if self.is_txn_open:
-            logger.debug(
-                    'Transaction is already active. Not opening another one.')
-            #logger.debug('before yield')
-            yield
-            #logger.debug('after yield')
+            if write:
+                will_open = not self.is_txn_rw
         else:
+            will_open = True
+
+        # If a new transaction needs to be opened and replace the old one,
+        # the old one must be put on hold and swapped out when the new txn
+        # is closed.
+        if will_open:
+            will_reset = self.is_txn_open
+
+        if will_open:
             #logger.debug('Beginning {} transaction.'.format(
             #    'RW' if write else 'RO'))
+            if will_reset:
+                hold_txn = self.txn
+
             try:
                 self._txn_begin(write=write)
                 self.is_txn_rw = write
@@ -359,9 +392,21 @@ cdef class BaseLmdbStore:
                 #logger.debug('In txn_ctx, after yield')
                 self._txn_commit()
                 #logger.debug('after _txn_commit')
+                if will_reset:
+                    lmdb.mdb_txn_reset(hold_txn)
+                    self.txn = hold_txn
+                    _check(lmdb.mdb_txn_renew(self.txn))
+                    self.is_txn_rw = False
             except:
                 self._txn_abort()
                 raise
+        else:
+            logger.info(
+                'Transaction is already active. Not opening another one.'
+            )
+            #logger.debug('before yield')
+            yield
+            #logger.debug('after yield')
 
 
     def begin(self, write=False):

+ 0 - 0
lakesuperior/store/ldp_rs/__init__.pxd


+ 0 - 53
lakesuperior/store/ldp_rs/lmdb_store.py

@@ -199,56 +199,3 @@ class LmdbStore(LmdbTriplestore, Store):
 
 
     ## PRIVATE METHODS ##
-
-    def _normalize_context(self, context):
-        """
-        Normalize a context parameter to conform to the model expectations.
-
-        :param context: Context URI or graph.
-        :type context: URIRef or Graph or None
-        """
-        if isinstance(context, Graph):
-            if context == self or isinstance(context.identifier, Variable):
-                context = None
-            else:
-                context = context.identifier
-                #logger.debug('Converted graph into URI: {}'.format(context))
-
-        return context
-
-
-    ## Convenience methods—not necessary for functioning but useful for
-    ## debugging.
-
-    #def _keys_in_ctx(self, pk_ctx):
-    #    """
-    #    Convenience method to list all keys in a context.
-
-    #    :param bytes pk_ctx: Pickled context URI.
-
-    #    :rtype: Iterator(tuple)
-    #    :return: Generator of triples.
-    #    """
-    #    with self.cur('c:spo') as cur:
-    #        if cur.set_key(pk_ctx):
-    #            tkeys = cur.iternext_dup()
-    #            return {self._key_to_triple(tk) for tk in tkeys}
-    #        else:
-    #            return set()
-
-
-    #def _ctx_for_key(self, tkey):
-    #    """
-    #    Convenience method to list all contexts that a key is in.
-
-    #    :param bytes tkey: Triple key.
-
-    #    :rtype: Iterator(rdflib.URIRef)
-    #    :return: Generator of context URIs.
-    #    """
-    #    with self.cur('spo:c') as cur:
-    #        if cur.set_key(tkey):
-    #            ctx = cur.iternext_dup()
-    #            return {self._unpickle(c) for c in ctx}
-    #        else:
-    #            return set()

+ 45 - 0
lakesuperior/store/ldp_rs/lmdb_triplestore.pxd

@@ -0,0 +1,45 @@
+cimport lakesuperior.cy_include.collections as cc
+cimport lakesuperior.cy_include.cylmdb as lmdb
+
+from lakesuperior.model.base cimport Key, DoubleKey, TripleKey, Buffer
+from lakesuperior.model.rdf.graph cimport Graph
+from lakesuperior.model.structures.keyset cimport Keyset
+from lakesuperior.store.base_lmdb_store cimport BaseLmdbStore
+
+cdef:
+    enum:
+        IDX_OP_ADD = 1
+        IDX_OP_REMOVE = -1
+
+    unsigned char lookup_rank[3]
+    unsigned char lookup_ordering[3][3]
+    unsigned char lookup_ordering_2bound[3][3]
+
+
+
+cdef class LmdbTriplestore(BaseLmdbStore):
+    cpdef dict stats(self)
+    cpdef size_t _len(self, context=*) except -1
+    cpdef void add(self, triple, context=*, quoted=*) except *
+    cpdef void add_graph(self, graph) except *
+    cpdef void _remove(self, tuple triple_pattern, context=*) except *
+    cpdef void _remove_graph(self, object gr_uri) except *
+    cpdef tuple all_namespaces(self)
+    cpdef Graph triple_keys(self, tuple triple_pattern, context=*, uri=*)
+
+    cdef:
+        void _index_triple(self, int op, TripleKey spok) except *
+        void _all_term_keys(self, term_type, cc.HashSet** tkeys) except *
+        void lookup_term(self, const Key tk, Buffer* data) except *
+        Graph _lookup(self, tuple triple_pattern)
+        Graph _lookup_1bound(self, unsigned char idx, Key luk)
+        Graph _lookup_2bound(
+            self, unsigned char idx1, unsigned char idx2, DoubleKey tks
+        )
+        object from_key(self, const Key tk)
+        Key to_key(self, term) except? 0
+        void all_contexts(self, Key** ctx, size_t* sz, triple=*) except *
+        Key _append(
+                self, Buffer *value,
+                unsigned char *dblabel=*, lmdb.MDB_txn *txn=*,
+                unsigned int flags=*) except? 0

File diff suppressed because it is too large
+ 106 - 746
lakesuperior/store/ldp_rs/lmdb_triplestore.pyx


+ 47 - 39
lakesuperior/store/ldp_rs/rsrc_centric_layout.py

@@ -9,7 +9,7 @@ from urllib.parse import urldefrag
 
 import arrow
 
-from rdflib import Dataset, Graph, Literal, URIRef, plugin
+from rdflib import Dataset, Literal, URIRef, plugin
 from rdflib.compare import to_isomorphic
 from rdflib.namespace import RDF
 from rdflib.query import ResultException
@@ -24,7 +24,7 @@ from lakesuperior.dictionaries.srv_mgd_terms import  srv_mgd_subjects, \
 from lakesuperior.globals import ROOT_RSRC_URI
 from lakesuperior.exceptions import (InvalidResourceError,
         ResourceNotExistsError, TombstoneError, PathSegmentError)
-from lakesuperior.store.ldp_rs.lmdb_triplestore import SimpleGraph, Imr
+from lakesuperior.model.rdf.graph import Graph
 
 
 META_GR_URI = nsc['fcsystem']['meta']
@@ -217,13 +217,15 @@ class RsrcCentricLayout:
         fname = path.join(
                 basedir, 'data', 'bootstrap', 'rsrc_centric_layout.sparql')
         with store.txn_ctx(True):
+            #import pdb; pdb.set_trace()
             with open(fname, 'r') as f:
                 data = Template(f.read())
                 self.ds.update(data.substitute(timestamp=arrow.utcnow()))
+        with store.txn_ctx():
             imr = self.get_imr('/', incl_inbound=False, incl_children=True)
 
-        gr = Graph(identifier=imr.uri)
-        gr += imr.data
+        #gr = Graph(identifier=imr.uri)
+        #gr += imr.data
         #checksum = to_isomorphic(gr).graph_digest()
         #digest = sha256(str(checksum).encode('ascii')).digest()
 
@@ -249,10 +251,9 @@ class RsrcCentricLayout:
         :param rdflib.term.URIRef ctx: URI of the optional context. If None,
             all named graphs are queried.
 
-        :rtype: SimpleGraph
+        :rtype: Graph
         """
-        return SimpleGraph(
-                store=self.store, lookup=((subject, None, None), ctx))
+        return self.store.triple_keys((subject, None, None), ctx)
 
 
     def count_rsrc(self):
@@ -291,15 +292,18 @@ class RsrcCentricLayout:
         if not incl_children:
             contexts.remove(nsc['fcstruct'][uid])
 
-        imr = Imr(uri=nsc['fcres'][uid])
+        imr = Graph(self.store, uri=nsc['fcres'][uid])
 
         for ctx in contexts:
-            imr |= SimpleGraph(
-                    lookup=((None, None, None), ctx), store=self.store).data
+            gr = self.store.triple_keys((None, None, None), ctx)
+            imr |= gr
 
         # Include inbound relationships.
         if incl_inbound and len(imr):
-            imr |= set(self.get_inbound_rel(nsc['fcres'][uid]))
+            gr = Graph(
+                self.store, data={*self.get_inbound_rel(nsc['fcres'][uid])}
+            )
+            imr |= gr
 
         if strict:
             self._check_rsrc_status(imr)
@@ -331,10 +335,11 @@ class RsrcCentricLayout:
         logger.debug('Getting metadata for: {}'.format(uid))
         if ver_uid:
             uid = self.snapshot_uid(uid, ver_uid)
-        imr = Imr(
-                uri=nsc['fcres'][uid],
-                lookup=((None, None, None), nsc['fcadmin'][uid]),
-                store=self.store)
+        imr = self.store.triple_keys(
+            (None, None, None),
+            context=nsc['fcadmin'][uid],
+            uri=nsc['fcres'][uid]
+        )
 
         if strict:
             self._check_rsrc_status(imr)
@@ -353,9 +358,11 @@ class RsrcCentricLayout:
         # graph. If multiple user-provided graphs will be supported, this
         # should use another query to get all of them.
         uri = nsc['fcres'][uid]
-        userdata = Imr(
-                uri=uri, lookup=((uri, None, None),nsc['fcmain'][uid]),
-                store=self.store)
+        userdata = self.store.triple_keys(
+            (None, None, None),
+            context=nsc['fcmain'][uid],
+            uri=uri
+        )
 
         return userdata
 
@@ -365,36 +372,34 @@ class RsrcCentricLayout:
         Get all metadata about a resource's versions.
 
         :param string uid: Resource UID.
-        :rtype: SimpleGraph
+        :rtype: Graph
         """
         # **Note:** This pretty much bends the ontology—it replaces the graph
         # URI with the subject URI. But the concepts of data and metadata in
         # Fedora are quite fluid anyways...
 
-        # Result graph.
-        imr = SimpleGraph(lookup=(
-            (nsc['fcres'][uid], nsc['fcrepo'].hasVersion, None),
-                nsc['fcadmin'][uid]), store=self.store)
-
-        vmeta = Imr(uri=nsc['fcres'][uid])
+        vmeta = Graph(self.store, uri=nsc['fcres'][uid])
 
         #Get version graphs proper.
-        for vtrp in imr:
+        for vtrp in self.store.triple_keys(
+            (nsc['fcres'][uid], nsc['fcrepo'].hasVersion, None),
+            nsc['fcadmin'][uid]
+        ):
             # Add the hasVersion triple to the result graph.
-            vmeta.add(vtrp)
-            vmeta_gr = SimpleGraph(
-                lookup=((
-                    None, nsc['foaf'].primaryTopic, vtrp[2]), HIST_GR_URI),
-                store=self.store)
+            vmeta.add((vtrp,))
+            vmeta_gr = self.store.triple_keys(
+                (None, nsc['foaf'].primaryTopic, vtrp[2]), HIST_GR_URI
+            )
             # Get triples in the meta graph filtering out undesired triples.
             for vmtrp in vmeta_gr:
-                for trp in SimpleGraph(lookup=((
-                        vmtrp[0], None, None), HIST_GR_URI), store=self.store):
+                for trp in self.store.triple_keys(
+                    (vmtrp[0], None, None), HIST_GR_URI
+                ):
                     if (
                             (trp[1] != nsc['rdf'].type
                             or trp[2] not in self.ignore_vmeta_types)
                             and (trp[1] not in self.ignore_vmeta_preds)):
-                        vmeta.add((vtrp[2], trp[1], trp[2]))
+                        vmeta.add(((vtrp[2], trp[1], trp[2]),))
 
         return vmeta
 
@@ -414,6 +419,7 @@ class RsrcCentricLayout:
         :return: Inbound triples or subjects.
         """
         # Only return non-historic graphs.
+        # TODO self.store.triple_keys?
         meta_gr = self.ds.graph(META_GR_URI)
         ptopic_uri = nsc['foaf'].primaryTopic
 
@@ -439,8 +445,9 @@ class RsrcCentricLayout:
         ctx_uri = nsc['fcstruct'][uid]
         cont_p = nsc['ldp'].contains
         def _recurse(dset, s, c):
-            new_dset = SimpleGraph(
-                    lookup=((s, cont_p, None), c), store=self.store)[s : cont_p]
+            new_dset = self.store.triple_keys(
+                (s, cont_p, None), c
+            )[s : cont_p]
             #new_dset = set(ds.graph(c)[s : cont_p])
             for ss in new_dset:
                 dset.add(ss)
@@ -459,9 +466,9 @@ class RsrcCentricLayout:
             return _recurse(set(), subj_uri, ctx_uri)
         else:
             #return ds.graph(ctx_uri)[subj_uri : cont_p : ])
-            return SimpleGraph(
-                    lookup=((subj_uri, cont_p, None), ctx_uri),
-                    store=self.store)[subj_uri : cont_p]
+            return self.store.triple_keys(
+                (subj_uri, cont_p, None), ctx_uri
+            )[subj_uri : cont_p]
 
 
     def get_last_version_uid(self, uid):
@@ -670,6 +677,7 @@ class RsrcCentricLayout:
         """
         Check if a resource is not existing or if it is a tombstone.
         """
+        #import pdb; pdb.set_trace()
         uid = self.uri_to_uid(imr.uri)
         if not len(imr):
             raise ResourceNotExistsError(uid)

+ 0 - 41
lakesuperior/store/ldp_rs/term.pxd

@@ -1,41 +0,0 @@
-from libc.stdint cimport uint64_t
-
-# cdefs for serialize and deserialize methods
-cdef:
-    #unsigned char *pack_data
-    unsigned char term_type
-    unsigned char *pack_fmt
-    unsigned char *term_data
-    unsigned char *term_datatype
-    unsigned char *term_lang
-    #size_t pack_size
-
-    struct IdentifierTerm:
-        char type
-        unsigned char *data
-
-    struct LiteralTerm:
-        char type
-        unsigned char *data
-        unsigned char *datatype
-        unsigned char *lang
-
-    int serialize(term, unsigned char **pack_data, size_t *pack_size) except -1
-    deserialize(unsigned char *data, size_t size)
-
-
-# cdefs for hash methods
-DEF _HLEN = 16
-
-ctypedef uint64_t Hash_128[2]
-ctypedef unsigned char Hash[_HLEN]
-
-cdef:
-    uint64_t term_hash_seed1
-    uint64_t term_hash_seed2
-    unsigned char *term_hash_seed
-    size_t SEED_LEN
-    size_t HLEN
-
-    void hash_(
-        const unsigned char *message, size_t message_size, Hash *digest)

+ 0 - 134
lakesuperior/store/ldp_rs/term.pyx

@@ -1,134 +0,0 @@
-from rdflib import URIRef, BNode, Literal
-
-#from cpython.mem cimport PyMem_Malloc, PyMem_Free
-from libc.stdint cimport uint64_t
-from libc.stdlib cimport malloc, free
-from libc.string cimport memcpy
-
-#from lakesuperior.cy_include.cyspookyhash cimport spookyhash_128
-from lakesuperior.cy_include cimport cytpl as tpl
-
-
-DEF LSUP_TERM_TYPE_URIREF = 1
-DEF LSUP_TERM_TYPE_BNODE = 2
-DEF LSUP_TERM_TYPE_LITERAL = 3
-DEF LSUP_PK_FMT_ID = b'S(cs)'
-DEF LSUP_PK_FMT_LIT = b'S(csss)'
-
-
-DEF _SEED_LEN = 8
-DEF _HLEN = 16
-
-HLEN = _HLEN
-SEED_LEN = _SEED_LEN
-
-term_hash_seed = b'\xff\xf2Q\xf2j\x0bG\xc1\x8a}\xca\x92\x98^y\x12'
-"""
-Seed for computing the term hash.
-
-This is a 16-byte string that will be split up into two ``uint64``
-numbers to make up the ``spookyhash_128`` seeds.
-"""
-memcpy(&term_hash_seed1, term_hash_seed, SEED_LEN)
-memcpy(&term_hash_seed2, term_hash_seed + SEED_LEN, SEED_LEN)
-
-# We only need one function from spookyhash. No need for a pxd file.
-cdef extern from 'spookyhash_api.h':
-    void spookyhash_128(
-            const void *input, size_t input_size, uint64_t *hash_1,
-            uint64_t *hash_2)
-
-
-cdef int serialize(
-        term, unsigned char **pack_data, size_t *pack_size) except -1:
-    cdef:
-        bytes term_data = term.encode()
-        bytes term_datatype
-        bytes term_lang
-        IdentifierTerm id_t
-        LiteralTerm lit_t
-
-    if isinstance(term, Literal):
-        term_datatype = (getattr(term, 'datatype') or '').encode()
-        term_lang = (getattr(term, 'language') or '').encode()
-
-        lit_t.type = LSUP_TERM_TYPE_LITERAL
-        lit_t.data = term_data
-        lit_t.datatype = <unsigned char *>term_datatype
-        lit_t.lang = <unsigned char *>term_lang
-
-        tpl.tpl_jot(tpl.TPL_MEM, pack_data, pack_size, LSUP_PK_FMT_LIT, &lit_t)
-    else:
-        if isinstance(term, URIRef):
-            id_t.type = LSUP_TERM_TYPE_URIREF
-        elif isinstance(term, BNode):
-            id_t.type = LSUP_TERM_TYPE_BNODE
-        else:
-            raise ValueError(f'Unsupported term type: {type(term)}')
-        id_t.data = term_data
-        tpl.tpl_jot(tpl.TPL_MEM, pack_data, pack_size, LSUP_PK_FMT_ID, &id_t)
-
-
-cdef deserialize(const unsigned char *data, const size_t data_size):
-    cdef:
-        char term_type
-        char *fmt = NULL
-        char *_pk = NULL
-        unsigned char *term_data = NULL
-        unsigned char *term_lang = NULL
-        unsigned char *term_datatype = NULL
-
-    datatype = None
-    lang = None
-
-    fmt = tpl.tpl_peek(tpl.TPL_MEM, data, data_size)
-    try:
-        if fmt == LSUP_PK_FMT_LIT:
-            _pk = tpl.tpl_peek(
-                    tpl.TPL_MEM | tpl.TPL_DATAPEEK, data, data_size, b'csss',
-                    &term_type, &term_data, &term_datatype, &term_lang)
-            if len(term_datatype) > 0:
-                datatype = term_datatype.decode()
-            elif len(term_lang) > 0:
-                lang = term_lang.decode()
-
-            return Literal(term_data.decode(), datatype=datatype, lang=lang)
-
-        elif fmt == LSUP_PK_FMT_ID:
-            _pk = tpl.tpl_peek(
-                    tpl.TPL_MEM | tpl.TPL_DATAPEEK, data, data_size, b'cs',
-                    &term_type, &term_data)
-            uri = term_data.decode()
-            if term_type == LSUP_TERM_TYPE_URIREF:
-                return URIRef(uri)
-            elif term_type == LSUP_TERM_TYPE_BNODE:
-                return BNode(uri)
-            else:
-                raise IOError(f'Unknown term type code: {term_type}')
-        else:
-            msg = f'Unknown structure pack format: {fmt}'
-            raise IOError(msg)
-    finally:
-        free(term_data)
-        free(term_datatype)
-        free(term_lang)
-        free(_pk)
-        free(fmt)
-
-
-cdef inline void hash_(
-        const unsigned char *message, size_t message_size, Hash *digest):
-    """
-    Get the hash value of a serialized object.
-
-    The hashing algorithm is `SpookyHash
-    <http://burtleburtle.net/bob/hash/spooky.html>`_ which produces 128-bit
-    (16-byte) digests.
-
-    The initial seeds are determined in the application configuration.
-    """
-    cdef Hash_128 seed = [term_hash_seed1, term_hash_seed2]
-
-    spookyhash_128(message, message_size, seed, seed + 1)
-
-    memcpy(digest, seed, sizeof(Hash))

+ 13 - 0
lakesuperior/toolbox.py

@@ -194,6 +194,19 @@ class Toolbox:
         return s, p, o
 
 
+    def globalize_imr(self, imr):
+        '''
+        Globalize an Imr.
+
+        :rtype: rdflib.Graph
+        '''
+        g_gr = Graph(identifier=self.globalize_term(imr.uri))
+        for trp in imr:
+            g_gr.add(self.globalize_triple(trp))
+
+        return g_gr
+
+
     def globalize_graph(self, gr):
         '''
         Globalize a graph.

+ 112 - 29
lakesuperior/util/benchmark.py

@@ -1,32 +1,52 @@
 #!/usr/bin/env python3
 
+import logging
 import sys
 
+from os import path
 from uuid import uuid4
 
 import arrow
 import click
+import rdflib
 import requests
 
 from matplotlib import pyplot as plt
 
 from lakesuperior.util.generators import (
         random_image, random_graph, random_utf8_string)
+from lakesuperior.exceptions import ResourceNotExistsError
 
 __doc__ = '''
 Benchmark script to measure write performance.
 '''
 
+def_mode = 'ldp'
 def_endpoint = 'http://localhost:8000/ldp'
 def_ct = 10000
 def_parent = '/pomegranate'
 def_gr_size = 200
 
+logging.disable(logging.WARN)
+
 
 @click.command()
+@click.option(
+    '--mode', '-m', default=def_mode,
+    help=(
+        'Mode of ingestion. One of `ldp`, `python`. With the former, the '
+        'HTTP/LDP web server is used. With the latter, the Python API is '
+        'used, in which case the server need not be running. '
+        f'Default: {def_endpoint}'
+    )
+)
 @click.option(
     '--endpoint', '-e', default=def_endpoint,
-    help=f'LDP endpoint. Default: {def_endpoint}')
+    help=(
+        'LDP endpoint. Only meaningful with `ldp` mode. '
+        f'Default: {def_endpoint}'
+    )
+)
 @click.option(
     '--count', '-c', default=def_ct,
     help='Number of resources to ingest. Default: {def_ct}')
@@ -40,9 +60,12 @@ def_gr_size = 200
     help='Delete container resource and its children if already existing. By '
     'default, the container is not deleted and new resources are added to it.')
 @click.option(
-    '--method', '-m', default='put',
-    help='HTTP method to use. Case insensitive. Either PUT '
-    f'or POST. Default: PUT')
+    '--method', '-X', default='put',
+    help=(
+        'HTTP method to use. Case insensitive. Either PUT or POST. '
+        'Default: PUT'
+    )
+)
 @click.option(
     '--graph-size', '-s', default=def_gr_size,
     help=f'Number of triples in each graph. Default: {def_gr_size}')
@@ -52,47 +75,78 @@ def_gr_size = 200
     '`n` (only  LDP-NR, i.e. binaries), or `b` (50/50% of both). '
     'Default: r')
 @click.option(
-    '--graph', '-g', is_flag=True, help='Plot a graph of ingest timings. '
+    '--plot', '-P', is_flag=True, help='Plot a graph of ingest timings. '
     'The graph figure is displayed on screen with basic manipulation and save '
     'options.')
 
 def run(
-        endpoint, count, parent, method, delete_container,
-        graph_size, resource_type, graph):
-
-    container_uri = endpoint + parent
+    mode, endpoint, count, parent, method, delete_container,
+    graph_size, resource_type, plot
+):
+    """
+    Run the benchmark.
+    """
 
     method = method.lower()
     if method not in ('post', 'put'):
-        raise ValueError(f'HTTP method not supported: {method}')
+        raise ValueError(f'Insertion method not supported: {method}')
+
+    mode = mode.lower()
+    if mode == 'ldp':
+        parent = '{}/{}'.format(endpoint.strip('/'), parent.strip('/'))
+
+        if delete_container:
+            print('Removing previously existing container.')
+            requests.delete(parent, headers={'prefer': 'no-tombstone'})
+        requests.put(parent)
 
-    if delete_container:
-        requests.delete(container_uri, headers={'prefer': 'no-tombstone'})
-    requests.put(container_uri)
+    elif mode == 'python':
+        from lakesuperior import env_setup
+        from lakesuperior.api import resource as rsrc_api
+
+        if delete_container:
+            try:
+                print('Removing previously existing container.')
+                rsrc_api.delete(parent, soft=False)
+            except ResourceNotExistsError:
+                pass
+        rsrc_api.create_or_replace(parent)
+    else:
+        raise ValueError(f'Mode not supported: {mode}')
 
-    print(f'Inserting {count} children under {container_uri}.')
 
     # URI used to establish an in-repo relationship. This is set to
     # the most recently created resource in each loop.
-    ref = container_uri
+    ref = parent
+
+    print(f'Inserting {count} children under {parent}.')
 
     wclock_start = arrow.utcnow()
-    if graph:
+    if plot:
         print('Results will be plotted.')
         # Plot coordinates: X is request count, Y is request timing.
         px = []
         py = []
         plt.xlabel('Requests')
         plt.ylabel('ms per request')
-        plt.title('FCREPO Benchmark')
+        plt.title('Lakesuperior / FCREPO Benchmark')
 
     try:
         for i in range(1, count + 1):
-            url = '{}/{}'.format(container_uri, uuid4()) if method == 'put' \
-                    else container_uri
+            #import pdb; pdb.set_trace()
+            if mode == 'ldp':
+                dest = (
+                    f'{parent}/{uuid4()}' if method == 'put'
+                    else parent
+                )
+            else:
+                dest = (
+                    path.join(parent, str(uuid4()))
+                    if method == 'put' else parent
+                )
 
             if resource_type == 'r' or (resource_type == 'b' and i % 2 == 0):
-                data = random_graph(graph_size, ref).serialize(format='ttl')
+                data = random_graph(graph_size, ref)
                 headers = {'content-type': 'text/turtle'}
             else:
                 img = random_image(name=uuid4(), ts=16, ims=512)
@@ -103,19 +157,21 @@ def run(
                         'content-disposition': 'attachment; filename="{}"'
                             .format(uuid4())}
 
-            #import pdb; pdb.set_trace()
             # Start timing after generating the data.
             ckpt = arrow.utcnow()
             if i == 1:
                 tcounter = ckpt - ckpt
                 prev_tcounter = tcounter
 
-            rsp = requests.request(method, url, data=data, headers=headers)
-            tdelta = arrow.utcnow() - ckpt
-            tcounter += tdelta
+            ref = (
+                _ingest_graph_ldp(
+                    method, dest, data.serialize(format='ttl'), headers, ref
+                )
+                if mode == 'ldp'
+                else _ingest_graph_py(method, dest, data, ref)
+            )
+            tcounter += (arrow.utcnow() - ckpt)
 
-            rsp.raise_for_status()
-            ref = rsp.headers['location']
             if i % 10 == 0:
                 avg10 = (tcounter - prev_tcounter) / 10
                 print(
@@ -123,7 +179,7 @@ def run(
                     f'Per resource: {avg10}')
                 prev_tcounter = tcounter
 
-                if graph:
+                if plot:
                     px.append(i)
                     # Divide by 1000 for µs → ms
                     py.append(avg10.microseconds // 1000)
@@ -136,7 +192,7 @@ def run(
     print(f'Total time spent ingesting resources: {tcounter}')
     print(f'Average time per resource: {tcounter.total_seconds()/i}')
 
-    if graph:
+    if plot:
         if resource_type == 'r':
             type_label = 'LDP-RS'
         elif resource_type == 'n':
@@ -144,12 +200,39 @@ def run(
         else:
             type_label = 'LDP-RS + LDP-NR'
         label = (
-            f'{container_uri}; {method.upper()}; {graph_size} trp/graph; '
+            f'{parent}; {method.upper()}; {graph_size} trp/graph; '
             f'{type_label}')
         plt.plot(px, py, label=label)
         plt.legend()
         plt.show()
 
 
+def _ingest_graph_ldp(method, uri, data, headers, ref):
+    """
+    Ingest the graph via HTTP/LDP.
+    """
+    rsp = requests.request(method, uri, data=data, headers=headers)
+    rsp.raise_for_status()
+    return rsp.headers['location']
+
+
+def _ingest_graph_py(method, dest, data, ref):
+    from lakesuperior.api import resource as rsrc_api
+
+    kwargs = {}
+    if isinstance(data, rdflib.Graph):
+        kwargs['graph'] = data
+    else:
+        kwargs['stream'] = data
+        kwargs['mimetype'] = 'image/png'
+
+    if method == 'put':
+        _, rsrc = rsrc_api.create_or_replace(dest, **kwargs)
+    else:
+        _, rsrc = rsrc_api.create(dest, **kwargs)
+
+    return rsrc.uid
+
+
 if __name__ == '__main__':
     run()

+ 2 - 1
requirements_dev.txt

@@ -1,5 +1,5 @@
 CoilMQ>=1.0.1
-Cython==0.29
+Cython==0.29.6
 Flask>=0.12.2
 HiYaPyCo>=0.4.11
 Pillow>=4.3.0
@@ -9,6 +9,7 @@ click-log>=0.2.1
 click>=6.7
 gevent>=1.3.6
 gunicorn>=19.7.1
+matplotlib
 numpy>=1.15.1
 pytest-flask
 pytest>=3.2.2

+ 15 - 0
sandbox/NOTES

@@ -0,0 +1,15 @@
+Uses for a graph:
+
+1. Create a graph from RDF input, manipulate or evaluate it, and output it as
+  serialized RDF (always detached) [NO USE CASE]
+2. Create a graph from RDF input, optionally manipulate it with other data from
+  the store or external RDF and store it (start detached, then convert keys;
+  or, start attached)
+3. Retrieve a graph from the store, optionally manipulate it, and output it as
+  serialized RDF (start attached, then detach)
+4. Retrieve a graph from the store, manipulate it, and put the changed graph
+  back in the store (always attached)
+
+Initially we might try to render the graph read-only when detached; this
+avoids implementing more complex operations such as add, remove and booleans.
+

+ 10 - 0
sandbox/txn_openLogic.txt

@@ -0,0 +1,10 @@
+txn_open    write       txn_rw      Open?
+n           -           -           y
+y           n           -           n
+y           y           y           n
+y           y           n           y
+
+txn_open    Open    Reset?
+n           y       n
+y           y       y
+

+ 112 - 46
setup.py

@@ -16,7 +16,9 @@ from os import path
 import lakesuperior
 
 # Use this version to build C files from .pyx sources.
-CYTHON_VERSION='0.29'
+CYTHON_VERSION='0.29.6'
+
+KLEN = 5 # TODO Move somewhere else (config?)
 
 try:
     import Cython
@@ -24,8 +26,16 @@ try:
 except ImportError:
     USE_CYTHON = False
 else:
-    if Cython.__version__ == CYTHON_VERSION:
+    cy_installed_version = Cython.__version__
+    if cy_installed_version == CYTHON_VERSION:
         USE_CYTHON = True
+    else:
+        raise ImportError(
+            f'The installed Cython version ({cy_installed_version}) '
+            f'does not match the required version ({CYTHON_VERSION}). '
+            'Please insstall the exact required Cython version in order to '
+            'generate the C sources.'
+        )
 
 
 # ``pytest_runner`` is referenced in ``setup_requires``.
@@ -40,11 +50,17 @@ with open(readme_fpath, encoding='utf-8') as f:
     long_description = f.read()
 
 # Extensions directory.
+coll_src_dir = path.join('ext', 'collections-c', 'src')
 lmdb_src_dir = path.join('ext', 'lmdb', 'libraries', 'liblmdb')
-tpl_src_dir = path.join('ext', 'tpl', 'src')
 spookyhash_src_dir = path.join('ext', 'spookyhash', 'src')
+tpl_src_dir = path.join('ext', 'tpl', 'src')
 
-include_dirs = [lmdb_src_dir, tpl_src_dir, spookyhash_src_dir]
+include_dirs = [
+    path.join(coll_src_dir, 'include'),
+    lmdb_src_dir,
+    spookyhash_src_dir,
+    tpl_src_dir,
+]
 
 cy_include_dir = path.join('lakesuperior', 'cy_include')
 
@@ -59,57 +75,126 @@ else:
     ext = pxdext = 'c'
 
 extensions = [
+    Extension(
+        'lakesuperior.model.base',
+        [
+            path.join(tpl_src_dir, 'tpl.c'),
+            path.join('lakesuperior', 'model', f'base.{ext}'),
+        ],
+        include_dirs=include_dirs,
+        extra_compile_args=['-fopenmp', '-g'],
+        extra_link_args=['-fopenmp', '-g']
+    ),
+    Extension(
+        'lakesuperior.model.callbacks',
+        [
+            path.join('lakesuperior', 'model', f'callbacks.{ext}'),
+        ],
+        include_dirs=include_dirs,
+        extra_compile_args=['-g'],
+        extra_link_args=['-g'],
+        #extra_compile_args=['-fopenmp'],
+        #extra_link_args=['-fopenmp']
+    ),
+    Extension(
+        'lakesuperior.model.structures.*',
+        [
+            path.join(spookyhash_src_dir, 'spookyhash.c'),
+            path.join(coll_src_dir, 'common.c'),
+            path.join(coll_src_dir, 'array.c'),
+            path.join(coll_src_dir, 'hashtable.c'),
+            path.join(coll_src_dir, 'hashset.c'),
+            path.join('lakesuperior', 'model', 'structures', f'*.{ext}'),
+        ],
+        include_dirs=include_dirs,
+        extra_compile_args=['-fopenmp', '-g'],
+        extra_link_args=['-fopenmp', '-g']
+    ),
     Extension(
         'lakesuperior.store.base_lmdb_store',
         [
+            path.join(coll_src_dir, 'common.c'),
+            path.join(coll_src_dir, 'array.c'),
+            path.join(coll_src_dir, 'hashtable.c'),
+            path.join(coll_src_dir, 'hashset.c'),
+            path.join(tpl_src_dir, 'tpl.c'),
             path.join(lmdb_src_dir, 'mdb.c'),
             path.join(lmdb_src_dir, 'midl.c'),
             path.join('lakesuperior', 'store', f'base_lmdb_store.{ext}'),
         ],
         include_dirs=include_dirs,
+        extra_compile_args=['-g'],
+        extra_link_args=['-g'],
     ),
     Extension(
-        'lakesuperior.store.ldp_rs.term',
+        'lakesuperior.model.rdf.*',
         [
             path.join(tpl_src_dir, 'tpl.c'),
+            path.join(spookyhash_src_dir, 'context.c'),
+            path.join(spookyhash_src_dir, 'globals.c'),
             path.join(spookyhash_src_dir, 'spookyhash.c'),
-            path.join('lakesuperior', 'store', 'ldp_rs', f'term.{ext}'),
+            path.join(coll_src_dir, 'common.c'),
+            path.join(coll_src_dir, 'array.c'),
+            path.join(coll_src_dir, 'hashtable.c'),
+            path.join(coll_src_dir, 'hashset.c'),
+            path.join('lakesuperior', 'model', 'rdf', f'*.{ext}'),
         ],
         include_dirs=include_dirs,
-        extra_compile_args=['-fopenmp'],
-        extra_link_args=['-fopenmp']
+        #extra_compile_args=['-fopenmp'],
+        #extra_link_args=['-fopenmp']
     ),
     Extension(
         'lakesuperior.store.ldp_rs.lmdb_triplestore',
         [
+            path.join(coll_src_dir, 'common.c'),
+            path.join(coll_src_dir, 'array.c'),
+            path.join(coll_src_dir, 'hashtable.c'),
+            path.join(coll_src_dir, 'hashset.c'),
             path.join(lmdb_src_dir, 'mdb.c'),
             path.join(lmdb_src_dir, 'midl.c'),
             path.join(
                 'lakesuperior', 'store', 'ldp_rs', f'lmdb_triplestore.{ext}'),
         ],
         include_dirs=include_dirs,
-        extra_compile_args=['-fopenmp'],
-        extra_link_args=['-fopenmp']
+        extra_compile_args=['-g', '-fopenmp'],
+        extra_link_args=['-g', '-fopenmp']
     ),
-    # For testing.
-    #Extension(
-    #    '*',
-    #    [
-    #        #path.join(tpl_src_dir, 'tpl.c'),
-    #        path.join(
-    #            path.dirname(lakesuperior.basedir), 'sandbox', f'*.{ext}'),
-    #    ],
-    #    include_dirs=include_dirs,
-    #),
+]
+
+# Great reference read about dependency management:
+# https://caremad.io/posts/2013/07/setup-vs-requirement/
+install_requires = [
+    'CoilMQ',
+    'Flask',
+    'HiYaPyCo',
+    'PyYAML',
+    'arrow',
+    'click',
+    'click-log',
+    'cymem',
+    'gevent',
+    'gunicorn',
+    'rdflib',
+    'rdflib-jsonld',
+    'requests',
+    'requests-toolbelt',
+    'sphinx-rtd-theme',
+    'stomp.py',
 ]
 
 if USE_CYTHON:
-    extensions = cythonize(extensions, include_path=include_dirs, compiler_directives={
-        'language_level': 3,
-        'boundscheck': False,
-        'wraparound': False,
-        'profile': True,
-    })
+    extensions = cythonize(
+        extensions,
+        include_path=include_dirs,
+        annotate=True,
+        compiler_directives={
+            'language_level': 3,
+            'boundscheck': False,
+            'wraparound': False,
+            'profile': True,
+            'embedsignature': True
+        }
+    )
 
 
 setup(
@@ -161,26 +246,7 @@ setup(
 
     packages=find_packages(exclude=['contrib', 'docs', 'tests']),
 
-    # Great reference read about dependency management:
-    # https://caremad.io/posts/2013/07/setup-vs-requirement/
-    install_requires=[
-        'CoilMQ',
-        'Flask',
-        'HiYaPyCo',
-        'PyYAML',
-        'arrow',
-        'cchardet',
-        'click',
-        'click-log',
-        'gevent',
-        'gunicorn',
-        'rdflib',
-        'rdflib-jsonld',
-        'requests',
-        'requests-toolbelt',
-        'sphinx-rtd-theme',
-        'stomp.py',
-    ],
+    install_requires=install_requires,
 
     setup_requires=[
         'setuptools>=18.0',

+ 850 - 0
tests/0_data_structures/test_0_0_graph.py

@@ -0,0 +1,850 @@
+import pdb
+import pytest
+
+from shutil import rmtree
+
+from rdflib import Graph, Namespace, URIRef
+
+from lakesuperior.model.rdf.graph import Graph
+from lakesuperior.store.ldp_rs.lmdb_store import LmdbStore
+
+
+@pytest.fixture(scope='class')
+def store():
+    """
+    Test LMDB store.
+
+    This store has a different life cycle than the one used for tests in higher
+    levels of the stack and is not bootstrapped (i.e. starts completely empty).
+    """
+    env_path = '/tmp/test_lmdbstore'
+    # Remove previous test DBs
+    rmtree(env_path, ignore_errors=True)
+    store = LmdbStore(env_path)
+    yield store
+    store.close()
+    store.destroy()
+
+
+@pytest.fixture(scope='class')
+def trp():
+    return (
+        (URIRef('urn:s:0'), URIRef('urn:p:0'), URIRef('urn:o:0')),
+        # Exact same as [0].
+        (URIRef('urn:s:0'), URIRef('urn:p:0'), URIRef('urn:o:0')),
+        # NOTE: s and o are in reversed order.
+        (URIRef('urn:o:0'), URIRef('urn:p:0'), URIRef('urn:s:0')),
+        (URIRef('urn:s:0'), URIRef('urn:p:1'), URIRef('urn:o:0')),
+        (URIRef('urn:s:0'), URIRef('urn:p:1'), URIRef('urn:o:1')),
+        (URIRef('urn:s:1'), URIRef('urn:p:1'), URIRef('urn:o:1')),
+        (URIRef('urn:s:1'), URIRef('urn:p:2'), URIRef('urn:o:2')),
+    )
+
+@pytest.mark.usefixtures('trp')
+@pytest.mark.usefixtures('store')
+class TestGraphInit:
+    """
+    Test initialization of graphs with different base data sets.
+    """
+    def test_empty(self, store):
+        """
+        Test creation of an empty graph.
+        """
+        # No transaction needed to init an empty graph.
+        gr = Graph(store)
+
+        # len() should not need a DB transaction open.
+        assert len(gr) == 0
+
+
+    def test_init_triples(self, trp, store):
+        """
+        Test creation using a Python set.
+        """
+        with store.txn_ctx():
+            gr = Graph(store, data=set(trp))
+
+            assert len(gr) == 6
+
+            for t in trp:
+                assert t in gr
+
+
+@pytest.mark.usefixtures('trp')
+@pytest.mark.usefixtures('store')
+class TestGraphLookup:
+    """
+    Test triple lookup.
+    """
+
+    def test_lookup_all_unbound(self, trp, store):
+        """
+        Test lookup ? ? ? (all unbound)
+        """
+        with store.txn_ctx():
+            gr = Graph(store, data=set(trp))
+
+            flt_gr = gr.lookup((None, None, None))
+
+            assert len(flt_gr) == 6
+
+            assert trp[0] in flt_gr
+            assert trp[2] in flt_gr
+            assert trp[3] in flt_gr
+            assert trp[4] in flt_gr
+            assert trp[5] in flt_gr
+            assert trp[6] in flt_gr
+
+
+    def test_lookup_s(self, trp, store):
+        """
+        Test lookup s ? ?
+        """
+        with store.txn_ctx():
+            gr = Graph(store, data=set(trp))
+
+            flt_gr = gr.lookup((URIRef('urn:s:0'), None, None))
+
+            assert len(flt_gr) == 3
+
+            assert trp[0] in flt_gr
+            assert trp[3] in flt_gr
+            assert trp[4] in flt_gr
+
+            assert trp[2] not in flt_gr
+            assert trp[5] not in flt_gr
+            assert trp[6] not in flt_gr
+
+            # Test for empty results.
+            empty_flt_gr = gr.lookup((URIRef('urn:s:8'), None, None))
+
+            assert len(empty_flt_gr) == 0
+
+
+    def test_lookup_p(self, trp, store):
+        """
+        Test lookup ? p ?
+        """
+        with store.txn_ctx():
+            gr = Graph(store, data=set(trp))
+
+            flt_gr = gr.lookup((None, URIRef('urn:p:0'), None))
+
+            assert len(flt_gr) == 2
+
+            assert trp[0] in flt_gr
+            assert trp[2] in flt_gr
+
+            assert trp[3] not in flt_gr
+            assert trp[4] not in flt_gr
+            assert trp[5] not in flt_gr
+            assert trp[6] not in flt_gr
+
+            # Test for empty results.
+            empty_flt_gr = gr.lookup((None, URIRef('urn:p:8'), None))
+
+            assert len(empty_flt_gr) == 0
+
+
+    def test_lookup_o(self, trp, store):
+        """
+        Test lookup ? ? o
+        """
+        with store.txn_ctx():
+            gr = Graph(store, data=set(trp))
+
+            flt_gr = gr.lookup((None, None, URIRef('urn:o:1')))
+
+            assert len(flt_gr) == 2
+
+            assert trp[4] in flt_gr
+            assert trp[5] in flt_gr
+
+            assert trp[0] not in flt_gr
+            assert trp[2] not in flt_gr
+            assert trp[3] not in flt_gr
+            assert trp[6] not in flt_gr
+
+            # Test for empty results.
+            empty_flt_gr = gr.lookup((None, None, URIRef('urn:o:8')))
+
+            assert len(empty_flt_gr) == 0
+
+
+    def test_lookup_sp(self, trp, store):
+        """
+        Test lookup s p ?
+        """
+        with store.txn_ctx():
+            gr = Graph(store, data=set(trp))
+
+            flt_gr = gr.lookup((URIRef('urn:s:0'), URIRef('urn:p:1'), None))
+
+            assert len(flt_gr) == 2
+
+            assert trp[3] in flt_gr
+            assert trp[4] in flt_gr
+
+            assert trp[0] not in flt_gr
+            assert trp[2] not in flt_gr
+            assert trp[5] not in flt_gr
+            assert trp[6] not in flt_gr
+
+            # Test for empty results.
+            empty_flt_gr = gr.lookup((URIRef('urn:s:0'), URIRef('urn:p:2'), None))
+
+            assert len(empty_flt_gr) == 0
+
+
+    def test_lookup_so(self, trp, store):
+        """
+        Test lookup s ? o
+        """
+        with store.txn_ctx():
+            gr = Graph(store, data=set(trp))
+
+            flt_gr = gr.lookup((URIRef('urn:s:0'), None, URIRef('urn:o:0')))
+
+            assert len(flt_gr) == 2
+
+            assert trp[0] in flt_gr
+            assert trp[3] in flt_gr
+
+            assert trp[2] not in flt_gr
+            assert trp[4] not in flt_gr
+            assert trp[5] not in flt_gr
+            assert trp[6] not in flt_gr
+
+            # Test for empty results.
+            empty_flt_gr = gr.lookup((URIRef('urn:s:0'), None, URIRef('urn:o:2')))
+
+            assert len(empty_flt_gr) == 0
+
+
+    def test_lookup_po(self, trp, store):
+        """
+        Test lookup ? p o
+        """
+        with store.txn_ctx():
+            gr = Graph(store, data=set(trp))
+
+            flt_gr = gr.lookup((None, URIRef('urn:p:1'), URIRef('urn:o:1')))
+
+            assert len(flt_gr) == 2
+
+            assert trp[4] in flt_gr
+            assert trp[5] in flt_gr
+
+            assert trp[0] not in flt_gr
+            assert trp[2] not in flt_gr
+            assert trp[3] not in flt_gr
+            assert trp[6] not in flt_gr
+
+            # Test for empty results.
+            empty_flt_gr = gr.lookup((None, URIRef('urn:p:1'), URIRef('urn:o:2')))
+
+            assert len(empty_flt_gr) == 0
+
+
+    def test_lookup_spo(self, trp, store):
+        """
+        Test lookup s p o
+        """
+        with store.txn_ctx():
+            gr = Graph(store, data=set(trp))
+
+            flt_gr = gr.lookup(
+                (URIRef('urn:s:1'), URIRef('urn:p:1'), URIRef('urn:o:1'))
+            )
+
+            assert len(flt_gr) == 1
+
+            assert trp[5] in flt_gr
+
+            assert trp[0] not in flt_gr
+            assert trp[2] not in flt_gr
+            assert trp[3] not in flt_gr
+            assert trp[4] not in flt_gr
+            assert trp[6] not in flt_gr
+
+            # Test for empty results.
+            empty_flt_gr = gr.lookup(
+                (URIRef('urn:s:1'), URIRef('urn:p:1'), URIRef('urn:o:2'))
+            )
+
+            assert len(empty_flt_gr) == 0
+
+
+@pytest.mark.usefixtures('trp')
+@pytest.mark.usefixtures('store')
+class TestGraphSlicing:
+    """
+    Test triple lookup.
+    """
+    # TODO
+    pass
+
+
+
+@pytest.mark.usefixtures('trp')
+@pytest.mark.usefixtures('store')
+class TestGraphOps:
+    """
+    Test various graph operations.
+    """
+    def test_len(self, trp, store):
+        """
+        Test the length of a graph with and without duplicates.
+        """
+        with store.txn_ctx():
+            gr = Graph(store)
+            assert len(gr) == 0
+
+            gr.add((trp[0],))
+            assert len(gr) == 1
+
+            gr.add((trp[1],)) # Same values
+            assert len(gr) == 1
+
+            gr.add((trp[2],))
+            assert len(gr) == 2
+
+            gr.add(trp)
+            assert len(gr) == 6
+
+
+    def test_dup(self, trp, store):
+        """
+        Test operations with duplicate triples.
+        """
+        with store.txn_ctx():
+            gr = Graph(store)
+
+            gr.add((trp[0],))
+            assert trp[1] in gr
+            assert trp[2] not in gr
+
+
+    def test_remove(self, trp, store):
+        """
+        Test adding and removing triples.
+        """
+        with store.txn_ctx():
+            gr = Graph(store)
+
+            gr.add(trp)
+            gr.remove(trp[0])
+            assert len(gr) == 5
+            assert trp[0] not in gr
+            assert trp[1] not in gr
+
+            # This is the duplicate triple.
+            gr.remove(trp[1])
+            assert len(gr) == 5
+
+            # This is the triple in reverse order.
+            gr.remove(trp[2])
+            assert len(gr) == 4
+
+            gr.remove(trp[4])
+            assert len(gr) == 3
+
+
+    def test_union(self, trp, store):
+        """
+        Test graph union.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, data={*trp[:3]})
+            gr2 = Graph(store, data={*trp[2:6]})
+
+            gr3 = gr1 | gr2
+
+            assert len(gr3) == 5
+            assert trp[0] in gr3
+            assert trp[4] in gr3
+
+
+    def test_ip_union(self, trp, store):
+        """
+        Test graph in-place union.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, data={*trp[:3]})
+            gr2 = Graph(store, data={*trp[2:6]})
+
+            gr1 |= gr2
+
+            assert len(gr1) == 5
+            assert trp[0] in gr1
+            assert trp[4] in gr1
+
+
+    def test_addition(self, trp, store):
+        """
+        Test graph addition.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, data={*trp[:3]})
+            gr2 = Graph(store, data={*trp[2:6]})
+
+            gr3 = gr1 + gr2
+
+            assert len(gr3) == 5
+            assert trp[0] in gr3
+            assert trp[4] in gr3
+
+
+    def test_ip_addition(self, trp, store):
+        """
+        Test graph in-place addition.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, data={*trp[:3]})
+            gr2 = Graph(store, data={*trp[2:6]})
+
+            gr1 += gr2
+
+            assert len(gr1) == 5
+            assert trp[0] in gr1
+            assert trp[4] in gr1
+
+
+    def test_subtraction(self, trp, store):
+        """
+        Test graph addition.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, data={*trp[:4]})
+            gr2 = Graph(store, data={*trp[2:6]})
+
+            gr3 = gr1 - gr2
+
+            assert len(gr3) == 1
+            assert trp[0] in gr3
+            assert trp[1] in gr3
+            assert trp[2] not in gr3
+            assert trp[3] not in gr3
+            assert trp[4] not in gr3
+
+            gr3 = gr2 - gr1
+
+            assert len(gr3) == 2
+            assert trp[0] not in gr3
+            assert trp[1] not in gr3
+            assert trp[2] not in gr3
+            assert trp[3] not in gr3
+            assert trp[4] in gr3
+            assert trp[5] in gr3
+
+
+    def test_ip_subtraction(self, trp, store):
+        """
+        Test graph in-place addition.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, data={*trp[:4]})
+            gr2 = Graph(store, data={*trp[2:6]})
+
+            gr1 -= gr2
+
+            assert len(gr1) == 1
+            assert trp[0] in gr1
+            assert trp[1] in gr1
+            assert trp[2] not in gr1
+            assert trp[3] not in gr1
+            assert trp[4] not in gr1
+
+
+
+    def test_intersect(self, trp, store):
+        """
+        Test graph intersextion.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, data={*trp[:4]})
+            gr2 = Graph(store, data={*trp[2:6]})
+
+            gr3 = gr1 & gr2
+
+            assert len(gr3) == 2
+            assert trp[2] in gr3
+            assert trp[3] in gr3
+            assert trp[0] not in gr3
+            assert trp[5] not in gr3
+
+
+    def test_ip_intersect(self, trp, store):
+        """
+        Test graph intersextion.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, data={*trp[:4]})
+            gr2 = Graph(store, data={*trp[2:6]})
+
+            gr1 &= gr2
+
+            assert len(gr1) == 2
+            assert trp[2] in gr1
+            assert trp[3] in gr1
+            assert trp[0] not in gr1
+            assert trp[5] not in gr1
+
+
+    def test_xor(self, trp, store):
+        """
+        Test graph intersextion.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, data={*trp[:4]})
+            gr2 = Graph(store, data={*trp[2:6]})
+
+            gr3 = gr1 ^ gr2
+
+            assert len(gr3) == 3
+            assert trp[2] not in gr3
+            assert trp[3] not in gr3
+            assert trp[0] in gr3
+            assert trp[5] in gr3
+
+
+    def test_ip_xor(self, trp, store):
+        """
+        Test graph intersextion.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, data={*trp[:4]})
+            gr2 = Graph(store, data={*trp[2:6]})
+
+            gr1 ^= gr2
+
+            assert len(gr1) == 3
+            assert trp[2] not in gr1
+            assert trp[3] not in gr1
+            assert trp[0] in gr1
+            assert trp[5] in gr1
+
+
+
+@pytest.mark.usefixtures('trp')
+@pytest.mark.usefixtures('store')
+class TestNamedGraphOps:
+    """
+    Test various operations on a named graph.
+    """
+    def test_len(self, trp, store):
+        """
+        Test the length of a graph with and without duplicates.
+        """
+        imr = Graph(store, uri='http://example.edu/imr01')
+        assert len(imr) == 0
+
+        with store.txn_ctx():
+            imr.add((trp[0],))
+            assert len(imr) == 1
+
+            imr.add((trp[1],)) # Same values
+            assert len(imr) == 1
+
+            imr.add((trp[2],))
+            assert len(imr) == 2
+
+            imr.add(trp)
+            assert len(imr) == 6
+
+
+    def test_dup(self, trp, store):
+        """
+        Test operations with duplicate triples.
+        """
+        imr = Graph(store, uri='http://example.edu/imr01')
+
+        with store.txn_ctx():
+            imr.add((trp[0],))
+            assert trp[1] in imr
+            assert trp[2] not in imr
+
+
+    def test_remove(self, trp, store):
+        """
+        Test adding and removing triples.
+        """
+        with store.txn_ctx():
+            imr = Graph(store, uri='http://example.edu/imr01', data={*trp})
+
+            imr.remove(trp[0])
+            assert len(imr) == 5
+            assert trp[0] not in imr
+            assert trp[1] not in imr
+
+            # This is the duplicate triple.
+            imr.remove(trp[1])
+            assert len(imr) == 5
+
+            # This is the triple in reverse order.
+            imr.remove(trp[2])
+            assert len(imr) == 4
+
+            imr.remove(trp[4])
+            assert len(imr) == 3
+
+
+    def test_union(self, trp, store):
+        """
+        Test graph union.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, uri='http://example.edu/imr01', data={*trp[:3]})
+            gr2 = Graph(store, uri='http://example.edu/imr02', data={*trp[2:6]})
+
+            gr3 = gr1 | gr2
+
+            assert len(gr3) == 5
+            assert trp[0] in gr3
+            assert trp[4] in gr3
+
+            assert gr3.uri == None
+
+
+    def test_ip_union(self, trp, store):
+        """
+        Test graph in-place union.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, uri='http://example.edu/imr01', data={*trp[:3]})
+            gr2 = Graph(store, uri='http://example.edu/imr02', data={*trp[2:6]})
+
+            gr1 |= gr2
+
+            assert len(gr1) == 5
+            assert trp[0] in gr1
+            assert trp[4] in gr1
+
+            assert gr1.uri == URIRef('http://example.edu/imr01')
+
+
+    def test_addition(self, trp, store):
+        """
+        Test graph addition.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, uri='http://example.edu/imr01', data={*trp[:3]})
+            gr2 = Graph(store, uri='http://example.edu/imr02', data={*trp[2:6]})
+
+            gr3 = gr1 + gr2
+
+            assert len(gr3) == 5
+            assert trp[0] in gr3
+            assert trp[4] in gr3
+
+            assert gr3.uri == None
+
+
+    def test_ip_addition(self, trp, store):
+        """
+        Test graph in-place addition.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, uri='http://example.edu/imr01', data={*trp[:3]})
+            gr2 = Graph(store, uri='http://example.edu/imr02', data={*trp[2:6]})
+
+            gr1 += gr2
+
+            assert len(gr1) == 5
+            assert trp[0] in gr1
+            assert trp[4] in gr1
+
+            assert gr1.uri == URIRef('http://example.edu/imr01')
+
+
+    def test_subtraction(self, trp, store):
+        """
+        Test graph addition.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, uri='http://example.edu/imr01', data={*trp[:4]})
+            gr2 = Graph(store, uri='http://example.edu/imr02', data={*trp[2:6]})
+
+            gr3 = gr1 - gr2
+
+            assert len(gr3) == 1
+            assert trp[0] in gr3
+            assert trp[1] in gr3
+            assert trp[2] not in gr3
+            assert trp[3] not in gr3
+            assert trp[4] not in gr3
+
+            assert gr3.uri == None
+
+            gr3 = gr2 - gr1
+
+            assert len(gr3) == 2
+            assert trp[0] not in gr3
+            assert trp[1] not in gr3
+            assert trp[2] not in gr3
+            assert trp[3] not in gr3
+            assert trp[4] in gr3
+            assert trp[5] in gr3
+
+            assert gr3.uri == None
+
+
+    def test_ip_subtraction(self, trp, store):
+        """
+        Test graph in-place addition.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, uri='http://example.edu/imr01', data={*trp[:4]})
+            gr2 = Graph(store, uri='http://example.edu/imr02', data={*trp[2:6]})
+
+            gr1 -= gr2
+
+            assert len(gr1) == 1
+            assert trp[0] in gr1
+            assert trp[1] in gr1
+            assert trp[2] not in gr1
+            assert trp[3] not in gr1
+            assert trp[4] not in gr1
+
+            assert gr1.uri == URIRef('http://example.edu/imr01')
+
+
+
+    def test_intersect(self, trp, store):
+        """
+        Test graph intersextion.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, uri='http://example.edu/imr01', data={*trp[:4]})
+            gr2 = Graph(store, uri='http://example.edu/imr02', data={*trp[2:6]})
+
+            gr3 = gr1 & gr2
+
+            assert len(gr3) == 2
+            assert trp[2] in gr3
+            assert trp[3] in gr3
+            assert trp[0] not in gr3
+            assert trp[5] not in gr3
+
+            assert gr3.uri == None
+
+
+    def test_ip_intersect(self, trp, store):
+        """
+        Test graph intersextion.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, uri='http://example.edu/imr01', data={*trp[:4]})
+            gr2 = Graph(store, uri='http://example.edu/imr02', data={*trp[2:6]})
+
+            gr1 &= gr2
+
+            assert len(gr1) == 2
+            assert trp[2] in gr1
+            assert trp[3] in gr1
+            assert trp[0] not in gr1
+            assert trp[5] not in gr1
+
+            assert gr1.uri == URIRef('http://example.edu/imr01')
+
+
+    def test_xor(self, trp, store):
+        """
+        Test graph intersextion.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, uri='http://example.edu/imr01', data={*trp[:4]})
+            gr2 = Graph(store, uri='http://example.edu/imr02', data={*trp[2:6]})
+
+            gr3 = gr1 ^ gr2
+
+            assert len(gr3) == 3
+            assert trp[2] not in gr3
+            assert trp[3] not in gr3
+            assert trp[0] in gr3
+            assert trp[5] in gr3
+
+            assert gr3.uri == None
+
+
+    def test_ip_xor(self, trp, store):
+        """
+        Test graph intersextion.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, uri='http://example.edu/imr01', data={*trp[:4]})
+            gr2 = Graph(store, uri='http://example.edu/imr02', data={*trp[2:6]})
+
+            gr1 ^= gr2
+
+            assert len(gr1) == 3
+            assert trp[2] not in gr1
+            assert trp[3] not in gr1
+            assert trp[0] in gr1
+            assert trp[5] in gr1
+
+            assert gr1.uri == URIRef('http://example.edu/imr01')
+
+
+@pytest.mark.usefixtures('trp')
+@pytest.mark.usefixtures('store')
+class TestHybridOps:
+    """
+    Test operations between IMR and graph.
+    """
+    def test_hybrid_union(self, trp, store):
+        """
+        Test hybrid IMR + graph union.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, uri='http://example.edu/imr01', data={*trp[:3]})
+            gr2 = Graph(store, data={*trp[2:6]})
+
+            gr3 = gr1 | gr2
+
+            assert len(gr3) == 5
+            assert trp[0] in gr3
+            assert trp[4] in gr3
+
+            assert isinstance(gr3, Graph)
+            assert gr3.uri == None
+
+            gr4 = gr2 | gr1
+
+            assert isinstance(gr4, Graph)
+
+            assert gr3 == gr4
+
+
+    def test_ip_union_imr(self, trp, store):
+        """
+        Test IMR + graph in-place union.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, uri='http://example.edu/imr01', data={*trp[:3]})
+            gr2 = Graph(store, data={*trp[2:6]})
+
+            gr1 |= gr2
+
+            assert len(gr1) == 5
+            assert trp[0] in gr1
+            assert trp[4] in gr1
+
+            assert gr1.uri == URIRef('http://example.edu/imr01')
+
+
+    def test_ip_union_gr(self, trp, store):
+        """
+        Test graph + IMR in-place union.
+        """
+        with store.txn_ctx():
+            gr1 = Graph(store, data={*trp[:3]})
+            gr2 = Graph(store, uri='http://example.edu/imr01', data={*trp[2:6]})
+
+            gr1 |= gr2
+
+            assert len(gr1) == 5
+            assert trp[0] in gr1
+            assert trp[4] in gr1
+
+            assert isinstance(gr1, Graph)

+ 121 - 29
tests/0_store/test_lmdb_store.py → tests/1_store/test_1_0_lmdb_store.py

@@ -1,12 +1,15 @@
+import pdb
 import pytest
 
 from os import path
 from shutil import rmtree
 
-from rdflib import Graph, Namespace, URIRef
+from rdflib import Namespace, URIRef
 from rdflib.graph import DATASET_DEFAULT_GRAPH_ID as RDFLIB_DEFAULT_GRAPH_URI
 from rdflib.namespace import RDF, RDFS
 
+from lakesuperior.model.rdf.graph import Graph
+from lakesuperior.store.base_lmdb_store import LmdbError
 from lakesuperior.store.ldp_rs.lmdb_store import LmdbStore
 
 
@@ -67,6 +70,12 @@ class TestStoreInit:
         assert not path.exists(env_path + '-lock')
 
 
+
+@pytest.mark.usefixtures('store')
+class TestTransactionContext:
+    '''
+    Tests for intializing and shutting down store and transactions.
+    '''
     def test_txn(self, store):
         '''
         Test opening and closing the main transaction.
@@ -106,20 +115,80 @@ class TestStoreInit:
         '''
         Test rolling back a transaction.
         '''
+        trp = (
+            URIRef('urn:nogo:s'), URIRef('urn:nogo:p'), URIRef('urn:nogo:o')
+        )
         try:
             with store.txn_ctx(True):
-                store.add((
-                    URIRef('urn:nogo:s'), URIRef('urn:nogo:p'),
-                    URIRef('urn:nogo:o')))
+                store.add(trp)
                 raise RuntimeError() # This should roll back the transaction.
         except RuntimeError:
             pass
 
         with store.txn_ctx():
-            res = set(store.triples((None, None, None)))
+            res = set(store.triples(trp))
         assert len(res) == 0
 
 
+    def test_nested_ro_txn(self, store):
+        """
+        Test two nested RO transactions.
+        """
+        trp = (URIRef('urn:s:0'), URIRef('urn:p:0'), URIRef('urn:o:0'))
+        with store.txn_ctx(True):
+            store.add(trp)
+        with store.txn_ctx():
+            with store.txn_ctx():
+                res = {*store.triples(trp)}
+                assert trp in {q[0] for q in res}
+            assert trp in {q[0] for q in res}
+
+
+    def test_nested_ro_txn_nowrite(self, store):
+        """
+        Test two nested RO transactions.
+        """
+        trp = (URIRef('urn:s:0'), URIRef('urn:p:0'), URIRef('urn:o:0'))
+        with pytest.raises(LmdbError):
+            with store.txn_ctx():
+                with store.txn_ctx():
+                    store.add(trp)
+
+
+    def test_nested_ro_rw_txn(self, store):
+        """
+        Test a RO transaction nested into a RW one.
+        """
+        trp = (URIRef('urn:s:1'), URIRef('urn:p:1'), URIRef('urn:o:1'))
+        with store.txn_ctx():
+            with store.txn_ctx(True):
+                store.add(trp)
+            # Outer txn should now see the new triple.
+            assert trp in {q[0] for q in store.triples(trp)}
+
+
+    def test_nested_rw_ro_txn(self, store):
+        """
+        Test that a RO transaction nested in a RW transaction can write.
+        """
+        trp = (URIRef('urn:s:2'), URIRef('urn:p:2'), URIRef('urn:o:2'))
+        with store.txn_ctx(True):
+            with store.txn_ctx():
+                store.add(trp)
+            assert trp in {q[0] for q in store.triples(trp)}
+
+
+    def test_nested_rw_rw_txn(self, store):
+        """
+        Test that a RW transaction nested in a RW transaction can write.
+        """
+        trp = (URIRef('urn:s:3'), URIRef('urn:p:3'), URIRef('urn:o:3'))
+        with store.txn_ctx(True):
+            with store.txn_ctx():
+                store.add(trp)
+            assert trp in {q[0] for q in store.triples(trp)}
+
+
 @pytest.mark.usefixtures('store')
 class TestBasicOps:
     '''
@@ -257,6 +326,42 @@ class TestBasicOps:
 
 
 
+@pytest.mark.usefixtures('store', 'bogus_trp')
+class TestExtendedOps:
+    '''
+    Test additional store operations.
+    '''
+
+    def test_all_terms(self, store, bogus_trp):
+        """
+        Test the "all terms" mehods.
+        """
+        with store.txn_ctx(True):
+            for trp in bogus_trp:
+                store.add(trp)
+
+        with store.txn_ctx():
+            all_s = store.all_terms('s')
+            all_p = store.all_terms('p')
+            all_o = store.all_terms('o')
+
+        assert len(all_s) == 1
+        assert len(all_p) == 100
+        assert len(all_o) == 1000
+
+        assert URIRef('urn:test_mp:s1') in all_s
+        assert URIRef('urn:test_mp:s1') not in all_p
+        assert URIRef('urn:test_mp:s1') not in all_o
+
+        assert URIRef('urn:test_mp:p10') not in all_s
+        assert URIRef('urn:test_mp:p10') in all_p
+        assert URIRef('urn:test_mp:p10') not in all_o
+
+        assert URIRef('urn:test_mp:o99') not in all_s
+        assert URIRef('urn:test_mp:o99') not in all_p
+        assert URIRef('urn:test_mp:o99') in all_o
+
+
 @pytest.mark.usefixtures('store', 'bogus_trp')
 class TestEntryCount:
     '''
@@ -648,7 +753,7 @@ class TestContext:
 
         with store.txn_ctx(True):
             store.add_graph(gr_uri)
-            assert gr_uri in {gr.identifier for gr in store.contexts()}
+            assert gr_uri in store.contexts()
 
 
     def test_add_graph_with_triple(self, store):
@@ -663,7 +768,7 @@ class TestContext:
             store.add(trp, ctx_uri)
 
         with store.txn_ctx():
-            assert ctx_uri in {gr.identifier for gr in store.contexts(trp)}
+            assert ctx_uri in store.contexts(trp)
 
 
     def test_empty_context(self, store):
@@ -674,10 +779,10 @@ class TestContext:
 
         with store.txn_ctx(True):
             store.add_graph(gr_uri)
-            assert gr_uri in {gr.identifier for gr in store.contexts()}
+            assert gr_uri in store.contexts()
         with store.txn_ctx(True):
             store.remove_graph(gr_uri)
-            assert gr_uri not in {gr.identifier for gr in store.contexts()}
+            assert gr_uri not in store.contexts()
 
 
     def test_context_ro_txn(self, store):
@@ -697,10 +802,10 @@ class TestContext:
         # allow a lookup in the same transaction, but this does not seem to be
         # possible.
         with store.txn_ctx():
-            assert gr_uri in {gr.identifier for gr in store.contexts()}
+            assert gr_uri in store.contexts()
         with store.txn_ctx(True):
             store.remove_graph(gr_uri)
-            assert gr_uri not in {gr.identifier for gr in store.contexts()}
+            assert gr_uri not in store.contexts()
 
 
     def test_add_trp_to_ctx(self, store):
@@ -731,7 +836,7 @@ class TestContext:
             assert len(set(store.triples((None, None, None), gr_uri))) == 3
             assert len(set(store.triples((None, None, None), gr2_uri))) == 1
 
-            assert gr2_uri in {gr.identifier for gr in store.contexts()}
+            assert gr2_uri in store.contexts()
             assert trp1 in _clean(store.triples((None, None, None)))
             assert trp1 not in _clean(store.triples((None, None, None),
                     RDFLIB_DEFAULT_GRAPH_URI))
@@ -747,11 +852,11 @@ class TestContext:
             res_no_ctx = store.triples(trp3)
             res_ctx = store.triples(trp3, gr2_uri)
             for res in res_no_ctx:
-                assert Graph(identifier=gr_uri) in res[1]
-                assert Graph(identifier=gr2_uri) in res[1]
+                assert Graph(uri=gr_uri) in res[1]
+                assert Graph(uri=gr2_uri) in res[1]
             for res in res_ctx:
-                assert Graph(identifier=gr_uri) in res[1]
-                assert Graph(identifier=gr2_uri) in res[1]
+                assert Graph(uri=gr_uri) in res[1]
+                assert Graph(uri=gr2_uri) in res[1]
 
 
     def test_delete_from_ctx(self, store):
@@ -825,19 +930,6 @@ class TestContext:
             assert len(set(store.triples(trp3))) == 1
 
 
-
-
-
-
-@pytest.mark.usefixtures('store')
-class TestTransactions:
-    '''
-    Tests for transaction handling.
-    '''
-    # @TODO Test concurrent reads and writes.
-    pass
-
-
 #@pytest.mark.usefixtures('store')
 #class TestRdflib:
 #    '''

+ 138 - 106
tests/1_api/test_resource_api.py → tests/2_api/test_2_0_resource_api.py

@@ -4,7 +4,7 @@ import pytest
 from io import BytesIO
 from uuid import uuid4
 
-from rdflib import Graph, Literal, URIRef
+from rdflib import Literal, URIRef
 
 from lakesuperior import env
 from lakesuperior.api import resource as rsrc_api
@@ -13,8 +13,8 @@ from lakesuperior.exceptions import (
         IncompatibleLdpTypeError, InvalidResourceError, ResourceNotExistsError,
         TombstoneError)
 from lakesuperior.globals import RES_CREATED, RES_UPDATED
-from lakesuperior.model.ldpr import Ldpr
-from lakesuperior.store.ldp_rs.lmdb_triplestore import SimpleGraph, Imr
+from lakesuperior.model.ldp.ldpr import Ldpr
+from lakesuperior.model.rdf.graph import Graph, from_rdf
 
 
 @pytest.fixture(scope='module')
@@ -67,10 +67,13 @@ class TestResourceCRUD:
         The ``dcterms:title`` property should NOT be included.
         """
         gr = rsrc_api.get_metadata('/')
-        assert isinstance(gr, SimpleGraph)
+        assert isinstance(gr, Graph)
         assert len(gr) == 9
-        assert gr[gr.uri : nsc['rdf'].type : nsc['ldp'].Resource ]
-        assert not gr[gr.uri : nsc['dcterms'].title : "Repository Root"]
+        with env.app_globals.rdf_store.txn_ctx():
+            assert gr[gr.uri : nsc['rdf'].type : nsc['ldp'].Resource ]
+            assert not gr[
+                gr.uri : nsc['dcterms'].title : Literal("Repository Root")
+            ]
 
 
     def test_get_root_node(self):
@@ -83,9 +86,10 @@ class TestResourceCRUD:
         assert isinstance(rsrc, Ldpr)
         gr = rsrc.imr
         assert len(gr) == 10
-        assert gr[gr.uri : nsc['rdf'].type : nsc['ldp'].Resource ]
-        assert gr[
-            gr.uri : nsc['dcterms'].title : Literal('Repository Root')]
+        with env.app_globals.rdf_store.txn_ctx():
+            assert gr[gr.uri : nsc['rdf'].type : nsc['ldp'].Resource ]
+            assert gr[
+                gr.uri : nsc['dcterms'].title : Literal('Repository Root')]
 
 
     def test_get_nonexisting_node(self):
@@ -102,16 +106,18 @@ class TestResourceCRUD:
         """
         uid = '/rsrc_from_graph'
         uri = nsc['fcres'][uid]
-        gr = Graph().parse(
-            data='<> a <http://ex.org/type#A> .', format='turtle',
-            publicID=uri)
+        with env.app_globals.rdf_store.txn_ctx():
+            gr = from_rdf(
+                data='<> a <http://ex.org/type#A> .', format='turtle',
+                publicID=uri)
         evt, _ = rsrc_api.create_or_replace(uid, graph=gr)
 
         rsrc = rsrc_api.get(uid)
-        assert rsrc.imr[
-                rsrc.uri : nsc['rdf'].type : URIRef('http://ex.org/type#A')]
-        assert rsrc.imr[
-                rsrc.uri : nsc['rdf'].type : nsc['ldp'].RDFSource]
+        with env.app_globals.rdf_store.txn_ctx():
+            assert rsrc.imr[
+                    rsrc.uri : nsc['rdf'].type : URIRef('http://ex.org/type#A')]
+            assert rsrc.imr[
+                    rsrc.uri : nsc['rdf'].type : nsc['ldp'].RDFSource]
 
 
     def test_create_ldp_nr(self):
@@ -124,38 +130,45 @@ class TestResourceCRUD:
                 uid, stream=BytesIO(data), mimetype='text/plain')
 
         rsrc = rsrc_api.get(uid)
-        assert rsrc.content.read() == data
+        with rsrc.imr.store.txn_ctx():
+            assert rsrc.content.read() == data
 
 
     def test_replace_rsrc(self):
         uid = '/test_replace'
         uri = nsc['fcres'][uid]
-        gr1 = Graph().parse(
-            data='<> a <http://ex.org/type#A> .', format='turtle',
-            publicID=uri)
+        with env.app_globals.rdf_store.txn_ctx():
+            gr1 = from_rdf(
+                data='<> a <http://ex.org/type#A> .', format='turtle',
+                publicID=uri
+            )
         evt, _ = rsrc_api.create_or_replace(uid, graph=gr1)
         assert evt == RES_CREATED
 
         rsrc = rsrc_api.get(uid)
-        assert rsrc.imr[
-                rsrc.uri : nsc['rdf'].type : URIRef('http://ex.org/type#A')]
-        assert rsrc.imr[
-                rsrc.uri : nsc['rdf'].type : nsc['ldp'].RDFSource]
-
-        gr2 = Graph().parse(
-            data='<> a <http://ex.org/type#B> .', format='turtle',
-            publicID=uri)
+        with env.app_globals.rdf_store.txn_ctx():
+            assert rsrc.imr[
+                    rsrc.uri : nsc['rdf'].type : URIRef('http://ex.org/type#A')]
+            assert rsrc.imr[
+                    rsrc.uri : nsc['rdf'].type : nsc['ldp'].RDFSource]
+
+        with env.app_globals.rdf_store.txn_ctx():
+            gr2 = from_rdf(
+                data='<> a <http://ex.org/type#B> .', format='turtle',
+                publicID=uri
+            )
         #pdb.set_trace()
         evt, _ = rsrc_api.create_or_replace(uid, graph=gr2)
         assert evt == RES_UPDATED
 
         rsrc = rsrc_api.get(uid)
-        assert not rsrc.imr[
-                rsrc.uri : nsc['rdf'].type : URIRef('http://ex.org/type#A')]
-        assert rsrc.imr[
-                rsrc.uri : nsc['rdf'].type : URIRef('http://ex.org/type#B')]
-        assert rsrc.imr[
-                rsrc.uri : nsc['rdf'].type : nsc['ldp'].RDFSource]
+        with env.app_globals.rdf_store.txn_ctx():
+            assert not rsrc.imr[
+                    rsrc.uri : nsc['rdf'].type : URIRef('http://ex.org/type#A')]
+            assert rsrc.imr[
+                    rsrc.uri : nsc['rdf'].type : URIRef('http://ex.org/type#B')]
+            assert rsrc.imr[
+                    rsrc.uri : nsc['rdf'].type : nsc['ldp'].RDFSource]
 
 
     def test_replace_incompatible_type(self):
@@ -167,9 +180,11 @@ class TestResourceCRUD:
         uid_rs = '/test_incomp_rs'
         uid_nr = '/test_incomp_nr'
         data = b'mock binary content'
-        gr = Graph().parse(
-            data='<> a <http://ex.org/type#A> .', format='turtle',
-            publicID=nsc['fcres'][uid_rs])
+        with env.app_globals.rdf_store.txn_ctx():
+            gr = from_rdf(
+                data='<> a <http://ex.org/type#A> .', format='turtle',
+                publicID=nsc['fcres'][uid_rs]
+            )
 
         rsrc_api.create_or_replace(uid_rs, graph=gr)
         rsrc_api.create_or_replace(
@@ -203,17 +218,18 @@ class TestResourceCRUD:
             (URIRef(uri), nsc['rdf'].type, nsc['foaf'].Organization),
         }
 
-        gr = Graph()
-        gr += init_trp
+        with env.app_globals.rdf_store.txn_ctx():
+            gr = Graph(data=init_trp)
         rsrc_api.create_or_replace(uid, graph=gr)
         rsrc_api.update_delta(uid, remove_trp, add_trp)
         rsrc = rsrc_api.get(uid)
 
-        assert rsrc.imr[
-                rsrc.uri : nsc['rdf'].type : nsc['foaf'].Organization]
-        assert rsrc.imr[rsrc.uri : nsc['foaf'].name : Literal('Joe Bob')]
-        assert not rsrc.imr[
-                rsrc.uri : nsc['rdf'].type : nsc['foaf'].Person]
+        with env.app_globals.rdf_store.txn_ctx():
+            assert rsrc.imr[
+                    rsrc.uri : nsc['rdf'].type : nsc['foaf'].Organization]
+            assert rsrc.imr[rsrc.uri : nsc['foaf'].name : Literal('Joe Bob')]
+            assert not rsrc.imr[
+                    rsrc.uri : nsc['rdf'].type : nsc['foaf'].Person]
 
 
     def test_delta_update_wildcard(self):
@@ -235,20 +251,21 @@ class TestResourceCRUD:
             (URIRef(uri), nsc['foaf'].name, Literal('Joan Knob')),
         }
 
-        gr = Graph()
-        gr += init_trp
+        with env.app_globals.rdf_store.txn_ctx():
+            gr = Graph(data=init_trp)
         rsrc_api.create_or_replace(uid, graph=gr)
         rsrc_api.update_delta(uid, remove_trp, add_trp)
         rsrc = rsrc_api.get(uid)
 
-        assert rsrc.imr[
-                rsrc.uri : nsc['rdf'].type : nsc['foaf'].Person]
-        assert rsrc.imr[rsrc.uri : nsc['foaf'].name : Literal('Joan Knob')]
-        assert not rsrc.imr[rsrc.uri : nsc['foaf'].name : Literal('Joe Bob')]
-        assert not rsrc.imr[
-            rsrc.uri : nsc['foaf'].name : Literal('Joe Average Bob')]
-        assert not rsrc.imr[
-            rsrc.uri : nsc['foaf'].name : Literal('Joe 12oz Bob')]
+        with env.app_globals.rdf_store.txn_ctx():
+            assert rsrc.imr[
+                    rsrc.uri : nsc['rdf'].type : nsc['foaf'].Person]
+            assert rsrc.imr[rsrc.uri : nsc['foaf'].name : Literal('Joan Knob')]
+            assert not rsrc.imr[rsrc.uri : nsc['foaf'].name : Literal('Joe Bob')]
+            assert not rsrc.imr[
+                rsrc.uri : nsc['foaf'].name : Literal('Joe Average Bob')]
+            assert not rsrc.imr[
+                rsrc.uri : nsc['foaf'].name : Literal('Joe 12oz Bob')]
 
 
     def test_sparql_update(self):
@@ -272,19 +289,20 @@ class TestResourceCRUD:
         ver_uid = rsrc_api.create_version(uid, 'v1').split('fcr:versions/')[-1]
 
         rsrc = rsrc_api.update(uid, update_str)
-        assert (
-            (rsrc.uri, nsc['dcterms'].title, Literal('Original title.'))
-            not in set(rsrc.imr))
-        assert (
-            (rsrc.uri, nsc['dcterms'].title, Literal('Title #2.'))
-            in set(rsrc.imr))
-        assert (
-            (rsrc.uri, nsc['dcterms'].title, Literal('Title #3.'))
-            in set(rsrc.imr))
-        assert ((
-                URIRef(str(rsrc.uri) + '#h1'),
-                nsc['dcterms'].title, Literal('This is a hash.'))
-            in set(rsrc.imr))
+        with env.app_globals.rdf_store.txn_ctx():
+            assert (
+                (rsrc.uri, nsc['dcterms'].title, Literal('Original title.'))
+                not in set(rsrc.imr))
+            assert (
+                (rsrc.uri, nsc['dcterms'].title, Literal('Title #2.'))
+                in set(rsrc.imr))
+            assert (
+                (rsrc.uri, nsc['dcterms'].title, Literal('Title #3.'))
+                in set(rsrc.imr))
+            assert ((
+                    URIRef(str(rsrc.uri) + '#h1'),
+                    nsc['dcterms'].title, Literal('This is a hash.'))
+                in set(rsrc.imr))
 
 
     def test_create_ldp_dc_post(self, dc_rdf):
@@ -297,8 +315,9 @@ class TestResourceCRUD:
 
         member_rsrc = rsrc_api.get('/member')
 
-        assert nsc['ldp'].Container in dc_rsrc.ldp_types
-        assert nsc['ldp'].DirectContainer in dc_rsrc.ldp_types
+        with env.app_globals.rdf_store.txn_ctx():
+            assert nsc['ldp'].Container in dc_rsrc.ldp_types
+            assert nsc['ldp'].DirectContainer in dc_rsrc.ldp_types
 
 
     def test_create_ldp_dc_put(self, dc_rdf):
@@ -311,8 +330,9 @@ class TestResourceCRUD:
 
         member_rsrc = rsrc_api.get('/member')
 
-        assert nsc['ldp'].Container in dc_rsrc.ldp_types
-        assert nsc['ldp'].DirectContainer in dc_rsrc.ldp_types
+        with env.app_globals.rdf_store.txn_ctx():
+            assert nsc['ldp'].Container in dc_rsrc.ldp_types
+            assert nsc['ldp'].DirectContainer in dc_rsrc.ldp_types
 
 
     def test_add_dc_member(self, dc_rdf):
@@ -326,8 +346,9 @@ class TestResourceCRUD:
         child_uid = rsrc_api.create(dc_uid, None).uid
         member_rsrc = rsrc_api.get('/member')
 
-        assert member_rsrc.imr[
-            member_rsrc.uri: nsc['dcterms'].relation: nsc['fcres'][child_uid]]
+        with env.app_globals.rdf_store.txn_ctx():
+            assert member_rsrc.imr[
+                member_rsrc.uri: nsc['dcterms'].relation: nsc['fcres'][child_uid]]
 
 
     def test_indirect_container(self, ic_rdf):
@@ -349,15 +370,17 @@ class TestResourceCRUD:
                 member_uid, rdf_data=ic_member_rdf, rdf_fmt='turtle')
 
         ic_rsrc = rsrc_api.get(ic_uid)
-        assert nsc['ldp'].Container in ic_rsrc.ldp_types
-        assert nsc['ldp'].IndirectContainer in ic_rsrc.ldp_types
-        assert nsc['ldp'].DirectContainer not in ic_rsrc.ldp_types
+        with env.app_globals.rdf_store.txn_ctx():
+            assert nsc['ldp'].Container in ic_rsrc.ldp_types
+            assert nsc['ldp'].IndirectContainer in ic_rsrc.ldp_types
+            assert nsc['ldp'].DirectContainer not in ic_rsrc.ldp_types
 
         member_rsrc = rsrc_api.get(member_uid)
         top_cont_rsrc = rsrc_api.get(cont_uid)
-        assert top_cont_rsrc.imr[
-            top_cont_rsrc.uri: nsc['dcterms'].relation:
-            nsc['fcres'][target_uid]]
+        with env.app_globals.rdf_store.txn_ctx():
+            assert top_cont_rsrc.imr[
+                top_cont_rsrc.uri: nsc['dcterms'].relation:
+                nsc['fcres'][target_uid]]
 
 
 
@@ -387,7 +410,8 @@ class TestAdvancedDelete:
         rsrc_api.resurrect(uid)
 
         rsrc = rsrc_api.get(uid)
-        assert nsc['ldp'].Resource in rsrc.ldp_types
+        with env.app_globals.rdf_store.txn_ctx():
+            assert nsc['ldp'].Resource in rsrc.ldp_types
 
 
     def test_hard_delete(self):
@@ -431,10 +455,12 @@ class TestAdvancedDelete:
         uid = '/test_soft_delete_children01'
         rsrc_api.resurrect(uid)
         parent_rsrc = rsrc_api.get(uid)
-        assert nsc['ldp'].Resource in parent_rsrc.ldp_types
+        with env.app_globals.rdf_store.txn_ctx():
+            assert nsc['ldp'].Resource in parent_rsrc.ldp_types
         for i in range(3):
             child_rsrc = rsrc_api.get('{}/child{}'.format(uid, i))
-            assert nsc['ldp'].Resource in child_rsrc.ldp_types
+            with env.app_globals.rdf_store.txn_ctx():
+                assert nsc['ldp'].Resource in child_rsrc.ldp_types
 
 
     def test_hard_delete_children(self):
@@ -513,24 +539,26 @@ class TestResourceVersioning:
         rsrc_api.create_or_replace(uid, rdf_data=rdf_data, rdf_fmt='turtle')
         ver_uid = rsrc_api.create_version(uid, 'v1').split('fcr:versions/')[-1]
         #FIXME Without this, the test fails.
-        set(rsrc_api.get_version(uid, ver_uid))
+        #set(rsrc_api.get_version(uid, ver_uid))
 
         rsrc_api.update(uid, update_str)
         current = rsrc_api.get(uid)
-        assert (
-            (current.uri, nsc['dcterms'].title, Literal('Title #2.'))
-            in current.imr)
-        assert (
-            (current.uri, nsc['dcterms'].title, Literal('Original title.'))
-            not in current.imr)
+        with env.app_globals.rdf_store.txn_ctx():
+            assert (
+                (current.uri, nsc['dcterms'].title, Literal('Title #2.'))
+                in current.imr)
+            assert (
+                (current.uri, nsc['dcterms'].title, Literal('Original title.'))
+                not in current.imr)
 
         v1 = rsrc_api.get_version(uid, ver_uid)
-        assert (
-            (v1.uri, nsc['dcterms'].title, Literal('Original title.'))
-            in set(v1))
-        assert (
-            (v1.uri, nsc['dcterms'].title, Literal('Title #2.'))
-            not in set(v1))
+        with env.app_globals.rdf_store.txn_ctx():
+            assert (
+                (v1.uri, nsc['dcterms'].title, Literal('Original title.'))
+                in set(v1))
+            assert (
+                (v1.uri, nsc['dcterms'].title, Literal('Title #2.'))
+                    not in set(v1))
 
 
     def test_revert_to_version(self):
@@ -543,9 +571,10 @@ class TestResourceVersioning:
         ver_uid = 'v1'
         rsrc_api.revert_to_version(uid, ver_uid)
         rev = rsrc_api.get(uid)
-        assert (
-            (rev.uri, nsc['dcterms'].title, Literal('Original title.'))
-            in rev.imr)
+        with env.app_globals.rdf_store.txn_ctx():
+            assert (
+                (rev.uri, nsc['dcterms'].title, Literal('Original title.'))
+                in rev.imr)
 
 
     def test_versioning_children(self):
@@ -567,18 +596,21 @@ class TestResourceVersioning:
         rsrc_api.create_or_replace(ch1_uid)
         ver_uid = rsrc_api.create_version(uid, ver_uid).split('fcr:versions/')[-1]
         rsrc = rsrc_api.get(uid)
-        assert nsc['fcres'][ch1_uid] in rsrc.imr[
-                rsrc.uri : nsc['ldp'].contains]
+        with env.app_globals.rdf_store.txn_ctx():
+            assert nsc['fcres'][ch1_uid] in rsrc.imr[
+                    rsrc.uri : nsc['ldp'].contains]
 
         rsrc_api.create_or_replace(ch2_uid)
         rsrc = rsrc_api.get(uid)
-        assert nsc['fcres'][ch2_uid] in rsrc.imr[
-                rsrc.uri : nsc['ldp'].contains]
+        with env.app_globals.rdf_store.txn_ctx():
+            assert nsc['fcres'][ch2_uid] in rsrc.imr[
+                    rsrc.uri : nsc['ldp'].contains]
 
         rsrc_api.revert_to_version(uid, ver_uid)
         rsrc = rsrc_api.get(uid)
-        assert nsc['fcres'][ch1_uid] in rsrc.imr[
-                rsrc.uri : nsc['ldp'].contains]
-        assert nsc['fcres'][ch2_uid] in rsrc.imr[
-                rsrc.uri : nsc['ldp'].contains]
+        with env.app_globals.rdf_store.txn_ctx():
+            assert nsc['fcres'][ch1_uid] in rsrc.imr[
+                    rsrc.uri : nsc['ldp'].contains]
+            assert nsc['fcres'][ch2_uid] in rsrc.imr[
+                    rsrc.uri : nsc['ldp'].contains]
 

+ 11 - 6
tests/1_api/test_admin_api.py → tests/2_api/test_2_1_admin_api.py

@@ -4,13 +4,14 @@ import pytest
 from io import BytesIO
 from uuid import uuid4
 
-from rdflib import Graph, URIRef
+from rdflib import URIRef
 
 from lakesuperior import env
 from lakesuperior.api import resource as rsrc_api
 from lakesuperior.api import admin as admin_api
 from lakesuperior.dictionaries.namespaces import ns_collection as nsc
 from lakesuperior.exceptions import ChecksumValidationError
+from lakesuperior.model.rdf.graph import Graph, from_rdf
 
 
 @pytest.mark.usefixtures('db')
@@ -25,9 +26,12 @@ class TestAdminApi:
         """
         uid1 = '/test_refint1'
         uid2 = '/test_refint2'
-        gr = Graph().parse(
-                data='<> <http://ex.org/ns#p1> <info:fcres{}> .'.format(uid1),
-                format='turtle', publicID=nsc['fcres'][uid2])
+        with env.app_globals.rdf_store.txn_ctx():
+            gr = from_rdf(
+                store=env.app_globals.rdf_store,
+                data=f'<> <http://ex.org/ns#p1> <info:fcres{uid1}> .',
+                format='turtle', publicID=nsc['fcres'][uid2]
+            )
         rsrc_api.create_or_replace(uid1, graph=gr)
 
         assert admin_api.integrity_check() == set()
@@ -76,8 +80,9 @@ class TestAdminApi:
 
         _, rsrc = rsrc_api.create_or_replace(uid, stream=content)
 
-        with open(rsrc.local_path, 'wb') as fh:
-            fh.write(uuid4().bytes)
+        with env.app_globals.rdf_store.txn_ctx():
+            with open(rsrc.local_path, 'wb') as fh:
+                fh.write(uuid4().bytes)
 
         with pytest.raises(ChecksumValidationError):
             admin_api.fixity_check(uid)

+ 1 - 1
tests/2_endpoints/test_ldp.py → tests/3_endpoints/test_3_0_ldp.py

@@ -18,7 +18,7 @@ from rdflib.term import Literal, URIRef
 
 from lakesuperior import env
 from lakesuperior.dictionaries.namespaces import ns_collection as nsc
-from lakesuperior.model.ldpr import Ldpr
+from lakesuperior.model.ldp.ldpr import Ldpr
 
 
 digest_algo = env.app_globals.config['application']['uuid']['algo']

+ 4 - 1
tests/2_endpoints/test_admin.py → tests/3_endpoints/test_3_1_admin.py

@@ -3,6 +3,7 @@ import pytest
 from io import BytesIO
 from uuid import uuid4
 
+from lakesuperior import env
 from lakesuperior.api import resource as rsrc_api
 
 
@@ -46,7 +47,9 @@ class TestAdminApi:
 
         rsrc = rsrc_api.get(f'/{uid}')
 
-        with open(rsrc.local_path, 'wb') as fh:
+        with env.app_globals.rdf_store.txn_ctx():
+            fname = rsrc.local_path
+        with open(fname, 'wb') as fh:
             fh.write(uuid4().bytes)
 
         rsp = self.client.get(fix_path)

+ 0 - 0
tests/2_endpoints/test_query.py → tests/3_endpoints/test_3_2_query.py


+ 0 - 0
tests/3_ancillary/test_toolbox.py → tests/4_ancillary/test_4_0_toolbox.py


Some files were not shown because too many files changed in this diff