Repository: ontolocy/neontology (language: python)

Issue:
tidying and updating docs



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f2403b0..51d960e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,12 +24,20 @@ jobs:
         # environment variables
         env:
           NEO4J_AUTH: ${{secrets.TEST_NEO4J_AUTH}}
+      testmemgraph:
+        image: memgraph/memgraph:latest
+        ports:
+          - 9687:7687
+          - 7444:7444
+        env:
+          MEMGRAPH_USER: ${{secrets.TEST_MEMGRAPH_USER}}
+          MEMGRAPH_PASSWORD: ${{secrets.TEST_MEMGRAPH_PASSWORD}}
 
     strategy:
       fail-fast: true
-      max-parallel: 2
+      max-parallel: 1
       matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
+        python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
 
     steps:
       - uses: actions/checkout@v4
@@ -53,5 +61,8 @@ jobs:
           TEST_NEO4J_URI: bolt://localhost:7687
           TEST_NEO4J_USERNAME: neo4j
           TEST_NEO4J_PASSWORD: ${{secrets.TEST_NEO4J_PASSWORD}}
+          TEST_MEMGRAPH_URI: bolt://localhost:9687
+          TEST_MEMGRAPH_USER: ${{secrets.TEST_MEMGRAPH_USER}}
+          TEST_MEMGRAPH_PASSWORD: ${{secrets.TEST_MEMGRAPH_PASSWORD}}
         run: |
-          pytest -x
+          pytest -x --benchmark-skip
diff --git a/.gitignore b/.gitignore
index f7a8a3b..e5b39ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,7 +3,7 @@
 .pyre_configuration
 .watchmanconfig
 .tenv
-docker-compose.yml
+docker-compose*
 
 # Byte-compiled / optimized / DLL files
 __pycache__/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..95c61e0
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,36 @@
+# Changelog
+
+## v2.0.0
+
+### Features
+
+- Added support for swappable `GraphEngine` backends, starting with Memgraph alongside Neo4j. [link](/docs/graph-engines.md)
+- Added support for exploring relationships from Nodes with `@related_nodes`, `@related_property` and `get_related_nodes()`.
+- Added import and export functionality.
+- Added neontology schema methods to nodes and relationships.
+
+### Changed
+
+- Removed default 'merged' and 'created' properties. Users will need to reimplement on custom base nodes / relationships if required. [link](/docs/recipes.md)
+- Changed the function signature for `init_neontology`. [link](/docs/usage.md)
+- Removed some automatic type conversion which may require users to add Pydantic serializers for types not supported natively by their database. [link](/docs/advanced-usage.md)
+- Changed behaviour of `GraphConnection` to more consistently raise an explicit error if the connection isn't established.
+- Renamed `auto_constrain` to `auto_constrain_neo4j`.
+- Renamed `apply_constraints` to `apply_neo4j_constraints`.
+- Support to merge multiple relationships with heterogenous source labels and target labels (removing need to separately specify labels)
+
+### Dependencies and Support
+
+- Dropped support for Python v3.7, v3.8 which are both now end of life
+- Bumped Pandas dependency to v2
+
+## v1.0.0
+
+### Features
+
+- Support for multiple labels on nodes as '__secondarylabels__'
+
+### Changed
+
+- Upgrade pydantic dependency to v2+, this has significant repercussions
+- Upgrade neo4j dependency to v5+
diff --git a/README.md b/README.md
index 0b46cf4..0219bbf 100644
--- a/README.md
+++ b/README.md
@@ -7,13 +7,13 @@
 ![GitHub License](https://img.shields.io/github/license/ontolocy/neontology)
 [![Pydantic v2](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/pydantic/pydantic/main/docs/badge/v2.json)](https://docs.pydantic.dev/)
 
-Easily ingest data into a Neo4j graph database with Python, Pydantic and pandas. Neontology is a simple object-graph mapper which lets you use [Pydantic](https://pydantic-docs.helpmanual.io/) models to define Nodes and Relationships. It imposes certain restrictions on how you model data, which aims to make life easier for most users but may provide too many limitations for others. The focus of Neontology is getting data into the database, for running complex queries and accessing data, consider using the Neo4j browser or bloom.
+> *Easily ingest data into a openCypher / GQL (Graph Query Language) graph database like Neo4j using Python, Pydantic and pandas.*
 
-Read the documentation [here](https://neontology.readthedocs.io/en/latest/).
+Neontology is a simple object-graph mapper which lets you use [Pydantic](https://pydantic-docs.helpmanual.io/) models to define Nodes and Relationships. It imposes certain restrictions on how you model data, which aims to make life easier for most users in areas like the construction of knowledge graphs and development of graph database applications.
 
-## Note on v1
+Neontology is inspired by projects like py2neo (which is no longer maintained), Beanie and SQLModel.
 
-With v1, we have upgraded to Pydantic v2 which brings some major changes (and improvements!). Read their [migration guide](https://docs.pydantic.dev/2.0/migration/) to see what changes you might need to make to your models.
+Read the documentation [here](https://neontology.readthedocs.io/en/latest/).
 
 ## Installation
 
@@ -21,18 +21,26 @@ With v1, we have upgraded to Pydantic v2 which brings some major changes (and im
 pip install neontology
 ```
 
+## Note on v2
+
+Version 2 introduces some new features and breaking changes.
+
+Initialization has changed to use a config object (see below) and 'merged'/'created' properties have been removed from the base node & base relationship (at a minimum you may need to [add them back](docs/recipes.md) to a custom base model if required).
+
+See the [changelog](CHANGELOG.md) or [read the docs](https://neontology.readthedocs.io/en/latest/) for more.
+
 ## Example
 
 ```python
 from typing import ClassVar, Optional, List
 import pandas as pd
-from neontology import BaseNode, BaseRelationship, init_neontology, auto_constrain
+from neontology import BaseNode, BaseRelationship, init_neontology, Neo4jConfig
 
 # We define nodes by inheriting from BaseNode
 class PersonNode(BaseNode):
     __primarylabel__: ClassVar[str] = "Person"
     __primaryproperty__: ClassVar[str] = "name"
-    __secondarylabels__: ClassVar[Optional[List]] = ["individual", "somebody"]
+    __secondarylabels__: ClassVar[Optional[List]] = ["Individual", "Somebody"]
     
     name: str
     age: int
@@ -45,11 +53,12 @@ class FollowsRel(BaseRelationship):
     target: PersonNode
 
 # initialise the connection to the database
-init_neontology(
-    neo4j_uri=NEO4J_URI,
-    neo4j_username=NEO4J_USERNAME,
-    neo4j_password=NEO4J_PASSWORD
-)   
+config = Neo4jConfig(
+    uri="neo4j+s://mydatabaseid.databases.neo4j.io", 
+    username="neo4j",
+    password="<PASSWORD>"
+)
+init_neontology(config) 
 
 # Define a couple of people
 alice = PersonNode(name="Alice", age=40)
@@ -65,14 +74,14 @@ rel = FollowsRel(source=bob,target=alice)
 rel.merge()
 
 # We can also use pandas DataFrames to create multiple nodes
-node_records = [{"name": "Freddy", "age": 42}, {"name": "Philipa", "age":42}]
+node_records = [{"name": "Freddy", "age": 42}, {"name": "Philippa", "age":42}]
 node_df = pd.DataFrame.from_records(node_records)
 
 PersonNode.merge_df(node_df)
 
 # We can also merge relationships from a pandas DataFrame, using the primary property values of the nodes
 rel_records = [
-    {"source": "Freddy", "target": "Philipa"},
+    {"source": "Freddy", "target": "Philippa"},
     {"source": "Alice", "target": "Freddy"}
 ]
 rel_df = pd.DataFrame.from_records(rel_records)
@@ -82,9 +91,13 @@ FollowsRel.merge_df(rel_df)
 
 ## Configuring your graph connection
 
-### With a dotenv file
+### On initialisation
+
+You can explicitly provide access information as in the example above with a config object.
 
-You can use a `.env` file as below, which should automatically get picked up by neontology.
+### With a dotenv file or environment variables
+
+You can use a `.env` file as below (or explicitly set them as environment variables), which should automatically get picked up by neontology.
 
 ```txt
 # .env
@@ -95,14 +108,78 @@ NEO4J_PASSWORD=<PASSWORD>
 
 With the above environment variables defined, you can just use `init_neontology()` without providing any arguments.
 
-### On initialisation
+## Executing Queries
+
+Neontology has limited, experimental support for running GQL/cypher queries.
 
-Alternatively, you can explicitly provide access information:
+Using a GraphConnection, you can call `evaluate_query` with a GQL/cypher query which returns nodes and relationships and get them back as Neontology Nodes Relationships.
+
+Once neontology is initialized, only one connection to the database is used under the hood which can be accessed with `GraphConnection`.
 
 ```python
-init_neontology(
-    neo4j_uri="neo4j+s://mydatabaseid.databases.neo4j.io",
-    neo4j_username="neo4j",
-    neo4j_password="password"
-)
+from neontology import init_neontology, GraphConnection
+
+# Only nodes and relationships that have already been defined can be returned as results.
+class PersonNode(BaseNode):
+    __primarylabel__: ClassVar[str] = "Person"
+    __primaryproperty__: ClassVar[str] = "name"
+    
+    name: str
+    age: int
+
+class FollowsRel(BaseRelationship):
+    __relationshiptype__: ClassVar[str] = "FOLLOWS"
+    
+    source: PersonNode
+    target: PersonNode
+
+
+init_neontology()
+
+gc = GraphConnection()
+
+cypher_query = """
+MATCH (n)
+WHERE n.name = $name
+OPTIONAL MATCH (n)-[r:FOLLOWS]-(o:Person)
+RETURN r, o
+"""
+
+results = gc.evaluate_query(cypher_query, {"name": "bob"})
+
+results.records[0]["nodes"]["o"]    # Get the "o" result of the 1st record as a PersonNode
+results.nodes                       # The nodes returned by the query as PersonNode objects
+results.relationships               # The relationships as FollowsRel objects
+
 ```
+
+For large or complex queries, data science or visualization/exploration, consider using a native driver or built-in interface (like Neo4j Browser/Bloom or Memgraph Lab).
+
+## Alternative Graph Engines
+
+Neontology has experimental support for GQL/openCypher property graph databases other than Neo4j:
+
+* Memgraph
+
+### Memgraph Engine
+
+[Memgraph](https://memgraph.com/) is a Neo4j compatible database.
+
+```python
+from neontology import init_neontology, MemgraphConfig
+from neontology.graphengines import MemgraphEngine
+
+config = MemgraphConfig(
+                "uri": "bolt://localhost:9687",
+                "username": "memgraphuser",
+                "password": "<MEMGRAPH PASSWORD>"
+            )
+
+init_neontology(config)
+```
+
+You can also use the following environment variables and just `init_neontology(MemgraphConfig())`:
+
+* `MEMGRAPH_URI`
+* `MEMGRAPH_USERNAME`
+* `MEMGRAPH_PASSWORD`
diff --git a/docs/.development.md b/docs/.development.md
index 94378b3..6c721fe 100644
--- a/docs/.development.md
+++ b/docs/.development.md
@@ -4,17 +4,11 @@
 
 You will need the requirements from requirements-dev.txt
 
-You will need to install watchman for [pyre](https://pyre-check.org/docs/getting-started/)
-
-```bash
-sudo apt install watchman
-```
-
 Where possible, configuration information goes in pyproject.toml.
 
 ## Testing
 
-We use pytest for testing.
+Pytest is used for testing.
 
 Run all the tests (stopping as soon as something fails):
 
@@ -22,40 +16,58 @@ Run all the tests (stopping as soon as something fails):
 pytest -x
 ```
 
-Many of the tests require access to a neo4j instance. These are identified with the 'uses_graph' mark. We can skip these tests like this:
+Many of the tests require access to a database instance. These are identified with the 'uses_graph' mark. We can skip these tests like this:
 
 ```bash
 pytest -m "not uses_graph"
 ```
 
-And check the coverage with:
+Tests are run against the different supported graph engines. You can use `-k` to run against a specific engine only:
+
+```bash
+pytest -k 'memgraph-engine'     # only run against the memgraph engine
+pytest -k 'not neo4j-engine'        # don't run against Neo4j
+```
+
+A small subset of tests include benchmarking (with [pytest-benchmark](https://pytest-benchmark.readthedocs.io/en/latest/usage.html)). Running these tests can be controlled with:
+
+`--benchmark-skip` - Skip running any tests that contain benchmarks.
+
+`--benchmark-disable` - Disable benchmarks. Benchmarked functions are only ran once and no stats are reported. Use this if you want to run the test but don’t do any benchmarking.
+
+`--benchmark-only` - Only run benchmarks.
+
+Check coverage with:
 
 ```bash
 pytest --cov=src/neontology
 pytest --cov=src/neontology --cov-report=html
 ```
 
-You can browse to the generated report at `htmlcov/index.html` or spin up a locally hosted version:
+Generated report is at `htmlcov/index.html` or view as a locally hosted version:
 
 ```bash
 python -m http.server --directory htmlcov
 ```
 
-## Linting
+## Linting and Formatting
 
-We use black for formatting.
+Use ruff:
 
 ```bash
-black src
+ruff check
+ruff check --fix
+ruff format --check --diff
+ruff format
 ```
 
-We sort imports with isort (in black compatability mode).
+Sort imports with isort (in black compatability mode):
 
 ```bash
 isort src
 ```
 
-Then we double check with flake8.
+Double check with flake8.
 
 flake8 config is defined in setup.cfg
 
@@ -63,14 +75,13 @@ flake8 config is defined in setup.cfg
 flake8 --max-line-length=127 src
 ```
 
-You can use pyre and mypy for double checking typing:
+Use mypy for checking typing:
 
 ```bash
 mypy src
-pyre
 ```
 
-You can also check docstrings with pydocstyle:
+Check docstrings with pydocstyle:
 
 ```bash
 pydocstyle src
@@ -91,6 +102,5 @@ Increment the version, push to Github and upload to pypi!
 
 ```bash
 git tag va.b.c
-git push origin va.b.c 
-# this should trigger the github action to build and upload the package
+git push origin va.b.c # triggers the github action to build & upload to pypi
 ```
diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md
index ae761b7..f80926d 100644
--- a/docs/advanced-usage.md
+++ b/docs/advanced-usage.md
@@ -1,47 +1,154 @@
 # Advanced Usage
 
-## Managing configuration with a .env file
-
-It is generally good practice to avoid storing connection details (and especially passwords) in your source code (and version control). Therefore, Neontology supports the use of .env files (or just normal environment variables) for:
+## Use multiple labels
 
-* NEO4J_URI
-* NEO4J_USERNAME
-* NEO4J_PASSWORD
+Sometimes you may want to apply additional labels to nodes, beyond just the primary label. Where this is the case, you can add those labels as a list using the class variable `__secondarylabels__`.
 
-For example:
+```python
+class ElephantNode(BaseNode):
+    __primaryproperty__: ClassVar[str] = "name"
+    __primarylabel__: ClassVar[Optional[str]] = "Elephant"
+    __secondarylabels__: ClassVar[Optional[list]] = ["Animal"]
+    name: str
 
-```txt
-# .env
-NEO4J_URI=neo4j+s://myneo4j.example.com
-NEO4J_USERNAME=neo4j
-NEO4J_PASSWORD=<PASSWORD>
+ellie = ElephantNode(name="Ellie")
 ```
 
-## Automatically apply constraints
+Note that methods such as `.match` use only the primary label.
 
-With neo4j, we can constrain label/property pairs to be unique and indexed.
+## Type Conversion / Serialization
 
-Neontology can automatically apply neo4j constraints for all defined nodes using the `auto_constrain` method.
+Not all graph databases natively support the same range of types as Python/Pydantic. Therefore, model fields annotated with complex types may need to go through some level of conversion before being written to the database. This can be achieved with Pydantic's [custom serializers](https://docs.pydantic.dev/2.9/concepts/serialization/#custom-serializers).
 
-Simply use `auto_constrain()` after defining your models and initialising your connection.
-
-## Use multiple labels
-
-Sometimes you may want to apply additional labels to nodes, beyond just the primary label. Where this is the case, you can add those labels as a list using the class variable `__secondarylabels__`.
+```
+from pydantic import field_serializer
+from uuid import UUID
 
-```python
 class ElephantNode(BaseNode):
     __primaryproperty__: ClassVar[str] = "name"
     __primarylabel__: ClassVar[Optional[str]] = "Elephant"
     __secondarylabels__: ClassVar[Optional[list]] = ["Animal"]
     name: str
+    id: UUID
+
+    @field_serializer("id")
+    def serialize_ref_url(self, id: UUID, _info):
+        return str(id)
+```
+
+## Retrieving related nodes and properties with BaseNode methods
+
+The power of GQL comes from the ability to quickly traverse relationships to understand what how a node relates to the rest of the graph. Neontology aims to make this easier by helping you run GQL directly from BaseNode models to find related nodes and properties - even if that involves traversing multiple hops to find what you're looking for.
+
+!!! EXPERIMENTAL
+    Support for these features is still experimental so may change in the future.
+
+### get_related()
+
+BaseNode subclasses have a `get_related` method which can be used to find nodes and relationships which are related to a BaseNode instance.
+
+If no arguments are given, this function will return all nodes with a direct outgoing relationship from the Node. However you can also specify keyword arguments to be more specific about which relationships you care about. For example:
+
+* `relationship_types` - list of one or more relationship types to look for (such as 'FOLLOWS').
+* `target_label` - the label of the target node you want to match on.
+* `incoming` - whether to include incoming relationships.
+* `outgoing` - whether to include outgoing relationships.
+* `limit` - the maximum number of nodes to return.
+
+The return type is a [NeontologyResult object](/queries/#querying-for-neontology-nodes-and-relationships) which will include identified nodes and relationships.
+
+### @related_nodes Decorator
+
+If you write a method on a Node, that returns a cyber/GQL string then adding the `@related_nodes` decorator will evaluate the GQL and return any Nodes returned by the query as Neontology Node objects.
+
+If you use `(#ThisNode)`, it will get replaced with the specific node that the method is called from (based on primary label and primary property).
+
+```python
+@related_nodes
+def followers(self):
+    return "MATCH (#ThisNode)<-[:FOLLOWS]-(o) RETURN o"
+```
+
+### @related_properties Decorator
+
+This decorator works like above, but instead of returning nodes, it expects the cypher/GQL to return a single object (such as a string, a list or a dict/mapping). Under the hood, it uses `evaluate_query_single`. Again, use `(#ThisNode)` to match on the given Node.
+
+```python
+@property
+@related_property
+def follower_count(self):
+    return "MATCH (#ThisNode)<-[:FOLLOWS]-(o) RETURN COUNT(DISTINCT o)"
+```
+
+### Example
+
+We can put this all together to add some handy extra functionality to nodes - for example, making it easy to access followers in a social graph.
+
+```python
+from neontology import BaseNode
+
+class AugmentedPerson(BaseNode):
+    __primaryproperty__: ClassVar[GQLIdentifier] = "name"
+    __primarylabel__: ClassVar[GQLIdentifier] = "AugmentedPerson"
+
+    name: str
+
+    @related_nodes
+    def followers(self):
+        return "MATCH (#ThisNode)<-[:FOLLOWS]-(o) RETURN o"
+
+    @property
+    @related_property
+    def follower_count(self):
+        return "MATCH (#ThisNode)<-[:FOLLOWS]-(o) RETURN COUNT(DISTINCT o)"
+
+    @property
+    @related_property
+    def follower_names(self):
+        return "MATCH (#ThisNode)<-[:FOLLOWS]-(o) RETURN COLLECT(DISTINCT o.name)"
+
+
+class FollowsRelationship(BaseRelationship):
+    __relationshiptype__: ClassVar[str] = "FOLLOWS"
+
+    source: AugmentedPerson
+    target: AugmentedPerson
 
-ellie = ElephantNode(name="Ellie")
 ```
 
-Note that methods such as `.match` and `auto_constrain` use only the primary label.
+We could then use this like:
+
+```python
+alice = AugmentedPerson(name="Alice")
+alice.merge()
+
+bob = AugmentedPerson(name="Bob")
+bob.merge()
+
+follows = AugmentedPersonRelationship(
+    source=alice, target=bob
+)
+follows.merge()
+
+follows2 = AugmentedPersonRelationship(
+    source=bob, target=alice
+)
+follows2.merge()
+
+# get people Alice follows (this will return Bob)
+alice_rels = alice.get_related_nodes(relationship_types=["FOLLOWS"])
+
+print(bob.follower_count)
+
+# 1
 
-## Set neo4j properties on match or on create
+print(alice.follower_names)
+
+# ["Bob"]
+
+```
+
+## Set properties on match or on create
 
 When we run MERGE operations with neo4j, sometimes we want to only alter properties under certain circumstances.
 
@@ -92,36 +199,3 @@ class MyRel(BaseRelationship):
 ```
 
 In this example, where a relationship with a given source and target exists with the same value for 'prop_to_merge_on', the relationship will be overwritten. If a new 'prop_to_merge_on' value is given then a new relationship will be created with that value.
-
-## Type Conversion/Coersion
-
-Neo4j doesn't support the same range of types as Python/Pydantic. Therefore, we do our best to coerce data types to fit into neo4j. However, you may see some data loss when converting between complex data types.
-
-## Executing Advanced Cypher Queries with Python
-
-The focus of Neontology is currently ingesting data rather than providing additional ways to query data. Cypher is an incredibly powerful language for doing that already.
-
-If you want to run plain cypher queries, you can do this using the [Neo4j driver](https://neo4j.com/docs/api/python-driver/current/index.html) which can be accessed on Neontology's GraphConnection object.
-
-The Neo4j driver has many features and different ways of executing queries, but the below recipe shows how we can write and [execute arbitrary queries](https://neo4j.com/docs/api/python-driver/current/api.html#neo4j.Driver.execute_query) with the driver to return data as Python lists / dictionaries. We will use Neo4j's built in support for [map projection](https://neo4j.com/docs/cypher-manual/current/values-and-types/maps/).
-
-```python
-import neo4j
-from neontology import init_neontology, GraphConnection
-
-init_neontology()
-
-gc = GraphConnection()
-
-cypher_query = """
-MATCH (p:Person)
-RETURN COLLECT({name: p.name})
-"""
-
-result = gc.driver.execute_query(cypher_query, result_transformer_=neo4j.Result.single)
-
-print(result)
-
-# [{'name': 'Alice'}, {'name': 'Bob'}]
-
-```
diff --git a/docs/changelog.md b/docs/changelog.md
deleted file mode 100644
index 59fb980..0000000
--- a/docs/changelog.md
+++ /dev/null
@@ -1,12 +0,0 @@
-# Changelog
-
-## v1.0.0
-
-### Features
-
-- Support for multiple labels on nodes as '__secondarylabels__'
-
-### Changed
-
-- Upgrade pydantic dependency to v2+, this has significant repurcussions
-- Upgrade neo4j dependency to v5+
diff --git a/docs/fastapi.md b/docs/fastapi.md
index cfd39bb..c1012d7 100644
--- a/docs/fastapi.md
+++ b/docs/fastapi.md
@@ -26,7 +26,7 @@ First we need to define models for the different types of node we want to store.
 from typing import ClassVar, Optional, List
 
 from fastapi import FastAPI, HTTPException
-from neontology import BaseNode, BaseRelationship, init_neontology
+from neontology import BaseNode, BaseRelationship, init_neontology, Neo4jConfig
 
 class TeamNode(BaseNode):
     __primaryproperty__: ClassVar[str] = "teamname"
@@ -62,13 +62,20 @@ app = FastAPI()
 
 @app.on_event("startup")
 async def startup_event():
-    init_neontology(
-        init_neontology(
-        neo4j_uri="NEO4J URI HERE",
-        neo4j_username="NEO4J USERNAME HERE",
-        neo4j_password="NEO4J PASSWORD HERE"
-        )
+
+    # here we declare the neo4j connection details explicitly (this can be bad for security)
+    # you could instead define them as environment variables or in a .env file
+    NEO4J_URI="neo4j+s://<database id>.databases.neo4j.io"  # neo4j Aura example
+    NEO4J_USERNAME="neo4j"
+    NEO4J_PASSWORD="<your password>"
+
+    config = Neo4jConfig(
+        uri=NEO4J_URI, 
+        username=NEO4J_USERNAME,
+        password=NEO4J_PASSWORD
     )
+    init_neontology(config)  
+
 
 @app.get("/")
 def read_root():
@@ -171,6 +178,8 @@ app = FastAPI()
 
 @app.on_event("startup")
 async def startup_event():
+    # make sure you've set NEO4J_URI, NEO4J_USERNAME and NEO4J_PASSWORD environment variables
+    # they could be defined in a .env file
     init_neontology()
 
 
diff --git a/docs/graph-engines.md b/docs/graph-engines.md
new file mode 100644
index 0000000..229cc13
--- /dev/null
+++ b/docs/graph-engines.md
@@ -0,0 +1,127 @@
+# Graph Engines
+
+By default, Neontology is set up to work with a Neo4j backend. However, it can also be configured to use other graph databases, starting with experimental support for Memgraph.
+
+!!! EXPERIMENTAL
+    Some of these features are still experimental so may change in the future.
+
+For large or complex queries, data science or visualization/exploration, consider using a native driver or built-in interface (like Neo4j Browser/Bloom or Memgraph Lab).
+
+## Graph Configs
+
+The easiest way to start is to pass a GraphConfig object to init_neontology to set up the backend appropriately. You can set config variables explicitly, or using environment variables.
+
+If you don't explicitly provide a GraphConfig, neontology will default to Neo4j and look for environment variables for configuration.
+
+You can also use the NEONTOLOGY_ENGINE environment variable to set the graph engine (`NEO4J` or `MEMGRAPH`) to use, which will then look for the respective configuration environment variables.
+
+### Neo4j
+
+```python
+from neontology import GraphConnection, init_neontology
+from neontology.graphengines import Neo4jConfig
+
+config = Neo4jConfig(
+        uri="bolt://localhost:7687",    # OR use NEO4J_URI environment variable
+        username="neo4j",               # OR use NEO4J_USERNAME environment variable
+        password="<PASSWORD>"           # OR use NEO4J_PASSWORD environment variable
+    )
+
+init_neontology(config)
+
+gc = GraphConnection()
+gc.evaluate_query_single("MATCH (n) RETURN COUNT(n)")
+```
+
+### Memgraph
+
+```python
+from neontology import GraphConnection, init_neontology
+from neontology.graphengines import MemgraphConfig
+
+config = MemgraphConfig(
+        uri="bolt://localhost:7687",    # OR use MEMGRAPH_URI environment variable
+        username="memgraphuser",        # OR use MEMGRAPH_USERNAME environment variable
+        password="<PASSWORD>"           # OR use MEMGRAPH_PASSWORD environment variable
+    )
+
+init_neontology(config)
+
+gc = GraphConnection()
+gc.evaluate_query_single("MATCH (n) RETURN COUNT(n)")
+```
+
+## Graph Engines and Graph Connections
+
+The typical way of using Neontology is to use the `init_neontology` function to initialize the connection to a database and then use the `GraphConnection` class to interact with the graph database elsewhere in your code. Behind the scenes, `GraphConnection` uses a `GraphEngine` for that database connection and you can also use a `GraphEngine` directly.
+
+There are a couple of reasons to use `GraphConnection`:
+
+1. It maintains a single connection to the database, rather than creating a new connection every time you need to talk to the database.
+2. It provides a uniform interface regardless of the underlying GraphEngine. This means you can easily swap out the backend in the future if you want to use a different graph database.
+
+You can access the underlying `GraphEngine` at `.engine` and you can access the native Python driver for the graph database at `.engine.driver` if you want to use functionality of the official Neo4j driver.
+
+## Neo4j
+
+If you just use `init_neontology`, Neontology will assume that you have a Neo4j backend. You can also declare this explicitly as above.
+
+### Using the Neo4j driver from Neontology
+
+You can also directly access the [Neo4j driver](https://neo4j.com/docs/api/python-driver/current/index.html) on Neontology's GraphConnection object.
+
+The Neo4j driver has many features and different ways of executing queries, but the below recipe shows how we can write and [execute arbitrary queries](https://neo4j.com/docs/api/python-driver/current/api.html#neo4j.Driver.execute_query) with the driver to return data as Python lists / dictionaries. We will use Neo4j's built in support for [map projection](https://neo4j.com/docs/cypher-manual/current/values-and-types/maps/).
+
+```python
+import neo4j
+from neontology import init_neontology, GraphConnection
+
+init_neontology()
+
+gc = GraphConnection()
+
+cypher_query = """
+MATCH (p:Person)
+RETURN COLLECT({name: p.name})
+"""
+
+result = gc.engine.driver.execute_query(cypher_query, result_transformer_=neo4j.Result.single)
+
+print(result)
+
+# [{'name': 'Alice'}, {'name': 'Bob'}]
+
+```
+
+### Applying constraints
+
+With neo4j, we can constrain label/property pairs to be unique and indexed.
+
+Neontology can automatically apply neo4j constraints for all defined nodes using the `auto_constrain_neo4j` method.
+
+Simply use `auto_constrain_neo4j()` after defining your models and initialising your connection.
+
+Note that auto constrain features only use a model's primary label (not secondary labels if they're defined).
+
+## Memgraph
+
+[Memgraph](https://memgraph.com/) is a Neo4j compatible database.
+
+In addition to configuring explicitly as above, you can also use the following environment variables and just `init_neontology(graph_engine=MemgraphEngine)`:
+
+* `MEMGRAPH_URI`
+* `MEMGRAPH_USER`
+* `MEMGRAPH_PASSWORD`
+
+```python
+from neontology import init_neontology
+from neontology.graph_engines import MemgraphEngine
+
+init_neontology(
+    engine=MemgraphConfig()
+)
+```
+
+### Memgraph Driver
+
+Memgraph also uses the Neo4j python driver, so works just like the Neo4j driver in this respect.
diff --git a/docs/index.md b/docs/index.md
index ad9a767..21eb891 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,22 +1,23 @@
-# Getting Started
+# Neontology: Neo4j, Python and Pydantic
 
-Easily ingest data into a Neo4j graph database with Python, Pydantic and pandas. Neontology is a simple object-graph mapper which lets you use Pydantic compatible models to define Nodes and Relationships.
+> *Easily ingest data into a GQL (Graph Query Language) graph database like Neo4j using Python, Pydantic and pandas.*
 
-## Install Neontology
+Neontology is a simple object-graph mapper which lets you use [Pydantic](https://pydantic-docs.helpmanual.io/) models to define Nodes and Relationships. It imposes certain restrictions on how you model data, which aims to make life easier for most users in areas like the construction of knowledge graphs and development of graph database web applications.
+
+Neontology is inspired by projects like py2neo (which is no longer maintained), Beanie and SQLModel.
+
+## Installation
 
 ```bash
 pip install neontology
 ```
 
-## A simple example
+## A Simple Example
 
 ```python
-from typing import ClassVar
-from neontology import BaseNode, BaseRelationship, init_neontology, auto_constrain
-
-NEO4J_URI="neo4j+s://dbid.databases.neo4j.io"
-NEO4J_USERNAME="neo4j"
-NEO4J_PASSWORD="don't keep your password in version control"
+from typing import ClassVar, Optional, List
+import pandas as pd
+from neontology import BaseNode, BaseRelationship, init_neontology, Neo4jConfig
 
 # We define nodes by inheriting from BaseNode
 class PersonNode(BaseNode):
@@ -34,11 +35,12 @@ class FollowsRel(BaseRelationship):
     target: PersonNode
 
 # initialise the connection to the database
-init_neontology(
-    neo4j_uri=NEO4J_URI,
-    neo4j_username=NEO4J_USERNAME,
-    neo4j_password=NEO4J_PASSWORD
-)   
+config = Neo4jConfig(
+    uri="neo4j+s://mydatabaseid.databases.neo4j.io", 
+    username="neo4j",
+    password="<PASSWORD>"
+)
+init_neontology(config) 
 
 # Define a couple of people
 alice = PersonNode(name="Alice", age=40)
@@ -52,4 +54,19 @@ bob.create()
 # Create a follows relationship between them
 rel = FollowsRel(source=bob,target=alice)
 rel.merge()
+
+# We can also use pandas DataFrames to create multiple nodes
+node_records = [{"name": "Freddy", "age": 42}, {"name": "Philippa", "age":42}]
+node_df = pd.DataFrame.from_records(node_records)
+
+PersonNode.merge_df(node_df)
+
+# We can also merge relationships from a pandas DataFrame, using the primary property values of the nodes
+rel_records = [
+    {"source": "Freddy", "target": "Philippa"},
+    {"source": "Alice", "target": "Freddy"}
+]
+rel_df = pd.DataFrame.from_records(rel_records)
+
+FollowsRel.merge_df(rel_df)
 ```
diff --git a/docs/queries.md b/docs/queries.md
new file mode 100644
index 0000000..8f1d102
--- /dev/null
+++ b/docs/queries.md
@@ -0,0 +1,101 @@
+# Queries
+
+Neontology has limited support for running cypher/GQL queries against the connected graph database.
+
+!!! EXPERIMENTAL
+    Some of these features are still experimental so may change in the future.
+
+For large or complex queries, data science or visualization/exploration, consider using a native driver or built-in interface (like Neo4j Browser/Bloom or Memgraph Lab).
+
+## Running Simple Single Queries
+
+If you want to quickly execute a cypher query which returns a **single** result, you can use the `execute_query_single` method on a `GraphConnection`. This will return whatever Python type the underlying Neo4j driver returns. For example, if you return a number, or a list of strings, you will get that straight back. If you return a node or relationship, you will get these in the underlying Neo4j driver's [types](https://neo4j.com/docs/python-manual/current/data-types/).
+
+You can also pass in parameters (for example to use as part of a WHERE clause).
+
+```python
+from neontology import init_neontology, GraphConnection
+
+init_neontology()
+
+gc = GraphConnection()
+
+cypher_query = """
+MATCH (p:Person)
+RETURN COLLECT({name: p.name})
+"""
+
+result = gc.evaluate_query_single(cypher_query)
+
+print(result)
+
+# [{'name': 'Alice'}, {'name': 'Bob'}]
+
+cypher_query_count = """
+MATCH (p:Person)
+RETURN COUNT(DISTINCT p)
+"""
+
+result_count = gc.evaluate_query_single(cypher_query_count)
+
+print(result_count)
+
+# 2
+
+cypher_query_params = """
+MATCH (p:Person)
+WHERE p.name = $name
+RETURN p.name
+"""
+
+params = {"name": "Bob"}
+
+result_params = gc.evaluate_query_single(cypher_query_params, params)
+
+print(result)
+
+# 'Bob'
+
+```
+
+## Querying for Neontology Nodes and Relationships
+
+If you want to run a cypher query and get back the nodes and relationships directly as Neontology Pydantic objects, you can use the `evaluate_query` method on a `GraphConnection`.
+
+This will search the Python environment for Neontology for defined nodes and relationships and use them to 'rehydrate' your query results.
+
+```python
+from neontology import init_neontology, GraphConnection, BaseNode
+
+init_neontology()
+
+gc = GraphConnection()
+
+class PersonNode(BaseNode):
+    __primarylabel__: ClassVar[str] = "Person"
+    __primaryproperty__: ClassVar[str] = "name"
+    
+    name: str
+    age: Optional[int] = None
+
+bob = PersonNode(name="Bob", age=40)
+bob.merge()
+
+cypher = "MATCH p RETURN p"
+
+results = gc.evaluate_query(cypher)
+
+print(results.nodes[0].name)
+
+# Bob
+
+```
+
+The returned `NeontologyResult` object has the following properties:
+
+* `records_raw` - the raw records returned by the Neo4j driver
+* `records` - the records converted into equivalent Neontology objects
+* `nodes` - a list of all the Neontology/Pydantic nodes returned
+* `paths` - any paths returned, represented as a list of relationships
+* `relationships` - a list of all the Neontology/Pydantic relationships returned
+* `node_link_data` - a dictionary with 'nodes' and 'edges' keys and corresponding values which can be used with other tools such as NetworkX and D3.
diff --git a/docs/recipes.md b/docs/recipes.md
new file mode 100644
index 0000000..9fea05f
--- /dev/null
+++ b/docs/recipes.md
@@ -0,0 +1,71 @@
+# Neo4j Recipes and Tips
+
+Common Python patterns and use cases you may come across when working with Neo4j or other databases using Neontology.
+
+## Created and Modified Timestamps
+
+A common requirement can be to record when nodes and relationships were created or modified in Neo4j (or any other graph database).
+
+If you're using Neontology, there are a couple of features which can help with this. The below example uses Pydantic and Neontology functionality to set a `created` timestamp when nodes are created and then updates the `merged` timestamp any time the Node is merged/modified.
+
+You can also manually override the values by explicitly setting them when creating/merging the node.
+
+```python
+from datetime import datetime
+from typing import Optional
+
+from pydantic import Field, field_validator, ValidationInfo
+
+from neontology import BaseNode
+
+class MyBaseNode(BaseNode):
+    merged: datetime = Field(
+        default_factory=datetime.now, 
+    )
+
+    # created property will only be set 'on create' - when the node is first created
+    created: Optional[datetime] = Field(
+                                    default=None, 
+                                    validate_default=True,
+                                    json_schema_extra={"set_on_create": True})
+
+    # Use Pydantic's validator functionality to set created off the merged value
+    @field_validator("created")
+    def set_created_to_merged(
+        cls, value: Optional[datetime], values: ValidationInfo
+    ) -> datetime:
+        """When the node is first created, we want the created value to be set equal to merged.
+        Otherwise they will be a tiny amount of time different.
+        """
+
+        # set created = merged (which was set to datetime.now())
+        if value is None:
+            return values.data["merged"]
+
+        # if the created value has been manually set, don't override it
+        else:
+            return value
+
+```
+
+Neontology v0 and v1 included `merged` and `created` fields with this functionality by default but it was removed in v2 to provide greater flexibility to users.
+
+## Set a property value to a unique ID
+
+Another common pattern is to assign each new node that goes into Neo4j with a unique identifier or key. Again, we can use Pydantic to help with this - combining Python's built-in uuid4 function with Pydantic's support for a [default_factory](https://docs.pydantic.dev/latest/concepts/fields/#default-values) to generate a property value.
+
+```python
+from uuid import uuid4
+
+from neontology import BaseNode
+
+class PersonNode(BaseNode):
+    __primarylabel__: ClassVar[str] = "PersonLabel"
+    __primaryproperty__: ClassVar[str] = "id"
+    
+    name: str
+    age: int
+    uuid: str = Field(default_factory=lambda: uuid4().hex)
+```
+
+Depending on what you're trying to achieve, you could also use a custom field_validator to help generate an appropriate unique value.
diff --git a/docs/setting-up-neo4j.md b/docs/setting-up-neo4j.md
index f7a784c..c171a2c 100644
--- a/docs/setting-up-neo4j.md
+++ b/docs/setting-up-neo4j.md
@@ -25,3 +25,22 @@ More on [stackoverflow](https://superuser.com/questions/1535269/how-to-connect-w
 ## Using Docker
 
 You can easily run the Community Edition of Neo4j free locally with [Docker](https://neo4j.com/developer/docker/).
+
+You could use a Docker Compose file to make things easier.
+
+```yml
+# docker-compose.yml
+version: "3"
+
+services:
+  neo4j:
+    image: neo4j:5-community
+    ports:
+      - 7474:7474
+      - 7373:7373
+      - 7687:7687
+    environment:
+      - NEO4J_AUTH
+```
+
+Where NEO4J_AUTH is defined as an environment variable (which could be in a `.env` file) of the format `<NEO4J USERNAME>/<NEO4J PASSWORD>`.
diff --git a/docs/usage.md b/docs/usage.md
index 4eefd57..7ca7f45 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -59,11 +59,30 @@ NEO4J_URI="neo4j+s://<database id>.databases.neo4j.io"  # neo4j Aura example
 NEO4J_USERNAME="neo4j"
 NEO4J_PASSWORD="<your password>"
 
-init_neontology(
-    neo4j_uri=NEO4J_URI,
-    neo4j_username=NEO4J_USERNAME,
-    neo4j_password=NEO4J_PASSWORD
-)   # initialise the connection to the database
+# initialise the connection to the database
+config = Neo4jConfig(
+    uri=NEO4J_URI, 
+    username=NEO4J_USERNAME,
+    password=NEO4J_PASSWORD
+)
+init_neontology(config) 
+```
+
+### With a dotenv file
+
+You can use a `.env` file as below, which should automatically get picked up by neontology.
+
+```txt
+# .env
+NEO4J_URI=neo4j+s://myneo4j.example.com
+NEO4J_USERNAME=neo4j
+NEO4J_PASSWORD=<PASSWORD>
+```
+
+Then you don't need to define a config (if using Neo4j):
+
+```python
+init_neontology()
 ```
 
 ## Creating a node
@@ -121,7 +140,7 @@ Running the above code (full example below) should create the following nodes an
 (Bob:Person)-[:FOLLOWS]->(Alice:Person)-[:FOLLOWS]->(Freddy:Person)-[:FOLLOWS]->(Philipa:Person)
 ```
 
-### Neo4j and Python Sample Code
+### Full Neo4j and Python Sample Code
 
 ```python
 # demo.py
@@ -144,8 +163,17 @@ class FollowsRel(BaseRelationship):
     source: PersonNode
     target: PersonNode
 
+NEO4J_URI="neo4j+s://<database id>.databases.neo4j.io"  # neo4j Aura example
+NEO4J_USERNAME="neo4j"
+NEO4J_PASSWORD="<your password>"
 
-init_neontology()  # initialise the connection to the database
+# Alternatively, you could use environment variables or a .env file
+config = Neo4jConfig(
+    uri=NEO4J_URI, 
+    username=NEO4J_USERNAME,
+    password=NEO4J_PASSWORD
+)
+init_neontology(config)  
 
 alice = PersonNode(name="Alice", age=40)
 alice.create()
@@ -155,13 +183,13 @@ bob.merge()
 rel = FollowsRel(source=bob, target=alice)
 rel.merge()
 
-node_records = [{"name": "Freddy", "age": 42}, {"name": "Philipa", "age": 42}]
+node_records = [{"name": "Freddy", "age": 42}, {"name": "Phillipa", "age": 42}]
 node_df = pd.DataFrame.from_records(node_records)
 
 PersonNode.merge_df(node_df)
 
 rel_records = [
-    {"source": "Freddy", "target": "Philipa"},
+    {"source": "Freddy", "target": "Phillipa"},
     {"source": "Alice", "target": "Freddy"},
 ]
 rel_df = pd.DataFrame.from_records(rel_records)
diff --git a/mkdocs.yml b/mkdocs.yml
index c8fe9ed..87586b3 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -5,7 +5,9 @@ nav:
     - Usage: usage.md
     - FastAPI Neo4j Example: fastapi.md
     - Advanced Usage: advanced-usage.md
+    - Running Queries: queries.md
+    - Cookbook: recipes.md
+    - Graph Engines: graph-engines.md
     - Setting Up Neo4j: setting-up-neo4j.md
-    - Changelog: changelog.md
 markdown_extensions:
     - admonition
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 917ad33..81df883 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,14 +1,16 @@
 # running the tests
 coverage>=6.4
-pytest>=7.1
+pytest>=8.1
 pytest-cov>=3.0
-py2neo>=2021.2
+pytest-benchmark>=4
 # doing the linting
 mypy>=0.961
 black>=22.3
 flake8>=4.0
-pyre-check>=0.9
 isort>=5.10
 pydocstyle[toml]>=6.1
+ruff
+# type stubs
+types-PyYAML
 # doing the docs
 mkdocs==1.*
diff --git a/requirements.txt b/requirements.txt
index e1e9742..96b31d2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,6 @@
-neo4j>=5.0
-pydantic>=2.0
-pandas>1,<3
-numpy
-python-dotenv
+neo4j>5,<6
+pydantic~=2.7
+pandas>2,<3
+numpy>1,<2
+python-dotenv>1,<2
+Jinja2>3,<4
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
index abe1dbb..ea93eca 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = neontology
-version = 1.3.1
+version = 2.0.0
 author = Ontolocy
 description = A Python package for modelling data in a Neo4j graph database with Pydantic and Pandas.
 long_description = file: README.md
@@ -11,12 +11,11 @@ project_urls =
 classifiers =
     Programming Language :: Python :: 3
     Programming Language :: Python :: 3 :: Only
-    Programming Language :: Python :: 3.7
-    Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
     Programming Language :: Python :: 3.10
     Programming Language :: Python :: 3.11
     Programming Language :: Python :: 3.12
+    Programming Language :: Python :: 3.13
     License :: OSI Approved :: MIT License
     Operating System :: OS Independent
     Intended Audience :: Developers
@@ -27,13 +26,14 @@ classifiers =
 package_dir =
     = src
 packages = find:
-python_requires = >=3.7
+python_requires = >=3.9
 install_requires =
-    python-dotenv>=0.10
-    neo4j>=5,<6
-    pydantic>=2,<3
-    pandas>1,<3
+    neo4j>5,<6
+    pydantic~=2.7
+    pandas>2,<3
     numpy>1,<2
+    python-dotenv>1,<2
+    Jinja2>3,<4
 
 [options.packages.find]
 where = src
diff --git a/src/neontology/__init__.py b/src/neontology/__init__.py
index dcefa25..6eebc8a 100644
--- a/src/neontology/__init__.py
+++ b/src/neontology/__init__.py
@@ -1,18 +1,29 @@
 # flake8: noqa
 
-from .basenode import BaseNode
+from .basenode import BaseNode, related_nodes, related_property
 from .baserelationship import BaseRelationship
+from .gql import GQLIdentifier, gql_identifier_adapter
 from .graphconnection import GraphConnection, init_neontology
-from .utils import auto_constrain
+from .graphengines.memgraphengine import MemgraphConfig
+from .graphengines.neo4jengine import Neo4jConfig
+from .utils import auto_constrain_neo4j
 
 __all__ = [
     # BaseNode
     "BaseNode",
+    "related_nodes",
+    "related_property",
     # BaseRelationship
     "BaseRelationship",
     # GraphConnection
     "init_neontology",
     "GraphConnection",
     # utils
-    "auto_constrain",
+    "auto_constrain_neo4j",
+    # GQL
+    "GQLIdentifier",
+    "gql_identifier_adapter",
+    # Engines
+    "Neo4jConfig",
+    "MemgraphConfig",
 ]
diff --git a/src/neontology/basenode.py b/src/neontology/basenode.py
index 0ad67ed..ec8bb1f 100644
--- a/src/neontology/basenode.py
+++ b/src/neontology/basenode.py
@@ -1,18 +1,85 @@
-from typing import Any, ClassVar, Dict, List, Optional, Type, TypeVar, Union
+import functools
+import json
+import warnings
+from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple, Union
 
 import numpy as np
 import pandas as pd
+from pydantic import ValidationError, model_validator
 
 from .commonmodel import CommonModel
+from .gql import gql_identifier_adapter, int_adapter
 from .graphconnection import GraphConnection
+from .schema_utils import NodeSchema, SchemaProperty, extract_type_mapping
 
-B = TypeVar("B", bound="BaseNode")
+
+def _find_this_node(query, params, node):
+    this_node = f"(ThisNode:{node.__primarylabel__} {{{node.__primaryproperty__}: $_neontology_pp}})"
+    params["_neontology_pp"] = node.get_pp()
+    new_query = query.replace("(#ThisNode)", this_node)
+
+    return new_query, params
+
+
+def _prepare_related_query(
+    node: "BaseNode", wrapped_function: Callable, *args: Any, **kwargs: Any
+) -> Tuple[str, dict]:
+    try:
+        query, params = wrapped_function(node, *args, **kwargs)
+    except ValueError:
+        query = wrapped_function(node, *args, **kwargs)
+
+        # if the function doesn't pass params, they may be taken from user provided parameters
+        params = {**kwargs}
+
+    # make it easy to match on this specific node
+    if "(#ThisNode)" in query:
+        new_query, params = _find_this_node(query, params, node)
+
+    else:
+        new_query = query
+
+    return new_query, params
+
+
+def related_property(f: Callable) -> Callable:
+    """Decorator to wrap functions on BaseNode subclasses and return a single result."""
+
+    @functools.wraps(f)
+    def wrapper(self: "BaseNode", *args: Any, **kwargs: Any) -> Optional[Any]:
+        new_query, params = _prepare_related_query(self, f, *args, **kwargs)
+
+        gc = GraphConnection()
+        result = gc.evaluate_query_single(new_query, params)
+
+        return result
+
+    wrapper.neontology_related_prop = True
+
+    return wrapper
+
+
+def related_nodes(f: Callable) -> Callable:
+    """Decorator to wrap functions on BaseNode subclasses and return a list of nodes."""
+
+    @functools.wraps(f)
+    def wrapper(self: "BaseNode", *args: Any, **kwargs: Any) -> List["BaseNode"]:
+        new_query, params = _prepare_related_query(self, f, *args, **kwargs)
+
+        gc = GraphConnection()
+        result = gc.evaluate_query(new_query, params)
+
+        return result.nodes
+
+    wrapper.neontology_related_nodes = True
+
+    return wrapper
 
 
 class BaseNode(CommonModel):  # pyre-ignore[13]
     __primaryproperty__: ClassVar[str]
     __primarylabel__: ClassVar[Optional[str]]
-    __secondarylabels__: ClassVar[Optional[list]] = []
+    __secondarylabels__: ClassVar[List[str]] = []
 
     def __init__(self, **data: dict):
         super().__init__(**data)
@@ -25,6 +92,9 @@ def __init__(self, **data: dict):
                 "Nodes to be used in the graph must define a primary label."
             )
 
+    def __str__(self) -> str:
+        return str(self.get_pp())
+
     def _get_merge_parameters(self) -> Dict[str, Any]:
         """
 
@@ -32,65 +102,124 @@ def _get_merge_parameters(self) -> Dict[str, Any]:
             Dict[str, Any]: a dictionary of key/value pairs.
         """
 
+        all_props = self.model_dump()
+
+        always_set = {k: all_props[k] for k in self._always_set}
+        set_on_match = {k: all_props[k] for k in self._set_on_match}
+        set_on_create = {k: all_props[k] for k in self._set_on_create}
+
         params = {
-            "pp": self.neo4j_dict()[self.__primaryproperty__],
-            "always_set": self._get_prop_values(self._always_set),
-            "set_on_match": self._get_prop_values(self._set_on_match),
-            "set_on_create": self._get_prop_values(self._set_on_create),
+            "pp": all_props[self.__primaryproperty__],
+            "always_set": always_set,
+            "set_on_match": set_on_match,
+            "set_on_create": set_on_create,
         }
 
         return params
 
-    def get_primary_property_value(self) -> Union[str, int]:
+    @classmethod
+    def get_related_node_methods(cls) -> dict:
+        related_node_attributes = {
+            name: getattr(cls, name)
+            # get all attributes, including methods, properties, and builtins
+            for name in dir(cls)
+            # only want methods
+            if callable(getattr(cls, name))
+            # filter to tagged methods
+            and hasattr(getattr(cls, name), "neontology_related_nodes")
+        }
+        return related_node_attributes
+
+    @classmethod
+    def get_related_property_methods(cls) -> dict:
+        related_prop_attributes = {
+            name: getattr(cls, name)
+            # get all attributes, including methods, properties, and builtins
+            for name in dir(cls)
+            # only want methods
+            if callable(getattr(cls, name))
+            # filter to tagged methods
+            and hasattr(getattr(cls, name), "neontology_related_prop")
+        }
+        return related_prop_attributes
+
+    @model_validator(mode="after")
+    def validate_identifiers(self) -> "BaseNode":
+        try:
+            gql_identifier_adapter.validate_strings(self.__primarylabel__)
+
+        except AttributeError:
+            pass
+        except ValidationError:
+            warnings.warn(
+                (
+                    "Primary Label should contain only alphanumeric characters and underscores."
+                    " It should begin with an alphabetic character."
+                )
+            )
+
+        try:
+            gql_identifier_adapter.validate_strings(self.__primaryproperty__)
+        except AttributeError:
+            pass
+        except ValidationError:
+            warnings.warn(
+                (
+                    "Primary Property should contain only alphanumeric characters and underscores."
+                    " It should begin with an alphabetic character."
+                )
+            )
+
+        return self
+
+    def get_pp(self) -> Union[str, int]:
         return self._get_merge_parameters()["pp"]
 
-    def create(self) -> None:
+    def get_primary_property_value(self) -> Union[str, int]:
+        warnings.warn(("get_primary_property_value is deprecated, use get_pp instead."))
+        return self.get_pp()
+
+    def create(self) -> "BaseNode":
         """Create this node in the graph."""
 
-        params = self.neo4j_dict()
+        # pp = self.get_pp()
+
+        # if self.match(pp) is not None:
+        #    raise RuntimeError(f"Node already exists: {pp}")
 
-        all_props = self.neo4j_dict()
+        all_props = self._engine_dict()
 
         pp_value = all_props.pop(self.__primaryproperty__)
 
-        params = {"pp": pp_value, "all_props": all_props}
+        node_details = [{"pp": pp_value, "props": all_props}]
 
         all_labels = [self.__primarylabel__] + self.__secondarylabels__
 
-        cypher = f"""
-        CREATE (n:{":".join(all_labels)} {{ {self.__primaryproperty__}: $pp }})
-        SET n += $all_props
-        RETURN n
-        """
-        graph = GraphConnection()
-        result = graph.cypher_write_single(cypher, params)
-        result_node = self.__class__(**dict(result["n"]))
-        return result_node
+        pp_key = self.__primaryproperty__
+
+        gc = GraphConnection()
 
-    def merge(self) -> None:
+        results = gc.create_nodes(all_labels, pp_key, node_details, self.__class__)
+
+        return results[0]
+
+    def merge(self) -> List["BaseNode"]:
         """Merge this node into the graph."""
 
-        params = self._get_merge_parameters()
+        node_list = [self._get_merge_parameters()]
 
         all_labels = [self.__primarylabel__] + self.__secondarylabels__
 
-        cypher = f"""
-        MERGE (n:{":".join(all_labels)} {{ {self.__primaryproperty__}: $pp }})
-        ON MATCH SET n += $set_on_match
-        ON CREATE SET n += $set_on_create
-        SET n += $always_set
-        RETURN n
-        """
+        pp_key = self.__primaryproperty__
 
-        graph = GraphConnection()
-        result = graph.cypher_write_single(cypher, params)
+        gc = GraphConnection()
 
-        result_node = self.__class__(**dict(result["n"]))
+        results = gc.merge_nodes(all_labels, pp_key, node_list, self.__class__)
 
-        return result_node
+        return results
 
     @classmethod
-    def create_nodes(cls: Type[B], nodes: List[B]) -> List[Union[str, int]]:
+    def create_nodes(cls, nodes: List["BaseNode"]) -> List["BaseNode"]:
         """Create the given nodes in the database.
 
         Args:
@@ -103,35 +232,22 @@ def create_nodes(cls: Type[B], nodes: List[B]) -> List[Union[str, int]]:
             TypeError: Raised if one of the nodes isn't of this type.
         """
 
-        for node in nodes:
-            if isinstance(node, cls) is False:
-                raise TypeError("Node was incorrect type.")
-
         node_list = [
-            {"props": x.neo4j_dict(), "pp": x.neo4j_dict()[cls.__primaryproperty__]}
+            {"props": x._engine_dict(), "pp": x._engine_dict()[cls.__primaryproperty__]}
             for x in nodes
         ]
 
         all_labels = [cls.__primarylabel__] + cls.__secondarylabels__
+        pp_key = cls.__primaryproperty__
 
-        cypher = f"""
-        UNWIND $node_list AS node
-        create (n:{":".join(all_labels)} {{{cls.__primaryproperty__}: node.pp}})
-        SET n = node.props
-        RETURN n
-        """
-
-        graph = GraphConnection()
-        results = graph.cypher_write_many(
-            cypher=cypher, params={"node_list": node_list}
-        )
+        gc = GraphConnection()
 
-        matched_nodes = [cls(**dict(x["n"])) for x in results]
+        results = gc.create_nodes(all_labels, pp_key, node_list, cls)
 
-        return matched_nodes
+        return results
 
     @classmethod
-    def merge_nodes(cls: Type[B], nodes: List[B]) -> List[B]:
+    def merge_nodes(cls, nodes: List["BaseNode"]) -> List["BaseNode"]:
         """Merge multiple nodes into the database.
 
         Args:
@@ -144,34 +260,20 @@ def merge_nodes(cls: Type[B], nodes: List[B]) -> List[B]:
             TypeError: Raised if any of the nodes provided don't match this class.
         """
 
-        for node in nodes:
-            if isinstance(node, cls) is False:
-                raise TypeError("Node was incorrect type.")
-
         node_list = [x._get_merge_parameters() for x in nodes]
 
         all_labels = [cls.__primarylabel__] + cls.__secondarylabels__
 
-        cypher = f"""
-        UNWIND $node_list AS node
-        MERGE (n:{":".join(all_labels)} {{{cls.__primaryproperty__}: node.pp}})
-        ON MATCH SET n += node.set_on_match
-        ON CREATE SET n += node.set_on_create
-        SET n += node.always_set
-        RETURN n
-        """
+        pp_key = cls.__primaryproperty__
 
-        graph = GraphConnection()
-        results = graph.cypher_write_many(
-            cypher=cypher, params={"node_list": node_list}
-        )
+        gc = GraphConnection()
 
-        matched_nodes = [cls(**dict(x["n"])) for x in results]
+        results = gc.merge_nodes(all_labels, pp_key, node_list, cls)
 
-        return matched_nodes
+        return results
 
     @classmethod
-    def merge_records(cls: Type[B], records: dict) -> List[B]:
+    def merge_records(cls, records: List[dict]) -> List["BaseNode"]:
         """Take a list of dictionaries and use them to merge in nodes in the graph.
 
         Each dictionary will be used to merge a node where dictionary key/value pairs
@@ -189,7 +291,7 @@ def merge_records(cls: Type[B], records: dict) -> List[B]:
         return cls.merge_nodes(nodes)
 
     @classmethod
-    def merge_df(cls: Type[B], df: pd.DataFrame, deduplicate: bool = True) -> pd.Series:
+    def merge_df(cls, df: pd.DataFrame, deduplicate: bool = True) -> pd.Series:
         """Merge in new nodes based on data in a dataframe.
 
         The dataframe columns must correspond to the Node properties.
@@ -207,31 +309,39 @@ def merge_df(cls: Type[B], df: pd.DataFrame, deduplicate: bool = True) -> pd.Ser
 
         input_df = df.replace([np.nan], None).copy()
 
+        # create a unique identifier field based on all rows
+        # we'll use this later to match up deduplicated rows to the original ordering
+        input_df["unique_identifier"] = input_df.astype(str).values.sum(axis=1)
+
         if deduplicate is True:
             # we don't wan't to waste time attempting to merge identical records
-            unique_df = input_df.drop_duplicates(ignore_index=True).copy()
+            unique_df = input_df.drop_duplicates(
+                subset="unique_identifier", ignore_index=True
+            ).copy()
         else:
             unique_df = input_df
 
-        records = unique_df.to_dict(orient="records")
+        model_data = unique_df.drop("unique_identifier", axis=1).copy()
+
+        records = model_data.to_dict(orient="records")
 
         unique_df["generated_nodes"] = pd.Series(cls.merge_records(records))
 
         # now we need to get the mapping from unique id to primary property
         # so that we can return the data in the same shape it was received
         input_df.insert(0, "ontolocy_merging_order", range(0, len(input_df)))
-        merge_cols = list(input_df.columns)
-        merge_cols.remove("ontolocy_merging_order")
         output_df = input_df.merge(
-            unique_df,
-            how="inner",
-            on=merge_cols,
-        ).sort_values("ontolocy_merging_order", ignore_index=True)
+            unique_df, how="outer", on="unique_identifier", suffixes=(None, "_y")
+        )
+
+        ordered_nodes = output_df.sort_values(
+            "ontolocy_merging_order", ignore_index=True
+        ).generated_nodes.copy()
 
-        return output_df.generated_nodes
+        return ordered_nodes
 
     @classmethod
-    def match(cls: Type[B], pp: str) -> Optional[B]:
+    def match(cls, pp: str) -> Optional["BaseNode"]:
         """MATCH a single node of this type with the given primary property.
 
         Args:
@@ -249,12 +359,14 @@ def match(cls: Type[B], pp: str) -> Optional[B]:
 
         params = {"pp": pp}
 
-        graph = GraphConnection()
+        gc = GraphConnection()
 
-        result = graph.cypher_read(cypher, params)
+        result = gc.evaluate_query(
+            cypher, params, node_classes={cls.__primarylabel__: cls}
+        )
 
-        if result:
-            return cls(**dict(result["n"]))
+        if result.nodes:
+            return result.nodes[0]
 
         else:
             return None
@@ -270,45 +382,209 @@ def delete(cls, pp: str) -> None:
             pp (str): Primary property value to match on.
         """
 
-        cypher = f"""
-        MATCH (n:{cls.__primarylabel__})
-        WHERE n.{cls.__primaryproperty__} = $pp
-        DETACH DELETE n
-        """
+        label = cls.__primarylabel__
 
-        params = {"pp": pp}
+        if label is None:
+            raise ValueError("Cannot delete nodes without a primary label.")
 
-        graph = GraphConnection()
+        pp_key = cls.__primaryproperty__
+        pp_value = pp
 
-        graph.cypher_write(cypher, params)
+        gc = GraphConnection()
+
+        gc.delete_nodes(label, pp_key, [pp_value])
 
     @classmethod
-    def match_nodes(cls: Type[B], limit: int = 100, skip: int = 0) -> List[B]:
+    def match_nodes(
+        cls, limit: Optional[int] = None, skip: Optional[int] = None
+    ) -> List["BaseNode"]:
         """Get nodes of this type from the database.
 
         Run a MATCH cypher query to retrieve any Nodes with the label of this class.
 
         Args:
-            limit (int, optional): Maximum number of results to return. Defaults to 100.
-            skip (int, optional): Skip through this many results (for pagination). Defaults to 0.
+            limit (int, optional): Maximum number of results to return. Defaults to None.
+            skip (int, optional): Skip through this many results (for pagination). Defaults to None.
 
         Returns:
             Optional[List[B]]: A list of node instances.
         """
 
-        cypher = f"""
-        MATCH(n:{cls.__primarylabel__})
-        RETURN n{{.*}}
-        ORDER BY n.created DESC
-        SKIP $skip
-        LIMIT $limit
+        gc = GraphConnection()
+        result = gc.match_nodes(cls, limit, skip)
+
+        return result
+
+    def get_related(
+        self,
+        relationship_types: list = [],
+        relationship_properties: Optional[dict] = None,
+        target_label: Optional[str] = None,
+        outgoing: bool = True,
+        incoming: bool = False,
+        depth: Optional[tuple] = None,
+        limit: Optional[int] = None,
+        skip: Optional[int] = None,
+        distinct: bool = False,
+    ) -> tuple:
+        if target_label:
+            target = f"o:{gql_identifier_adapter.validate_strings(target_label)}"
+        else:
+            target = "o"
+
+        if relationship_types:
+            rel_type_match = "r:" + "|".join(
+                [gql_identifier_adapter.validate_strings(x) for x in relationship_types]
+            )
+
+        else:
+            rel_type_match = "r"
+
+        if relationship_properties:
+            rel_prop_match = (
+                "{"
+                + ", ".join(
+                    [
+                        f"{gql_identifier_adapter.validate_strings(x)}: ${x}"
+                        for x in relationship_properties
+                    ]
+                )
+                + "}"
+            )
+
+            pass_on_params = dict(relationship_properties)
+
+        else:
+            rel_prop_match = ""
+            pass_on_params = {}
+
+        if outgoing and incoming:
+            out_dir = "-"
+            in_dir = "-"
+
+        elif outgoing:
+            out_dir = "->"
+            in_dir = "-"
+
+        elif not outgoing and not incoming:
+            raise ValueError("Must specify at least one of incoming or outgoing.")
+
+        else:
+            out_dir = "-"
+            in_dir = "<-"
+
+        if depth:
+            min_depth, max_depth = depth
+
+            rel_depth = f"*{min_depth}..{max_depth}"
+        else:
+            rel_depth = ""
+
+        if distinct:
+            return_distinct = "DISTINCT"
+
+        else:
+            return_distinct = ""
+
+        query = f"""
+        MATCH (#ThisNode){in_dir}[{rel_type_match}{rel_depth} {rel_prop_match}]{out_dir}({target})
+        RETURN {return_distinct} o, r, ThisNode
         """
 
-        params = {"skip": skip, "limit": limit}
+        if skip:
+            query += f" SKIP {int_adapter.validate_python(skip)} "
+
+        if limit:
+            query += f" LIMIT {int_adapter.validate_python(limit)} "
+
+        new_query, params = _find_this_node(query, pass_on_params, self)
+
+        gc = GraphConnection()
+        result = gc.evaluate_query(new_query, params)
+
+        return result
+
+    @classmethod
+    @related_property
+    def get_count(cls):
+        return f"MATCH (n:{cls.__primarylabel__}) RETURN COUNT(DISTINCT n)"
+
+    def _prep_dump_dict(self, dumped_model: dict) -> dict:
+        dumped_model["LABEL"] = self.__primarylabel__
+
+        return dumped_model
+
+    def neontology_dump(
+        self, exclude: Optional[set] = None, exclude_none: bool = True, **kwargs
+    ) -> dict:
+        dumped_model = self.model_dump(
+            exclude_none=exclude_none, exclude=exclude, **kwargs
+        )
+
+        return self._prep_dump_dict(dumped_model)
+
+    def neontology_dump_json(
+        self, exclude: Optional[set] = None, exclude_none: bool = True, **kwargs
+    ) -> str:
+        # pydantic converts values to be json serializable, make use of this first
+        original_json = self.model_dump_json(
+            exclude_none=exclude_none, exclude=exclude, **kwargs
+        )
+        model_dict = json.loads(original_json)
+        model_dict["LABEL"] = self.__primarylabel__
+        return json.dumps(self._prep_dump_dict(model_dict))
+
+    @classmethod
+    def neontology_schema(cls, include_outgoing_rels: bool = True) -> NodeSchema:
+        if not cls.__primarylabel__:
+            raise ValueError(
+                "Node does not have a primary label defined for generating schema."
+            )
+
+        schema_dict: dict = {}
+        schema_dict["label"] = cls.__primarylabel__
+        schema_dict["title"] = cls.__name__
+        schema_dict["secondary_labels"] = cls.__secondarylabels__
+
+        model_properties: list = []
+
+        for field_name, field_props in cls.model_fields.items():
+            field_type = extract_type_mapping(
+                field_props.annotation, show_optional=True
+            )
+
+            node_property = SchemaProperty(
+                type_annotation=field_type,
+                name=field_name,
+                required=field_props.is_required(),
+            )
+
+            if field_props.is_required() is True:
+                model_properties.insert(0, node_property)
+
+            # put optional fields at the end
+            else:
+                model_properties.append(node_property)
+
+        schema_dict["properties"] = model_properties
+        schema_dict["outgoing_relationships"] = []
+
+        if include_outgoing_rels is False:
+            return NodeSchema(**schema_dict)
+
+        else:
+            from .utils import get_rels_by_source, get_rels_by_type
+
+            outgoing_rels = get_rels_by_source().get(cls.__primarylabel__, set())
+            all_rel_types = get_rels_by_type()
+
+            for rel in outgoing_rels:
+                rel_class = all_rel_types[rel].relationship_class
 
-        graph = GraphConnection()
-        records = graph.cypher_read_many(cypher, params)
+                rel_schema = rel_class.neontology_schema(
+                    source_labels=[schema_dict["label"]]
+                )
 
-        nodes = [cls(**dict(x["n"])) for x in records]
+                schema_dict["outgoing_relationships"].append(rel_schema)
 
-        return nodes
+        return NodeSchema(**schema_dict)
diff --git a/src/neontology/baserelationship.py b/src/neontology/baserelationship.py
index d732807..177348d 100644
--- a/src/neontology/baserelationship.py
+++ b/src/neontology/baserelationship.py
@@ -1,31 +1,18 @@
-"""Defines the BaseRelationship class.
-
-The BaseRelationship class is used for creating and matching on relationships in the graph.
-
-    Typical usage example:
-
-    class MyRel(BaseRelationship):
-
-        __relationshiptype__: ClassVar[Optional[str]] = "MY_REL"
-
-        source: SourceNode
-        target: TargetNode
-
-    my_rel = MyRel(source=source_node, target=target_node)
-    my_rel.merge()
-
-"""
-
+import itertools
+import json
+import warnings
 from typing import Any, ClassVar, Dict, List, Optional, Type, TypeVar
 
 import numpy as np
 import pandas as pd
-from pydantic import PrivateAttr
+from pydantic import BaseModel, PrivateAttr, ValidationError, model_validator
 
 from neontology.graphconnection import GraphConnection
 
 from .basenode import BaseNode
 from .commonmodel import CommonModel
+from .gql import gql_identifier_adapter
+from .schema_utils import RelationshipSchema, SchemaProperty, extract_type_mapping
 
 R = TypeVar("R", bound="BaseRelationship")
 
@@ -36,25 +23,44 @@ class BaseRelationship(CommonModel):  # pyre-ignore[13]
 
     __relationshiptype__: ClassVar[Optional[str]] = None
 
-    _merge_on: List[
-        str
-    ] = PrivateAttr()  # what relationship properties should we merge on
+    _merge_on: List[str] = (
+        PrivateAttr()
+    )  # what relationship properties should we merge on
 
     def __init__(self, **data: dict):
         super().__init__(**data)
 
-        self._merge_on = self._get_prop_usage("merge_on")
-
         # we can define 'abstract' relationships which don't have a label
         # these are to provide common properties to be used by subclassed relationships
         # but shouldn't be put in the graph or even instantiated
         if self.__relationshiptype__ is None:
             raise NotImplementedError(
-                "Nodes to be used in the graph must define a primary label."
+                "Relationships to be used in the graph must define a relationship type."
             )
 
     @classmethod
-    def get_relationship_type(cls) -> str:
+    def _set_prop_usage(cls) -> None:
+        super()._set_prop_usage()
+        cls._merge_on = cls._get_prop_usage("merge_on")
+
+    @model_validator(mode="after")
+    def validate_identifiers(self) -> "BaseRelationship":
+        try:
+            gql_identifier_adapter.validate_strings(self.__relationshiptype__)
+        except AttributeError:
+            pass
+        except ValidationError:
+            warnings.warn(
+                (
+                    "Relationship Type should contain only alphanumeric characters and underscores."
+                    " It should begin with an alphabetic character."
+                )
+            )
+
+        return self
+
+    @classmethod
+    def get_relationship_type(cls) -> Optional[str]:
         """Get the relationship type to use for creating and matching this relationship.
 
         If __relationship__ has been specified, use that.
@@ -77,19 +83,27 @@ def _get_merge_parameters(
 
         exclusions = {"source", "target"}
 
-        # these properties will be referenced individually
-        merge_props = self._get_prop_values(self._merge_on, exclude=exclusions)
+        # get all the properties
+        all_props = self.model_dump(exclude=exclusions)
+
+        # merge_props properties will be referenced individually with kwargs
+        merge_props = {k: all_props[k] for k in self._merge_on}
+
+        always_set = {k: all_props[k] for k in self._always_set}
+
+        set_on_match = {k: all_props[k] for k in self._set_on_match}
+
+        set_on_create = {k: all_props[k] for k in self._set_on_create}
+
+        source_prop = self.source.model_dump()[source_prop]
+        target_prop = self.target.model_dump()[target_prop]
 
         params = {
-            "source_prop": self.source.neo4j_dict()[source_prop],
-            "target_prop": self.target.neo4j_dict()[target_prop],
-            "always_set": self._get_prop_values(self._always_set, exclude=exclusions),
-            "set_on_match": self._get_prop_values(
-                self._set_on_match, exclude=exclusions
-            ),
-            "set_on_create": self._get_prop_values(
-                self._set_on_create, exclude=exclusions
-            ),
+            "source_prop": source_prop,
+            "target_prop": target_prop,
+            "always_set": always_set,
+            "set_on_match": set_on_match,
+            "set_on_create": set_on_create,
             **merge_props,
         }
 
@@ -102,37 +116,43 @@ def merge(
         source_label = self.source.__primarylabel__
         target_label = self.target.__primarylabel__
 
-        source_pp = self.source.__primaryproperty__
-        target_pp = self.target.__primaryproperty__
+        if not source_label or not target_label:
+            raise ValueError(
+                "Source and target Nodes must have a defined primary label for creating a relationship."
+            )
 
-        params = self._get_merge_parameters(
-            source_prop=source_pp, target_prop=target_pp
+        source_prop = self.source.__primaryproperty__
+        target_prop = self.target.__primaryproperty__
+
+        rel_props = self._get_merge_parameters(
+            source_prop=source_prop, target_prop=target_prop
         )
 
-        rel_type = self.get_relationship_type()
+        merge_on_props = self._merge_on
 
-        # build a string of properties to merge on "prop_name: $prop_name"
-        merge_props = ", ".join([f"{x}: ${x}" for x in self._merge_on])
+        rel_type = self.get_relationship_type()
 
-        cypher = f"""
-        MATCH (source:{source_label} {{ {source_pp}: $source_prop }}),
-            (target:{target_label} {{ {target_pp}: $target_prop }})
-        MERGE (source)-[r:{rel_type} {{ {merge_props} }}]->(target)
-        ON MATCH SET r += $set_on_match
-        ON CREATE SET r += $set_on_create
-        SET r += $always_set
-        """
+        if not rel_type:
+            raise ValueError(
+                "Realtionship must have a defined relationship type for creating a relationship."
+            )
 
-        graph = GraphConnection()
+        gc = GraphConnection()
 
-        graph.cypher_write(cypher, params)
+        gc.merge_relationships(
+            source_label,
+            target_label,
+            source_prop,
+            target_prop,
+            rel_type,
+            merge_on_props,
+            [rel_props],
+        )
 
     @classmethod
     def merge_relationships(
         cls: Type[R],
         rels: List[R],
-        source_type: Optional[Type[BaseNode]] = None,
-        target_type: Optional[Type[BaseNode]] = None,
         source_prop: Optional[str] = None,
         target_prop: Optional[str] = None,
     ) -> None:
@@ -152,54 +172,55 @@ def merge_relationships(
             TypeError: If relationships are provided which aren't of this class
         """
 
-        if source_type is None:
-            source_type = cls.model_fields["source"].annotation
+        # define the properties to merge on
+        merge_on_props = cls._get_prop_usage("merge_on")
 
-        if target_type is None:
-            target_type = cls.model_fields["target"].annotation
+        # sources and targets could have different primary labels
+        # to operate efficiently, we group like source and targets for batch creation of relationships
+        grouped_rels = itertools.groupby(
+            rels, lambda x: (x.source.__class__, x.target.__class__)
+        )
 
-        for rel in rels:
-            if isinstance(rel, cls) is False:
-                raise TypeError("Relationship was incorrect type.")
-            if type(rel.source) is not source_type:
-                raise TypeError("Received an inappropriate kind of source node.")
-            if type(rel.target) is not target_type:
-                raise TypeError("Received an inappropriate kind of target node.")
+        for node_clases, common_rels in grouped_rels:
+            src_class = node_clases[0]
+            tgt_class = node_clases[1]
 
-        if source_prop is None:
-            source_prop = source_type.__primaryproperty__
+            source_label = src_class.__primarylabel__
+            target_label = tgt_class.__primarylabel__
 
-        if target_prop is None:
-            target_prop = target_type.__primaryproperty__
+            if source_prop is None:
+                source_prop = src_class.__primaryproperty__
 
-        source_label = source_type.__primarylabel__
-        target_label = target_type.__primarylabel__
-
-        # build a string of properties to merge on "prop_name: $prop_name"
-        # we need to instantiate the class so that _merge_on is generated as part of __init__
-        merge_props = ", ".join([f"{x}: ${x}" for x in cls._get_prop_usage("merge_on")])
-
-        rel_list: List[Dict[str, Any]] = [
-            x._get_merge_parameters(source_prop, target_prop) for x in rels
-        ]
-
-        rel_type = cls.get_relationship_type()
-
-        cypher = f"""
-        UNWIND $rel_list AS rel
-        MATCH (source:{source_label})
-        WHERE source.{source_prop} = rel.source_prop
-        MATCH (target:{target_label})
-        WHERE target.{target_prop} = rel.target_prop
-        MERGE (source)-[r:{rel_type} {{ {merge_props} }}]->(target)
-        ON MATCH SET r += rel.set_on_match
-        ON CREATE SET r += rel.set_on_create
-        SET r += rel.always_set
-        """
+            if target_prop is None:
+                target_prop = tgt_class.__primaryproperty__
 
-        graph = GraphConnection()
+            if not source_label or not target_label:
+                raise ValueError(
+                    "Source and target Nodes must have a defined primary label to create a relationship."
+                )
 
-        graph.cypher_write(cypher=cypher, params={"rel_list": rel_list})
+            rel_type = cls.get_relationship_type()
+
+            if not rel_type:
+                raise ValueError(
+                    "Relationship must have a defined relationship type for creating a relationship."
+                )
+
+            rel_list: List[Dict[str, Any]] = [
+                x._get_merge_parameters(source_prop, target_prop) for x in common_rels
+            ]
+
+            gc = GraphConnection()
+
+            gc.merge_relationships(
+                source_label,
+                target_label,
+                source_prop,
+                target_prop,
+                rel_type,
+                merge_on_props,
+                rel_list,
+            )
 
     @classmethod
     def merge_records(
@@ -232,6 +253,9 @@ def merge_records(
         if target_type is None:
             target_type = cls.model_fields["target"].annotation
 
+        if not source_type or not target_type:
+            raise ValueError("Source and target Nodes types not defined.")
+
         if source_prop is None:
             source_prop = source_type.__primaryproperty__
 
@@ -248,15 +272,11 @@ def merge_records(
                 **{target_prop: record["target"]}
             )
 
-            hydrated_list.append(hydrated)
-
-        rels = [cls(**x) for x in hydrated_list]
+            hydrated_list.append(cls(**hydrated))
 
         cls.merge_relationships(
-            rels,
-            source_type=source_type,
+            hydrated_list,
             source_prop=source_prop,
-            target_type=target_type,
             target_prop=target_prop,
         )
 
@@ -289,3 +309,149 @@ def merge_df(
                 target_type=target_type,
                 target_prop=target_prop,
             )
+
+    @classmethod
+    def match_relationships(
+        cls, limit: Optional[int] = None, skip: Optional[int] = None
+    ) -> List["BaseRelationship"]:
+        gc = GraphConnection()
+        result = gc.match_relationships(cls, limit, skip)
+
+        return result
+
+    @classmethod
+    def get_count(cls):
+        gc = GraphConnection()
+        cypher = (
+            f"MATCH (n)-[r:{cls.__relationshiptype__}]->(o) RETURN COUNT(DISTINCT r)"
+        )
+        result = gc.evaluate_query_single(cypher)
+        return result
+
+    def _prep_dump_dict(self, dumped_model: dict, exclude_node_props: True) -> dict:
+        if exclude_node_props is True:
+            dumped_model["source"] = self.source.get_pp()
+            dumped_model["SOURCE_LABEL"] = self.source.__primarylabel__
+            dumped_model["target"] = self.target.get_pp()
+            dumped_model["TARGET_LABEL"] = self.target.__primarylabel__
+
+        else:
+            dumped_model["source"]["LABEL"] = self.source.__primarylabel__
+            dumped_model["source"]["PK"] = self.source.get_pp()
+
+            dumped_model["target"]["LABEL"] = self.target.__primarylabel__
+            dumped_model["target"]["PK"] = self.target.get_pp()
+
+        dumped_model["RELATIONSHIP_TYPE"] = self.__relationshiptype__
+
+        return dumped_model
+
+    def neontology_dump(
+        self,
+        exclude_node_props: bool = True,
+        exclude: Optional[set] = None,
+        exclude_none: bool = True,
+        **kwargs,
+    ) -> dict:
+        dumped_model = self.model_dump(
+            exclude_none=exclude_none, exclude=exclude, **kwargs
+        )
+
+        return self._prep_dump_dict(dumped_model, exclude_node_props)
+
+    def neontology_dump_json(
+        self,
+        exclude_node_props: bool = True,
+        exclude: Optional[set] = None,
+        exclude_none: bool = True,
+        **kwargs,
+    ) -> str:
+        # pydantic converts values to be json serializable, make use of this first
+        original_json = self.model_dump_json(
+            exclude_none=exclude_none, exclude=exclude, **kwargs
+        )
+        model_dict = json.loads(original_json)
+
+        return json.dumps(self._prep_dump_dict(model_dict, exclude_node_props))
+
+    @classmethod
+    def neontology_schema(
+        cls,
+        source_labels: Optional[List[str]] = None,
+        target_labels: Optional[List[str]] = None,
+    ) -> RelationshipSchema:
+        schema_properties = []
+        rel_type = cls.__relationshiptype__
+
+        if not rel_type:
+            raise ValueError("Relationship doesn't have a relationship type.")
+
+        for field_name, field_props in cls.model_fields.items():
+            if field_name in ["source", "target"]:
+                continue
+
+            field_type = extract_type_mapping(
+                field_props.annotation, show_optional=True
+            )
+
+            required_field = field_props.is_required()
+
+            rel_prop = SchemaProperty(
+                type_annotation=field_type,
+                name=field_name,
+                required=required_field,
+            )
+
+            if required_field is True:
+                schema_properties.insert(0, rel_prop)
+
+            else:
+                schema_properties.append(rel_prop)
+
+        source_type = cls.model_fields["source"].annotation
+        target_type = cls.model_fields["target"].annotation
+
+        if not target_labels:
+            if (
+                hasattr(target_type, "__primarylabel__")
+                and target_type.__primarylabel__
+            ):
+                target_labels = [target_type.__primarylabel__]
+
+        if not source_labels:
+            if (
+                hasattr(source_type, "__primarylabel__")
+                and source_type.__primarylabel__
+            ):
+                source_labels = [source_type.__primarylabel__]
+
+        if not source_labels or not target_labels:
+            from neontology.utils import get_node_types
+
+            if not target_labels:
+                # return concrete subclasses of the abstract node class given
+                retrieved_node_types = get_node_types(target_type)
+                target_labels = list(retrieved_node_types.keys())
+
+            if not source_labels:
+                # return concrete subclasses of the abstract node class given
+                retrieved_node_types = get_node_types(source_type)
+                source_labels = list(retrieved_node_types.keys())
+
+        schema = RelationshipSchema(
+            name=rel_type,
+            relationship_type=rel_type,
+            properties=schema_properties,
+            target_labels=target_labels,
+            source_labels=source_labels,
+        )
+
+        return schema
+
+
+class RelationshipTypeData(BaseModel):
+    relationship_class: type[BaseRelationship]
+    source_class: type[BaseNode]
+    target_class: type[BaseNode]
+    all_source_classes: List[type[BaseNode]]
+    all_target_classes: List[type[BaseNode]]
diff --git a/src/neontology/commonmodel.py b/src/neontology/commonmodel.py
index 7173f10..c769666 100644
--- a/src/neontology/commonmodel.py
+++ b/src/neontology/commonmodel.py
@@ -1,59 +1,34 @@
-from abc import ABC, abstractmethod
-from datetime import date, datetime, time, timedelta
-from typing import Any, ClassVar, Dict, List, Optional, Set
-
-from neo4j.time import Date as Neo4jDate
-from neo4j.time import DateTime as Neo4jDateTime
-from neo4j.time import Time as Neo4jTime
-from pydantic import (
-    BaseModel,
-    ConfigDict,
-    Field,
-    PrivateAttr,
-    field_validator,
-    model_validator,
-)
-
-
-class CommonModel(BaseModel, ABC):
+from typing import Any, Dict, List, Set
+
+from pydantic import BaseModel, ConfigDict, PrivateAttr, model_validator
+from pydantic_core import PydanticCustomError
+
+from .graphconnection import GraphConnection
+
+
+class CommonModel(BaseModel):
     model_config = ConfigDict(
         validate_assignment=True,
         extra="forbid",
         arbitrary_types_allowed=True,
     )
 
-    created: datetime = Field(
-        default_factory=datetime.now, json_schema_extra={"set_on_create": True}
-    )
-    merged: Optional[datetime] = Field(default=None, validate_default=True)
-
     _set_on_match: List[str] = PrivateAttr()
     _set_on_create: List[str] = PrivateAttr()
     _always_set: List[str] = PrivateAttr()
 
-    _neo4j_supported_types: ClassVar[Any] = (
-        list,
-        bool,
-        int,
-        bytearray,
-        float,
-        str,
-        bytes,
-        date,
-        time,
-        datetime,
-        timedelta,
-    )
-
     def __init__(self, **data: dict):
         super().__init__(**data)
+        self._set_prop_usage()
 
-        self._set_on_match = self._get_prop_usage("set_on_match")
-        self._set_on_create = self._get_prop_usage("set_on_create")
-        self._always_set = [
+    @classmethod
+    def _set_prop_usage(cls) -> None:
+        cls._set_on_match = cls._get_prop_usage("set_on_match")
+        cls._set_on_create = cls._get_prop_usage("set_on_create")
+        cls._always_set = [
             x
-            for x in self.model_dump().keys()
-            if x not in self._set_on_match + self._set_on_create + ["source", "target"]
+            for x in cls.model_fields.keys()
+            if x not in cls._set_on_match + cls._set_on_create + ["source", "target"]
         ]
 
     @classmethod
@@ -77,70 +52,31 @@ def _get_prop_values(
             Dict[str, Any]: a dictionary of key/value pairs.
         """
 
-        prop_values = {
-            k: v for k, v in self.neo4j_dict(exclude=exclude).items() if k in props
-        }
+        # prop_values = {
+        #    k: v for k, v in self._engine_dict(exclude=exclude).items() if k in props
+        # }
 
-        return prop_values
+        return self._engine_dict(exclude=exclude, include=set(props))
 
-    @abstractmethod
-    def _get_merge_parameters(self) -> Dict[str, Any]:
-        raise NotImplementedError
-
-    @classmethod
-    def export_type_converter(cls, value: Any) -> Any:
-        if isinstance(value, dict):
-            raise TypeError("Neo4j doesn't support dict types for properties.")
-
-        elif isinstance(value, (tuple, set)):
-            new_value = list(value)
-            return cls.export_type_converter(new_value)
-
-        elif isinstance(value, list):
-            # items in a list must all be the same type
-            item_type = type(value[0])
-            for item in value:
-                if isinstance(item, item_type) is False:
-                    raise TypeError(
-                        "For neo4j, all items in a list must be of the same type."
-                    )
-
-            return [cls.export_type_converter(x) for x in value]
-
-        elif isinstance(value, cls._neo4j_supported_types) is False:
-            return str(value)
-
-        else:
-            return value
-
-    @classmethod
-    def _export_dict_converter(cls, original_dict: Dict[str, Any]) -> Dict[str, Any]:
-        """_summary_
-
-        Args:
-            export_dict (Dict[str, Any]): _description_
+    def _engine_dict(self, exclude: Set[str] = set(), **kwargs: Any) -> Dict[str, Any]:
+        """Return a dict made up of only types compatible with the GraphEngine
 
         Returns:
-            Dict[str, Any]: _description_
+            dict: a dictionary export of this model instance
         """
 
-        export_dict = original_dict.copy()
+        pydantic_export_dict = self.model_dump(
+            exclude_none=True, exclude=exclude, **kwargs
+        )
 
-        for k, v in export_dict.items():
-            export_dict[k] = cls.export_type_converter(v)
+        # return pydantic_export_dict
 
-        return export_dict
+        try:
+            gc = GraphConnection()
+            export_dict = gc.engine.export_dict_converter(pydantic_export_dict)
 
-    def neo4j_dict(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]:
-        """Return a dict made up of only types compatible with neo4j
-
-        Returns:
-            dict: a dictionary export of this model instance
-        """
-
-        export_dict = self.model_dump(exclude_none=True, **kwargs)
-
-        export_dict = self._export_dict_converter(export_dict)
+        except RuntimeError:
+            export_dict = pydantic_export_dict
 
         return export_dict
 
@@ -148,49 +84,22 @@ def neo4j_dict(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]:
     # validators
     #
 
-    @field_validator("merged")
-    def set_merged_to_created(
-        cls, value: Optional[datetime], values: Dict[str, Any]
-    ) -> datetime:
-        """By default, set the 'merged' time equal to the 'created' time.
-
-        If the 'merged' value has been explicitly set, this is preserved.
-
-        Args:
-            value (Optional[datetime]): the value of the field.
-            values (Dict[str, Any]): a dictionary of field/value pairs set so far.
-
-        Returns:
-            datetime: The merged datetime value.
-        """
-
-        if value is None:
-            return values.data["created"]
-        else:
-            return value
-
     @model_validator(mode="before")
     @classmethod
-    def neo4j_datetime_to_native(cls, values: Dict[str, Any]) -> Dict[str, Any]:
-        """Datetimes come back from Neo4j as a non standard DateTime type.
-
-        We check for any values where that is the case and convert them to
-            native Python datetimes.
-
-        See https://neo4j.com/docs/api/python-driver/4.4/temporal_types.html for further info.
-
-        Args:
-            values (Dict[str, Any]): Dictionary of field/value pairs from pydantic.
-
-        Returns:
-            Dict[str, Any]: Returns the dictionary, with any Neo4jDateTimes updated.
+    def deprecated_merged_created(cls, data: Any) -> Any:
+        """Neontology v0 and v1 included and auto-populated this property.
+        Flag a warning whe
+            temporarily supported/deprecated before being removed.
         """
 
-        if not isinstance(values, dict):
-            raise ValueError
-
-        for key in values:
-            if isinstance(values[key], (Neo4jDateTime, Neo4jDate, Neo4jTime)):
-                values[key] = values[key].to_native()
-
-        return values
+        if ("created" in data and "created" not in cls.model_fields) or (
+            "merged" in data and "merged" not in cls.model_fields
+        ):
+            raise PydanticCustomError(
+                "created_or_merged_fields",
+                (
+                    "Native neontology support for 'merged' and 'created' properties has been removed."
+                    " Consider adding these fields to your model(s) and read the docs for further info"
+                ),
+            )
+        return data
diff --git a/src/neontology/gql.py b/src/neontology/gql.py
new file mode 100644
index 0000000..89837f8
--- /dev/null
+++ b/src/neontology/gql.py
@@ -0,0 +1,12 @@
+from pydantic import StringConstraints, TypeAdapter
+from typing_extensions import Annotated
+
+GQLIdentifier = Annotated[
+    str, StringConstraints(strict=True, pattern=r"^[a-zA-Z][a-zA-Z0-9_]+$")
+]
+
+# simple pydantic TypeAdapter for use validating strings before
+# inserting them into GQL statements
+gql_identifier_adapter = TypeAdapter(GQLIdentifier)
+
+int_adapter = TypeAdapter(int)
diff --git a/src/neontology/graphconnection.py b/src/neontology/graphconnection.py
index e17ec80..9f3e53c 100644
--- a/src/neontology/graphconnection.py
+++ b/src/neontology/graphconnection.py
@@ -1,13 +1,19 @@
+from __future__ import annotations
+
+import logging
 import os
-from typing import Any, Dict, List, Optional
+from typing import TYPE_CHECKING, Any, List, Optional
+from warnings import warn
+
+from .graphengines import MemgraphConfig, Neo4jConfig
+from .graphengines.graphengine import GraphEngineBase, GraphEngineConfig
+from .result import NeontologyResult
 
-from dotenv import load_dotenv
-from neo4j import GraphDatabase, Neo4jDriver
-from neo4j import Record as Neo4jRecord
-from neo4j import Result as Neo4jResult
-from neo4j import Transaction as Neo4jTransaction
+if TYPE_CHECKING:
+    from .basenode import BaseNode
+    from .baserelationship import BaseRelationship
 
-from .result import NeontologyResult, neo4j_records_to_neontology_records
+logger = logging.getLogger(__name__)
 
 
 class GraphConnection(object):
@@ -17,18 +23,14 @@ class GraphConnection(object):
 
     def __new__(
         cls,
-        neo4j_uri: Optional[str] = None,
-        neo4j_username: Optional[str] = None,
-        neo4j_password: Optional[str] = None,
+        config: Optional[GraphEngineConfig] = None,
     ) -> "GraphConnection":
         """Make sure we only have a single connection to the GraphDatabase.
 
         This connection then gets used by all instances.
 
         Args:
-            neo4j_uri (Optional[str], optional): Neo4j URI to connect to. Defaults to None.
-            neo4j_username (Optional[str], optional): Neo4j username. Defaults to None.
-            neo4j_password (Optional[str], optional): Neo4j password. Defaults to None.
+            config: GraphEngineConfig to setup the desired GraphEngine
 
         Returns:
             GraphConnection: Instance of the connection
@@ -39,200 +41,184 @@ def __new__(
 
             if GraphConnection._instance:
                 try:
-                    driver = GraphConnection._instance.driver = GraphDatabase.driver(  # type: ignore
-                        neo4j_uri, auth=(neo4j_username, neo4j_password)
-                    )
-                    driver.verify_connectivity()
+                    GraphConnection._instance.engine = config.engine(config)  # type: ignore[union-attr,arg-type]
 
-                    from .utils import get_node_types, get_rels_by_type
-
-                    # capture all possible types of node and relationship
-                    cls.global_nodes = get_node_types()
-                    cls.global_rels = get_rels_by_type()
+                except Exception as exc:
+                    GraphConnection._instance = None
 
-                except Exception as error:
-                    print(
-                        "Error: connection not established. Have you run init_neontology? {}".format(
-                            error
+                    raise RuntimeError(
+                        (
+                            "Error: connection not established. Have you run init_neontology?"
+                            f" Underlying exception: {type(exc).__name__}"
                         )
-                    )
-                    GraphConnection._instance = None
+                    ) from exc
+
+                # capture all currently defined types of node and relationship
+                from .utils import get_node_types, get_rels_by_type
+
+                cls.global_nodes = get_node_types()
+                cls.global_rels = get_rels_by_type()
 
             else:
                 GraphConnection._instance = None
 
         return cls._instance
 
-    def __del__(self) -> None:
-        """Close the driver gracefully when the class gets deleted."""
-
-        self.driver.close()
-
     def __init__(
         self,
-        neo4j_uri: Optional[str] = None,
-        neo4j_username: Optional[str] = None,
-        neo4j_password: Optional[str] = None,
+        config: Optional[GraphEngineConfig] = None,
     ) -> None:
         if self._instance:
-            self.driver: Neo4jDriver = self._instance.driver
-
-    def run_transaction_single(
-        self, tx: Neo4jTransaction, query: str, params: Dict[str, Any]
-    ) -> Optional[Neo4jRecord]:
-        """Run a transaction which is expected to return a single result.
-
-        Args:
-            tx (Neo4jTransaction): Neo4j Transaction object
-            query (str): cypher query to run
-            params (Dict[str, Any]): Parameters to pass to the query
-
-        Returns:
-            Optional[Neo4jRecord]: The result
-        """
-
-        return tx.run(query, **params).single()
-
-    def run_transaction_many(
-        self, tx: Neo4jTransaction, query: str, params: Dict[str, Any]
-    ) -> List[Neo4jRecord]:
-        """Run a transation which is expected to return multiple nodes.
-
-        Args:
-            tx (Neo4jTransaction): Neo4j Transaction object
-            query (str): cypher query to run
-            params (Dict[str, Any]): parameters to pass the query
-
-        Returns:
-            List[Neo4jRecord]: a list of the results
-        """
-
-        return [record for record in tx.run(query, **params)]
-
-    def cypher_write(self, cypher: str, params: Dict[str, Any] = {}) -> None:
-        """Execute a write transaction.
-
-        Args:
-            cypher (str): cypher query
-            params (Dict[str, Any]): parameters to pass to the query
-        """
-
-        with self.driver.session() as session:
-            session.execute_write(self.run_transaction_single, cypher, params)
-
-    def cypher_write_single(self, cypher: str, params: Dict[str, Any] = {}) -> None:
-        """Execute a write transaction.
-
-        Args:
-            cypher (str): cypher query
-            params (Dict[str, Any]): parameters to pass to the query
-        """
-
-        with self.driver.session() as session:
-            return session.execute_write(self.run_transaction_single, cypher, params)
-
-    def cypher_write_many(self, cypher: str, params: Dict[str, Any] = {}) -> None:
-        """Execute a write transaction.
-
-        Args:
-            cypher (str): cypher query
-            params (Dict[str, Any]): parameters to pass to the query
-        """
-
-        with self.driver.session() as session:
-            return session.execute_write(self.run_transaction_many, cypher, params)
-
-    def cypher_read(
-        self, cypher: str, params: Dict[str, Any] = {}
-    ) -> Optional[Neo4jRecord]:
-        """Run a cypher read only query which is expected to return a single result.
-
-        Args:
-            cypher (str): cypher query string
-            params (Dict[str, Any]): parameters to pass to the query
-
-        Returns:
-            Neo4jRecord: the resulting Neo4j 'Record', or None
-        """
-
-        with self.driver.session() as session:
-            return session.execute_read(self.run_transaction_single, cypher, params)
+            self.engine: GraphEngineBase = self._instance.engine
 
-    def cypher_read_many(
-        self, cypher: str, params: Dict[str, Any] = {}
-    ) -> List[Neo4jRecord]:
-        """Run a cypher read query which will return multiple records.
+        if self.engine.verify_connection() is False:
+            raise RuntimeError(
+                "Error: connection not established. Have you run init_neontology?"
+            )
 
-        Args:
-            cypher (str): cypher string to run
-            params (Dict[str, Any]): parameters to pass to the query
+    @classmethod
+    def change_engine(
+        cls,
+        config: GraphEngineConfig,
+    ) -> None:
+        if not cls._instance:
+            raise RuntimeError(
+                "Error: Can't change the engine without initializing Neontology first."
+            )
 
-        Returns:
-            List[Neo4jRecord]: A list of Neo4j 'Records' returned by the query.
-        """
+        cls._instance.engine.close_connection()
+        cls._instance.engine = config.engine(config)
 
-        with self.driver.session() as session:
-            return session.execute_read(self.run_transaction_many, cypher, params)
+    def evaluate_query_single(self, cypher: str, params: dict = {}) -> Optional[Any]:
+        return self.engine.evaluate_query_single(cypher, params)
 
-    def apply_constraint(self, label: str, property: str) -> None:
-        cypher = f"""
-        CREATE CONSTRAINT IF NOT EXISTS
-        FOR (n:{label})
-        REQUIRE n.{property} IS UNIQUE
-        """
+    def evaluate_query(
+        self,
+        cypher: str,
+        params: dict = {},
+        node_classes: dict = {},
+        relationship_classes: dict = {},
+        refresh_classes: bool = True,
+    ) -> NeontologyResult:
+        if refresh_classes is True:
+            from .utils import get_node_types, get_rels_by_type
+
+            # capture all currently defined types of node and relationship
+            self.global_nodes = get_node_types()
+            self.global_rels = get_rels_by_type()
+
+        if not node_classes:
+            node_classes = self.global_nodes
+
+        if not relationship_classes:
+            relationship_classes = self.global_rels
+
+        return self.engine.evaluate_query(
+            cypher, params, node_classes, relationship_classes
+        )
 
-        self.cypher_write(cypher)
+    def create_nodes(
+        self, labels: list, pp_key: str, properties: list, node_class: type["BaseNode"]
+    ) -> List["BaseNode"]:
+        return self.engine.create_nodes(labels, pp_key, properties, node_class)
 
-    def evaluate_query_single(self, cypher, params={}):
-        result = self.driver.execute_query(
-            cypher, parameters_=params, result_transformer_=Neo4jResult.single
-        )
+    def merge_nodes(
+        self, labels: list, pp_key: str, properties: list, node_class: type["BaseNode"]
+    ) -> List["BaseNode"]:
+        return self.engine.merge_nodes(labels, pp_key, properties, node_class)
 
-        if result:
-            return result.value()
+    def match_nodes(
+        self,
+        node_class: type["BaseNode"],
+        limit: Optional[int] = None,
+        skip: Optional[int] = None,
+    ) -> List["BaseNode"]:
+        return self.engine.match_nodes(node_class, limit, skip)
 
-        else:
-            return None
+    def match_relationships(
+        self,
+        relationship_class: type["BaseRelationship"],
+        limit: Optional[int] = None,
+        skip: Optional[int] = None,
+    ) -> List["BaseRelationship"]:
+        return self.engine.match_relationships(relationship_class, limit, skip)
 
-    def evaluate_query(self, cypher, params={}):
-        result = self.driver.execute_query(cypher, parameters_=params)
+    def delete_nodes(self, label: str, pp_key: str, pp_values: list) -> None:
+        self.engine.delete_nodes(label, pp_key, pp_values)
 
-        neo4j_records = result.records
-        neontology_records = neo4j_records_to_neontology_records(
-            neo4j_records, self.global_nodes, self.global_rels
+    def merge_relationships(
+        self,
+        source_label: str,
+        target_label: str,
+        source_prop: str,
+        target_prop: str,
+        rel_type: str,
+        merge_on_props: List[str],
+        rel_props: List[dict],
+    ) -> None:
+        self.engine.merge_relationships(
+            source_label,
+            target_label,
+            source_prop,
+            target_prop,
+            rel_type,
+            merge_on_props,
+            rel_props,
         )
 
-        return NeontologyResult(
-            records=neo4j_records, neontology_records=neontology_records
+    def close(self) -> None:
+        self.engine.close_connection()
+
+
+def init_neontology(config: Optional[GraphEngineConfig] = None, **kwargs) -> None:
+    """Initialise neontology."""
+
+    graph_engines = {
+        "NEO4J": Neo4jConfig,
+        "MEMGRAPH": MemgraphConfig,
+    }
+
+    if (
+        "neo4j_uri" in kwargs
+        or "neo4j_username" in kwargs
+        or "neo4j_password" in kwargs
+    ):
+        warn(
+            (
+                "Neo4j keyword arguments in init_neontology are being deprecated "
+                "- use config dictionary instead. Read the docs for new syntax."
+            ),
+            DeprecationWarning,
+            stacklevel=2,
         )
 
+        neo4j_config = {}
 
-def init_neontology(
-    neo4j_uri: Optional[str] = None,
-    neo4j_username: Optional[str] = None,
-    neo4j_password: Optional[str] = None,
-) -> None:
-    """Initialise neontology.
+        if kwargs.get("neo4j_uri"):
+            neo4j_config["uri"] = kwargs.get("neo4j_uri")
 
-    If connection properties are explicitly passed in, use these.
-    If not, attempt to load from enviornment variables (optionally in a .env file.)
+        if kwargs.get("neo4j_username"):
+            neo4j_config["username"] = kwargs.get("neo4j_username")
 
-    Args:
-        neo4j_uri (Optional[str], optional): Neo4j URI to connect to. Defaults to None.
-        neo4j_username (Optional[str], optional): Neo4j username. Defaults to None.
-        neo4j_password (Optional[str], optional): Neo4j password. Defaults to None.
-    """
+        if kwargs.get("neo4j_password"):
+            neo4j_config["password"] = kwargs.get("neo4j_password")
 
-    # try to load environment variables from .env file
-    load_dotenv()
+        config = Neo4jConfig(**neo4j_config)
 
-    if neo4j_uri is None:
-        neo4j_uri = os.getenv("NEO4J_URI")
+    if config is None:
+        graph_engine = os.getenv("NEONTOLOGY_ENGINE")
 
-    if neo4j_password is None:
-        neo4j_password = os.getenv("NEO4J_PASSWORD")
+        if graph_engine:
+            logger.info(
+                f"No GraphConfig provided, using defaults based on specified engine: {graph_engine}."
+            )
+            config = graph_engines[graph_engine]()
 
-    if neo4j_username is None:
-        neo4j_username = os.getenv("NEO4J_USERNAME")
+        else:
+            logger.info(
+                "No GraphConfig provided and no Graph Engine specified, using Neo4j."
+            )
+            config = Neo4jConfig()
 
-    GraphConnection(neo4j_uri, neo4j_username, neo4j_password)
+    GraphConnection(config)
+    logger.info("Neontology initialized.")
diff --git a/src/neontology/graphengines/__init__.py b/src/neontology/graphengines/__init__.py
new file mode 100644
index 0000000..c295ceb
--- /dev/null
+++ b/src/neontology/graphengines/__init__.py
@@ -0,0 +1,4 @@
+from .memgraphengine import MemgraphConfig, MemgraphEngine
+from .neo4jengine import Neo4jConfig, Neo4jEngine
+
+__all__ = ["MemgraphConfig", "MemgraphEngine", "Neo4jConfig", "Neo4jEngine"]
diff --git a/src/neontology/graphengines/graphengine.py b/src/neontology/graphengines/graphengine.py
new file mode 100644
index 0000000..175ef3e
--- /dev/null
+++ b/src/neontology/graphengines/graphengine.py
@@ -0,0 +1,313 @@
+from __future__ import annotations
+
+from datetime import date, datetime, time, timedelta
+from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional
+
+from pydantic import BaseModel
+
+from ..gql import gql_identifier_adapter, int_adapter
+from ..result import NeontologyResult
+
+if TYPE_CHECKING:
+    from ..basenode import BaseNode
+    from ..baserelationship import BaseRelationship
+
+
+class GraphEngineBase:
+    _supported_types: ClassVar[Any] = (
+        list,
+        bool,
+        int,
+        bytearray,
+        float,
+        str,
+        bytes,
+        date,
+        time,
+        datetime,
+        timedelta,
+    )
+
+    def __init__(self, config: Optional[dict]) -> None:
+        """Initialise connection to the engine
+
+        Args:
+            config (Optional[dict]): _description_
+        """
+        pass
+
+    @classmethod
+    def _export_type_converter(cls, value: Any) -> Any:
+        if isinstance(value, dict):
+            raise TypeError("Neontology doesn't support dict types for properties.")
+
+        elif isinstance(value, (tuple, set)):
+            new_value = list(value)
+            return cls._export_type_converter(new_value)
+
+        elif isinstance(value, list):
+            if len(value) == 0:
+                return []
+            # items in a list must all be the same type
+            item_type = type(value[0])
+            for item in value:
+                if isinstance(item, item_type) is False:
+                    raise TypeError(
+                        "For neo4j, all items in a list must be of the same type."
+                    )
+
+            return [cls._export_type_converter(x) for x in value]
+
+        elif isinstance(value, cls._supported_types) is False:
+            return str(value)
+
+        else:
+            return value
+
+    @classmethod
+    def export_dict_converter(cls, original_dict: Dict[str, Any]) -> Dict[str, Any]:
+        """_summary_
+
+        Args:
+            export_dict (Dict[str, Any]): _description_
+
+        Returns:
+            Dict[str, Any]: _description_
+        """
+
+        export_dict = original_dict.copy()
+
+        for k, v in export_dict.items():
+            export_dict[k] = cls._export_type_converter(v)
+
+        return export_dict
+
+    def verify_connection(self) -> bool:
+        raise NotImplementedError
+
+    def close_connection(self) -> None:
+        raise NotImplementedError
+
+    def evaluate_query(
+        self,
+        cypher: str,
+        params: Dict[str, Any] = {},
+        node_classes: dict = {},
+        relationship_classes: dict = {},
+    ) -> NeontologyResult:
+        raise NotImplementedError
+
+    def evaluate_query_single(self, cypher: str, params: Dict[str, Any]) -> Any:
+        raise NotImplementedError
+
+    def apply_constraint(self, label: str, property: str) -> None:
+        raise NotImplementedError
+
+    def drop_constraint(self, constraint_name: str) -> None:
+        raise NotImplementedError
+
+    def get_constraints(self) -> list:
+        raise NotImplementedError
+
+    def create_nodes(
+        self, labels: list, pp_key: str, properties: list, node_class: type["BaseNode"]
+    ) -> List["BaseNode"]:
+        """
+        Args:
+            labels (list): a list of labels to give created nodes
+            pp_key (str): the primary property for the nodes
+            properties (list): A list of dictionaries representing each node to be created.
+                two keys with associated values pp (the value to assign the primary property)
+                and props (dict with key value pairs for all other properties).
+
+        Returns:
+            list: list of created Nodes
+        """
+
+        label_identifiers = [gql_identifier_adapter.validate_strings(x) for x in labels]
+
+        cypher = f"""
+        UNWIND $node_list AS node
+        create (n:{":".join(label_identifiers)} {{{gql_identifier_adapter.validate_strings(pp_key)}: node.pp}})
+        SET n += node.props
+        RETURN n
+        """
+
+        params = {"node_list": properties}
+
+        node_classes = {node_class.__primarylabel__: node_class}
+
+        results = self.evaluate_query(cypher, params, node_classes)
+
+        return results.nodes
+
+    def merge_nodes(
+        self, labels: list, pp_key: str, properties: list, node_class: type["BaseNode"]
+    ) -> List["BaseNode"]:
+        """_summary_
+
+        Args:
+            labels (list): _description_
+            pp_key (str): _description_
+            properties (list): A list of dictionaries representing each node to be created.
+                four keys with associated values: pp (the value to assign the primary property)
+                set_on_match, set_on_create and always_set (dicts with key value pairs for all other properties).
+
+        Returns:
+            list: list of merged Nodes
+        """
+
+        label_identifiers = [gql_identifier_adapter.validate_strings(x) for x in labels]
+
+        cypher = f"""
+        UNWIND $node_list AS node
+        MERGE (n:{":".join(label_identifiers)} {{{gql_identifier_adapter.validate_strings(pp_key)}: node.pp}})
+        ON MATCH SET n += node.set_on_match
+        ON CREATE SET n += node.set_on_create
+        SET n += node.always_set
+        RETURN n
+        """
+
+        params = {"node_list": properties}
+
+        node_classes = {node_class.__primarylabel__: node_class}
+
+        results = self.evaluate_query(cypher, params, node_classes)
+
+        return results.nodes
+
+    def delete_nodes(self, label: str, pp_key: str, pp_values: List[Any]) -> None:
+        cypher = f"""
+        UNWIND $pp_values AS pp
+        MATCH (n:{gql_identifier_adapter.validate_strings(label)})
+        WHERE n.{gql_identifier_adapter.validate_strings(pp_key)} = pp
+        DETACH DELETE n
+        """
+
+        params = {"pp_values": pp_values}
+
+        self.evaluate_query_single(cypher, params)
+
+    def merge_relationships(
+        self,
+        source_label: str,
+        target_label: str,
+        source_prop: str,
+        target_prop: str,
+        rel_type: str,
+        merge_on_props: List[str],
+        rel_props: List[dict],
+    ) -> None:
+        # build a string of properties to merge on "prop_name: $prop_name"
+        merge_props = ", ".join(
+            [
+                f"{gql_identifier_adapter.validate_strings(x)}: rel.{x}"
+                for x in merge_on_props
+            ]
+        )
+
+        cypher = f"""
+        UNWIND $rel_list AS rel
+        MATCH (source:{gql_identifier_adapter.validate_strings(source_label)})
+        WHERE source.{gql_identifier_adapter.validate_strings(source_prop)} = rel.source_prop
+        MATCH (target:{gql_identifier_adapter.validate_strings(target_label)})
+        WHERE target.{gql_identifier_adapter.validate_strings(target_prop)} = rel.target_prop
+        MERGE (source)-[r:{gql_identifier_adapter.validate_strings(rel_type)} {{ {merge_props} }}]->(target)
+        ON MATCH SET r += rel.set_on_match
+        ON CREATE SET r += rel.set_on_create
+        SET r += rel.always_set
+        """
+
+        params = {"rel_list": rel_props}
+
+        self.evaluate_query_single(cypher, params)
+
+    def match_nodes(
+        self,
+        node_class: type["BaseNode"],
+        limit: Optional[int] = None,
+        skip: Optional[int] = None,
+    ) -> List["BaseNode"]:
+        """Get nodes of this type from the database.
+
+        Run a MATCH cypher query to retrieve any Nodes with the label of this class.
+
+        Args:
+            limit (int, optional): Maximum number of results to return. Defaults to None.
+            skip (int, optional): Skip through this many results (for pagination). Defaults to None.
+
+        Returns:
+            Optional[List[B]]: A list of node instances.
+        """
+
+        cypher = f"""
+        MATCH(n:{node_class.__primarylabel__})
+        RETURN n
+        """
+
+        params = {}
+
+        if skip:
+            cypher += " SKIP $skip "
+            params["skip"] = int_adapter.validate_python(skip)
+
+        if limit:
+            cypher += " LIMIT $limit "
+            params["limit"] = int_adapter.validate_python(limit)
+
+        result = self.evaluate_query(
+            cypher, params, node_classes={node_class.__primarylabel__: node_class}
+        )
+
+        return result.nodes
+
+    def match_relationships(
+        self,
+        relationship_class: type["BaseRelationship"],
+        limit: Optional[int] = None,
+        skip: Optional[int] = None,
+    ) -> List["BaseRelationship"]:
+        """Get nodes of this type from the database.
+
+        Run a MATCH cypher query to retrieve any Nodes with the label of this class.
+
+        Args:
+            limit (int, optional): Maximum number of results to return. Defaults to None.
+            skip (int, optional): Skip through this many results (for pagination). Defaults to None.
+
+        Returns:
+            Optional[List[B]]: A list of node instances.
+        """
+
+        from ..utils import get_node_types, get_rels_by_type
+
+        cypher = f"""
+        MATCH (n)-[r:{relationship_class.__relationshiptype__}]->(o)
+        RETURN DISTINCT n, r, o
+        """
+
+        params = {}
+
+        if skip:
+            cypher += " SKIP $skip "
+            params["skip"] = int_adapter.validate_python(skip)
+
+        if limit:
+            cypher += " LIMIT $limit "
+            params["limit"] = int_adapter.validate_python(limit)
+
+        rel_types = get_rels_by_type()
+        node_classes = get_node_types()
+
+        result = self.evaluate_query(
+            cypher,
+            params,
+            node_classes=node_classes,
+            relationship_classes=rel_types,
+        )
+
+        return result.relationships
+
+
+class GraphEngineConfig(BaseModel):
+    engine: ClassVar[type[GraphEngineBase]]
diff --git a/src/neontology/graphengines/memgraphengine.py b/src/neontology/graphengines/memgraphengine.py
new file mode 100644
index 0000000..4a42ef2
--- /dev/null
+++ b/src/neontology/graphengines/memgraphengine.py
@@ -0,0 +1,101 @@
+import os
+from typing import Any, ClassVar, Optional
+
+from dotenv import load_dotenv
+from neo4j import GraphDatabase
+from neo4j import Result as Neo4jResult
+from pydantic import model_validator
+
+from ..result import NeontologyResult
+from .graphengine import GraphEngineBase, GraphEngineConfig
+from .neo4jengine import neo4j_records_to_neontology_records
+
+
+class MemgraphEngine(GraphEngineBase):
+    """
+    Note. the memgraph engine is very similar to the Neo4j engine as they
+        can both use the same Neo4j driver
+    Args:
+        Neo4jEngine (_type_): _description_
+    """
+
+    def __init__(self, config: "MemgraphConfig") -> None:
+        """Initialise connection to the engine
+
+        Args:
+            config - Takes a MemgraphConfig object.
+        """
+
+        self.driver = GraphDatabase.driver(  # type: ignore
+            config.uri,
+            auth=(config.username, config.password),
+        )
+
+    def verify_connection(self) -> bool:
+        try:
+            self.driver.verify_connectivity()
+            return True
+        except:  # noqa: E722
+            return False
+
+    def close_connection(self) -> None:
+        try:
+            self.driver.close()
+        except AttributeError:
+            pass
+
+    def evaluate_query(
+        self,
+        cypher: str,
+        params: dict = {},
+        node_classes: dict = {},
+        relationship_classes: dict = {},
+    ) -> NeontologyResult:
+        result = self.driver.execute_query(cypher, parameters_=params)
+
+        neo4j_records = result.records
+        neontology_records, nodes, rels, paths = neo4j_records_to_neontology_records(
+            neo4j_records, node_classes, relationship_classes
+        )
+
+        return NeontologyResult(
+            records_raw=neo4j_records,
+            records=neontology_records,
+            nodes=nodes,
+            relationships=rels,
+            paths=paths,
+        )
+
+    def evaluate_query_single(self, cypher: str, params: dict = {}) -> Optional[Any]:
+        result = self.driver.execute_query(
+            cypher, parameters_=params, result_transformer_=Neo4jResult.single
+        )
+
+        if result:
+            return result.value()
+
+        else:
+            return None
+
+
+class MemgraphConfig(GraphEngineConfig):
+    engine: ClassVar[type[MemgraphEngine]] = MemgraphEngine
+    uri: str
+    username: str
+    password: str
+
+    @model_validator(mode="before")
+    @classmethod
+    def populate_defaults(cls, data: Any) -> Any:
+        load_dotenv()
+
+        if data.get("uri") is None:
+            data["uri"] = os.getenv("MEMGRAPH_URI")
+
+        if data.get("username") is None:
+            data["username"] = os.getenv("MEMGRAPH_USERNAME")
+
+        if data.get("password") is None:
+            data["password"] = os.getenv("MEMGRAPH_PASSWORD")
+
+        return data
diff --git a/src/neontology/graphengines/neo4jengine.py b/src/neontology/graphengines/neo4jengine.py
new file mode 100644
index 0000000..28ba9e1
--- /dev/null
+++ b/src/neontology/graphengines/neo4jengine.py
@@ -0,0 +1,277 @@
+import itertools
+import os
+import warnings
+from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional
+
+from dotenv import load_dotenv
+from neo4j import GraphDatabase
+from neo4j import Record as Neo4jRecord
+from neo4j import Result as Neo4jResult
+from neo4j.graph import Node as Neo4jNode
+from neo4j.graph import Path as Neo4jPath
+from neo4j.graph import Relationship as Neo4jRelationship
+from neo4j.time import Date as Neo4jDate
+from neo4j.time import DateTime as Neo4jDateTime
+from neo4j.time import Time as Neo4jTime
+from pydantic import model_validator
+
+from ..gql import gql_identifier_adapter
+from ..result import NeontologyResult
+from .graphengine import GraphEngineBase, GraphEngineConfig
+
+if TYPE_CHECKING:
+    from ..basenode import BaseNode
+    from ..baserelationship import BaseRelationship, RelationshipTypeData
+
+
+def convert_neo4j_types(input_dict: dict) -> dict:
+    output_dict = dict(input_dict)
+
+    for key in output_dict:
+        try:
+            new_date = output_dict[key].to_native()
+
+            if isinstance(output_dict[key], (Neo4jDateTime, Neo4jDate, Neo4jTime)):
+                output_dict[key] = new_date
+
+        except AttributeError:
+            pass
+
+    return output_dict
+
+
+def neo4j_node_to_neontology_node(
+    neo4j_node: Neo4jNode, node_classes: dict
+) -> Optional["BaseNode"]:
+    node_labels = list(neo4j_node.labels)
+
+    primary_labels = set(node_labels).intersection(set(node_classes.keys()))
+
+    secondary_labels = set(node_labels).difference(set(node_classes.keys()))
+
+    if len(primary_labels) == 1:
+        primary_label = primary_labels.pop()
+
+        node_dict = convert_neo4j_types(dict(neo4j_node))
+
+        node = node_classes[primary_label](**node_dict)
+
+        # warn if the secondary labels aren't what's expected
+
+        if set(node.__secondarylabels__) != secondary_labels:
+            warnings.warn(f"Unexpected secondary labels returned: {secondary_labels}")
+
+        return node
+
+    # gracefully handle cases where we don't have a class defined
+    # for the identified label or where we get more than one valid primary label
+    else:
+        warnings.warn(f"Unexpected primary labels returned: {primary_labels}")
+
+        return None
+
+
+def neo4j_relationship_to_neontology_rel(
+    neo4j_rel: Neo4jRelationship, node_classes: dict, rel_classes: dict
+) -> Optional["BaseRelationship"]:
+    rel_type = neo4j_rel.type
+    rel_type_data = rel_classes[rel_type]
+
+    if not rel_type_data:
+        warnings.warn(
+            (
+                f"Could not find a class for {rel_type} relationship type."
+                " Did you define the class before initializing Neontology?"
+            )
+        )
+        return None
+
+    if (
+        not neo4j_rel.start_node
+        or not neo4j_rel.start_node.labels
+        or not neo4j_rel.end_node
+        or not neo4j_rel.end_node.labels
+    ):
+        warnings.warn(
+            (
+                f"{rel_type} relationship type query did not include nodes."
+                " To get neontology relationships, return source and target "
+                "nodes as part of result."
+            )
+        )
+        return None
+
+    src_node = neo4j_node_to_neontology_node(neo4j_rel.start_node, node_classes)
+    tgt_node = neo4j_node_to_neontology_node(neo4j_rel.end_node, node_classes)
+
+    rel_props = convert_neo4j_types(dict(neo4j_rel))
+    rel_props["source"] = src_node
+    rel_props["target"] = tgt_node
+
+    return rel_type_data.relationship_class(**rel_props)
+
+
+def neo4j_records_to_neontology_records(
+    records: List[Neo4jRecord],
+    node_classes: dict,
+    rel_classes: Dict[str, "RelationshipTypeData"],
+) -> tuple:
+    new_records = []
+
+    for record in records:
+        new_record: Dict[str, dict] = {"nodes": {}, "relationships": {}, "paths": {}}
+
+        for key, entry in record.items():
+            if isinstance(entry, Neo4jNode):
+                neontology_node = neo4j_node_to_neontology_node(entry, node_classes)
+
+                if neontology_node:
+                    new_record["nodes"][key] = neontology_node
+
+            elif isinstance(entry, Neo4jRelationship):
+                neontology_rel = neo4j_relationship_to_neontology_rel(
+                    entry, node_classes, rel_classes
+                )
+
+                if neontology_rel:
+                    new_record["relationships"][key] = neontology_rel
+
+            elif isinstance(entry, Neo4jPath):
+                entry_path = []
+                for step in entry:
+                    step_rel = neo4j_relationship_to_neontology_rel(
+                        step, node_classes, rel_classes
+                    )
+                    entry_path.append(step_rel)
+                if entry_path:
+                    new_record["paths"][key] = entry_path
+
+        new_records.append(new_record)
+
+    nodes_list_of_lists = [x["nodes"].values() for x in new_records]
+
+    nodes = list(itertools.chain.from_iterable(nodes_list_of_lists))
+
+    nodes_map = {f"{x.__primarylabel__}:{x.get_pp()}": x for x in nodes}
+
+    unique_nodes = list(nodes_map.values())
+
+    rels_list_of_lists = [x["relationships"].values() for x in new_records]
+    rels = list(itertools.chain.from_iterable(rels_list_of_lists))
+
+    paths_list_of_lists = [x["paths"].values() for x in new_records]
+    paths = list(itertools.chain.from_iterable(paths_list_of_lists))
+
+    return new_records, unique_nodes, rels, paths
+
+
+class Neo4jEngine(GraphEngineBase):
+    def __init__(self, config: "Neo4jConfig") -> None:
+        """Initialise connection to the engine
+
+        Args:
+            config - Takes a Neo4jConfig object.
+        """
+
+        self.driver = GraphDatabase.driver(  # type: ignore
+            config.uri,
+            auth=(config.username, config.password),
+        )
+
+    def verify_connection(self) -> bool:
+        try:
+            self.driver.verify_connectivity()
+            return True
+        except:  # noqa: E722
+            return False
+
+    def close_connection(self) -> None:
+        try:
+            self.driver.close()
+        except AttributeError:
+            pass
+
+    def evaluate_query(
+        self,
+        cypher: str,
+        params: dict = {},
+        node_classes: dict = {},
+        relationship_classes: dict = {},
+    ) -> NeontologyResult:
+        result = self.driver.execute_query(cypher, parameters_=params)
+
+        neo4j_records = result.records
+        neontology_records, nodes, rels, paths = neo4j_records_to_neontology_records(
+            neo4j_records, node_classes, relationship_classes
+        )
+
+        return NeontologyResult(
+            records_raw=neo4j_records,
+            records=neontology_records,
+            nodes=nodes,
+            relationships=rels,
+            paths=paths,
+        )
+
+    def evaluate_query_single(self, cypher: str, params: dict = {}) -> Optional[Any]:
+        result = self.driver.execute_query(
+            cypher, parameters_=params, result_transformer_=Neo4jResult.single
+        )
+
+        if result:
+            return result.value()
+
+        else:
+            return None
+
+    def apply_constraint(self, label: str, property: str) -> None:
+        cypher = f"""
+        CREATE CONSTRAINT IF NOT EXISTS
+        FOR (n:{gql_identifier_adapter.validate_strings(label)})
+        REQUIRE n.{gql_identifier_adapter.validate_strings(property)} IS UNIQUE
+        """
+
+        self.evaluate_query_single(cypher)
+
+    def drop_constraint(self, constraint_name: str) -> None:
+        drop_cypher = f"""
+        DROP CONSTRAINT {gql_identifier_adapter.validate_strings(constraint_name)}
+        """
+        self.evaluate_query_single(drop_cypher)
+
+    def get_constraints(self) -> list:
+        get_constraints_query = """
+        SHOW CONSTRAINTS yield name
+        RETURN COLLECT(DISTINCT name)
+        """
+
+        constraints = self.evaluate_query_single(get_constraints_query)
+
+        if constraints:
+            return constraints
+
+        else:
+            return []
+
+
+class Neo4jConfig(GraphEngineConfig):
+    engine: ClassVar[type[Neo4jEngine]] = Neo4jEngine
+    uri: str
+    username: str
+    password: str
+
+    @model_validator(mode="before")
+    @classmethod
+    def populate_defaults(cls, data: Any) -> Any:
+        load_dotenv()
+
+        if data.get("uri") is None:
+            data["uri"] = os.getenv("NEO4J_URI")
+
+        if data.get("username") is None:
+            data["username"] = os.getenv("NEO4J_USERNAME")
+
+        if data.get("password") is None:
+            data["password"] = os.getenv("NEO4J_PASSWORD")
+
+        return data
diff --git a/src/neontology/result.py b/src/neontology/result.py
index 3caaaad..29dac9f 100644
--- a/src/neontology/result.py
+++ b/src/neontology/result.py
@@ -1,114 +1,52 @@
-import itertools
-import warnings
-from typing import List
+import json
+from typing import Any
 
-from neo4j import Record as Neo4jRecord
-from neo4j.graph import Node as Neo4jNode
-from neo4j.graph import Relationship as Neo4jRelationship
 from pydantic import BaseModel, computed_field
 
 
-def neo4j_records_to_neontology_records(
-    records: List[Neo4jRecord], node_classes: list, rel_classes: list
-) -> list:
-    new_records = []
-
-    for record in records:
-        new_record = {"nodes": {}, "relationships": {}}
-        for key, entry in record.items():
-            if isinstance(entry, Neo4jNode):
-                node_label = list(entry.labels)[0]
-
-                # gracefully handle cases where we don't have a class defined
-                # for the identified label
-                try:
-                    node = node_classes[node_label](**dict(entry))
-                    new_record["nodes"][key] = node
-                except KeyError:
-                    warnings.warn(
-                        (
-                            f"Could not find a class for {node_label} label."
-                            " Did you define the class before initializing Neontology?"
-                        )
-                    )
-                    pass
-
-            elif isinstance(entry, Neo4jRelationship):
-                rel_type = entry.type
-
-                rel_dict = rel_classes[rel_type]
-
-                if not rel_dict:
-                    warnings.warn(
-                        (
-                            f"Could not find a class for {rel_type} relationship type."
-                            " Did you define the class before initializing Neontology?"
-                        )
-                    )
-                    continue
-
-                src_label = list(entry.nodes[0].labels)[0]
-                tgt_label = list(entry.nodes[1].labels)[0]
-
-                src_node = node_classes[src_label](**dict(entry.nodes[0]))
-                tgt_node = node_classes[tgt_label](**dict(entry.nodes[1]))
-
-                rel_props = dict(entry)
-                rel_props["source"] = src_node
-                rel_props["target"] = tgt_node
-
-                rel = rel_dict["rel_class"](**rel_props)
-
-                new_record["relationships"][key] = rel
-
-        new_records.append(new_record)
-
-    return new_records
-
-
 class NeontologyResult(BaseModel):
+    records_raw: Any
     records: list
-    neontology_records: list
-
-    @computed_field
-    @property
-    def nodes(self) -> list:
-        nodes_list_of_lists = [x["nodes"].values() for x in self.neontology_records]
-        return list(itertools.chain.from_iterable(nodes_list_of_lists))
-
-    @computed_field
-    @property
-    def relationships(self) -> list:
-        nodes_list_of_lists = [
-            x["relationships"].values() for x in self.neontology_records
-        ]
-        return list(itertools.chain.from_iterable(nodes_list_of_lists))
+    nodes: list
+    relationships: list
+    paths: list
 
-    @computed_field
+    @computed_field  # type: ignore[misc]
     @property
     def node_link_data(self) -> dict:
         nodes = [
             {
-                "id": x.get_primary_property_value(),
-                "label": x.__primarylabel__,
-                "name": str(x),
+                **x.neontology_dump(),
+                **{
+                    "__pp__": x.get_pp(),
+                    "__str__": str(x),
+                },
             }
             for x in self.nodes
         ]
 
-        links = [
-            {
-                "source": x.source.get_primary_property_value(),
-                "target": x.target.get_primary_property_value(),
-            }
-            for x in self.relationships
-        ]
+        links = [x.neontology_dump() for x in self.relationships]
 
         unique_nodes = list({frozenset(item.items()): item for item in nodes}.values())
         unique_links = list({frozenset(item.items()): item for item in links}.values())
-        data = {
-            "nodes": unique_nodes,
-            "links": unique_links,
-        }
+        data = {"nodes": unique_nodes, "edges": unique_links, "directed": True}
 
         return data
+
+    def neontology_dump(self) -> dict:
+        nodes = [x.neontology_dump() for x in self.nodes]
+        relationships = [x.neontology_dump() for x in self.relationships]
+
+        data = {"nodes": nodes, "edges": relationships}
+
+        return data
+
+    def neontology_dump_json(self) -> str:
+        nodes = [json.loads(x.neontology_dump_json()) for x in self.nodes]
+        relationships = [
+            json.loads(x.neontology_dump_json()) for x in self.relationships
+        ]
+
+        data = {"nodes": nodes, "edges": relationships}
+
+        return json.dumps(data)
diff --git a/src/neontology/schema_utils.py b/src/neontology/schema_utils.py
new file mode 100644
index 0000000..1fdbb5e
--- /dev/null
+++ b/src/neontology/schema_utils.py
@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+import enum
+from logging import getLogger
+from typing import Any, List, Optional, Union, get_args, get_origin
+
+from jinja2 import Template
+from pydantic import BaseModel
+
+logger = getLogger(__name__)
+
+
+class NeontologyAnnotationData(BaseModel):
+    representation: str
+    core_type: Any
+    optional: bool = False
+    union: bool = False
+    enum_values: Optional[list] = None
+
+
+class SchemaProperty(BaseModel):
+    name: str
+    type_annotation: NeontologyAnnotationData
+    required: bool
+
+
+class RelationshipSchema(BaseModel):
+    name: str
+    relationship_type: str
+    source_labels: List[str]
+    target_labels: List[str]
+
+    properties: List[SchemaProperty]
+
+
+class NodeSchema(BaseModel):
+    label: str
+    title: str
+    secondary_labels: List[str]
+    properties: List[SchemaProperty]
+    outgoing_relationships: List[RelationshipSchema] = []
+
+    def md_node_table(self) -> str:
+        """Take a node schema and produce markdown ontology documentation"""
+
+        schema_template_raw = """
+| Property Name | Type | Required |
+| ------------- | ---- | -------- |
+{% for field in model_schema.properties -%}
+| {{field.name}} | {{field.type_annotation.representation}} | {{field.required}} |
+{% endfor %}
+"""
+
+        schema_template = Template(schema_template_raw)
+
+        return schema_template.render(model_schema=self).strip()
+
+    def md_rel_tables(self, heading_level: int = 3) -> str:
+        """Take a node schema and produce markdown ontology documentation"""
+
+        schema_template_raw = """
+{% if model_schema.outgoing_relationships %}
+{% for outgoing_rel in model_schema.outgoing_relationships -%}
+{{"#"*heading_level}} {{ outgoing_rel.relationship_type }}
+
+Target Label(s): {{ outgoing_rel.target_labels |join(', ') }}
+{% if outgoing_rel.properties %}
+| Property Name | Type | Required |
+| ------------- | ---- | -------- |
+{% for rel_prop in outgoing_rel.properties -%}
+| {{rel_prop.name}} | {{rel_prop.type_annotation.representation}} | {{rel_prop.required}} |
+{% endfor %}
+{% endif %}
+
+{% endfor %}
+{% endif %}
+"""
+
+        schema_template = Template(schema_template_raw)
+
+        return schema_template.render(
+            model_schema=self, heading_level=heading_level
+        ).strip()
+
+
+def extract_type_mapping(
+    annotation: Any, show_optional: bool = True
+) -> NeontologyAnnotationData:
+    if isinstance(annotation, type):
+        # we have a plain type, just return the name
+
+        if issubclass(annotation, enum.Enum):
+            enum_values = [e.value for e in annotation]
+            return NeontologyAnnotationData(
+                representation="Enum",
+                core_type=annotation,
+                enum_values=enum_values,
+            )
+
+        return NeontologyAnnotationData(
+            representation=str(annotation.__name__), core_type=annotation
+        )
+
+    elif get_origin(annotation) == Union:
+        # We can only support union's of a single type plus none (i.e. Optional)
+        if len(get_args(annotation)) == 2 and type(None) in get_args(annotation):
+            # we need to extract the optional type
+            for entry in get_args(annotation):
+                if isinstance(entry, type(None)):
+                    pass
+                else:
+                    # we do this recursively in case the next layer down is something like List[int]
+                    if show_optional is True:
+                        representation = (
+                            f"Optional[{extract_type_mapping(entry).representation}]"
+                        )
+                        core_type = extract_type_mapping(entry).core_type
+                        return NeontologyAnnotationData(
+                            optional=True,
+                            representation=representation,
+                            core_type=core_type,
+                        )
+                    else:
+                        return extract_type_mapping(entry)
+                raise TypeError(f"Unsupported type annotation: {annotation}")
+        else:
+            raise TypeError(f"Unsupported union type: {annotation}")
+
+    elif get_origin(annotation) == list:
+        if len(get_args(annotation)) == 1:
+            try:
+                # field type is something like typing.List[str]
+                representation = str(annotation).split(".")[1]
+
+            except IndexError:
+                # some Python versions support just list[x]
+                representation = str(annotation).title()
+            return NeontologyAnnotationData(
+                representation=representation,
+                core_type=annotation,
+            )
+        else:
+            raise TypeError(f"Cannot have lists of multiple types: {annotation}")
+
+    logger.warn(f"Complex type annotation: {annotation}")
+    return NeontologyAnnotationData(
+        representation=str(annotation.__name__), core_type=annotation
+    )
diff --git a/src/neontology/tools/__init__.py b/src/neontology/tools/__init__.py
new file mode 100644
index 0000000..bd4a9fa
--- /dev/null
+++ b/src/neontology/tools/__init__.py
@@ -0,0 +1,3 @@
+from .import_files import import_json, import_md, import_records, import_yaml
+
+__all__ = ["import_records", "import_json", "import_md", "import_yaml"]
diff --git a/src/neontology/tools/import_files.py b/src/neontology/tools/import_files.py
new file mode 100644
index 0000000..caefc94
--- /dev/null
+++ b/src/neontology/tools/import_files.py
@@ -0,0 +1,141 @@
+import json
+from logging import getLogger
+from pathlib import Path
+from typing import Generator, List, Optional, Sequence
+
+import yaml
+
+from .import_records import import_records
+
+logger = getLogger(__name__)
+
+
+def _identify_filepaths(input_path: str, path_pattern: str) -> List[Path]:
+    path = Path(input_path)
+
+    if path.is_file():
+        return [path]
+
+    return list(path.glob(path_pattern))
+
+
+def _batch(iterable: Sequence, batch_size: int = 1) -> Generator:
+    full_length = len(iterable)
+    for ndx in range(0, full_length, batch_size):
+        yield iterable[ndx : min(ndx + batch_size, full_length)]  # noqa: E203
+
+
+def import_json(
+    path: str,
+    path_pattern: str = "**/*.json",
+    batch_size: Optional[int] = None,
+    check_unmatched: bool = True,
+    error_on_unmatched: bool = False,
+    validate_only: bool = False,
+) -> None:
+    file_paths = _identify_filepaths(path, path_pattern)
+
+    if len(file_paths) == 0:
+        logger.warning("Didn't find any files to import")
+        return
+
+    if not batch_size:
+        batch_size = len(file_paths)
+
+    for file_batch in _batch(file_paths, batch_size):
+        input_records = []
+
+        for file_path in file_batch:
+            logger.info("Processing %s", file_path)
+            with open(file_path, "r") as json_file:
+                raw_record_data = json.load(json_file)
+
+            input_records.append(raw_record_data)
+
+        import_records(
+            input_records,
+            check_unmatched=check_unmatched,
+            error_on_unmatched=error_on_unmatched,
+            validate_only=validate_only,
+        )
+
+
+def import_yaml(
+    path: str,
+    path_pattern: str = "**/*.yaml",
+    batch_size: Optional[int] = None,
+    check_unmatched: bool = True,
+    error_on_unmatched: bool = False,
+    validate_only: bool = False,
+) -> None:
+    file_paths = _identify_filepaths(path, path_pattern)
+
+    if len(file_paths) == 0:
+        logger.warning("Didn't find any files to import")
+        return
+
+    if batch_size is None:
+        batch_size = len(file_paths)
+
+    for file_batch in _batch(file_paths, batch_size):
+        input_records = []
+
+        for file_path in file_batch:
+            logger.info("Processing %s", file_path)
+            with open(file_path, "r") as yaml_file:
+                raw_record_data = yaml.safe_load_all(yaml_file)
+
+                input_records.append([x for x in raw_record_data])
+
+        import_records(
+            input_records,
+            check_unmatched=check_unmatched,
+            error_on_unmatched=error_on_unmatched,
+            validate_only=validate_only,
+        )
+
+
+def import_md(
+    path: str,
+    path_pattern: str = "**/*.md",
+    batch_size: Optional[int] = None,
+    check_unmatched: bool = True,
+    error_on_unmatched: bool = False,
+    validate_only: bool = False,
+) -> None:
+    file_paths = _identify_filepaths(path, path_pattern)
+
+    if len(file_paths) == 0:
+        logger.warning("Didn't find any files to import")
+        return
+
+    if not batch_size:
+        batch_size = len(file_paths)
+
+    for file_batch in _batch(file_paths, batch_size):
+        input_records = []
+
+        for file_path in file_batch:
+            logger.info("Processing %s", file_path)
+
+            with open(file_path, "r") as md_file:
+                raw_entry = md_file.read()
+
+            entry = raw_entry.strip().split("---", maxsplit=2)
+
+            frontmatter = entry[1]
+            markdown_text = entry[2].strip()
+
+            record = next(yaml.safe_load_all(frontmatter))
+            body_property = record.pop("BODY_PROPERTY")
+
+            record[body_property] = markdown_text
+
+            input_records.append(record)
+
+        import_records(
+            input_records,
+            check_unmatched=check_unmatched,
+            error_on_unmatched=error_on_unmatched,
+            validate_only=validate_only,
+        )
diff --git a/src/neontology/tools/import_records.py b/src/neontology/tools/import_records.py
new file mode 100644
index 0000000..a61a28c
--- /dev/null
+++ b/src/neontology/tools/import_records.py
@@ -0,0 +1,357 @@
+import logging
+from collections import defaultdict
+from copy import deepcopy
+from typing import Any, Dict, List, Optional, Tuple, Union
+
+from pydantic import BaseModel, model_validator
+
+from ..gql import gql_identifier_adapter
+from ..graphconnection import GraphConnection
+from ..utils import get_node_types, get_rels_by_type
+
+logger = logging.getLogger(__name__)
+
+
+def nested_dict(n, type):
+    if n == 1:
+        return defaultdict(type)
+    else:
+        return defaultdict(lambda: nested_dict(n - 1, type))
+
+
+class NeontologyNodeRaw(BaseModel):
+    LABEL: str
+
+
+class NeontologyRelationshipRaw(BaseModel):
+    RELATIONSHIP_TYPE: str
+    TARGET_LABEL: str
+    TARGET_NODES: Optional[List[NeontologyNodeRaw]] = None
+    TARGETS: Optional[List[str]] = None
+    TARGET_PROPERTY: Optional[str] = None
+
+
+class NeontologyNodeRecord(BaseModel):
+    input_record: NeontologyNodeRaw
+    label: str
+    output_record: dict
+
+    @model_validator(mode="before")
+    def populate_node_fields(cls, data):
+        if not data.get("label"):
+            data["label"] = data.get("input_record", {}).get("LABEL")
+
+        if not data.get("output_record"):
+            data["output_record"] = {
+                k: v
+                for k, v in data.get("input_record", {}).items()
+                if k not in ["LABEL"]
+            }
+
+        return data
+
+
+class NeontologyRelationshipRecord(BaseModel):
+    input_record: NeontologyRelationshipRaw
+    relationship_type: str
+    target_prop: Optional[str] = None
+    source_label: str
+    target_label: str
+    source: Any
+    target: Any
+    relationship_properties: dict
+    output_record: dict
+
+    @model_validator(mode="before")
+    def populate_rel_fields(cls, data):
+        if not data.get("RELATIONSHIP_TYPE"):
+            data["relationship_type"] = data.get("input_record", {}).get(
+                "RELATIONSHIP_TYPE"
+            )
+
+        if not data.get("source_label"):
+            data["source_label"] = data.get("input_record", {}).get("SOURCE_LABEL")
+
+        if not data.get("target_label"):
+            data["target_label"] = data.get("input_record", {}).get("TARGET_LABEL")
+
+        if not data.get("target_prop"):
+            data["target_prop"] = data.get("input_record", {}).get("TARGET_PROPERTY")
+
+        if not data.get("source"):
+            data["source"] = data.get("input_record", {}).get("source")
+
+        if not data.get("target"):
+            data["target"] = data.get("input_record", {}).get("target")
+
+        if not data.get("relationship_properties"):
+            data["relationship_properties"] = {
+                k: v
+                for k, v in data.get("input_record", {}).items()
+                if k
+                not in [
+                    "SOURCE_LABEL",
+                    "TARGET_LABEL",
+                    "TARGET_PROPERTY",
+                    "RELATIONSHIP_TYPE",
+                    "TARGET_NODES",
+                    "TARGETS",
+                    "source",
+                    "target",
+                ]
+            }
+
+        if not data.get("output_record"):
+            data["output_record"] = {
+                k: v
+                for k, v in data.get("input_record", {}).items()
+                if k
+                not in [
+                    "SOURCE_LABEL",
+                    "TARGET_LABEL",
+                    "TARGET_PROPERTY",
+                    "RELATIONSHIP_TYPE",
+                    "TARGET_NODES",
+                    "TARGETS",
+                ]
+            }
+
+        return data
+
+    @model_validator(mode="after")
+    def populate_fields(self):
+        node_types = get_node_types()
+        self.target_prop = node_types[self.target_label].__primaryproperty__
+
+        return self
+
+
+def _import_nodes(input_records: List[NeontologyNodeRecord]):
+    mapped_records = defaultdict(list)
+    node_types = get_node_types()
+
+    for record in input_records:
+        mapped_records[record.label].append(record.output_record)
+
+    for label, node_records in mapped_records.items():
+        node_class = node_types[label]
+        node_class.merge_records(node_records)
+
+
+def _import_relationships(
+    input_records: List[NeontologyRelationshipRecord],
+    check_unmatched: bool,
+    error_on_unmatched: bool,
+):
+    # if we're meant to warn on unmatched, we need to look up source and target
+
+    mapped_records = nested_dict(4, list)
+    rel_types = get_rels_by_type()
+    node_types = get_node_types()
+
+    for record in input_records:
+        rel_type = record.relationship_type
+
+        # when we merge relationship records, we pass in target prop and source prop
+        # we also need to hydrate based on the given source and target labels
+        # therefore we need to group together records which share those properties
+        mapped_records[rel_type][record.target_prop][record.source_label][
+            record.target_label
+        ].append(record)
+
+    for rel_type, rel_records_by_prop in mapped_records.items():
+        for target_prop, rel_records_by_source_label in rel_records_by_prop.items():
+            for (
+                source_label,
+                rel_records_by_target_label,
+            ) in rel_records_by_source_label.items():
+                for target_label, rel_entries in rel_records_by_target_label.items():
+                    if check_unmatched is True:
+                        for rel_entry in rel_entries:
+                            gc = GraphConnection()
+
+                            gql_identifier_adapter.validate_strings(target_label)
+                            gql_identifier_adapter.validate_strings(target_prop)
+
+                            cypher = f"""
+                                MATCH (n:{target_label})
+                                WHERE n.{target_prop} = $val
+                                RETURN n
+                                """
+
+                            params = {"val": rel_entry.target}
+
+                            result = gc.evaluate_query(cypher, params)
+
+                            if len(result.nodes) > 1:
+                                message = (
+                                    f"Matched {len(result.nodes)} on {rel_type} for {target_label}"
+                                    f" WHERE {target_prop} = {rel_entry.target}"
+                                )
+
+                                if error_on_unmatched is True:
+                                    raise ValueError(message)
+
+                                logger.warning(message)
+
+                            elif len(result.nodes) == 0:
+                                error_msg = f"No target node for {rel_type} to {rel_entry.target}"
+
+                                if error_on_unmatched is True:
+                                    raise ValueError(error_msg)
+
+                                logger.warning(error_msg)
+
+                    source_type = node_types[source_label]
+                    target_type = node_types[target_label]
+
+                    rel_class = rel_types[rel_type].relationship_class
+
+                    output_records = [x.output_record for x in rel_entries]
+
+                    rel_class.merge_records(
+                        output_records,
+                        source_type=source_type,
+                        target_type=target_type,
+                        target_prop=target_prop,
+                    )
+
+
+def _process_sub_records(
+    source_node_record: NeontologyNodeRecord, subrecords
+) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
+    node_types = get_node_types()
+
+    output_nodes = []
+    output_rels = []
+
+    # hydrate the source node to get the pp
+    # then dump it
+
+    source_label = source_node_record.label
+    source_class = node_types[source_label]
+    source_node = source_class(**source_node_record.output_record)
+    source_node_pk = source_node.get_pp()
+
+    # iterate through the subrecords
+
+    for record in subrecords:
+        target_prop = record.get("TARGET_PROPERTY")
+        target_class = node_types[record["TARGET_LABEL"]]
+
+        rel_targets = []
+
+        # handle new nodes that need to be created
+        # we need to hydrate these to get the pp
+        # then dump them
+        if "TARGET_NODES" in record:
+            for entry in record["TARGET_NODES"]:
+                target_node_record = NeontologyNodeRecord(input_record=entry)
+                tgt_node = target_class(**target_node_record.output_record)
+                rel_targets.append(tgt_node.get_pp())
+                output_nodes.append(target_node_record)
+
+        if "TARGETS" in record:
+            rel_targets += record["TARGETS"]
+
+        # now do the relationships
+        # convert to neontology style
+        rel_dict = record
+        rel_dict["source"] = source_node_pk
+        rel_dict["SOURCE_LABEL"] = source_label
+
+        if target_prop:
+            rel_dict["TARGET_PROPERTY"] = target_prop
+
+        output_raw_records = [{**rel_dict, **{"target": x}} for x in rel_targets]
+
+        output_rels += [
+            NeontologyRelationshipRecord(input_record=x) for x in output_raw_records
+        ]
+
+    # returns a set of records to be used as input to import_records
+    return output_nodes, output_rels
+
+
+def _prepare_records(
+    input_records: Union[List[Dict[str, Any]], Dict[str, Any]],
+) -> tuple:
+    input_records = input_records.copy()
+
+    if isinstance(input_records, dict):
+        # handle the situation where we've just been passed a single record
+        if "nodes" not in input_records and "edges" not in input_records:
+            raw_records = [input_records]
+
+        # handle the situation where we've got 'link data'
+        # which consists of node records and link/relationship records
+        else:
+            raw_records = input_records.get("nodes", [])
+            raw_records += input_records.get("edges", [])
+
+    else:
+        raw_records = input_records.copy()
+
+    input_nodes = []
+    input_relationships = []
+
+    for record in raw_records:
+        if "LABEL" in record.keys():
+            # pull out relationships
+            # append the input_node
+            # now parse the relationships
+            rel_records = record.pop("RELATIONSHIPS_OUT", None)
+
+            node_record = NeontologyNodeRecord(input_record=record)
+
+            input_nodes.append(node_record)
+
+            if rel_records:
+                logger.warning(
+                    (
+                        "Importing relationships which are sub-records to a Node. "
+                        "Note that this requires associated nodes to have explicit or deterministic primary keys."
+                    )
+                )
+
+                new_nodes, new_rels = _process_sub_records(node_record, rel_records)
+                input_nodes += new_nodes
+                input_relationships += new_rels
+
+        elif "RELATIONSHIP_TYPE" in record.keys():
+            input_relationships.append(
+                NeontologyRelationshipRecord(input_record=record)
+            )
+
+        else:
+            raise ValueError(
+                (
+                    "Input record does not have LABEL (for node) "
+                    "or RELATIONSHIP_TYPE (for relationship). %s",
+                    record,
+                )
+            )
+
+    return input_nodes, input_relationships
+
+
+def import_records(
+    records: list,
+    validate_only: bool = False,
+    check_unmatched: bool = True,
+    error_on_unmatched: bool = False,
+) -> None:
+    input_nodes = []
+    input_rels = []
+
+    fresh_records = deepcopy(records)
+
+    for entry in fresh_records:
+        prepared_nodes, prepared_rels = _prepare_records(entry)
+
+        input_nodes += prepared_nodes
+        input_rels += prepared_rels
+
+    if validate_only is False:
+        _import_nodes(input_nodes)
+        _import_relationships(input_rels, check_unmatched, error_on_unmatched)
diff --git a/src/neontology/utils.py b/src/neontology/utils.py
index 893c62e..6b01146 100644
--- a/src/neontology/utils.py
+++ b/src/neontology/utils.py
@@ -1,14 +1,23 @@
+from __future__ import annotations
+
 from collections import defaultdict
-from typing import Dict, Set, Type
+from typing import Dict, List, Set, Type
 
 from .basenode import BaseNode
-from .baserelationship import BaseRelationship
+from .baserelationship import BaseRelationship, RelationshipTypeData
 from .graphconnection import GraphConnection
 
 
 def get_node_types(base_type: Type[BaseNode] = BaseNode) -> Dict[str, Type[BaseNode]]:
     node_types = {}
 
+    # if we're starting with a node type that has a primary label, include this in results
+    if (
+        hasattr(base_type, "__primarylabel__")
+        and base_type.__primarylabel__ is not None
+    ):
+        node_types[base_type.__primarylabel__] = base_type
+
     for subclass in base_type.__subclasses__():
         # we can define 'abstract' nodes which don't have a label
         # these are to provide common properties to be used by subclassed nodes
@@ -27,24 +36,47 @@ def get_node_types(base_type: Type[BaseNode] = BaseNode) -> Dict[str, Type[BaseN
     return node_types
 
 
+def generate_relationship_type_data(rel_class: Type[BaseRelationship]):
+    defined_source_class = rel_class.model_fields["source"].annotation
+    defined_target_class = rel_class.model_fields["target"].annotation
+
+    all_source_classes = list(get_node_types(defined_source_class).values())
+    all_target_classes = list(get_node_types(defined_target_class).values())
+
+    return RelationshipTypeData(
+        relationship_class=rel_class,
+        source_class=defined_source_class,
+        target_class=defined_target_class,
+        all_source_classes=all_source_classes,
+        all_target_classes=all_target_classes,
+    )
+
+
 def get_rels_by_type(
     base_type: Type[BaseRelationship] = BaseRelationship,
-) -> Dict[str, dict]:
+) -> Dict[str, RelationshipTypeData]:
     rel_types: dict = defaultdict(dict)
 
+    if (
+        hasattr(base_type, "__relationshiptype__")
+        and base_type.__relationshiptype__ is not None
+    ):
+        rel_types[base_type.__relationshiptype__] = generate_relationship_type_data(
+            base_type
+        )
+
     for rel_subclass in base_type.__subclasses__():
         # we can define 'abstract' relationships which don't have a label
         # these are to provide common properties to be used by subclassed relationships
         # but shouldn't be put in the graph
+
         if (
             hasattr(rel_subclass, "__relationshiptype__")
             and rel_subclass.__relationshiptype__ is not None
         ):
-            rel_types[rel_subclass.__relationshiptype__] = {
-                "rel_class": rel_subclass,
-                "source_class": rel_subclass.model_fields["source"].annotation,
-                "target_class": rel_subclass.model_fields["target"].annotation,
-            }
+            rel_types[rel_subclass.__relationshiptype__] = (
+                generate_relationship_type_data(rel_subclass)
+            )
 
         if rel_subclass.__subclasses__():
             subclass_rel_types = get_rels_by_type(rel_subclass)
@@ -75,14 +107,14 @@ def get_rels_by_node(
 
     for rel_type, entry in all_rels.items():
         try:
-            node_label = entry[node_dir].__primarylabel__
+            node_label = entry.model_dump()[node_dir].__primarylabel__
         except AttributeError:
             node_label = None
 
         if node_label is not None:
             by_node[node_label].add(rel_type)
 
-        for node_subclass in all_subclasses(entry[node_dir]):
+        for node_subclass in all_subclasses(entry.model_dump()[node_dir]):
             subclass_label = node_subclass.__primarylabel__
             if subclass_label is not None:
                 by_node[subclass_label].add(rel_type)
@@ -93,16 +125,30 @@ def get_rels_by_node(
 def get_rels_by_source(
     base_type: Type[BaseRelationship] = BaseRelationship,
 ) -> Dict[str, Set[str]]:
-    return get_rels_by_node(by_source=True)
+    return get_rels_by_node(base_type, by_source=True)
 
 
 def get_rels_by_target(
     base_type: Type[BaseRelationship] = BaseRelationship,
 ) -> Dict[str, Set[str]]:
-    return get_rels_by_node(by_source=False)
+    return get_rels_by_node(base_type, by_source=False)
+
 
+def apply_neo4j_constraints(node_types: List[type[BaseNode]]) -> None:
+    """Apply constraints based on primary properties for arbitrary set of node types"""
+
+    graph = GraphConnection()
 
-def auto_constrain() -> None:
+    for node_type in node_types:
+        label = node_type.__primarylabel__
+        if not label:
+            raise ValueError(
+                "Node must have an explicit primary label to apply a constraint."
+            )
+        graph.engine.apply_constraint(label, node_type.__primaryproperty__)
+
+
+def auto_constrain_neo4j() -> None:
     """Automatically apply constraints
 
     Get information about all the defined nodes in the current environment.
@@ -110,7 +156,6 @@ def auto_constrain() -> None:
     Apply constraints based on the primary label and primary property for each node.
     """
 
-    graph = GraphConnection()
+    node_types = list(get_node_types().values())
 
-    for node_label, node_type in get_node_types().items():
-        graph.apply_constraint(node_label, node_type.__primaryproperty__)
+    apply_neo4j_constraints(node_types)


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 34d355c..97e93af 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,32 +1,100 @@
 # type: ignore
 
 import os
+import logging
 
 import pytest
-from py2neo import Graph
 from dotenv import load_dotenv
 
 from neontology import GraphConnection, init_neontology
+from neontology.graphengines import Neo4jConfig, MemgraphConfig
 
 
-@pytest.fixture(scope="session")
-def neo4j_db():
+logger = logging.getLogger(__name__)
 
+
+def reset_constraints():
+    gc = GraphConnection()
+
+    try:
+        constraints = gc.engine.get_constraints()
+
+    except NotImplementedError:
+        return
+
+    for constraint_name in constraints:
+        gc.engine.drop_constraint(constraint_name)
+
+
+@pytest.fixture(
+    scope="session",
+    params=[
+        pytest.param(
+            {
+                "graph_config_vars": {
+                    "uri": "TEST_NEO4J_URI",
+                    "username": "TEST_NEO4J_USERNAME",
+                    "password": "TEST_NEO4J_PASSWORD",
+                },
+                "graph_engine": "NEO4J",
+            },
+            id="neo4j-engine",
+        ),
+        pytest.param(
+            {
+                "graph_config_vars": {
+                    "uri": "TEST_MEMGRAPH_URI",
+                    "username": "TEST_MEMGRAPH_USER",
+                    "password": "TEST_MEMGRAPH_PASSWORD",
+                },
+                "graph_engine": "MEMGRAPH",
+            },
+            id="memgraph-engine",
+        ),
+    ],
+)
+def get_graph_config(request, tmp_path_factory) -> tuple:
     load_dotenv()
 
-    neo4j_uri = os.getenv("TEST_NEO4J_URI")
-    neo4j_username = os.getenv("TEST_NEO4J_USERNAME")
-    neo4j_password = os.getenv("TEST_NEO4J_PASSWORD")
+    graph_engines = {
+        "NEO4J": Neo4jConfig,
+        "MEMGRAPH": MemgraphConfig,
+    }
+
+    graph_config_vars = request.param["graph_config_vars"]
+
+    graph_config = {}
+
+    # build config using environment variables
+    for key, value in graph_config_vars.items():
+        if value == "TMP FILE":
+            file_path = tmp_path_factory.mktemp("graph_db") / f"{key}.pytest"
+            graph_config[key] = file_path
+
+            logger.info(f"Graph DB at {file_path}")
+
+        else:
+            graph_config[key] = os.getenv(value)
+            assert graph_config[key] is not None
+
+    graph_engine = request.param["graph_engine"]
+
+    config = graph_engines[graph_engine](**graph_config)
+
+    return config
+
 
-    assert neo4j_uri is not None
-    assert neo4j_username is not None
-    assert neo4j_password is not None
+@pytest.fixture(
+    scope="session",
+)
+def graph_db(request, tmp_path_factory, get_graph_config):
+    load_dotenv()
 
-    init_neontology(neo4j_uri, neo4j_username, neo4j_password)
+    init_neontology(get_graph_config)
 
-    print("initialised neontology")
+    gc = GraphConnection()
 
-    graph = Graph(neo4j_uri, auth=(neo4j_username, neo4j_password))
+    gc.change_engine(get_graph_config)
 
     # confirm we're starting with an empty database
     cypher = """
@@ -34,33 +102,13 @@ def neo4j_db():
     RETURN COUNT(n)
     """
 
-    node_count = graph.evaluate(cypher)
+    node_count = gc.evaluate_query_single(cypher)
+
     assert (
         node_count == 0
     ), f"Looks like there are {node_count} nodes in the database, it should be empty."
 
-    all_constraints_cypher = """
-    SHOW CONSTRAINTS yield name
-    RETURN COLLECT(DISTINCT name)
-    """
-
-    constraints = graph.evaluate(all_constraints_cypher)
-
-    for constraint_name in constraints:
-
-        drop_cypher = f"""
-        DROP CONSTRAINT {constraint_name}
-        """
-        graph.evaluate(drop_cypher)
-
-    yield graph
-
-    graph_connection = GraphConnection()
-
-    # tidy up by explicitly closing the graph connection here
-    # otherwise something weird happens which closes the connection before
-    # our GraphConnection gets the chance to
-    graph_connection.driver.close()
+    yield gc
 
 
 def pytest_collection_modifyitems(config, items):
@@ -71,15 +119,20 @@ def pytest_collection_modifyitems(config, items):
 
 
 @pytest.fixture(scope="function")
-def use_graph(neo4j_db):
-
-    yield neo4j_db
+def use_graph(graph_db):
+    yield graph_db
 
     # at the end of every individual test function, we want to empty the database
 
     cypher = """
-    MATCH (n)
-    DETACH DELETE n
+    MATCH (n) DETACH DELETE n;
     """
 
-    neo4j_db.evaluate(cypher)
+    try:
+        graph_db.evaluate_query_single(cypher)
+    except RuntimeError:
+        pass
+
+    # not all engines will implement constraints, so we don't always have to reset them
+
+    reset_constraints()
diff --git a/tests/test_basenode.py b/tests/test_basenode.py
index 1edc80c..0d277e8 100644
--- a/tests/test_basenode.py
+++ b/tests/test_basenode.py
@@ -1,28 +1,89 @@
 # type: ignore
-from typing import ClassVar, Optional
+from typing import ClassVar, Optional, List
 from datetime import datetime
-from uuid import UUID
+from uuid import UUID, uuid4
 
 import pandas as pd
-from pydantic import Field, field_validator
+from pydantic import Field, field_validator, ValidationInfo, field_serializer
 import pytest
 
-from neontology import BaseNode
+from neontology import (
+    BaseNode,
+    BaseRelationship,
+    related_property,
+    related_nodes,
+    GQLIdentifier,
+)
 
 
 class PracticeNode(BaseNode):
-    __primaryproperty__: ClassVar[str] = "pp"
-    __primarylabel__: ClassVar[Optional[str]] = "PracticeNode"
+    __primaryproperty__: ClassVar[GQLIdentifier] = "pp"
+    __primarylabel__: ClassVar[Optional[GQLIdentifier]] = "PracticeNode"
 
     pp: str
 
 
+class PracticeNodeDated(BaseNode):
+    __primaryproperty__: ClassVar[GQLIdentifier] = "pp"
+    __primarylabel__: ClassVar[Optional[GQLIdentifier]] = "PracticeNodeDated"
+
+    pp: str
+
+    test_merged: datetime = Field(
+        default_factory=datetime.now,
+    )
+
+    # created property will only be set 'on create' - when the node is first created
+    test_created: Optional[datetime] = Field(
+        default=None, validate_default=True, json_schema_extra={"set_on_create": True}
+    )
+
+    @field_validator("test_created")
+    def set_test_created_to_merged(
+        cls, value: Optional[datetime], values: ValidationInfo
+    ) -> datetime:
+        """When the node is first created, we want the created value
+        to be set equal to merged.
+        Otherwise they will be a tiny amount of time different.
+        """
+
+        # if the created value has been manually set, don't override it
+        if value is None:
+            return values.data["test_merged"]
+        else:
+            return value
+
+
 def test_set_pp_field_valid():
     tn = PracticeNode(pp="Some Value")
 
     assert tn.pp == "Some Value"
 
 
+def test_engine_dict_internals():
+    # verify that an error is raised when we
+    # execute a function which depends on the db
+    with pytest.raises(RuntimeError):
+        PracticeNode.match_nodes()
+
+    tn = PracticeNode(pp="Some Value")
+
+    # should be able to call these functions without an engine connection
+    assert tn._engine_dict() == {"pp": "Some Value"}
+
+
+def test_get_merge_parameters():
+    tn = PracticeNode(pp="Some Value")
+
+    assert tn._get_merge_parameters() == {
+        "always_set": {"pp": "Some Value"},
+        "pp": "Some Value",
+        "set_on_create": {},
+        "set_on_match": {},
+    }
+    assert tn.get_pp() == "Some Value"
+
+
 def test_create(use_graph):
     tn = PracticeNode(pp="Test Node")
 
@@ -34,11 +95,67 @@ def test_create(use_graph):
     RETURN n
     """
 
-    result = use_graph.evaluate(cypher)
+    result = use_graph.evaluate_query(cypher)
+
+    assert result.nodes[0].__primarylabel__ == "PracticeNode"
+
+    assert result.nodes[0].pp == "Test Node"
+
+
+def test_create_if_exists(use_graph):
+    """
+    Neontology does not check if a node already exists, it is for the user to enforce this at the database level.
+    """
+
+    tn = PracticeNode(pp="Test Node")
 
-    assert result.has_label("PracticeNode")
+    tn.create()
 
-    assert result.get("pp") == "Test Node"
+    cypher = """
+    MATCH (n:PracticeNode)
+    WHERE n.pp = 'Test Node'
+    RETURN n
+    """
+
+    result = use_graph.evaluate_query(cypher)
+
+    assert result.nodes[0].__primarylabel__ == "PracticeNode"
+
+    assert result.nodes[0].pp == "Test Node"
+
+    tn.create()
+
+    node_count = use_graph.evaluate_query_single(
+        "MATCH (n:PracticeNode) WHERE n.pp = 'Test Node' RETURN COUNT(n)"
+    )
+
+    assert node_count == 2
+
+
+def test_create_multiple_if_exists(use_graph):
+    tn = PracticeNode(pp="Test Node")
+
+    tn.create()
+
+    cypher = """
+    MATCH (n:PracticeNode)
+    WHERE n.pp = 'Test Node'
+    RETURN n
+    """
+
+    result = use_graph.evaluate_query(cypher)
+
+    assert result.nodes[0].__primarylabel__ == "PracticeNode"
+
+    assert result.nodes[0].pp == "Test Node"
+
+    PracticeNode.create_nodes([tn])
+
+    node_count = use_graph.evaluate_query_single(
+        "MATCH (n:PracticeNode) WHERE n.pp = 'Test Node' RETURN COUNT(n)"
+    )
+
+    assert node_count == 2
 
 
 def test_no_primary_label():
@@ -77,13 +194,15 @@ class MultipleLabelNode(BaseNode):
     RETURN n
     """
 
-    result = use_graph.evaluate(cypher)
+    result = use_graph.evaluate_query(cypher)
 
-    assert result.has_label("PrimaryLabel")
-    assert result.has_label("ExtraLabel1")
-    assert result.has_label("ExtraLabel2")
+    assert result.nodes[0].__primarylabel__ == "PrimaryLabel"
 
-    assert result.get("pp") == "Test Node"
+    # confirm the secondary labels were written to the database
+    assert "ExtraLabel1" in result.records_raw[0].values()[0].labels
+    assert "ExtraLabel2" in result.records_raw[0].values()[0].labels
+
+    assert result.nodes[0].pp == "Test Node"
 
 
 def test_create_multilabel_inheritance(use_graph):
@@ -107,12 +226,12 @@ class Human(Mammal):
     RETURN n
     """
 
-    result = use_graph.evaluate(cypher)
+    result = use_graph.evaluate_query(cypher)
 
-    assert result.has_label("Human")
-    assert result.has_label("Mammal")
+    assert "Human" in result.records_raw[0].values()[0].labels
+    assert "Mammal" in result.records_raw[0].values()[0].labels
 
-    assert result.get("pp") == "Bob"
+    assert result.nodes[0].pp == "Bob"
 
 
 def test_create_multilabel_inheritance_multiple(use_graph):
@@ -151,42 +270,48 @@ class Elephant(Animal):
     RETURN COUNT(DISTINCT n)
     """
 
-    result = use_graph.evaluate(cypher)
+    result = use_graph.evaluate_query_single(cypher)
 
     assert result == 2
 
 
 def test_merge_defined_label_inherited(use_graph):
-    class SpecialPracticeNode(BaseNode):
-        __primarylabel__: ClassVar[Optional[str]] = "SpecialTestLabel"
+    class Mammal(BaseNode):
         __primaryproperty__: ClassVar[str] = "pp"
+        __secondarylabels__: ClassVar[Optional[list]] = ["Mammal"]
+        pp: str
 
+    class Human(Mammal):
+        __primaryproperty__: ClassVar[str] = "pp"
+        __primarylabel__: ClassVar[Optional[str]] = "Human"
         pp: str
 
-    tn = SpecialPracticeNode(pp="Special Test Node")
+    tn = Human(pp="Bob")
 
     tn.merge()
 
     cypher = """
-    MATCH (n:SpecialTestLabel)
-    WHERE n.pp = 'Special Test Node'
+    MATCH (n:Human)
+    WHERE n.pp = 'Bob'
     RETURN n
     """
 
-    result = use_graph.evaluate(cypher)
+    result = use_graph.evaluate_query(cypher)
 
-    assert result.has_label("SpecialTestLabel")
+    assert "Human" in result.records_raw[0].values()[0].labels
+    assert "Mammal" in result.records_raw[0].values()[0].labels
 
-    assert result.get("pp") == "Special Test Node"
+    assert result.nodes[0].pp == "Bob"
 
 
-def test_merge_multiple_defined_label_inherited(use_graph):
-    class SpecialPracticeNode(BaseNode):
-        __primarylabel__: ClassVar[Optional[str]] = "SpecialTestLabel"
-        __primaryproperty__: ClassVar[str] = "pp"
+class SpecialPracticeNode(BaseNode):
+    __primarylabel__: ClassVar[Optional[str]] = "SpecialTestLabel1"
+    __primaryproperty__: ClassVar[str] = "pp"
 
-        pp: str
+    pp: str
 
+
+def test_merge_multiple_defined_label(use_graph):
     tn = SpecialPracticeNode(pp="Special Test Node")
 
     tn2 = SpecialPracticeNode(pp="Special Test Node2")
@@ -194,23 +319,17 @@ class SpecialPracticeNode(BaseNode):
     SpecialPracticeNode.merge_nodes([tn, tn2])
 
     cypher = """
-    MATCH (n:SpecialTestLabel)
+    MATCH (n:SpecialTestLabel1)
     RETURN COLLECT(DISTINCT n.pp) as node_names
     """
 
-    results = use_graph.evaluate(cypher)
+    results = use_graph.evaluate_query_single(cypher)
 
     assert "Special Test Node" in results
     assert "Special Test Node2" in results
 
 
-def test_create_multiple_defined_label_inherited(use_graph):
-    class SpecialPracticeNode(BaseNode):
-        __primarylabel__: ClassVar[Optional[str]] = "SpecialTestLabel"
-        __primaryproperty__: ClassVar[str] = "pp"
-
-        pp: str
-
+def test_create_multiple_defined_label(use_graph):
     tn = SpecialPracticeNode(pp="Special Test Node")
 
     tn2 = SpecialPracticeNode(pp="Special Test Node2")
@@ -218,11 +337,11 @@ class SpecialPracticeNode(BaseNode):
     SpecialPracticeNode.create_nodes([tn, tn2])
 
     cypher = """
-    MATCH (n:SpecialTestLabel)
+    MATCH (n:SpecialTestLabel1)
     RETURN COLLECT(DISTINCT n.pp) as node_names
     """
 
-    results = use_graph.evaluate(cypher)
+    results = use_graph.evaluate_query_single(cypher)
 
     assert "Special Test Node" in results
     assert "Special Test Node2" in results
@@ -236,22 +355,19 @@ def test_creation_datetime(use_graph):
 
     my_datetime = datetime(year=2022, month=5, day=4, hour=3, minute=21)
 
-    bn = PracticeNode(pp="Test Node", created=my_datetime)
+    bn = PracticeNodeDated(pp="Test Node", test_created=my_datetime)
 
     bn.create()
 
     cypher = """
-    MATCH (n:PracticeNode)
+    MATCH (n:PracticeNodeDated)
     WHERE n.pp = 'Test Node'
-    RETURN n
+    RETURN n.test_created.year
     """
 
-    result = use_graph.evaluate(cypher)
+    result = use_graph.evaluate_query_single(cypher)
 
-    # if the created date has been stored as a Neo4j datetime,
-    # it should come back as an interchange DateTime format from py2neo
-    # we call to_native to convert it back to a native Python datetime
-    assert result.get("created").to_native() == my_datetime
+    assert result == 2022
 
 
 def test_match_nodes(use_graph):
@@ -273,6 +389,43 @@ def test_match_nodes(use_graph):
     assert "Special Test Node2" in pps
 
 
+def test_match_nodes_limit(use_graph):
+    tn = PracticeNode(pp="Special Test Node")
+    tn.merge()
+
+    tn2 = PracticeNode(pp="Special Test Node2")
+
+    tn2.merge()
+
+    results = PracticeNode.match_nodes(limit=1)
+
+    assert len(results) == 1
+
+    assert results[0].pp in ["Special Test Node", "Special Test Node2"]
+
+
+def test_match_nodes_skip(use_graph):
+    tn = PracticeNode(pp="Special Test Node")
+    tn.merge()
+
+    tn2 = PracticeNode(pp="Special Test Node2")
+
+    tn2.merge()
+
+    results1 = PracticeNode.match_nodes(limit=1)
+
+    assert len(results1) == 1
+
+    assert results1[0].pp in ["Special Test Node", "Special Test Node2"]
+
+    results2 = PracticeNode.match_nodes(limit=1, skip=1)
+
+    assert len(results2) == 1
+
+    # make sure we have actually skipped through the results
+    assert results1[0].pp != results2[0].pp
+
+
 def test_match_node(use_graph):
     tn = PracticeNode(pp="Special Test Node")
 
@@ -325,60 +478,143 @@ def test_delete_node(use_graph):
     assert result is None
 
 
+class ModelTestString(BaseNode):
+    __primaryproperty__: ClassVar[str] = "pp"
+    __primarylabel__: ClassVar[Optional[str]] = "TestModelString"
+    pp: str
+    test_prop_string: str
+
+
+class ModelTestInt(BaseNode):
+    __primaryproperty__: ClassVar[str] = "pp"
+    __primarylabel__: ClassVar[Optional[str]] = "TestModelInt"
+    pp: str
+    test_prop_int: int
+
+
+class ModelTestTuple(BaseNode):
+    __primaryproperty__: ClassVar[str] = "pp"
+    __primarylabel__: ClassVar[Optional[str]] = "TestModelTuple"
+    pp: str
+    test_prop_tuple: tuple
+
+
+class ModelTestSet(BaseNode):
+    __primaryproperty__: ClassVar[str] = "pp"
+    __primarylabel__: ClassVar[Optional[str]] = "TestModelSet"
+    pp: str
+    test_prop_set: set
+
+
+class ModelTestUUID(BaseNode):
+    __primaryproperty__: ClassVar[str] = "pp"
+    __primarylabel__: ClassVar[Optional[str]] = "TestModelUUID"
+    pp: str
+    test_prop_uuid: UUID
+
+
+class ModelTestDateTime(BaseNode):
+    __primaryproperty__: ClassVar[str] = "pp"
+    __primarylabel__: ClassVar[Optional[str]] = "TestModelDateTime"
+    pp: str
+    test_prop_datetime: datetime
+
+
+class ModelTestStringList(BaseNode):
+    __primaryproperty__: ClassVar[str] = "pp"
+    __primarylabel__: ClassVar[Optional[str]] = "TestModelStringList"
+    pp: str
+    test_prop_list: list
+
+
+class ModelTestIntListExplicit(BaseNode):
+    __primaryproperty__: ClassVar[str] = "pp"
+    __primarylabel__: ClassVar[Optional[str]] = "TestModelIntListExplicit"
+    pp: str
+    test_prop_int_list_exp: List[int]
+
+
 @pytest.mark.parametrize(
-    "field_type,python_value,neo4j_values",
+    "test_model,test_prop,input_value,expected_value",
     [
-        (str, "hello world", ["hello world"]),
-        (tuple, ("hello", "world"), [["hello", "world"]]),
-        (set, {"foo", "bar"}, [["bar", "foo"], ["foo", "bar"]]),
+        (ModelTestString, "test_prop_string", "hello world", ["hello world"]),
+        (ModelTestInt, "test_prop_int", 5071, [5071]),
+        (ModelTestTuple, "test_prop_tuple", ("hello", "world"), [["hello", "world"]]),
+        (
+            ModelTestSet,
+            "test_prop_set",
+            {"foo", "bar"},
+            [["bar", "foo"], ["foo", "bar"]],
+        ),
         (
-            UUID,
+            ModelTestUUID,
+            "test_prop_uuid",
             UUID("32d4a4cb-29c3-4aa8-9b55-7790431819e3"),
             ["32d4a4cb-29c3-4aa8-9b55-7790431819e3"],
         ),
         (
-            datetime,
+            ModelTestDateTime,
+            "test_prop_datetime",
             datetime(year=1984, month=1, day=2),
             [datetime(year=1984, month=1, day=2)],
         ),
         (
-            list,
+            ModelTestStringList,
+            "test_prop_list",
             ["foo", "bar"],
             [["foo", "bar"]],
         ),
         (
-            list,
+            ModelTestIntListExplicit,
+            "test_prop_int_list_exp",
             [1, 2, 3],
             [[1, 2, 3]],
         ),
     ],
 )
-def test_neo4j_dict_create(use_graph, field_type, python_value, neo4j_values):
-    class TestModel(BaseNode):
-        __primaryproperty__: ClassVar[str] = "pp"
-        __primarylabel__: ClassVar[Optional[str]] = "TestModel"
-        pp: str
-        test_prop: field_type
-
+def test_property_types(use_graph, test_model, test_prop, input_value, expected_value):
     pp = "test_node"
 
-    testmodel = TestModel(test_prop=python_value, pp=pp)
+    input_data = {"pp": pp}
+    input_data[test_prop] = input_value
+
+    testmodel = test_model(**input_data)
 
     testmodel.create()
 
-    result = TestModel.match(pp)
+    result = test_model.match(pp)
 
-    assert result.test_prop == python_value
+    assert result.model_dump()[test_prop] == input_value
 
-    cypher = """
-    MATCH (n:TestModel)
+    cypher = f"""
+    MATCH (n:{test_model.__primarylabel__})
     WHERE n.pp = 'test_node'
-    RETURN n
+    RETURN n.{test_prop}
     """
 
-    cypher_result = use_graph.evaluate(cypher)
+    cypher_result = use_graph.evaluate_query_single(cypher)
 
-    assert cypher_result.get("test_prop") in neo4j_values
+    # in the case of sets, we may get the result back ordered one of two ways
+    # therefore, we check that the result is one of the expected values rather
+    assert cypher_result in expected_value
+
+
+def test_empty_list_property(use_graph):
+    class TestModelListProp(BaseNode):
+        __primaryproperty__: ClassVar[str] = "pp"
+        __primarylabel__: ClassVar[Optional[str]] = "TestModel1"
+        pp: str
+        list_prop: list
+
+    pp = "test_node"
+
+    testmodel = TestModelListProp(list_prop=[], pp=pp)
+
+    testmodel.create()
+
+    result = TestModelListProp.match(pp)
+
+    assert result.list_prop == []
 
 
 def test_set_on_match(use_graph):
@@ -386,7 +622,7 @@ def test_set_on_match(use_graph):
 
     class TestModel(BaseNode):
         __primaryproperty__: ClassVar[str] = "pp"
-        __primarylabel__: ClassVar[Optional[str]] = "TestModel"
+        __primarylabel__: ClassVar[Optional[str]] = "TestModel2"
         pp: str = "test_node"
         only_set_on_match: Optional[str] = Field(
             json_schema_extra={"set_on_match": True}, default=None
@@ -397,22 +633,22 @@ class TestModel(BaseNode):
     test_node.merge()
 
     cypher = """
-    MATCH (n:TestModel)
+    MATCH (n:TestModel2)
     WHERE n.pp = 'test_node'
     RETURN n
     """
 
-    cypher_result = use_graph.evaluate(cypher)
+    cypher_result = use_graph.evaluate_query(cypher)
 
-    assert cypher_result.get("only_set_on_match") is None
-    assert cypher_result.get("normal_field") == "Bar"
+    assert cypher_result.nodes[0].only_set_on_match is None
+    assert cypher_result.nodes[0].normal_field == "Bar"
 
     test_node2 = TestModel(only_set_on_match="Foo", normal_field="Bar", pp="test_node")
     test_node2.merge()
 
-    cypher_result2 = use_graph.evaluate(cypher)
+    cypher_result2 = use_graph.evaluate_query(cypher)
 
-    assert cypher_result2.get("only_set_on_match") == "Foo"
+    assert cypher_result2.nodes[0].only_set_on_match == "Foo"
 
 
 def test_set_on_create(use_graph):
@@ -420,7 +656,7 @@ def test_set_on_create(use_graph):
 
     class TestModel(BaseNode):
         __primaryproperty__: ClassVar[str] = "pp"
-        __primarylabel__: ClassVar[Optional[str]] = "TestModel"
+        __primarylabel__: ClassVar[Optional[str]] = "TestModel3"
         pp: str = "test_node"
         only_set_on_create: str = Field(json_schema_extra={"set_on_create": True})
         normal_field: str
@@ -429,43 +665,44 @@ class TestModel(BaseNode):
     test_node.merge()
 
     cypher = """
-    MATCH (n:TestModel)
+    MATCH (n:TestModel3)
     WHERE n.pp = 'test_node'
     RETURN n
     """
 
-    cypher_result = use_graph.evaluate(cypher)
+    cypher_result = use_graph.evaluate_query(cypher)
 
-    assert cypher_result.get("only_set_on_create") == "Foo"
-    assert cypher_result.get("normal_field") == "Bar"
+    assert cypher_result.nodes[0].only_set_on_create == "Foo"
+    assert cypher_result.nodes[0].normal_field == "Bar"
 
     test_node2 = TestModel(only_set_on_create="Fee", normal_field="Fi", pp="test_node")
     test_node2.merge()
 
-    cypher_result2 = use_graph.evaluate(cypher)
+    cypher_result2 = use_graph.evaluate_query(cypher)
 
-    assert cypher_result2.get("only_set_on_create") == "Foo"
-    assert cypher_result2.get("normal_field") == "Fi"
+    assert cypher_result2.nodes[0].only_set_on_create == "Foo"
+    assert cypher_result2.nodes[0].normal_field == "Fi"
 
 
-def test_merge_df_with_duplicates(use_graph):
-    class Person(BaseNode):
-        __primaryproperty__: ClassVar[str] = "identifier"
-        __primarylabel__: ClassVar[
-            str
-        ] = "PersonLabel"  # optionally specify the label to use
+class Person(BaseNode):
+    __primaryproperty__: ClassVar[str] = "identifier"
+    __primarylabel__: ClassVar[str] = (
+        "PersonLabel1"  # optionally specify the label to use
+    )
+
+    name: str
+    age: int
+    identifier: Optional[str] = Field(default=None, validate_default=True)
 
-        name: str
-        age: int
-        identifier: Optional[str] = Field(default=None, validate_default=True)
+    @field_validator("identifier")
+    def set_identifier(cls, v, values):
+        if v is None:
+            v = f"{values.data['name']}_{values.data['age']}"
 
-        @field_validator("identifier")
-        def set_identifier(cls, v, values):
-            if v is None:
-                v = f"{values.data['name']}_{values.data['age']}"
+        return v
 
-            return v
 
+def test_merge_df_with_duplicates(use_graph):
     people_records = [
         {"name": "arthur", "age": 70},
         {"name": "betty", "age": 65},
@@ -492,6 +729,61 @@ def set_identifier(cls, v, values):
     assert results[3].name == "ted"
 
 
+class Person2(BaseNode):
+    __primaryproperty__: ClassVar[str] = "name"
+    __primarylabel__: ClassVar[str] = (
+        "PersonLabel2"  # optionally specify the label to use
+    )
+
+    name: str
+    age: int
+    favorite_colors: Optional[list] = None
+
+
+def test_merge_df_with_lists(use_graph):
+    people_records = [
+        {"name": "arthur", "age": 70, "favorite_colors": ["red"]},
+        {"name": "betty", "age": 65, "favorite_colors": ["red", "blue"]},
+        {"name": "ted", "age": 50, "favorite_colors": []},
+        {"name": "ben", "age": 75},
+    ]
+
+    people_df = pd.DataFrame.from_records(people_records)
+
+    Person2.merge_df(people_df, deduplicate=False)
+
+    arthur = Person2.match("arthur")
+    assert arthur.favorite_colors == ["red"]
+
+    betty = Person2.match("betty")
+    assert betty.favorite_colors == ["red", "blue"]
+
+    ted = Person2.match("ted")
+    assert ted.favorite_colors == []
+
+    ben = Person2.match("ben")
+    assert ben.favorite_colors is None
+
+
+def test_get_count(use_graph):
+    people_records = [
+        {"name": "arthur", "age": 70, "favorite_colors": ["red"]},
+        {"name": "betty", "age": 65, "favorite_colors": ["red", "blue"]},
+        {"name": "ted", "age": 50, "favorite_colors": []},
+        {"name": "ben", "age": 75},
+    ]
+
+    people_df = pd.DataFrame.from_records(people_records)
+
+    Person2.merge_df(people_df, deduplicate=False)
+
+    assert Person2.get_count() == 4
+
+
+def test_get_count_none(use_graph):
+    assert Person2.get_count() == 0
+
+
 def test_merge_empty_df():
     df = pd.DataFrame()
 
@@ -499,3 +791,212 @@ def test_merge_empty_df():
 
     assert len(result) == 0
     assert isinstance(result, pd.Series)
+
+
+class AugmentedPerson(BaseNode):
+    __primaryproperty__: ClassVar[GQLIdentifier] = "name"
+    __primarylabel__: ClassVar[GQLIdentifier] = "AugmentedPerson"
+
+    name: str
+
+    @related_nodes
+    def followers(self):
+        return "MATCH (#ThisNode)<-[:AUGMENTED_PERSON_FOLLOWS]-(o) RETURN o"
+
+    @related_property
+    def follower_count(self):
+        return "MATCH (#ThisNode)<-[:AUGMENTED_PERSON_FOLLOWS]-(o) RETURN COUNT(DISTINCT o)"
+
+    @property
+    @related_property
+    def follower_names(self):
+        return "MATCH (#ThisNode)<-[:AUGMENTED_PERSON_FOLLOWS]-(o) RETURN COLLECT(DISTINCT o.name)"
+
+
+class AugmentedPersonRelationship(BaseRelationship):
+    __relationshiptype__: ClassVar[str] = "AUGMENTED_PERSON_FOLLOWS"
+
+    source: AugmentedPerson
+    target: AugmentedPerson
+
+    follow_tag: Optional[str] = None
+
+
+def test_get_related_node_methods():
+    assert set(AugmentedPerson.get_related_node_methods().keys()) == {
+        "followers",
+    }
+
+
+def test_get_related_prop_methods():
+    # note that only decorated methods, not properties are returned
+    assert set(AugmentedPerson.get_related_property_methods().keys()) == {
+        "follower_count",
+        "get_count",
+    }
+
+
+def test_node_schema():
+    schema = AugmentedPerson.neontology_schema()
+
+    assert schema.properties[0].name == "name"
+    assert schema.properties[0].required is True
+    assert schema.outgoing_relationships[0].name == "AUGMENTED_PERSON_FOLLOWS"
+
+
+def test_node_schema_md():
+    schema = AugmentedPerson.neontology_schema()
+
+    schema_md = schema.md_node_table()
+
+    assert "| Property Name | Type | Required |" in schema_md
+    assert "| name | str | True |" in schema_md
+
+
+def test_rels_schema_md():
+    schema = AugmentedPerson.neontology_schema()
+
+    schema_md = schema.md_rel_tables(heading_level=4)
+
+    assert "#### AUGMENTED_PERSON_FOLLOWS" in schema_md
+    assert "| follow_tag | Optional[str] | False |" in schema_md
+
+
+def test_related_nodes(use_graph):
+    alice = AugmentedPerson(name="Alice")
+    alice.merge()
+
+    bob = AugmentedPerson(name="Bob")
+    bob.merge()
+
+    follows = AugmentedPersonRelationship(
+        source=alice, target=bob, follow_tag="test-tag"
+    )
+    follows.merge()
+
+    follows2 = AugmentedPersonRelationship(
+        source=bob, target=alice, follow_tag="second-tag"
+    )
+    follows2.merge()
+
+    alice_rels = alice.get_related()
+
+    print(alice_rels)
+
+    related_nodes = [x for x in alice_rels.nodes if x.get_pp() != alice.get_pp()]
+
+    assert len(related_nodes) == 1
+    assert related_nodes[0].name == "Bob"
+
+    bobs_followers = bob.get_related(
+        relationship_types=["AUGMENTED_PERSON_FOLLOWS"],
+        incoming=True,
+        outgoing=False,
+        relationship_properties={"follow_tag": "test-tag"},
+    )
+
+    assert len(bobs_followers.nodes) == 2  # this will include bob himself
+    assert bobs_followers.nodes[0].name == "Alice"
+
+    bobs_rels = bob.get_related(incoming=True, distinct=True)
+
+    assert len(bobs_rels.nodes) == 2
+    assert bobs_rels.nodes[0].name == "Alice"
+
+    assert len(bobs_rels.relationships) == 2
+
+
+def test_related_nodes_unmerged(use_graph):
+    alice = AugmentedPerson(name="Alice")
+
+    alice_rels = alice.get_related()
+
+    assert len(alice_rels.nodes) == 0
+
+
+def test_related_nodes_no_rels(use_graph):
+    alice = AugmentedPerson(name="Alice")
+    alice.merge()
+
+    bob = AugmentedPerson(name="Bob")
+    bob.merge()
+
+    alice_rels = alice.get_related()
+
+    assert len(alice_rels.nodes) == 0
+
+
+def test_retrieve_property(use_graph):
+    alice = AugmentedPerson(name="Alice")
+    alice.merge()
+
+    bob = AugmentedPerson(name="Bob")
+    bob.merge()
+
+    follows = AugmentedPersonRelationship(
+        source=alice, target=bob, follow_tag="test-tag"
+    )
+    follows.merge()
+
+    assert bob.follower_count() == 1
+    assert bob.follower_names == ["Alice"]
+
+
+def test_retrieve_property_none(use_graph):
+    alice = AugmentedPerson(name="Alice")
+    alice.merge()
+
+    bob = AugmentedPerson(name="Bob")
+    bob.merge()
+
+    assert bob.follower_count() == 0
+    assert not bob.follower_names
+
+
+def test_retrieve_nodes_none(use_graph):
+    alice = AugmentedPerson(name="Alice")
+    alice.merge()
+
+    bob = AugmentedPerson(name="Bob")
+    bob.merge()
+
+    followers = bob.followers()
+
+    assert len(followers) == 0
+
+
+class ComplexPerson(BaseNode):
+    __primaryproperty__: ClassVar[str] = "identifier"
+    __primarylabel__: ClassVar[str] = (
+        "PersonLabel1"  # optionally specify the label to use
+    )
+
+    name: str = Field(default_factory=uuid4)
+    age: int
+    favorite_colors: list = ["red", "green", "blue"]
+    favorite_numbers: list = [1, 2, 3]
+    extra_str1: UUID = Field(default_factory=uuid4)
+    extra_str2: UUID = Field(default_factory=uuid4)
+
+    identifier: Optional[str] = Field(default=None, validate_default=True)
+
+    @field_validator("identifier")
+    def set_identifier(cls, v, values):
+        if v is None:
+            v = f"{values.data['name']}_{values.data['age']}"
+
+        return v
+
+    @field_serializer("extra_str1", "extra_str2")
+    def serialize_to_str(self, v: UUID):
+        return str(v)
+
+
+def test_create_mass_nodes(use_graph, benchmark):
+    people_records = [{"age": x, "name": uuid4().hex} for x in range(1000)]
+
+    people_df = pd.DataFrame.from_records(people_records)
+
+    benchmark(ComplexPerson.merge_df, people_df)
+
+    assert Person.get_count() == 1000
diff --git a/tests/test_baserelationship.py b/tests/test_baserelationship.py
index b4f8cd3..ab1b1bf 100644
--- a/tests/test_baserelationship.py
+++ b/tests/test_baserelationship.py
@@ -1,6 +1,7 @@
 # type: ignore
 
 from typing import ClassVar, Optional
+from uuid import uuid4
 
 import pandas as pd
 from pydantic import Field
@@ -19,9 +20,12 @@ class PracticeNode(BaseNode):
 
 
 class PracticeRelationship(BaseRelationship):
+    __relationshiptype__: ClassVar[Optional[str]] = "PRACTICE_RELATIONSHIP"
+
     source: PracticeNode
     target: PracticeNode
-    __relationshiptype__: ClassVar[Optional[str]] = "PRACTICE_RELATIONSHIP"
+
+    practice_rel_prop: str = "Default Practice Relationship Property"
 
 
 def test_base_relationship():
@@ -37,6 +41,19 @@ def test_base_relationship():
     assert br.get_relationship_type() == "PRACTICE_RELATIONSHIP"
 
 
+def test_rel_schema():
+    schema = PracticeRelationship.neontology_schema()
+
+    assert schema.relationship_type == "PRACTICE_RELATIONSHIP"
+    assert schema.source_labels == ["PracticeNode"]
+
+    practice_rel_prop = [x for x in schema.properties if x.name == "practice_rel_prop"][
+        0
+    ]
+
+    assert practice_rel_prop.type_annotation.representation == "str"
+
+
 def test_source_target_type():
     source_node = "Not a BaseNode"
     target_node = "Also not a BaseNode"
@@ -56,20 +73,46 @@ def test_merge_relationship(use_graph):
     target_node = PracticeNode(pp="Target Node")
     target_node.create()
 
-    br = PracticeRelationship(source=source_node, target=target_node)
+    br = PracticeRelationship(
+        source=source_node,
+        target=target_node,
+        practice_rel_prop="Default Practice Relationship Property",
+    )
     br.merge()
 
     cypher = """
     MATCH (src:PracticeNode {pp: 'Source Node'})-[r]->(tgt:PracticeNode {pp: 'Target Node'})
-    WITH r, TYPE(r) as type_r
-    RETURN COLLECT(DISTINCT r{.*, type_r})
+    RETURN r.practice_rel_prop
     """
 
-    result = use_graph.evaluate(cypher)
+    result = use_graph.evaluate_query_single(cypher)
 
-    assert len(result) == 1
+    assert result == "Default Practice Relationship Property"
 
-    assert result[0]["type_r"] == "PRACTICE_RELATIONSHIP"
+
+def test_match_relationship(use_graph):
+    source_node = PracticeNode(pp="Source Node")
+    source_node.create()
+
+    target_node = PracticeNode(pp="Target Node")
+    target_node.create()
+
+    br = PracticeRelationship(
+        source=source_node,
+        target=target_node,
+        practice_rel_prop="TESTING MATCH RELATIONSHIP",
+    )
+    br.merge()
+
+    rels = PracticeRelationship.match_relationships()
+
+    assert rels[0].practice_rel_prop == "TESTING MATCH RELATIONSHIP"
+
+
+class RelMergeOnMatchTest(PracticeRelationship):
+    __relationshiptype__: ClassVar[Optional[str]] = "TEST_REL_MERGE_ON_MATCH"
+    prop_to_merge_on: str = Field(json_schema_extra={"merge_on": True})
+    my_prop: str
 
 
 def test_merge_relationship_merge_on_match(use_graph):
@@ -79,12 +122,7 @@ def test_merge_relationship_merge_on_match(use_graph):
     target_node = PracticeNode(pp="Target Node")
     target_node.create()
 
-    class TestRel(PracticeRelationship):
-        __relationshiptype__: ClassVar[Optional[str]] = "TEST_REL"
-        prop_to_merge_on: str = Field(json_schema_extra={"merge_on": True})
-        my_prop: str
-
-    br = TestRel(
+    br = RelMergeOnMatchTest(
         source=source_node,
         target=target_node,
         prop_to_merge_on="MergeMe",
@@ -93,20 +131,15 @@ class TestRel(PracticeRelationship):
     br.merge()
 
     cypher = """
-    MATCH (src:PracticeNode {pp: 'Source Node'})-[r]->(tgt:PracticeNode {pp: 'Target Node'})
-    WITH r, TYPE(r) as type_r
-    RETURN COLLECT(DISTINCT r{.*, type_r})
+    MATCH (src:PracticeNode {pp: 'Source Node'})-[r:TEST_REL_MERGE_ON_MATCH]->(tgt:PracticeNode {pp: 'Target Node'})
+    RETURN r.my_prop
     """
 
-    result = use_graph.evaluate(cypher)
-
-    assert len(result) == 1
+    result = use_graph.evaluate_query_single(cypher)
 
-    assert result[0]["type_r"] == "TEST_REL"
-    assert result[0]["prop_to_merge_on"] == "MergeMe"
-    assert result[0]["my_prop"] == "Foo"
+    assert result == "Foo"
 
-    br2 = TestRel(
+    br2 = RelMergeOnMatchTest(
         source=source_node,
         target=target_node,
         prop_to_merge_on="MergeMe",
@@ -114,13 +147,15 @@ class TestRel(PracticeRelationship):
     )
     br2.merge()
 
-    result2 = use_graph.evaluate(cypher)
+    result2 = use_graph.evaluate_query_single(cypher)
 
-    assert len(result2) == 1
+    assert result2 == "Bar"
 
-    assert result2[0]["type_r"] == "TEST_REL"
-    assert result2[0]["prop_to_merge_on"] == "MergeMe"
-    assert result2[0]["my_prop"] == "Bar"
+
+class RelMergeOnCreateTest(PracticeRelationship):
+    __relationshiptype__: ClassVar[Optional[str]] = "TEST_REL_MERGE_ON_CREATE"
+    prop_to_merge_on: str = Field(json_schema_extra={"merge_on": True})
+    my_prop: str
 
 
 def test_merge_relationship_merge_on_create(use_graph):
@@ -130,12 +165,7 @@ def test_merge_relationship_merge_on_create(use_graph):
     target_node = PracticeNode(pp="Target Node")
     target_node.create()
 
-    class TestRel(PracticeRelationship):
-        __relationshiptype__: ClassVar[Optional[str]] = "TEST_REL"
-        prop_to_merge_on: str = Field(json_schema_extra={"merge_on": True})
-        my_prop: str
-
-    br = TestRel(
+    br = RelMergeOnCreateTest(
         source=source_node,
         target=target_node,
         prop_to_merge_on="MergeMe",
@@ -144,20 +174,15 @@ class TestRel(PracticeRelationship):
     br.merge()
 
     cypher = """
-    MATCH (src:PracticeNode {pp: 'Source Node'})-[r]->(tgt:PracticeNode {pp: 'Target Node'})
-    WITH r, TYPE(r) as type_r
-    RETURN COLLECT(DISTINCT r{.*, type_r})
+    MATCH (src:PracticeNode {pp: 'Source Node'})-[r:TEST_REL_MERGE_ON_CREATE]->(tgt:PracticeNode {pp: 'Target Node'})
+    RETURN COLLECT(r.my_prop)
     """
 
-    result = use_graph.evaluate(cypher)
-
-    assert len(result) == 1
+    result = use_graph.evaluate_query_single(cypher)
 
-    assert result[0]["type_r"] == "TEST_REL"
-    assert result[0]["prop_to_merge_on"] == "MergeMe"
-    assert result[0]["my_prop"] == "Foo"
+    assert result == ["Foo"]
 
-    br2 = TestRel(
+    br2 = RelMergeOnCreateTest(
         source=source_node,
         target=target_node,
         prop_to_merge_on="Don'tMergeMe",
@@ -165,16 +190,9 @@ class TestRel(PracticeRelationship):
     )
     br2.merge()
 
-    result2 = use_graph.evaluate(cypher)
-
-    assert len(result2) == 2
+    result2 = use_graph.evaluate_query_single(cypher)
 
-    my_props = []
-
-    for res in result2:
-        my_props.append(res["my_prop"])
-
-    assert set(my_props) == {"Foo", "Bar"}
+    assert set(result2) == {"Foo", "Bar"}
 
 
 def test_default_relationship_type():
@@ -217,6 +235,14 @@ class NewRelType(BaseRelationship):
     assert rel.get_relationship_type() == "TEST_RELATIONSHIP_TYPE"
 
 
+class NewRelType(BaseRelationship):
+    source: PracticeNode
+    target: PracticeNode
+    __relationshiptype__: ClassVar[Optional[str]] = "TEST_NEW_RELATIONSHIP_TYPE"
+
+    new_rel_prop: str
+
+
 def test_merge_relationships_defined_types(use_graph):
     node1 = PracticeNode(pp="Source Node")
     node1.create()
@@ -224,104 +250,111 @@ def test_merge_relationships_defined_types(use_graph):
     node2 = PracticeNode(pp="Target Node")
     node2.create()
 
-    class NewRelType(BaseRelationship):
-        source: PracticeNode
-        target: PracticeNode
-        __relationshiptype__: ClassVar[Optional[str]] = "TEST_RELATIONSHIP_TYPE"
+    rel1 = NewRelType(source=node1, target=node2, new_rel_prop="Rel 1")
 
-    rel1 = NewRelType(source=node1, target=node2)
-
-    rel2 = NewRelType(source=node2, target=node1)
+    rel2 = NewRelType(source=node2, target=node1, new_rel_prop="Rel 2")
 
     NewRelType.merge_relationships([rel1, rel2])
 
     cypher = """
-    MATCH (src:PracticeNode)-[r]->(tgt:PracticeNode)
-    WITH DISTINCT r, TYPE(r) as type_r
-    RETURN COLLECT(r{.*, type_r})
+    MATCH (src:PracticeNode)-[r:TEST_NEW_RELATIONSHIP_TYPE]->(tgt:PracticeNode)
+    RETURN COLLECT(r.new_rel_prop)
     """
 
-    results = use_graph.evaluate(cypher)
+    results = use_graph.evaluate_query_single(cypher)
 
     assert len(results) == 2
 
-    for result in results:
-        assert result["type_r"] == "TEST_RELATIONSHIP_TYPE"
+    assert set(results) == {"Rel 1", "Rel 2"}
 
 
-def test_merge_df(use_graph):
-    class SubclassNode(PracticeNode):
-        __primarylabel__: ClassVar[Optional[str]] = "SubclassNode"
-        myprop: str
+def test_get_count(use_graph):
+    node1 = PracticeNode(pp="Source Node")
+    node1.create()
+
+    node2 = PracticeNode(pp="Target Node")
+    node2.create()
+
+    rel1 = NewRelType(source=node1, target=node2, new_rel_prop="Rel 1")
+    rel2 = NewRelType(source=node2, target=node1, new_rel_prop="Rel 2")
+
+    NewRelType.merge_relationships([rel1, rel2])
+
+    assert NewRelType.get_count() == 2
+
+
+def test_get_count_none(use_graph):
+    assert NewRelType.get_count() == 0
 
+
+class SubclassNode(PracticeNode):
+    __primarylabel__: ClassVar[Optional[str]] = "SubclassNode"
+    myprop: str
+
+
+class NewRelType2(BaseRelationship):
+    source: SubclassNode
+    target: PracticeNode
+    __relationshiptype__: ClassVar[Optional[str]] = "TEST_NEW_RELATIONSHIP_TYPE2"
+
+    new_rel_prop: str
+
+
+def test_merge_df(use_graph):
     source_node = SubclassNode(pp="Source Node", myprop="Some Value")
     source_node.merge()
 
     target_node = PracticeNode(pp="Target Node")
     target_node.merge()
 
-    class NewRelType(BaseRelationship):
-        source: PracticeNode
-        target: PracticeNode
-
-        __relationshiptype__: ClassVar[Optional[str]] = "NEWRELTYPE"
-
-    rel_records = [{"source": "Source Node", "target": "Target Node"}]
+    rel_records = [
+        {"source": "Source Node", "target": "Target Node", "new_rel_prop": "New Rel 3"}
+    ]
 
     df = pd.DataFrame.from_records(rel_records)
 
-    NewRelType.merge_df(df, SubclassNode, PracticeNode)
+    NewRelType2.merge_df(df, SubclassNode, PracticeNode)
 
     cypher = """
-    MATCH (src:SubclassNode {pp: 'Source Node'})-[r]->(tgt:PracticeNode {pp: 'Target Node'})
-    WITH r, TYPE(r) as type_r
-    RETURN COLLECT(DISTINCT r{.*, type_r})
+    MATCH (src:SubclassNode {pp: 'Source Node'})-[r:TEST_NEW_RELATIONSHIP_TYPE2]->(tgt:PracticeNode {pp: 'Target Node'})
+    RETURN r.new_rel_prop
     """
 
-    result = use_graph.evaluate(cypher)
-
-    assert len(result) == 1
+    result = use_graph.evaluate_query_single(cypher)
 
-    assert result[0]["type_r"] == "NEWRELTYPE"
+    assert result == "New Rel 3"
 
 
 def test_merge_df_alt_prop(use_graph):
-    class SubclassNode(PracticeNode):
-        __primarylabel__: ClassVar[Optional[str]] = "SubclassNode"
-        myprop: str
-
     source_node = SubclassNode(pp="Source Node", myprop="My Prop Value")
     source_node.merge()
 
     target_node = PracticeNode(pp="Target Node")
     target_node.merge()
 
-    class NewRelType(BaseRelationship):
-        source: PracticeNode
-        target: PracticeNode
-
-        __relationshiptype__: ClassVar[Optional[str]] = "NEWRELTYPE"
-
-    rel_records = [{"source": "My Prop Value", "target": "Target Node"}]
+    rel_records = [
+        {
+            "source": "My Prop Value",
+            "target": "Target Node",
+            "new_rel_prop": "New Rel 4",
+        }
+    ]
 
     df = pd.DataFrame.from_records(rel_records)
 
-    NewRelType.merge_df(df, SubclassNode, PracticeNode, source_prop="myprop")
+    NewRelType2.merge_df(df, SubclassNode, PracticeNode, source_prop="myprop")
 
     cypher = """
-    MATCH (src:SubclassNode {pp: 'Source Node'})-[r]->(tgt:PracticeNode {pp: 'Target Node'})
-    WITH r, TYPE(r) as type_r
-    RETURN COLLECT(DISTINCT r{.*, type_r})
+    MATCH (src:SubclassNode {pp: 'Source Node'})-[r:TEST_NEW_RELATIONSHIP_TYPE2]->(tgt:PracticeNode {pp: 'Target Node'})
+    RETURN r.new_rel_prop
     """
 
-    result = use_graph.evaluate(cypher)
-
-    assert len(result) == 1
+    result = use_graph.evaluate_query_single(cypher)
 
-    assert result[0]["type_r"] == "NEWRELTYPE"
+    assert result == "New Rel 4"
 
 
-def test_merge_df_bad_node_type(use_graph):
+def OLD_test_merge_df_bad_node_type(use_graph):
     class DifferentNode(PracticeNode):
         __primarylabel__: ClassVar[Optional[str]] = "DifferentNode"
         __primaryproperty__: ClassVar[str] = "myprop"
@@ -331,19 +364,13 @@ class DifferentNode(PracticeNode):
 
     target_node = PracticeNode(pp="Target Node")
 
-    class NewRelType(BaseRelationship):
-        source: PracticeNode
-        target: PracticeNode
-
-        __relationshiptype__: ClassVar[Optional[str]] = "NEWRELTYPE"
-
-    rels = [NewRelType(source=source_node, target=target_node)]
+    rels = [NewRelType(source=source_node, target=target_node, new_rel_prop="BAD")]
 
     with pytest.raises(TypeError):
         NewRelType.merge_relationships(rels, PracticeNode, DifferentNode)
 
 
-def test_merge_df_bad_node_type2(use_graph):
+def OLD_test_merge_df_bad_node_type2(use_graph):
     class DifferentNode(PracticeNode):
         __primarylabel__: ClassVar[Optional[str]] = "DifferentNode"
         __primaryproperty__: ClassVar[str] = "myprop"
@@ -353,13 +380,7 @@ class DifferentNode(PracticeNode):
 
     target_node = PracticeNode(pp="Target Node")
 
-    class NewRelType(BaseRelationship):
-        source: PracticeNode
-        target: PracticeNode
-
-        __relationshiptype__: ClassVar[Optional[str]] = "NEWRELTYPE"
-
-    rels = [NewRelType(source=source_node, target=target_node)]
+    rels = [NewRelType(source=source_node, target=target_node, new_rel_prop="BAD2")]
 
     with pytest.raises(TypeError):
         NewRelType.merge_relationships(rels, PracticeNode, PracticeNode)
@@ -374,34 +395,48 @@ def test_merge_empty_df():
 
 
 def test_merge_records(use_graph):
-    class SubclassNode(PracticeNode):
-        __primarylabel__: ClassVar[Optional[str]] = "SubclassNode"
-        myprop: str
-
     source_node = SubclassNode(pp="Source Node", myprop="My Prop Value")
     source_node.merge()
 
     target_node = PracticeNode(pp="Target Node")
     target_node.merge()
 
-    class NewRelType(BaseRelationship):
-        source: SubclassNode
-        target: PracticeNode
-
-        __relationshiptype__: ClassVar[Optional[str]] = "NEWRELTYPE"
-
-    records = [{"source": "Source Node", "target": "Target Node"}]
+    records = [
+        {"source": "Source Node", "target": "Target Node", "new_rel_prop": "New Rel 5"}
+    ]
 
-    NewRelType.merge_records(records)
+    NewRelType2.merge_records(records)
 
     cypher = """
-    MATCH (src:SubclassNode {pp: 'Source Node'})-[r]->(tgt:PracticeNode {pp: 'Target Node'})
-    WITH r, TYPE(r) as type_r
-    RETURN COLLECT(DISTINCT r{.*, type_r})
+    MATCH (src:SubclassNode {pp: 'Source Node'})-[r:TEST_NEW_RELATIONSHIP_TYPE2]->(tgt:PracticeNode {pp: 'Target Node'})
+    RETURN r.new_rel_prop
     """
 
-    result = use_graph.evaluate(cypher)
+    result = use_graph.evaluate_query_single(cypher)
+
+    assert result == "New Rel 5"
+
+
+def test_create_mass_rels(use_graph, benchmark):
+    practice_records = [{"pp": uuid4().hex} for x in range(1000)]
 
-    assert len(result) == 1
+    records_df = pd.DataFrame.from_records(practice_records)
+
+    PracticeNode.merge_df(records_df)
+
+    assert PracticeNode.get_count() == 1000
+
+    people_rels = [
+        {"source": practice_records[x]["pp"], "target": practice_records[x + 1]["pp"]}
+        for x in range(999)
+    ]
+
+    rels_df = pd.DataFrame.from_records(people_rels)
+
+    benchmark(PracticeRelationship.merge_df, rels_df)
+
+    result = use_graph.evaluate_query_single(
+        "MATCH (n)-[r]->(o) RETURN COUNT(DISTINCT(r))"
+    )
 
-    assert result[0]["type_r"] == "NEWRELTYPE"
+    assert result == 999
diff --git a/tests/test_commonmodel.py b/tests/test_commonmodel.py
index a357b43..4e7f5e9 100644
--- a/tests/test_commonmodel.py
+++ b/tests/test_commonmodel.py
@@ -1,9 +1,8 @@
-# type: ignore
-
 from datetime import datetime
 from uuid import UUID
+from typing import Optional
 
-from pydantic import Field
+from pydantic import Field, ValidationError
 import pytest
 
 from neontology.commonmodel import CommonModel
@@ -13,17 +12,28 @@ class PracticeModel(CommonModel):
     def _get_merge_parameters(self):
         return {}
 
+    optional_string: Optional[str] = None
+
 
 def test_common_model_creation():
-    common = PracticeModel()
+    common = PracticeModel(optional_string="testing123")
+
+    assert isinstance(common.optional_string, str)
 
-    assert isinstance(common.created, datetime)
 
+def test_merged_created_deprecation(capsys):
+    now_time = datetime.now()
 
-def test_merged_equals_created():
-    common = PracticeModel()
+    with pytest.raises(ValidationError):
+        PracticeModel(merged=now_time, created=now_time)
+        captured = capsys.readouterr()
 
-    assert common.created == common.merged
+        assert (
+            "Native neontology support for 'merged' and 'created' properties has been removed."
+            in captured.err
+        )
+
+        assert 0
 
 
 def test_set_on_match():
@@ -35,9 +45,13 @@ class TestModel(PracticeModel):
 
     test_model = TestModel(only_set_on_match="Foo", normal_field="Bar")
 
-    assert test_model._set_on_create == ["created"]
+    assert test_model._set_on_create == []
     assert test_model._set_on_match == ["only_set_on_match"]
-    assert test_model._always_set == ["merged", "normal_field"]
+
+    assert test_model._always_set == [
+        "optional_string",
+        "normal_field",
+    ]
 
 
 def test_set_on_create():
@@ -49,9 +63,12 @@ class TestModel(PracticeModel):
 
     test_model = TestModel(only_set_on_create="Foo", normal_field="Bar")
 
-    assert test_model._set_on_create == ["created", "only_set_on_create"]
+    assert test_model._set_on_create == ["only_set_on_create"]
     assert test_model._set_on_match == []
-    assert test_model._always_set == ["merged", "normal_field"]
+    assert test_model._always_set == [
+        "optional_string",
+        "normal_field",
+    ]
 
 
 @pytest.mark.parametrize(
@@ -77,23 +94,25 @@ class TestModel(PracticeModel):
         ),
     ],
 )
-def test_neo4j_dict(field_type, python_value, neo4j_values):
+def test_engine_dict(field_type, python_value, neo4j_values, use_graph):
     class TestModel(PracticeModel):
         test_prop: field_type
 
     testmodel = TestModel(test_prop=python_value)
 
-    assert testmodel.neo4j_dict()["test_prop"] in neo4j_values
+    test_prop_result = testmodel._engine_dict()["test_prop"]
+
+    assert test_prop_result in neo4j_values
 
 
 @pytest.mark.parametrize(
     "field_type,python_value", [(dict, {"foo": "bar"}), (list, [123, "foo"])]
 )
-def test_neo4j_dict_bad_types(field_type, python_value):
+def test_engine_dict_bad_types(field_type, python_value, use_graph):
     class TestModel(PracticeModel):
         test_prop: field_type
 
     testmodel = TestModel(test_prop=python_value)
 
     with pytest.raises(TypeError):
-        testmodel.neo4j_dict()
+        testmodel._engine_dict()
diff --git a/tests/test_graph_connection.py b/tests/test_graph_connection.py
index 4b4114d..598b87b 100644
--- a/tests/test_graph_connection.py
+++ b/tests/test_graph_connection.py
@@ -1,22 +1,28 @@
 from typing import ClassVar, Optional
+
 import pytest
 
-from neontology.graphconnection import GraphConnection
 
+from neontology.graphconnection import GraphConnection
 from neontology.basenode import BaseNode
 from neontology.baserelationship import BaseRelationship
 
 
-class PracticeNode(BaseNode):
+class PracticeNodeGC(BaseNode):
     __primaryproperty__: ClassVar[str] = "pp"
-    __primarylabel__: ClassVar[Optional[str]] = "PracticeNode"
+    __primarylabel__: ClassVar[Optional[str]] = "PracticeNodeGC"
     pp: str
 
 
-class PracticeRelationship(BaseRelationship):
-    source: PracticeNode
-    target: PracticeNode
-    __relationshiptype__: ClassVar[Optional[str]] = "PRACTICE_RELATIONSHIP"
+class PracticeRelationshipGC(BaseRelationship):
+    source: PracticeNodeGC
+    target: PracticeNodeGC
+    __relationshiptype__: ClassVar[Optional[str]] = "PRACTICE_RELATIONSHIP_GC"
+
+
+create_test_node_table_cypher = (
+    "CREATE NODE TABLE TestNode(name STRING, PRIMARY KEY (name))"
+)
 
 
 def test_evaluate_query_single(use_graph):
@@ -48,12 +54,8 @@ def test_evaluate_query_single_node(use_graph):
 def test_evaluate_query_single_multiple(use_graph):
     gc = GraphConnection()
 
-    create_cypher = """
-    CREATE (tn1:TestNode {name: "Foo"})
-    CREATE (tn2:TestNode {name: "Bar"})
-    """
-
-    gc.evaluate_query_single(create_cypher)
+    gc.evaluate_query_single("CREATE (tn1:TestNode {name: 'Foo'})")
+    gc.evaluate_query_single("CREATE (tn1:TestNode {name: 'Bar'})")
 
     match_cypher = """
     MATCH (n:TestNode)
@@ -67,16 +69,12 @@ def test_evaluate_query_single_multiple(use_graph):
 def test_evaluate_query_single_collected(use_graph):
     gc = GraphConnection()
 
-    create_cypher = """
-    CREATE (tn1:TestNode {name: "Foo"})
-    CREATE (tn2:TestNode {name: "Bar"})
-    """
-
-    result = gc.evaluate_query_single(create_cypher)
+    gc.evaluate_query_single("CREATE (tn1:TestNode {name: 'Foo'})")
+    gc.evaluate_query_single("CREATE (tn1:TestNode {name: 'Bar'})")
 
     match_cypher = """
     MATCH (n:TestNode)
-    WITH n as nodes ORDER BY n.name
+    WITH n as nodes ORDER BY n.name LIMIT 5
     RETURN COLLECT(nodes.name)
     """
 
@@ -93,16 +91,15 @@ def test_evaluate_query_empty(use_graph):
     result = gc.evaluate_query(cypher)
 
     assert result.records == []
-    assert result.neontology_records == []
     assert result.nodes == []
     assert result.relationships == []
-    assert result.node_link_data == {"links": [], "nodes": []}
+    assert result.node_link_data == {"edges": [], "nodes": [], "directed": True}
 
 
 def test_evaluate_query_records(use_graph):
-    foo = PracticeNode(pp="foo")
-    bar = PracticeNode(pp="bar")
-    rel = PracticeRelationship(source=foo, target=bar)
+    foo = PracticeNodeGC(pp="foo")
+    bar = PracticeNodeGC(pp="bar")
+    rel = PracticeRelationshipGC(source=foo, target=bar)
 
     foo.merge()
     bar.merge()
@@ -117,9 +114,9 @@ def test_evaluate_query_records(use_graph):
 
 
 def test_evaluate_query_neontology_records(use_graph):
-    foo = PracticeNode(pp="foo")
-    bar = PracticeNode(pp="bar")
-    rel = PracticeRelationship(source=foo, target=bar)
+    foo = PracticeNodeGC(pp="foo")
+    bar = PracticeNodeGC(pp="bar")
+    rel = PracticeRelationshipGC(source=foo, target=bar)
 
     foo.merge()
     bar.merge()
@@ -130,15 +127,15 @@ def test_evaluate_query_neontology_records(use_graph):
     gc = GraphConnection()
     result = gc.evaluate_query(cypher)
 
-    assert result.neontology_records[0]["nodes"]["n"].pp == "foo"
-    assert result.neontology_records[0]["nodes"]["o"].pp == "bar"
-    assert result.neontology_records[0]["relationships"]["r"].source.pp == "foo"
-    assert result.neontology_records[0]["relationships"]["r"].target.pp == "bar"
+    assert result.records[0]["nodes"]["n"].pp == "foo"
+    assert result.records[0]["nodes"]["o"].pp == "bar"
+    assert result.records[0]["relationships"]["r"].source.pp == "foo"
+    assert result.records[0]["relationships"]["r"].target.pp == "bar"
 
 
 def test_evaluate_query_nodes(use_graph):
-    foo = PracticeNode(pp="foo")
-    bar = PracticeNode(pp="bar")
+    foo = PracticeNodeGC(pp="foo")
+    bar = PracticeNodeGC(pp="bar")
 
     foo.merge()
     bar.merge()
@@ -153,9 +150,9 @@ def test_evaluate_query_nodes(use_graph):
 
 
 def test_evaluate_query_relationships(use_graph):
-    foo = PracticeNode(pp="foo")
-    bar = PracticeNode(pp="bar")
-    rel = PracticeRelationship(source=foo, target=bar)
+    foo = PracticeNodeGC(pp="foo")
+    bar = PracticeNodeGC(pp="bar")
+    rel = PracticeRelationshipGC(source=foo, target=bar)
 
     foo.merge()
     bar.merge()
@@ -166,14 +163,59 @@ def test_evaluate_query_relationships(use_graph):
     gc = GraphConnection()
     result = gc.evaluate_query(cypher)
 
-    assert result.relationships[0].__relationshiptype__ == "PRACTICE_RELATIONSHIP"
+    assert result.relationships[0].__relationshiptype__ == "PRACTICE_RELATIONSHIP_GC"
     assert result.relationships[0].source.pp == "foo"
     assert result.relationships[0].target.pp == "bar"
 
 
+def test_evaluate_query_paths(use_graph):
+    foo = PracticeNodeGC(pp="foo")
+    bar = PracticeNodeGC(pp="bar")
+    baz = PracticeNodeGC(pp="baz")
+    rel1 = PracticeRelationshipGC(source=foo, target=bar)
+    rel2 = PracticeRelationshipGC(source=bar, target=baz)
+
+    foo.merge()
+    bar.merge()
+    baz.merge()
+    rel1.merge()
+    rel2.merge()
+
+    cypher = "MATCH p = (n)-[r]->(o)-[r1]->(o2) RETURN *"
+
+    gc = GraphConnection()
+    result = gc.evaluate_query(cypher)
+
+    for entry in result.records_raw:
+        print(entry)
+
+    # print(result)
+
+    print(result.paths)
+
+    assert result.paths[0][0].source.get_pp() == "foo"
+    assert result.paths[0][1].target.get_pp() == "baz"
+
+
+def test_evaluate_query_nodes_records_simple(use_graph):
+    foo = PracticeNodeGC(pp="foo")
+    bar = PracticeNodeGC(pp="bar")
+
+    foo.merge()
+    bar.merge()
+
+    cypher = "MATCH (n) RETURN n ORDER BY n.pp DESC"
+
+    gc = GraphConnection()
+    result = gc.evaluate_query(cypher)
+
+    assert result.records[0]["nodes"]["n"].pp == "foo"
+    assert result.records[1]["nodes"]["n"].pp == "bar"
+
+
 def test_evaluate_query_params(use_graph):
-    foo = PracticeNode(pp="foo")
-    bar = PracticeNode(pp="bar")
+    foo = PracticeNodeGC(pp="foo")
+    bar = PracticeNodeGC(pp="bar")
 
     foo.merge()
     bar.merge()
@@ -190,20 +232,149 @@ def test_evaluate_query_params(use_graph):
 def test_undefined_label(use_graph):
     gc = GraphConnection()
 
+    result = gc.evaluate_query_single("CREATE (tn1:TestNode {name: 'Foo'})")
+    result = gc.evaluate_query_single("CREATE (tn1:TestNode {name: 'Bar'})")
+
+    match_cypher = """
+    MATCH (n:TestNode)
+    RETURN n
+    """
+
+    with pytest.warns(UserWarning, match="Unexpected primary labels returned:"):
+        result = gc.evaluate_query(match_cypher)
+
+    assert len(result.records) == 2
+    assert len(result.nodes) == 0
+
+
+class SpecialTestNodeGC(BaseNode):
+    __primaryproperty__: ClassVar[str] = "pp"
+    __primarylabel__: ClassVar[Optional[str]] = "SpecialTestNodeGC"
+    pp: str
+
+
+def test_multiple_primary_labels(use_graph):
+    gc = GraphConnection()
+
     create_cypher = """
-    CREATE (tn1:TestNode {name: "Foo"})
-    CREATE (tn2:TestNode {name: "Bar"})
+    CREATE (tn1:SpecialTestNodeGC:PracticeNodeGC {pp: "Foo"})
     """
 
     result = gc.evaluate_query_single(create_cypher)
 
     match_cypher = """
-    MATCH (n:TestNode)
+    MATCH (n:SpecialTestNodeGC)
     RETURN n
     """
 
-    with pytest.warns():
+    with pytest.warns(
+        UserWarning,
+        match=r"Unexpected primary labels returned: {('SpecialTestNodeGC'|'PracticeNodeGC'), ('SpecialTestNodeGC'|'PracticeNodeGC')}",
+    ):
         result = gc.evaluate_query(match_cypher)
 
-    assert len(result.records) == 2
+    assert len(result.records) == 1
     assert len(result.nodes) == 0
+
+
+def test_warn_on_unexpected_secondary_labels(use_graph):
+    gc = GraphConnection()
+
+    # create a node which looks like a practice node but has additional labels
+
+    create_cypher = """
+    CREATE (tn1:PracticeNodeGC:TestNode {pp: "Foo"})
+    """
+
+    result = gc.evaluate_query_single(create_cypher)
+
+    match_cypher = """
+    MATCH (n:PracticeNodeGC)
+    RETURN n
+    """
+
+    # check we raise a warning
+
+    with pytest.warns(
+        UserWarning, match="Unexpected secondary labels returned: {'TestNode'}"
+    ):
+        result = gc.evaluate_query(match_cypher)
+
+    # we should still capture as records and nodes
+    assert len(result.records) == 1
+    assert len(result.nodes) == 1
+
+
+def test_evaluate_rel_only_query(use_graph):
+    """When we return just a relationship,
+    this can only be turned into a 'neontology relationship'
+    if the result also includes the source and target nodes.
+    """
+
+    source_node = PracticeNodeGC(pp="Source Node")
+    source_node.create()
+
+    target_node = PracticeNodeGC(pp="Target Node")
+    target_node.create()
+
+    br = PracticeRelationshipGC(source=source_node, target=target_node)
+
+    br.merge()
+
+    gc = GraphConnection()
+
+    cypher = """
+    MATCH (a)-[r]->(b)
+    RETURN r
+    """
+
+    with pytest.warns(
+        UserWarning,
+        match=r"PRACTICE_RELATIONSHIP_GC relationship type query did not include nodes.",
+    ):
+        result = gc.evaluate_query(cypher)
+
+    assert len(result.records) == 1
+    assert len(result.nodes) == 0
+    assert len(result.relationships) == 0
+
+    cypher2 = """
+    MATCH (a)-[r]->(b)
+    RETURN r, a
+    """
+
+    with pytest.warns(
+        UserWarning,
+        match=r"PRACTICE_RELATIONSHIP_GC relationship type query did not include nodes.",
+    ):
+        result2 = gc.evaluate_query(cypher2)
+
+    assert len(result2.records) == 1
+    assert len(result2.nodes) == 1
+    assert len(result2.relationships) == 0
+
+    cypher3 = """
+    MATCH (a)-[r]->(b)
+    RETURN r, b
+    """
+
+    with pytest.warns(
+        UserWarning,
+        match=r"PRACTICE_RELATIONSHIP_GC relationship type query did not include nodes.",
+    ):
+        result3 = gc.evaluate_query(cypher3)
+
+    assert len(result3.records) == 1
+    assert len(result3.nodes) == 1
+    assert len(result3.relationships) == 0
+
+    cypher4 = """
+    MATCH (a)-[r]->(b)
+    RETURN r, b, a
+    """
+
+    result4 = gc.evaluate_query(cypher4)
+
+    assert len(result4.records) == 1
+    assert len(result4.nodes) == 2
+    assert len(result4.relationships) == 1
diff --git a/tests/test_tools/test_import_files.py b/tests/test_tools/test_import_files.py
new file mode 100644
index 0000000..e5ef4d0
--- /dev/null
+++ b/tests/test_tools/test_import_files.py
@@ -0,0 +1,264 @@
+from typing import ClassVar
+
+import pytest
+
+from neontology import BaseNode
+from neontology.tools.import_files import import_json, import_md, import_yaml
+
+
+class ExampleFileImportNode(BaseNode):
+    __primarylabel__: ClassVar[str] = "ExampleFileImportLabel"
+    __primaryproperty__: ClassVar[str] = "name"
+
+    name: str
+    description: str
+
+
+def test_import_multiple_files(use_graph, tmp_path_factory):
+    # write some markdowns to a tmpdir and use dir_path import
+    dir_path = tmp_path_factory.mktemp("data")
+
+    md1 = """
+    ---
+    LABEL: "ExampleFileImportLabel"
+    BODY_PROPERTY: "description"
+    name: "My first node"
+    ---
+    This is my first node!
+    """
+
+    md1_path = dir_path / "md1.md"
+
+    with open(md1_path, "w") as md1_f:
+        md1_f.write(md1)
+
+    md2 = """
+    ---
+    LABEL: "ExampleFileImportLabel"
+    BODY_PROPERTY: "description"
+    name: "My second node"
+    ---
+    This is my second node!
+    """
+
+    md2_path = dir_path / "md2.md"
+
+    with open(md2_path, "w") as md2_f:
+        md2_f.write(md2)
+
+    import_md(dir_path)
+
+    cypher = """
+    MATCH (n:ExampleFileImportLabel)
+    RETURN COUNT(DISTINCT n)
+    """
+
+    result = use_graph.evaluate_query_single(cypher)
+
+    assert result == 2
+
+
+def test_import_single_file_md(use_graph, tmp_path_factory):
+    # write some markdowns to a tmpdir and use dir_path import
+    dir_path = tmp_path_factory.mktemp("data")
+
+    md1 = """
+    ---
+    LABEL: "ExampleFileImportLabel"
+    BODY_PROPERTY: "description"
+    name: "My first node"
+    ---
+    This is my first node!
+    """
+
+    md1_path = dir_path / "md1.md"
+
+    with open(md1_path, "w") as md1_f:
+        md1_f.write(md1)
+
+    import_md(md1_path)
+
+    cypher = """
+    MATCH (n:ExampleFileImportLabel)
+    RETURN COUNT(DISTINCT n)
+    """
+
+    result = use_graph.evaluate_query_single(cypher)
+
+    assert result == 1
+
+
+def test_import_single_file_no_label(use_graph, tmp_path_factory):
+    # write some markdowns to a tmpdir and use dir_path import
+    dir_path = tmp_path_factory.mktemp("data")
+
+    md1 = """
+    ---
+    BODY_PROPERTY: "description"
+    name: "My first node"
+    ---
+    This is my first node!
+    """
+
+    md1_path = dir_path / "md1.md"
+
+    with open(md1_path, "w") as md1_f:
+        md1_f.write(md1)
+
+    with pytest.raises(ValueError):
+        import_md(md1_path)
+
+
+def test_import_single_md_file_no_label(use_graph, tmp_path_factory):
+    # write some markdowns to a tmpdir and use dir_path import
+    dir_path = tmp_path_factory.mktemp("data")
+
+    md1 = """
+    ---
+    LABEL: "ExampleFileImportLabel"
+    name: "My first node"
+    ---
+    This is my first node!
+    """
+
+    md1_path = dir_path / "md1.md"
+
+    with open(md1_path, "w") as md1_f:
+        md1_f.write(md1)
+
+    with pytest.raises(KeyError):
+        import_md(md1_path)
+
+
+def test_import_multiple_files_json(use_graph, tmp_path_factory):
+    # write some markdowns to a tmpdir and use dir_path import
+    dir_path = tmp_path_factory.mktemp("data")
+
+    md1 = """{
+        "LABEL": "ExampleFileImportLabel",
+        "name": "My first node",
+        "description": "This is my first node!"
+    }"""
+
+    md1_path = dir_path / "md1.json"
+
+    with open(md1_path, "w") as md1_f:
+        md1_f.write(md1)
+
+    md2 = """{
+            "LABEL": "ExampleFileImportLabel",
+            "name": "My second node",
+            "description": "This is my second node!"
+        }"""
+
+    md2_path = dir_path / "md2.json"
+
+    with open(md2_path, "w") as md2_f:
+        md2_f.write(md2)
+
+    import_json(dir_path)
+
+    cypher = """
+    MATCH (n:ExampleFileImportLabel)
+    RETURN COUNT(DISTINCT n)
+    """
+
+    result = use_graph.evaluate_query_single(cypher)
+
+    assert result == 2
+
+
+def test_import_single_file_json(use_graph, tmp_path_factory):
+    # write some markdowns to a tmpdir and use dir_path import
+    dir_path = tmp_path_factory.mktemp("data")
+
+    md1 = """{
+        "LABEL": "ExampleFileImportLabel",
+        "name": "My first node",
+        "description": "This is my first node!"
+    }"""
+
+    md1_path = dir_path / "md1.json"
+
+    with open(md1_path, "w") as md1_f:
+        md1_f.write(md1)
+
+    import_json(md1_path)
+
+    cypher = """
+    MATCH (n:ExampleFileImportLabel)
+    RETURN COUNT(DISTINCT n)
+    """
+
+    result = use_graph.evaluate_query_single(cypher)
+
+    assert result == 1
+
+
+def test_import_multiple_files_yaml(use_graph, tmp_path_factory):
+    # write some markdowns to a tmpdir and use dir_path import
+    dir_path = tmp_path_factory.mktemp("data")
+
+    md1 = """
+    LABEL: "ExampleFileImportLabel"
+    name: "My first node"
+    description: "This is my first node!"
+    """
+
+    md1_path = dir_path / "md1.yaml"
+
+    with open(md1_path, "w") as md1_f:
+        md1_f.write(md1)
+
+    md2 = """
+    LABEL: "ExampleFileImportLabel"
+    name: "My second node"
+    description: "This is my second node!"
+    """
+
+    md2_path = dir_path / "md2.yaml"
+
+    with open(md2_path, "w") as md2_f:
+        md2_f.write(md2)
+
+    import_yaml(dir_path)
+
+    cypher = """
+    MATCH (n:ExampleFileImportLabel)
+    RETURN COUNT(DISTINCT n)
+    """
+
+    result = use_graph.evaluate_query_single(cypher)
+
+    assert result == 2
+
+
+def test_import_single_file_yaml(use_graph, tmp_path_factory):
+    # write some markdowns to a tmpdir and use dir_path import
+    dir_path = tmp_path_factory.mktemp("data")
+
+    md1 = """---
+LABEL: "ExampleFileImportLabel"
+name: "My first node"
+description: "This is my first node!"
+---
+LABEL: "ExampleFileImportLabel"
+name: "My second node"
+description: "This is my second node!"
+"""
+
+    md1_path = dir_path / "md1.yaml"
+
+    with open(md1_path, "w") as md1_f:
+        md1_f.write(md1)
+
+    import_yaml(md1_path)
+
+    cypher = """
+    MATCH (n:ExampleFileImportLabel)
+    RETURN COUNT(DISTINCT n)
+    """
+
+    result = use_graph.evaluate_query_single(cypher)
+
+    assert result == 2
diff --git a/tests/test_tools/test_import_records.py b/tests/test_tools/test_import_records.py
new file mode 100644
index 0000000..dc31052
--- /dev/null
+++ b/tests/test_tools/test_import_records.py
@@ -0,0 +1,204 @@
+from uuid import uuid4, UUID
+from typing import ClassVar
+import logging
+
+import pytest
+from pydantic import Field, ValidationError, field_serializer
+
+from neontology import BaseNode, BaseRelationship
+from neontology.tools.import_records import import_records
+
+logger = logging.getLogger(__name__)
+
+
+class PersonImportNode(BaseNode):
+    __primarylabel__: ClassVar[str] = "PersonImportLabel"
+    __primaryproperty__: ClassVar[str] = "name"
+
+    name: str
+    age: int
+
+    import_id: UUID = Field(default_factory=uuid4)
+
+    @field_serializer("import_id")
+    def serialize_to_str(self, v: UUID):
+        return str(v)
+
+
+class FollowsImportRel(BaseRelationship):
+    __relationshiptype__: ClassVar[str] = "IMPORT_FOLLOWS"
+
+    source: PersonImportNode
+    target: PersonImportNode
+
+    import_follows_prop_1: str
+
+
+records_raw = {
+    "nodes": [
+        {
+            "LABEL": "PersonImportLabel",
+            "name": "Bob",
+            "age": 84,
+            "import_id": UUID("154b0bb7-33ce-4c5b-ba6a-44b5b09b0067"),
+        },
+        {
+            "LABEL": "PersonImportLabel",
+            "name": "Alice",
+            "age": 76,
+            "import_id": UUID("78fcd953-1d0d-47ae-871c-670a0a7a7406"),
+        },
+    ],
+    "edges": [
+        {
+            "source": "Bob",
+            "target": "Alice",
+            "import_follows_prop_1": "TEST IMPORT FOLLOWS PROPERTY VALUE",
+            "SOURCE_LABEL": "PersonImportLabel",
+            "TARGET_LABEL": "PersonImportLabel",
+            "RELATIONSHIP_TYPE": "IMPORT_FOLLOWS",
+        }
+    ],
+}
+
+
+def test_export_import(use_graph):
+    archy = PersonImportNode(name="archy", age=55)
+    betty = PersonImportNode(name="betty", age=66)
+    bobalicerel = FollowsImportRel(
+        source=archy, target=betty, import_follows_prop_1="testing"
+    )
+
+    import_data = {
+        "nodes": [archy.neontology_dump(), betty.neontology_dump()],
+        "edges": [bobalicerel.neontology_dump()],
+    }
+
+    import_records([import_data])
+
+    assert len(PersonImportNode.match_nodes()) == 2
+    assert len(FollowsImportRel.match_relationships()) == 1
+
+
+def test_dump_and_import(use_graph):
+    archy = PersonImportNode(name="archy", age=55)
+    archy.merge()
+    betty = PersonImportNode(name="betty", age=66)
+    betty.merge()
+    bobalicerel = FollowsImportRel(
+        source=archy, target=betty, import_follows_prop_1="testing"
+    )
+    bobalicerel.merge()
+
+    assert len(PersonImportNode.match_nodes()) == 2
+    assert len(FollowsImportRel.match_relationships()) == 1
+
+    results = use_graph.evaluate_query("MATCH (n)-[r]->(o) RETURN *")
+
+    import_data = results.neontology_dump()
+
+    use_graph.evaluate_query_single("MATCH (n) DETACH DELETE n")
+
+    assert len(PersonImportNode.match_nodes()) == 0
+    assert len(FollowsImportRel.match_relationships()) == 0
+
+    import_records([import_data])
+
+    assert len(PersonImportNode.match_nodes()) == 2
+    assert len(FollowsImportRel.match_relationships()) == 1
+
+
+def test_import_records_basic_link_data(use_graph):
+    import_records([records_raw], error_on_unmatched=True)
+
+    assert len(PersonImportNode.match_nodes()) == 2
+    assert len(FollowsImportRel.match_relationships()) == 1
+
+
+def test_import_records_sub_record_target_nodes(use_graph):
+    with_sub_records = {
+        "LABEL": "PersonImportLabel",
+        "name": "Bob",
+        "age": 84,
+        "import_id": UUID("154b0bb7-33ce-4c5b-ba6a-44b5b09b0067"),
+        "RELATIONSHIPS_OUT": [
+            {
+                "TARGET_NODES": [
+                    {
+                        "LABEL": "PersonImportLabel",
+                        "name": "Alice",
+                        "age": 76,
+                        "import_id": UUID("78fcd953-1d0d-47ae-871c-670a0a7a7406"),
+                    },
+                ],
+                "RELATIONSHIP_TYPE": "IMPORT_FOLLOWS",
+                "TARGET_LABEL": "PersonImportLabel",
+                "import_follows_prop_1": "TEST IMPORT FOLLOWS PROPERTY VALUE 2",
+            }
+        ],
+    }
+
+    import_records([with_sub_records], error_on_unmatched=True)
+
+    assert len(PersonImportNode.match_nodes()) == 2
+    assert len(FollowsImportRel.match_relationships()) == 1
+
+
+def test_import_records_sub_record_targets(use_graph):
+    with_sub_records = [
+        {
+            "LABEL": "PersonImportLabel",
+            "name": "Bob",
+            "age": 84,
+            "import_id": UUID("154b0bb7-33ce-4c5b-ba6a-44b5b09b0067"),
+            "RELATIONSHIPS_OUT": [
+                {
+                    "TARGETS": ["Alice"],
+                    "RELATIONSHIP_TYPE": "IMPORT_FOLLOWS",
+                    "TARGET_LABEL": "PersonImportLabel",
+                    "import_follows_prop_1": "TEST IMPORT FOLLOWS PROPERTY VALUE 3",
+                }
+            ],
+        },
+        {
+            "LABEL": "PersonImportLabel",
+            "name": "Alice",
+            "age": 76,
+            "import_id": UUID("78fcd953-1d0d-47ae-871c-670a0a7a7406"),
+        },
+    ]
+
+    import_records([with_sub_records], error_on_unmatched=True)
+
+    assert len(PersonImportNode.match_nodes()) == 2
+    assert len(FollowsImportRel.match_relationships()) == 1
+
+
+def test_import_records_bad_node(use_graph):
+    bad_records = [
+        {
+            "LABELED": "PersonImportLabel",
+            "name": "Alice",
+            "age": 76,
+            "import_id": UUID("78fcd953-1d0d-47ae-871c-670a0a7a7406"),
+        },
+    ]
+
+    with pytest.raises(ValueError):
+        import_records(bad_records, error_on_unmatched=True)
+
+
+def test_import_records_bad_node_validate_only(use_graph):
+    # doesn't have a TARGET_LABEL
+    bad_records = [
+        {
+            "source": "Bob",
+            "target": "Alice",
+            "import_follows_prop_1": "TEST IMPORT FOLLOWS PROPERTY VALUE",
+            "SOURCE_LABEL": "PersonImportLabel",
+            "RELATIONSHIP_TYPE": "IMPORT_FOLLOWS",
+        }
+    ]
+
+    with pytest.raises(ValidationError):
+        import_records(bad_records, validate_only=True, error_on_unmatched=True)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index c6a1dbd..20f0bb2 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,7 +1,13 @@
 from typing import ClassVar, Optional
 
-from neontology.utils import get_rels_by_source, get_node_types
-
+from neontology.utils import (
+    get_rels_by_source,
+    get_node_types,
+    apply_neo4j_constraints,
+    auto_constrain_neo4j,
+    get_rels_by_type,
+)
+from neontology import GraphConnection
 from neontology.basenode import BaseNode
 from neontology.baserelationship import BaseRelationship
 
@@ -24,8 +30,39 @@ class MyRelType(BaseRelationship):
     assert rels_by_source["MyNodeType"] == {"MY_REL_TYPE"}
 
 
+def test_get_rels_by_type_subclasses():
+    class MyNodeType1(BaseNode):
+        __primarylabel__: ClassVar[Optional[str]] = "MyNodeType1"
+        __primaryproperty__: ClassVar[str] = "pp"
+        pp: str
+
+    class MyAbstractRelType(BaseRelationship):
+        source: MyNodeType1
+        target: MyNodeType1
+
+    class MyRelType1(MyAbstractRelType):
+        __relationshiptype__: ClassVar[Optional[str]] = "MY_REL_TYPE1"
+
+    class MyRelType2(MyAbstractRelType):
+        __relationshiptype__: ClassVar[Optional[str]] = "MY_REL_TYPE2"
+
+    class MyRelType3(BaseRelationship):
+        source: MyNodeType1
+        target: MyNodeType1
+        __relationshiptype__: ClassVar[Optional[str]] = "MY_REL_TYPE_3"
+
+    rels_by_type = get_rels_by_type(MyAbstractRelType)
+
+    assert set(rels_by_type.keys()) == {"MY_REL_TYPE1", "MY_REL_TYPE2"}
+
+    assert (
+        rels_by_type["MY_REL_TYPE1"].all_source_classes[0].__primarylabel__
+        == "MyNodeType1"
+    )
+
+
 def test_no_primary_label_get_node_types():
-    class SpecialPracticeNode(BaseNode):
+    class SpecialPracticeLabelNode(BaseNode):
         __primaryproperty__: ClassVar[str] = "pp"
         pp: str
 
@@ -33,4 +70,67 @@ class SpecialPracticeNode(BaseNode):
 
     assert isinstance(node_types, dict)
 
-    assert "SpecialPracticeNode" not in node_types.keys()
+    assert "SpecialPracticeLabelNode" not in node_types.keys()
+
+
+class SpecialPracticeNodeAC(BaseNode):
+    __primaryproperty__: ClassVar[str] = "pp"
+    __primarylabel__: ClassVar[str] = "SpecialPracticeNodeAC"
+    pp: str
+
+
+def test_auto_constrain_neo4j(use_graph):
+    gc = GraphConnection()
+
+    # make sure we start with no constraints
+
+    try:
+        constraints = gc.engine.get_constraints()
+
+        for constraint_name in constraints:
+            gc.engine.drop_constraint(constraint_name)
+
+        result = gc.engine.get_constraints()
+
+        assert len(result) == 0
+
+        auto_constrain_neo4j()
+
+        result2 = gc.engine.get_constraints()
+
+        assert len(result2) >= 2
+
+    # not all graph engines do constraints
+    except NotImplementedError:
+        pass
+
+
+def test_apply_neo4j_constraints(use_graph):
+    class SpecialPracticeNode(BaseNode):
+        __primaryproperty__: ClassVar[str] = "pp"
+        __primarylabel__: ClassVar[str] = "SpecialPracticeNode"
+        pp: str
+
+    gc = GraphConnection()
+
+    try:
+        # make sure we start with no constraints
+
+        constraints = gc.engine.get_constraints()
+
+        for constraint_name in constraints:
+            gc.engine.drop_constraint(constraint_name)
+
+        result = gc.engine.get_constraints()
+
+        assert len(result) == 0
+
+        apply_neo4j_constraints([SpecialPracticeNode])
+
+        result2 = gc.engine.get_constraints()
+
+        assert len(result2) == 1
+
+    # not all graph engines do constraints
+    except NotImplementedError:
+        pass