#!/usr/bin/env python
# -*- coding: utf-8 -*-

# Copyright 2011-2014, Nigel Small
# 
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# 
#     http://www.apache.org/licenses/LICENSE-2.0
# 
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

""" The neo4j module provides the main `Neo4j <http://neo4j.org/>`_ client
functionality and will be the starting point for most applications. The main
classes provided are:

- :py:class:`GraphDatabaseService` - an instance of a Neo4j database server,
  providing a number of graph-global methods for handling nodes and
  relationships
- :py:class:`Node` - a representation of a database node
- :py:class:`Relationship` - a representation of a relationship between two
  database nodes
- :py:class:`Path` - a sequence of alternating nodes and relationships
- :py:class:`Index` - a index of key-value pairs for storing links to nodes or
  relationships
- :py:class:`ReadBatch` - a batch of read requests to be carried out within a
  single transaction
- :py:class:`WriteBatch` - a batch of write requests to be carried out within
  a single transaction
"""


from __future__ import division, unicode_literals

from collections import namedtuple
from datetime import datetime
import base64
import json
import logging
import re

from .packages.httpstream import (http,
                                  Resource as _Resource,
                                  ResourceTemplate as _ResourceTemplate,
                                  ClientError as _ClientError,
                                  ServerError as _ServerError)
from .packages.jsonstream import assembled, grouped
from .packages.httpstream.numbers import CREATED, NOT_FOUND, CONFLICT
from .packages.urimagic import (Authority, URI, URITemplate,
                                Query, percent_encode)

from . import __version__
from .exceptions import *
from .util import *


DEFAULT_SCHEME = "http"
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 7474
DEFAULT_NETLOC = "{0}:{1}".format(DEFAULT_HOST, DEFAULT_PORT)
DEFAULT_URI = "{0}://{1}".format(DEFAULT_SCHEME, DEFAULT_NETLOC)

PRODUCT = ("py2neo", __version__)

NON_ALPHA_NUM = re.compile("[^0-9A-Za-z_]")
SIMPLE_NAME = re.compile(r"[A-Za-z_][0-9A-Za-z_]*")

http.default_encoding = "UTF-8"

batch_log = logging.getLogger(__name__ + ".batch")
cypher_log = logging.getLogger(__name__ + ".cypher")

_headers = {
    None: [("X-Stream", "true")]
}

_http_rewrites = {}


def _add_header(key, value, host_port=None):
    """ Add an HTTP header to be sent with all requests if no `host_port`
    is provided or only to those matching the value supplied otherwise.
    """
    if host_port in _headers:
        _headers[host_port].append((key, value))
    else:
        _headers[host_port] = [(key, value)]


def _get_headers(host_port):
    """Fetch all HTTP headers relevant to the `host_port` provided.
    """
    uri_headers = {}
    for n, headers in _headers.items():
        if n is None or n == host_port:
            uri_headers.update(headers)
    return uri_headers


def authenticate(host_port, user_name, password):
    """ Set HTTP basic authentication values for specified `host_port`. The
    code below shows a simple example::

        # set up authentication parameters
        neo4j.authenticate("camelot:7474", "arthur", "excalibur")

        # connect to authenticated graph database
        graph_db = neo4j.GraphDatabaseService("http://camelot:7474/db/data/")

    Note: a `netloc` can be either a server name or a server name and port
    number but must match exactly that used within the GraphDatabaseService
    URI.

    :param host_port: the host and optional port requiring authentication
        (e.g. "bigserver", "camelot:7474")
    :param user_name: the user name to authenticate as
    :param password: the password
    """
    credentials = (user_name + ":" + password).encode("UTF-8")
    value = "Basic " + base64.b64encode(credentials).decode("ASCII")
    _add_header("Authorization", value, host_port=host_port)


def familiar(*resources):
    """ Return :py:const:`True` if all resources share a common service root.

    :param resources:
    :return:
    """
    if len(resources) < 2:
        return True
    return all(_.service_root == resources[0].service_root for _ in resources)


def rewrite(from_scheme_host_port, to_scheme_host_port):
    """ Automatically rewrite all URIs directed to the scheme, host and port
    specified in `from_scheme_host_port` to that specified in
    `to_scheme_host_port`.

    As an example::

        # implicitly convert all URIs beginning with <http://localhost:7474>
        # to instead use <https://dbserver:9999>
        neo4j.rewrite(("http", "localhost", 7474), ("https", "dbserver", 9999))

    If `to_scheme_host_port` is :py:const:`None` then any rewrite rule for
    `from_scheme_host_port` is removed.

    This facility is primarily intended for use by database servers behind
    proxies which are unaware of their externally visible network address.
    """
    global _http_rewrites
    if to_scheme_host_port is None:
        try:
            del _http_rewrites[from_scheme_host_port]
        except KeyError:
            pass
    else:
        _http_rewrites[from_scheme_host_port] = to_scheme_host_port


def _hydrated(data):
    """ Takes input iterable, assembles and resolves any Resource objects,
    returning the result.
    """
    if isinstance(data, dict):
        for cls in (Relationship, Node, Path):
            if has_all(data, cls.signature):
                return cls._hydrated(data)
        else:
            raise ValueError("Cannot determine object type", data)
    elif is_collection(data):
        return type(data)([_hydrated(datum) for datum in data])
    else:
        return data


def _node(*args, **kwargs):
    """ Cast the arguments provided to a :py:class:`neo4j.Node`. The following
    general combinations are possible:

    - ``node()``
    - ``node(node_instance)``
    - ``node(property_dict)``
    - ``node(**properties)``

    If :py:const:`None` is passed as the only argument, :py:const:`None` is
    returned instead of a ``Node`` instance.

    Examples::

        node()
        node(Node("http://localhost:7474/db/data/node/1"))
        node({"name": "Alice"})
        node(name="Alice")

    Other representations::

        {"name": "Alice"}

    """
    if len(args) == 0:
        return Node.abstract(**kwargs)
    elif len(args) == 1 and not kwargs:
        arg = args[0]
        if arg is None:
            return None
        elif isinstance(arg, Node):
            return arg
        elif isinstance(arg, dict):
            return Node.abstract(**arg)
        else:
            raise TypeError("Cannot cast node from {0}".format(arg))
    else:
        raise TypeError("Cannot cast node from {0}".format((args, kwargs)))


def _rel(*args, **kwargs):
    """ Cast the arguments provided to a :py:class:`neo4j.Relationship`. The
    following general combinations are possible:

    - ``rel(relationship_instance)``
    - ``rel((start_node, type, end_node))``
    - ``rel((start_node, type, end_node, properties))``
    - ``rel((start_node, (type, properties), end_node))``
    - ``rel(start_node, (type, properties), end_node)``
    - ``rel(start_node, type, end_node, properties)``
    - ``rel(start_node, type, end_node, **properties)``

    Examples::

        rel(Relationship("http://localhost:7474/db/data/relationship/1"))
        rel((alice, "KNOWS", bob))
        rel((alice, "KNOWS", bob, {"since": 1999}))
        rel((alice, ("KNOWS", {"since": 1999}), bob))
        rel(alice, ("KNOWS", {"since": 1999}), bob)
        rel(alice, "KNOWS", bob, {"since": 1999})
        rel(alice, "KNOWS", bob, since=1999)

    Other representations::

        (alice, "KNOWS", bob)
        (alice, "KNOWS", bob, {"since": 1999})
        (alice, ("KNOWS", {"since": 1999}), bob)

    """
    if len(args) == 1 and not kwargs:
        arg = args[0]
        if isinstance(arg, Relationship):
            return arg
        elif isinstance(arg, tuple):
            if len(arg) == 3:
                return _UnboundRelationship.cast(arg[1]).bind(arg[0], arg[2])
            elif len(arg) == 4:
                return Relationship.abstract(arg[0], arg[1], arg[2], **arg[3])
            else:
                raise TypeError("Cannot cast relationship from {0}".format(arg))
        else:
            raise TypeError("Cannot cast relationship from {0}".format(arg))
    elif len(args) == 3:
        rel = _UnboundRelationship.cast(args[1])
        rel._properties.update(kwargs)
        return rel.bind(args[0], args[2])
    elif len(args) == 4:
        props = args[3]
        props.update(kwargs)
        return Relationship.abstract(*args[0:3], **props)
    else:
        raise TypeError("Cannot cast relationship from {0}".format((args, kwargs)))


class Resource(object):
    """ Basic RESTful web resource with JSON metadata. Wraps an
    `httpstream.Resource`.
    """

    def __init__(self, uri):
        uri = URI(uri)
        scheme_host_port = (uri.scheme, uri.host, uri.port)
        if scheme_host_port in _http_rewrites:
            scheme_host_port = _http_rewrites[scheme_host_port]
            # This is fine - it's all my code anyway...
            uri._URI__set_scheme(scheme_host_port[0])
            uri._URI__set_authority("{0}:{1}".format(scheme_host_port[1],
                                                     scheme_host_port[2]))
        if uri.user_info:
            authenticate(uri.host_port, *uri.user_info.partition(":")[0::2])
        self._resource = _Resource(uri)
        self._metadata = None
        self._subresources = {}
        self._headers = _get_headers(self.__uri__.host_port)
        self._product = PRODUCT

    def __repr__(self):
        """ Return a valid Python representation of this object.
        """
        return repr(self._resource)

    def __eq__(self, other):
        """ Determine equality of two objects based on URI.
        """
        return self._resource == other._resource

    def __ne__(self, other):
        """ Determine inequality of two objects based on URI.
        """
        return self._resource != other._resource

    @property
    def __uri__(self):
        return self._resource.__uri__

    @property
    def __metadata__(self):
        if not self._metadata:
            self.refresh()
        return self._metadata

    @property
    def is_abstract(self):
        """ Indicates whether this entity is abstract (i.e. not bound
        to a concrete entity within the database)
        """
        return not bool(self.__uri__)

    @property
    def service_root(self):
        return ServiceRoot.get_instance(URI(self._resource).resolve("/"))

    @property
    def graph_db(self):
        return self.service_root.graph_db

    def refresh(self):
        """ Refresh resource metadata.
        """
        if not self.is_abstract:
            self._metadata = ResourceMetadata(self._get().content)

    def _get(self):
        try:
            return self._resource.get(headers=self._headers,
                                      product=self._product)
        except _ClientError as e:
            raise ClientError(e)
        except _ServerError as e:
            raise ServerError(e)

    def _put(self, body=None):
        try:
            return self._resource.put(body=body,
                                      headers=self._headers,
                                      product=self._product)
        except _ClientError as e:
            raise ClientError(e)
        except _ServerError as e:
            raise ServerError(e)

    def _post(self, body=None):
        try:
            return self._resource.post(body=body,
                                       headers=self._headers,
                                       product=self._product)
        except _ClientError as e:
            raise ClientError(e)
        except _ServerError as e:
            raise ServerError(e)

    def _delete(self):
        try:
            return self._resource.delete(headers=self._headers,
                                         product=self._product)
        except _ClientError as e:
            raise ClientError(e)
        except _ServerError as e:
            raise ServerError(e)

    def _subresource(self, key, cls=None):
        if key not in self._subresources:
            try:
                uri = URI(self.__metadata__[key])
            except KeyError:
                raise KeyError("Key {0} not found in resource "
                               "metadata".format(repr(key)), self.__metadata__)
            if not cls:
                cls = Resource
            self._subresources[key] = cls(uri)
        return self._subresources[key]


class ResourceMetadata(object):

    def __init__(self, metadata):
        self._metadata = dict(metadata)

    def __contains__(self, key):
        return key in self._metadata

    def __getitem__(self, key):
        return self._metadata[key]

    def __iter__(self):
        return iter(self._metadata.items())


class ResourceTemplate(_ResourceTemplate):

    def expand(self, **values):
        return Resource(_ResourceTemplate.expand(self, **values).uri)


class Cacheable(object):

    _instances = {}

    @classmethod
    def get_instance(cls, uri):
        """ Fetch a cached instance if one is available, otherwise create,
        cache and return a new instance.

        :param uri: URI of the cached resource
        :return: a resource instance
        """
        if uri not in cls._instances:
            cls._instances[uri] = cls(uri)
        return cls._instances[uri]


class ServiceRoot(Cacheable, Resource):
    """ Neo4j REST API service root resource.
    """

    def __init__(self, uri=None):
        Resource.__init__(self, uri or DEFAULT_URI)
        self._load2neo = None
        self._load2neo_checked = False

    @property
    def graph_db(self):
        return GraphDatabaseService.get_instance(self.__metadata__["data"])

    @property
    def load2neo(self):
        if not self._load2neo_checked:
            self._load2neo = Resource(URI(self).resolve("/load2neo"))
            try:
                self._load2neo.refresh()
            except ClientError:
                self._load2neo = None
            finally:
                self._load2neo_checked = True
        if self._load2neo is None:
            raise NotImplementedError("Load2neo extension not available")
        else:
            return self._load2neo

    @property
    def monitor(self):
        manager = Resource(self.__metadata__["management"])
        return Monitor(manager.__metadata__["services"]["monitor"])


class Monitor(Cacheable, Resource):

    def __init__(self, uri=None):
        if uri is None:
            uri = ServiceRoot().monitor.__uri__
        Resource.__init__(self, uri)

    def fetch_latest_stats(self):
        """ Fetch the latest server statistics as a list of 2-tuples, each
        holding a `datetime` object and a named tuple of node, relationship and
        property counts.
        """
        counts = namedtuple("Stats", ("node_count",
                                      "relationship_count",
                                      "property_count"))
        uri = self.__metadata__["resources"]["latest_data"]
        latest_data = Resource(uri)._get().content
        timestamps = latest_data["timestamps"]
        data = latest_data["data"]
        data = zip(
            (datetime.fromtimestamp(t) for t in timestamps),
            (counts(*x) for x in zip(
                (numberise(n) for n in data["node_count"]),
                (numberise(n) for n in data["relationship_count"]),
                (numberise(n) for n in data["property_count"]),
            )),
        )
        return data


class GraphDatabaseService(Cacheable, Resource):
    """ An instance of a `Neo4j <http://neo4j.org/>`_ database identified by
    its base URI. Generally speaking, this is the only URI which a system
    attaching to this service should need to be directly aware of; all further
    entity URIs will be discovered automatically from within response content
    when possible (see `Hypermedia <http://en.wikipedia.org/wiki/Hypermedia>`_)
    or will be derived from existing URIs.

    The following code illustrates how to connect to a database server and
    display its version number::

        from py2neo import neo4j
        
        graph_db = neo4j.GraphDatabaseService()
        print(graph_db.neo4j_version)

    :param uri: the base URI of the database (defaults to <http://localhost:7474/db/data/>)
    """

    def __init__(self, uri=None):
        if uri is None:
            uri = ServiceRoot().graph_db.__uri__
        Resource.__init__(self, uri)
        self._indexes = {Node: {}, Relationship: {}}

    def __len__(self):
        """ Return the size of this graph (i.e. the number of relationships).
        """
        return self.size

    @property
    def _load2neo(self):
        return self.service_root.load2neo

    def clear(self):
        """ Clear all nodes and relationships from the graph.

        .. warning::
            This method will permanently remove **all** nodes and relationships
            from the graph and cannot be undone.
        """
        batch = WriteBatch(self)
        batch.append_cypher("START r=rel(*) DELETE r")
        batch.append_cypher("START n=node(*) DELETE n")
        batch.run()

    def create(self, *abstracts):
        """ Create multiple nodes and/or relationships as part of a single
        batch.

        The abstracts provided may use any accepted notation, as described in
        the section on py2neo fundamentals.
        For a node, simply pass a dictionary of properties; for a relationship, pass a tuple of
        (start, type, end) or (start, type, end, data) where start and end
        may be :py:class:`Node` instances or zero-based integral references
        to other node entities within this batch::

            # create a single node
            alice, = graph_db.create({"name": "Alice"})

            # create multiple nodes
            people = graph_db.create(
                {"name": "Alice", "age": 33}, {"name": "Bob", "age": 44},
                {"name": "Carol", "age": 55}, {"name": "Dave", "age": 66},
            )

            # create two nodes with a connecting relationship
            alice, bob, rel = graph_db.create(
                {"name": "Alice"}, {"name": "Bob"},
                (0, "KNOWS", 1, {"since": 2006})
            )

            # create a node plus a relationship to pre-existing node
            ref_node = graph_db.get_reference_node()
            alice, rel = graph_db.create(
                {"name": "Alice"}, (ref_node, "PERSON", 0)
            )

        :return: list of :py:class:`Node` and/or :py:class:`Relationship`
            instances

        .. warning::
            This method will *always* return a list, even when only creating
            a single node or relationship. To automatically unpack a list
            containing a single item, append a trailing comma to the variable
            name on the left of the assignment operation.

        """
        if not abstracts:
            return []
        batch = WriteBatch(self)
        for abstract in abstracts:
            batch.create(abstract)
        return batch.submit()

    def delete(self, *entities):
        """ Delete multiple nodes and/or relationships as part of a single
        batch.
        """
        if not entities:
            return
        batch = WriteBatch(self)
        for entity in entities:
            if entity is not None:
                batch.delete(entity)
        batch.run()

    def find(self, label, property_key=None, property_value=None):
        """ Iterate through a set of labelled nodes, optionally filtering
        by property key and value
        """
        uri = URI(self).resolve("/".join(["label", label, "nodes"]))
        if property_key:
            uri = uri.resolve("?" + Query.encode({property_key: json.dumps(property_value, ensure_ascii=False)}))
        try:
            for i, result in grouped(Resource(uri)._get()):
                yield _hydrated(assembled(result))
        except ClientError as err:
            if err.status_code != NOT_FOUND:
                raise

    def get_properties(self, *entities):
        """ Fetch properties for multiple nodes and/or relationships as part
        of a single batch; returns a list of dictionaries in the same order
        as the supplied entities.
        """
        if not entities:
            return []
        if len(entities) == 1:
            return [entities[0].get_properties()]
        batch = BatchRequestList(self)
        for entity in entities:
            batch.append_get(batch._uri_for(entity, "properties"))
        responses = batch._execute()
        try:
            return [BatchResponse(rs, raw=True).body or {}
                    for rs in responses.json]
        finally:
            responses.close()

    def load_geoff(self, geoff):
        """ Load Geoff data via the load2neo extension.

        ::

            >>> from py2neo import neo4j
            >>> graph_db = neo4j.GraphDatabaseService()
            >>> graph_db.load_geoff("(alice)<-[:KNOWS]->(bob)")
            [{u'alice': Node('http://localhost:7474/db/data/node/1'),
              u'bob': Node('http://localhost:7474/db/data/node/2')}]

        :param geoff: geoff data to load
        :return: list of node mappings
        """
        loader = Resource(self._load2neo.__metadata__["geoff_loader"])
        return [
            dict((key, self.node(value)) for key, value in line[0].items())
            for line in loader._post(geoff).tsj
        ]

    @property
    def load2neo_version(self):
        """ The load2neo extension version, if available.
        """
        return version_tuple(self._load2neo.__metadata__["load2neo_version"])

    def match(self, start_node=None, rel_type=None, end_node=None,
              bidirectional=False, limit=None):
        """ Iterate through all relationships matching specified criteria.

        Examples are as follows::

            # all relationships from the graph database
            # ()-[r]-()
            rels = list(graph_db.match())

            # all relationships outgoing from `alice`
            # (alice)-[r]->()
            rels = list(graph_db.match(start_node=alice))

            # all relationships incoming to `alice`
            # ()-[r]->(alice)
            rels = list(graph_db.match(end_node=alice))

            # all relationships attached to `alice`, regardless of direction
            # (alice)-[r]-()
            rels = list(graph_db.match(start_node=alice, bidirectional=True))

            # all relationships from `alice` to `bob`
            # (alice)-[r]->(bob)
            rels = list(graph_db.match(start_node=alice, end_node=bob))

            # all relationships outgoing from `alice` of type "FRIEND"
            # (alice)-[r:FRIEND]->()
            rels = list(graph_db.match(start_node=alice, rel_type="FRIEND"))

            # up to three relationships outgoing from `alice` of type "FRIEND"
            # (alice)-[r:FRIEND]->()
            rels = list(graph_db.match(start_node=alice, rel_type="FRIEND", limit=3))

        :param start_node: concrete start :py:class:`Node` to match or
            :py:const:`None` if any
        :param rel_type: type of relationships to match or :py:const:`None` if
            any
        :param end_node: concrete end :py:class:`Node` to match or
            :py:const:`None` if any
        :param bidirectional: :py:const:`True` if reversed relationships should
            also be included
        :param limit: maximum number of relationships to match or
            :py:const:`None` if no limit
        :return: matching relationships
        :rtype: generator
        """
        if start_node is None and end_node is None:
            query = "START a=node(*)"
            params = {}
        elif end_node is None:
            query = "START a=node({A})"
            start_node = _cast(start_node, Node, abstract=False)
            params = {"A": start_node._id}
        elif start_node is None:
            query = "START b=node({B})"
            end_node = _cast(end_node, Node, abstract=False)
            params = {"B": end_node._id}
        else:
            query = "START a=node({A}),b=node({B})"
            start_node = _cast(start_node, Node, abstract=False)
            end_node = _cast(end_node, Node, abstract=False)
            params = {"A": start_node._id, "B": end_node._id}
        if rel_type is None:
            rel_clause = ""
        elif is_collection(rel_type):
            if self.neo4j_version >= (2, 0, 0):
                # yuk, version sniffing :-(
                separator = "|:"
            else:
                separator = "|"
            rel_clause = ":" + separator.join("`{0}`".format(_)
                                              for _ in rel_type)
        else:
            rel_clause = ":`{0}`".format(rel_type)
        if bidirectional:
            query += " MATCH (a)-[r" + rel_clause + "]-(b) RETURN r"
        else:
            query += " MATCH (a)-[r" + rel_clause + "]->(b) RETURN r"
        if limit is not None:
            query += " LIMIT {0}".format(int(limit))
        results = CypherQuery(self, query).stream(**params)
        try:
            for result in results:
                yield result[0]
        finally:
            results.close()

    def match_one(self, start_node=None, rel_type=None, end_node=None,
                  bidirectional=False):
        """ Fetch a single relationship matching specified criteria.

        :param start_node: concrete start :py:class:`Node` to match or
            :py:const:`None` if any
        :param rel_type: type of relationships to match or :py:const:`None` if
            any
        :param end_node: concrete end :py:class:`Node` to match or
            :py:const:`None` if any
        :param bidirectional: :py:const:`True` if reversed relationships should
            also be included
        :return: a matching :py:class:`Relationship` or :py:const:`None`

        .. seealso::
           :py:func:`GraphDatabaseService.match <py2neo.neo4j.GraphDatabaseService.match>`
        """
        rels = list(self.match(start_node, rel_type, end_node,
                               bidirectional, 1))
        if rels:
            return rels[0]
        else:
            return None

    @property
    def neo4j_version(self):
        """ The database software version as a 4-tuple of (``int``, ``int``,
        ``int``, ``str``).
        """
        return version_tuple(self.__metadata__["neo4j_version"])

    def node(self, id_):
        """ Fetch a node by ID.
        """
        return Node(URI(self).resolve("node/" + str(id_)))

    @property
    def node_labels(self):
        """ The set of node labels currently defined within the graph.
        """
        resource = Resource(URI(self).resolve("labels"))
        try:
            return set(_hydrated(assembled(resource._get())))
        except ClientError as err:
            if err.status_code == NOT_FOUND:
                raise NotImplementedError("Node labels not available for this "
                                          "Neo4j server version")
            else:
                raise

    @property
    def order(self):
        """ The number of nodes in this graph.
        """
        return CypherQuery(self, "START n=node(*) "
                                 "RETURN count(n)").execute_one()

    def relationship(self, id_):
        """ Fetch a relationship by ID.
        """
        return Relationship(URI(self).resolve("relationship/" + str(id_)))

    @property
    def relationship_types(self):
        """ The set of relationship types currently defined within the graph.
        """
        resource = self._subresource("relationship_types")
        return set(_hydrated(assembled(resource._get())))

    @property
    def schema(self):
        """ The Schema resource for this graph.

        .. seealso::
            :py:func:`Schema <py2neo.neo4j.Schema>`
        """
        return Schema.get_instance(URI(self).resolve("schema"))

    @property
    def size(self):
        """ The number of relationships in this graph.
        """
        return CypherQuery(self, "START r=rel(*) "
                                 "RETURN count(r)").execute_one()

    @property
    def supports_foreach_pipe(self):
        """ Indicates whether the server supports pipe syntax for FOREACH.
        """
        return self.neo4j_version >= (2, 0)

    @property
    def supports_index_uniqueness_modes(self):
        """ Indicates whether the server supports `get_or_create` and
        `create_or_fail` uniqueness modes on batched index methods.
        """
        return self.neo4j_version >= (1, 9)

    @property
    def supports_node_labels(self):
        """ Indicates whether the server supports node labels.
        """
        return self.neo4j_version >= (2, 0)

    @property
    def supports_optional_match(self):
        """ Indicates whether the server supports Cypher OPTIONAL MATCH
        clauses.
        """
        return self.neo4j_version >= (2, 0)

    @property
    def supports_schema_indexes(self):
        """ Indicates whether the server supports schema indexes.
        """
        return self.neo4j_version >= (2, 0)

    @property
    def supports_cypher_transactions(self):
        """ Indicates whether the server supports explicit Cypher transactions.
        """
        return "transaction" in self.__metadata__

    def _index_manager(self, content_type):
        """ Fetch the index management resource for the given `content_type`.

        :param content_type:
        :return:
        """
        if content_type is Node:
            uri = self.__metadata__["node_index"]
        elif content_type is Relationship:
            uri = self.__metadata__["relationship_index"]
        else:
            raise IndexTypeError(content_type.__class__.__name__)
        return Resource(uri)

    def get_indexes(self, content_type):
        """ Fetch a dictionary of all available indexes of a given type.

        :param content_type: either :py:class:`neo4j.Node` or
            :py:class:`neo4j.Relationship`
        :return: a list of :py:class:`Index` instances of the specified type
        """
        index_manager = self._index_manager(content_type)
        index_index = index_manager._get().content
        if index_index:
            self._indexes[content_type] = dict(
                (key, Index(content_type, value["template"]))
                for key, value in index_index.items()
            )
        else:
            self._indexes[content_type] = {}
        return self._indexes[content_type]

    def get_index(self, content_type, index_name):
        """ Fetch a specific index from the current database, returning an
        :py:class:`Index` instance. If an index with the supplied `name` and
        content `type` does not exist, :py:const:`None` is returned.

        :param content_type: either :py:class:`neo4j.Node` or
            :py:class:`neo4j.Relationship`
        :param index_name: the name of the required index
        :return: an :py:class:`Index` instance or :py:const:`None`

        .. seealso:: :py:func:`get_or_create_index`
        .. seealso:: :py:class:`Index`
        """
        if index_name not in self._indexes[content_type]:
            self.get_indexes(content_type)
        if index_name in self._indexes[content_type]:
            return self._indexes[content_type][index_name]
        else:
            return None

    def get_or_create_index(self, content_type, index_name, config=None):
        """ Fetch a specific index from the current database, returning an
        :py:class:`Index` instance. If an index with the supplied `name` and
        content `type` does not exist, one is created with either the
        default configuration or that supplied in `config`::

            # get or create a node index called "People"
            people = graph_db.get_or_create_index(neo4j.Node, "People")

            # get or create a relationship index called "Friends"
            friends = graph_db.get_or_create_index(neo4j.Relationship, "Friends")

        :param content_type: either :py:class:`neo4j.Node` or
            :py:class:`neo4j.Relationship`
        :param index_name: the name of the required index
        :return: an :py:class:`Index` instance

        .. seealso:: :py:func:`get_index`
        .. seealso:: :py:class:`Index`
        """
        index = self.get_index(content_type, index_name)
        if index:
            return index
        index_manager = self._index_manager(content_type)
        rs = index_manager._post({"name": index_name, "config": config or {}})
        index = Index(content_type, assembled(rs)["template"])
        self._indexes[content_type].update({index_name: index})
        return index

    def delete_index(self, content_type, index_name):
        """ Delete the entire index identified by the type and name supplied.

        :param content_type: either :py:class:`neo4j.Node` or
            :py:class:`neo4j.Relationship`
        :param index_name: the name of the index to delete
        :raise LookupError: if the specified index does not exist
        """
        if index_name not in self._indexes[content_type]:
            self.get_indexes(content_type)
        if index_name in self._indexes[content_type]:
            index = self._indexes[content_type][index_name]
            index._delete()
            del self._indexes[content_type][index_name]
        else:
            raise LookupError("Index not found")

    def get_indexed_node(self, index_name, key, value):
        """ Fetch the first node indexed with the specified details, returning
        :py:const:`None` if none found.

        :param index_name: the name of the required index
        :param key: the index key
        :param value: the index value
        :return: a :py:class:`Node` instance
        """
        index = self.get_index(Node, index_name)
        if index:
            nodes = index.get(key, value)
            if nodes:
                return nodes[0]
        return None

    def get_or_create_indexed_node(self, index_name, key, value, properties=None):
        """ Fetch the first node indexed with the specified details, creating
        and returning a new indexed node if none found.

        :param index_name: the name of the required index
        :param key: the index key
        :param value: the index value
        :param properties: properties for the new node, if one is created
            (optional)
        :return: a :py:class:`Node` instance
        """
        index = self.get_or_create_index(Node, index_name)
        return index.get_or_create(key, value, properties or {})

    def get_indexed_relationship(self, index_name, key, value):
        """ Fetch the first relationship indexed with the specified details,
        returning :py:const:`None` if none found.

        :param index_name: the name of the required index
        :param key: the index key
        :param value: the index value
        :return: a :py:class:`Relationship` instance
        """
        index = self.get_index(Relationship, index_name)
        if index:
            relationships = index.get(key, value)
            if relationships:
                return relationships[0]
        return None


class CypherQuery(object):
    """ A reusable Cypher query. To create a new query object, a graph and the
    query text need to be supplied::

        >>> from py2neo import neo4j
        >>> graph_db = neo4j.GraphDatabaseService()
        >>> query = neo4j.CypherQuery(graph_db, "CREATE (a) RETURN a")

    """

    def __init__(self, graph_db, query):
        self._cypher = Resource(graph_db.__metadata__["cypher"])
        self._query = query

    def __str__(self):
        return self._query

    @property
    def string(self):
        """ The text of the query.
        """
        return self._query

    def _execute(self, **params):
        if __debug__:
            cypher_log.debug("Query: " + repr(self._query))
            if params:
                cypher_log.debug("Params: " + repr(params))
        try:
            return self._cypher._post({
                "query": self._query,
                "params": dict(params or {}),
            })
        except ClientError as e:
            if e.exception:
                # A CustomCypherError is a dynamically created subclass of
                # CypherError with the same name as the underlying server
                # exception
                CustomCypherError = type(str(e.exception), (CypherError,), {})
                raise CustomCypherError(e)
            else:
                raise CypherError(e)

    def run(self, **params):
        """ Execute the query and discard any results.

        :param params:
        """
        self._execute(**params).close()

    def execute(self, **params):
        """ Execute the query and return the results.

        :param params:
        :return:
        :rtype: :py:class:`CypherResults <py2neo.neo4j.CypherResults>`
        """
        return CypherResults(self._execute(**params))

    def execute_one(self, **params):
        """ Execute the query and return the first value from the first row.

        :param params:
        :return:
        """
        try:
            return self.execute(**params).data[0][0]
        except IndexError:
            return None

    def stream(self, **params):
        """ Execute the query and return a result iterator.

        :param params:
        :return:
        :rtype: :py:class:`IterableCypherResults <py2neo.neo4j.IterableCypherResults>`
        """
        return IterableCypherResults(self._execute(**params))


class CypherResults(object):
    """ A static set of results from a Cypher query.
    """

    signature = ("columns", "data")

    @classmethod
    def _hydrated(cls, data):
        """ Takes assembled data...
        """
        return [
            Record(data["columns"], _hydrated(row))
            for row in data["data"]
        ]

    def __init__(self, response):
        content = response.json
        self._columns = tuple(content["columns"])
        self._data = [
            Record(self._columns, _hydrated(row))
            for row in content["data"]
        ]

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        return False

    def __len__(self):
        return len(self._data)

    def __getitem__(self, item):
        return self._data[item]

    @property
    def columns(self):
        """ Column names.
        """
        return self._columns

    @property
    def data(self):
        """ List of result records.
        """
        return self._data

    def __iter__(self):
        return iter(self._data)


class IterableCypherResults(object):
    """ An iterable set of results from a Cypher query.

    ::

        query = graph_db.cypher.query("START n=node(*) RETURN n LIMIT 10")
        for record in query.stream():
            print record[0]

    Each record returned is cast into a :py:class:`namedtuple` with names
    derived from the resulting column names.

    .. note ::
        Results are available as returned from the server and are decoded
        incrementally. This means that there is no need to wait for the
        entire response to be received before processing can occur.
    """

    def __init__(self, response):
        self._response = response
        self._redo_buffer = []
        self._buffered = self._buffered_results()
        self._columns = None
        self._fetch_columns()

    def _fetch_columns(self):
        redo = []
        section = []
        for key, value in self._buffered:
            if key and key[0] == "columns":
                section.append((key, value))
            else:
                redo.append((key, value))
                if key and key[0] == "data":
                    break
        self._redo_buffer.extend(redo)
        self._columns = tuple(assembled(section)["columns"])

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()
        return False

    def _buffered_results(self):
        for result in self._response:
            while self._redo_buffer:
                yield self._redo_buffer.pop(0)
            yield result

    def __iter__(self):
        for key, section in grouped(self._buffered):
            if key[0] == "data":
                for i, row in grouped(section):
                    yield Record(self._columns, _hydrated(assembled(row)))

    @property
    def columns(self):
        """ Column names.
        """
        return self._columns

    def close(self):
        """ Close results and free resources.
        """
        self._response.close()


class Schema(Cacheable, Resource):

    def __init__(self, *args, **kwargs):
        Resource.__init__(self, *args, **kwargs)
        if not self.service_root.graph_db.supports_schema_indexes:
            raise NotImplementedError("Schema index support requires "
                                      "version 2.0 or above")
        self._index_template = \
            URITemplate(str(URI(self)) + "/index/{label}")
        self._index_key_template = \
            URITemplate(str(URI(self)) + "/index/{label}/{property_key}")

    def get_indexed_property_keys(self, label):
        """ Fetch a list of indexed property keys for a label.

        :param label:
        :return:
        """
        if not label:
            raise ValueError("Label cannot be empty")
        resource = Resource(self._index_template.expand(label=label))
        try:
            response = resource._get()
        except ClientError as err:
            if err.status_code == NOT_FOUND:
                return []
            else:
                raise
        else:
            return [
                indexed["property-keys"][0]
                for indexed in response.json
            ]

    def create_index(self, label, property_key):
        """ Index a property key for a label.

        :param label:
        :param property_key:
        :return:
        """
        if not label or not property_key:
            raise ValueError("Neither label nor property key can be empty")
        resource = Resource(self._index_template.expand(label=label))
        property_key = bytearray(property_key, "utf-8").decode("utf-8")
        try:
            resource._post({"property_keys": [property_key]})
        except ClientError as err:
            if err.status_code == CONFLICT:
                raise ValueError("Property key already indexed")
            else:
                raise

    def drop_index(self, label, property_key):
        """ Remove label index for a given property key.

        :param label:
        :param property_key:
        :return:
        """
        if not label or not property_key:
            raise ValueError("Neither label nor property key can be empty")
        uri = self._index_key_template.expand(label=label,
                                              property_key=property_key)
        resource = Resource(uri)
        try:
            resource._delete()
        except ClientError as err:
            if err.status_code == NOT_FOUND:
                raise LookupError("Property key not found")
            else:
                raise


class _Entity(Resource):
    """ Base class from which :py:class:`Node` and :py:class:`Relationship`
    classes inherit. Provides property management functionality by defining
    standard Python container handler methods.
    """

    def __init__(self, uri):
        Resource.__init__(self, uri)
        self._properties = {}

    def __contains__(self, key):
        return key in self.get_properties()

    def __delitem__(self, key):
        self.update_properties({key: None})

    def __getitem__(self, key):
        return self.get_properties().get(key, None)

    def __iter__(self):
        return self.get_properties().__iter__()

    def __len__(self):
        return len(self.get_properties())

    def __nonzero__(self):
        return True

    def __setitem__(self, key, value):
        self.update_properties({key: value})

    @property
    def _properties_resource(self):
        return self._subresource("properties")

    @property
    def _id(self):
        """ Return the internal ID for this entity.

        :return: integer ID of this entity within the database or
            :py:const:`None` if abstract
        """
        if self.is_abstract:
            return None
        else:
            return int(URI(self).path.segments[-1])

    def delete(self):
        """ Delete this entity from the database.
        """
        self._delete()

    @property
    def exists(self):
        """ Detects whether this entity still exists in the database.
        """
        try:
            self._get()
        except ClientError as err:
            if err.status_code == NOT_FOUND:
                return False
            else:
                raise
        else:
            return True

    def get_cached_properties(self):
        """ Fetch last known properties without calling the server.

        :return: dictionary of properties
        """
        if self.is_abstract:
            return self._properties
        else:
            return self.__metadata__["data"]

    def get_properties(self):
        """ Fetch all properties.

        :return: dictionary of properties
        """
        if not self.is_abstract:
            self._properties = assembled(self._properties_resource._get()) or {}
        return self._properties

    def set_properties(self, properties):
        """ Replace all properties with those supplied.

        :param properties: dictionary of new properties
        """
        self._properties = dict(properties)
        if not self.is_abstract:
            if self._properties:
                self._properties_resource._put(compact(self._properties))
            else:
                self._properties_resource._delete()

    def delete_properties(self):
        """ Delete all properties.
        """
        self.set_properties({})

    def update_properties(self, properties):
        raise NotImplementedError("_Entity.update_properties")


class Node(_Entity):
    """ A node within a graph, identified by a URI. For example:

        >>> from py2neo import neo4j
        >>> alice = neo4j.Node("http://localhost:7474/db/data/node/1")

    Typically, concrete nodes will not be constructed directly in this way
    by client applications. Instead, methods such as
    :py:func:`GraphDatabaseService.create` build node objects indirectly as
    required. Once created, nodes can be treated like any other container type
    so as to manage properties::

        # get the `name` property of `node`
        name = node["name"]

        # set the `name` property of `node` to `Alice`
        node["name"] = "Alice"

        # delete the `name` property from `node`
        del node["name"]

        # determine the number of properties within `node`
        count = len(node)

        # determine existence of the `name` property within `node`
        if "name" in node:
            pass

        # iterate through property keys in `node`
        for key in node:
            value = node[key]

    :param uri: URI identifying this node
    """

    signature = ("self",)

    @classmethod
    def _hydrated(cls, data):
        obj = cls(data["self"])
        obj._metadata = ResourceMetadata(data)
        obj._properties = data.get("data", {})
        return obj

    @classmethod
    def abstract(cls, **properties):
        """ Create and return a new abstract node containing properties drawn
        from the keyword arguments supplied. An abstract node is not bound to
        a concrete node within a database but properties can be managed
        similarly to those within bound nodes::

            >>> alice = Node.abstract(name="Alice")
            >>> alice["name"]
            'Alice'
            >>> alice["age"] = 34
            alice.get_properties()
            {'age': 34, 'name': 'Alice'}

        If more complex property keys are required, abstract nodes may be
        instantiated with the ``**`` syntax::

            >>> alice = Node.abstract(**{"first name": "Alice"})
            >>> alice["first name"]
            'Alice'

        :param properties: node properties
        """
        instance = cls(None)
        instance._properties = dict(properties)
        return instance

    def __init__(self, uri):
        _Entity.__init__(self, uri)

    def __eq__(self, other):
        other = _cast(other, Node)
        if self.__uri__:
            return _Entity.__eq__(self, other)
        else:
            return self._properties == other._properties

    def __ne__(self, other):
        other = _cast(other, Node)
        if self.__uri__:
            return _Entity.__ne__(self, other)
        else:
            return self._properties != other._properties

    def __repr__(self):
        if not self.is_abstract:
            return "{0}({1})".format(
                self.__class__.__name__,
                repr(str(self.__uri__))
            )
        elif self._properties:
            return "node({1})".format(
                self.__class__.__name__,
                repr(self._properties)
            )
        else:
            return "node()".format(
                self.__class__.__name__
            )

    def __str__(self):
        """ Return Cypher/Geoff style representation of this node.
        """
        if self.is_abstract:
            return "({0})".format(json.dumps(self._properties, separators=(",", ":"), ensure_ascii=False))
        elif self._properties:
            return "({0} {1})".format(
                "" if self._id is None else self._id,
                json.dumps(self._properties, separators=(",", ":"), ensure_ascii=False),
            )
        else:
            return "({0})".format("" if self._id is None else self._id)

    def __hash__(self):
        if self.is_abstract:
            return hash(tuple(sorted(self._properties.items())))
        else:
            return hash(self.__uri__)

    def delete_related(self):
        """ Delete this node along with all related nodes and relationships.
        """
        if self.graph_db.supports_foreach_pipe:
            query = ("START a=node({a}) "
                     "MATCH (a)-[rels*0..]-(z) "
                     "FOREACH(r IN rels| DELETE r) "
                     "DELETE a, z")
        else:
            query = ("START a=node({a}) "
                     "MATCH (a)-[rels*0..]-(z) "
                     "FOREACH(r IN rels: DELETE r) "
                     "DELETE a, z")
        CypherQuery(self.graph_db, query).execute(a=self._id)

    def isolate(self):
        """ Delete all relationships connected to this node, both incoming and
        outgoing.
        """
        CypherQuery(self.graph_db, "START a=node({a}) "
                                   "MATCH a-[r]-b "
                                   "DELETE r").execute(a=self._id)

    def match(self, rel_type=None, other_node=None, limit=None):
        """ Iterate through matching relationships attached to this node,
        regardless of direction.

        :param rel_type: type of relationships to match or :py:const:`None` if
            any
        :param other_node: concrete :py:class:`Node` to match for other end of
            relationship or :py:const:`None` if any
        :param limit: maximum number of relationships to match or
            :py:const:`None` if no limit
        :return: matching relationships
        :rtype: generator

        .. seealso::
           :py:func:`GraphDatabaseService.match <py2neo.neo4j.GraphDatabaseService.match>`
        """
        return self.service_root.graph_db.match(self, rel_type, other_node,
                                                True, limit)

    def match_incoming(self, rel_type=None, start_node=None, limit=None):
        """ Iterate through matching relationships where this node is the end
        node.

        :param rel_type: type of relationships to match or :py:const:`None` if
            any
        :param start_node: concrete start :py:class:`Node` to match or
            :py:const:`None` if any
        :param limit: maximum number of relationships to match or
            :py:const:`None` if no limit
        :return: matching relationships
        :rtype: generator

        .. seealso::
           :py:func:`GraphDatabaseService.match <py2neo.neo4j.GraphDatabaseService.match>`
        """
        return self.service_root.graph_db.match(start_node, rel_type, self,
                                                False, limit)

    def match_outgoing(self, rel_type=None, end_node=None, limit=None):
        """ Iterate through matching relationships where this node is the start
        node.

        :param rel_type: type of relationships to match or :py:const:`None` if
            any
        :param end_node: concrete end :py:class:`Node` to match or
            :py:const:`None` if any
        :param limit: maximum number of relationships to match or
            :py:const:`None` if no limit
        :return: matching relationships
        :rtype: generator

        .. seealso::
           :py:func:`GraphDatabaseService.match <py2neo.neo4j.GraphDatabaseService.match>`
        """
        return self.service_root.graph_db.match(self, rel_type, end_node,
                                                False, limit)

    def create_path(self, *items):
        """ Create a new path, starting at this node and chaining together the
        alternating relationships and nodes provided::

            (self)-[rel_0]->(node_0)-[rel_1]->(node_1) ...
                   |-----|  |------| |-----|  |------|
             item:    0        1        2        3

        Each relationship may be specified as one of the following:

        - an existing Relationship instance
        - a string holding the relationship type, e.g. "KNOWS"
        - a (`str`, `dict`) tuple holding both the relationship type and
          its properties, e.g. ("KNOWS", {"since": 1999})

        Nodes can be any of the following:

        - an existing Node instance
        - an integer containing the ID of an existing node
        - a `dict` holding a set of properties for a new node
        - a 3-tuple holding an index name, key and value for identifying
          indexed nodes, e.g. ("People", "email", "bob@example.com")
        - :py:const:`None`, representing an unspecified node that will be
          created as required

        :param items: alternating relationships and nodes
        :return: `Path` object representing the newly-created path
        """
        path = Path(self, *items)
        return path.create(self.service_root.graph_db)

    def get_or_create_path(self, *items):
        """ Identical to `create_path` except will reuse parts of the path
        which already exist.

        Some examples::

            # add dates to calendar, starting at calendar_root
            christmas_day = calendar_root.get_or_create_path(
                "YEAR",  {"number": 2000},
                "MONTH", {"number": 12},
                "DAY",   {"number": 25},
            )
            # `christmas_day` will now contain a `Path` object
            # containing the nodes and relationships used:
            # (CAL)-[:YEAR]->(2000)-[:MONTH]->(12)-[:DAY]->(25)

            # adding a second, overlapping path will reuse
            # nodes and relationships wherever possible
            christmas_eve = calendar_root.get_or_create_path(
                "YEAR",  {"number": 2000},
                "MONTH", {"number": 12},
                "DAY",   {"number": 24},
            )
            # `christmas_eve` will contain the same year and month nodes
            # as `christmas_day` but a different (new) day node:
            # (CAL)-[:YEAR]->(2000)-[:MONTH]->(12)-[:DAY]->(25)
            #                                  |
            #                                [:DAY]
            #                                  |
            #                                  v
            #                                 (24)

        """
        path = Path(self, *items)
        return path.get_or_create(self.service_root.graph_db)

    def update_properties(self, properties):
        """ Update properties with the values supplied.

        :param properties: dictionary of properties to integrate with existing
            properties
        """
        if self.is_abstract:
            self._properties.update(properties)
            self._properties = compact(self._properties)
        else:
            query, params = ["START a=node({A})"], {"A": self._id}
            for i, (key, value) in enumerate(properties.items()):
                value_tag = "V" + str(i)
                query.append("SET a.`" + key + "`={" + value_tag + "}")
                params[value_tag] = value
            query.append("RETURN a")
            rel = CypherQuery(self.graph_db, " ".join(query)).execute_one(**params)
            self._properties = rel.__metadata__["data"]

    def _label_resource(self):
        if self.is_abstract:
            raise TypeError("Abstract nodes cannot have labels")
        try:
            return self._subresource("labels")
        except KeyError:
            raise NotImplementedError("Labels are not supported in this "
                                      "version of Neo4j")

    def get_labels(self):
        """ Fetch all labels associated with this node.

        :return: :py:class:`set` of text labels
        """
        return set(assembled(self._label_resource()._get()))

    def add_labels(self, *labels):
        """ Add one or more labels to this node.

        For example::

            >>> from py2neo import neo4j, node
            >>> graph_db = neo4j.GraphDatabaseService()
            >>> alice, = graph_db.create(node(name="Alice"))
            >>> alice.add_labels("female", "human")

        :param labels: one or more text labels
        """
        labels = [ustr(label) for label in set(flatten(labels))]
        self._label_resource()._post(labels)

    def remove_labels(self, *labels):
        """ Remove one or more labels from this node.

        :param labels: one or more text labels
        """
        labels = [ustr(label) for label in set(flatten(labels))]
        batch = WriteBatch(self.graph_db)
        for label in labels:
            batch.remove_label(self, label)
        batch.run()

    def set_labels(self, *labels):
        """ Replace all labels on this node.

        :param labels: one or more text labels
        """
        labels = [ustr(label) for label in set(flatten(labels))]
        self._label_resource()._put(labels)


class Relationship(_Entity):
    """ A relationship within a graph, identified by a URI.
    
    :param uri: URI identifying this relationship
    """

    signature = ("self", "type")

    @classmethod
    def _hydrated(cls, data):
        obj = cls(data["self"])
        obj._metadata = ResourceMetadata(data)
        obj._properties = data.get("data", {})
        return obj

    @classmethod
    def abstract(cls, start_node, type_, end_node, **properties):
        """ Create and return a new abstract relationship.
        """
        instance = cls(None)
        instance._start_node = start_node
        instance._type = type_
        instance._end_node = end_node
        instance._properties = dict(properties)
        return instance

    def __init__(self, uri):
        _Entity.__init__(self, uri)
        self._start_node = None
        self._type = None
        self._end_node = None

    def __eq__(self, other):
        other = _cast(other, Relationship)
        if self.__uri__:
            return _Entity.__eq__(self, other)
        else:
            return (self._start_node == other._start_node and
                    self._type == other._type and
                    self._end_node == other._end_node and
                    self._properties == other._properties)

    def __ne__(self, other):
        other = _cast(other, Relationship)
        if self.__uri__:
            return _Entity.__ne__(self, other)
        else:
            return (self._start_node != other._start_node or
                    self._type != other._type or
                    self._end_node != other._end_node or
                    self._properties != other._properties)

    def __repr__(self):
        if not self.is_abstract:
            return "{0}({1})".format(
                self.__class__.__name__,
                repr(str(self.__uri__))
            )
        elif self._properties:
            return "rel({1}, {2}, {3}, {4})".format(
                self.__class__.__name__,
                repr(self.start_node),
                repr(self.type),
                repr(self.end_node),
                repr(self._properties)
            )
        else:
            return "rel({1}, {2}, {3})".format(
                self.__class__.__name__,
                repr(self.start_node),
                repr(self.type),
                repr(self.end_node)
            )

    def __str__(self):
        type_str = str(self.type)
        if not SIMPLE_NAME.match(type_str):
            type_str = json.dumps(type_str, ensure_ascii=False)
        if self._properties:
            return "{0}-[:{1} {2}]->{3}".format(
                str(self.start_node),
                type_str,
                json.dumps(self._properties, separators=(",", ":"), ensure_ascii=False),
                str(self.end_node),
            )
        else:
            return "{0}-[:{1}]->{2}".format(
                str(self.start_node),
                type_str,
                str(self.end_node),
            )

    def __hash__(self):
        if self.__uri__:
            return hash(self.__uri__)
        else:
            return hash(tuple(sorted(self._properties.items())))

    @property
    def end_node(self):
        """ Return the end node of this relationship.
        """
        if self.__uri__ and not self._end_node:
            self._end_node = Node(self.__metadata__['end'])
        return self._end_node

    @property
    def start_node(self):
        """ Return the start node of this relationship.
        """
        if self.__uri__ and not self._start_node:
            self._start_node = Node(self.__metadata__['start'])
        return self._start_node

    @property
    def type(self):
        """ Return the type of this relationship as a string.
        """
        if self.__uri__ and not self._type:
            self._type = self.__metadata__['type']
        return self._type

    def update_properties(self, properties):
        """ Update the properties for this relationship with the values
        supplied.
        """
        if self.is_abstract:
            self._properties.update(properties)
            self._properties = compact(self._properties)
        else:
            query, params = ["START a=rel({A})"], {"A": self._id}
            for i, (key, value) in enumerate(properties.items()):
                value_tag = "V" + str(i)
                query.append("SET a.`" + key + "`={" + value_tag + "}")
                params[value_tag] = value
            query.append("RETURN a")
            rel = CypherQuery(self.graph_db, " ".join(query)).execute_one(**params)
            self._properties = rel.__metadata__["data"]


class _UnboundRelationship(object):
    """ An abstract, partial relationship with no start or end nodes.
    """

    @classmethod
    def cast(cls, arg):
        if isinstance(arg, cls):
            return arg
        elif isinstance(arg, Relationship):
            return cls(arg.type, **arg.get_properties())
        elif isinstance(arg, tuple):
            if len(arg) == 1:
                return cls(str(arg[0]))
            elif len(arg) == 2:
                return cls(str(arg[0]), **arg[1])
            else:
                raise TypeError(arg)
        else:
            return cls(str(arg))

    def __init__(self, type_, **properties):
        self._type = type_
        self._properties = dict(properties)

    def __eq__(self, other):
        return (self._type == other._type and
                self._properties == other._properties)

    def __ne__(self, other):
        return (self._type != other._type or
                self._properties != other._properties)

    def __repr__(self):
        return "({0}, {1})".format(
            repr(str(self._type)),
            repr(self._properties),
        )

    def __str__(self):
        return "-[:{0}]->".format(
            json.dumps(str(self._type), ensure_ascii=False),
        )

    def bind(self, start_node, end_node):
        return Relationship.abstract(start_node, self._type, end_node,
                                     **self._properties)


class Path(object):
    """ A representation of a sequence of nodes connected by relationships. for
    example::

        >>> from py2neo import neo4j, node
        >>> alice, bob, carol = node(name="Alice"), node(name="Bob"), node(name="Carol")
        >>> abc = neo4j.Path(alice, "KNOWS", bob, "KNOWS", carol)
        >>> abc.nodes
        [node(**{'name': 'Alice'}), node(**{'name': 'Bob'}), node(**{'name': 'Carol'})]
        >>> dave, eve = node(name="Dave"), node(name="Eve")
        >>> de = neo4j.Path(dave, "KNOWS", eve)
        >>> de.nodes
        [node(**{'name': 'Dave'}), node(**{'name': 'Eve'})]
        >>> abcde = neo4j.Path.join(abc, "KNOWS", de)
        >>> str(abcde)
        '({"name":"Alice"})-[:"KNOWS"]->({"name":"Bob"})-[:"KNOWS"]->({"name":"Carol"})-[:"KNOWS"]->({"name":"Dave"})-[:"KNOWS"]->({"name":"Eve"})'

    """

    signature = ("length", "nodes", "relationships", "start", "end")

    @classmethod
    def _hydrated(cls, data):
        nodes = map(Node, data["nodes"])
        rels = map(Relationship, data["relationships"])
        return Path(*round_robin(nodes, rels))

    def __init__(self, node, *rels_and_nodes):
        self._nodes = [_node(node)]
        self._nodes.extend(_node(n) for n in rels_and_nodes[1::2])
        if len(rels_and_nodes) % 2 != 0:
            # If a trailing relationship is supplied, add a dummy end node
            self._nodes.append(_node())
        self._relationships = [
            _UnboundRelationship.cast(r)
            for r in rels_and_nodes[0::2]
        ]

    def __repr__(self):
        out = ", ".join(repr(item) for item in round_robin(self._nodes,
                                                           self._relationships))
        return "Path({0})".format(out)

    def __str__(self):
        out = []
        for i, rel in enumerate(self._relationships):
            out.append(str(self._nodes[i]))
            out.append(str(rel))
        out.append(str(self._nodes[-1]))
        return "".join(out)

    def __nonzero__(self):
        return bool(self._relationships)

    def __len__(self):
        return len(self._relationships)

    def __eq__(self, other):
        return (self._nodes == other._nodes and
                self._relationships == other._relationships)

    def __ne__(self, other):
        return (self._nodes != other._nodes or
                self._relationships != other._relationships)

    def __getitem__(self, item):
        size = len(self._relationships)
        def adjust(value, default=None):
            if value is None:
                return default
            if value < 0:
                return value + size
            else:
                return value
        if isinstance(item, slice):
            if item.step is not None:
                raise ValueError("Steps not supported in path slicing")
            start, stop = adjust(item.start, 0), adjust(item.stop, size)
            path = Path(self._nodes[start])
            for i in range(start, stop):
                path._relationships.append(self._relationships[i])
                path._nodes.append(self._nodes[i + 1])
            return path
        else:
            i = int(item)
            if i < 0:
                i += len(self._relationships)
            return Path(self._nodes[i], self._relationships[i],
                        self._nodes[i + 1])

    def __iter__(self):
        return iter(
            _rel((self._nodes[i], rel, self._nodes[i + 1]))
            for i, rel in enumerate(self._relationships)
        )

    @property
    def order(self):
        """ The number of nodes within this path.
        """
        return len(self._nodes)

    @property
    def size(self):
        """ The number of relationships within this path.
        """
        return len(self._relationships)

    @property
    def nodes(self):
        """ Return a list of all the nodes which make up this path.
        """
        return list(self._nodes)

    @property
    def relationships(self):
        """ Return a list of all the relationships which make up this path.
        """
        return [
            _rel((self._nodes[i], rel, self._nodes[i + 1]))
            for i, rel in enumerate(self._relationships)
        ]

    @classmethod
    def join(cls, left, rel, right):
        """ Join the two paths `left` and `right` with the relationship `rel`.
        """
        if isinstance(left, Path):
            left = left[:]
        else:
            left = Path(left)
        if isinstance(right, Path):
            right = right[:]
        else:
            right = Path(right)
        left._relationships.append(_UnboundRelationship.cast(rel))
        left._nodes.extend(right._nodes)
        left._relationships.extend(right._relationships)
        return left

    def _create_query(self, unique):
        nodes, path, values, params = [], [], [], {}

        def append_node(i, node):
            if node is None:
                path.append("(n{0})".format(i))
                values.append("n{0}".format(i))
            elif node.is_abstract:
                path.append("(n{0} {{p{0}}})".format(i))
                params["p{0}".format(i)] = compact(node._properties)
                values.append("n{0}".format(i))
            else:
                path.append("(n{0})".format(i))
                nodes.append("n{0}=node({{i{0}}})".format(i))
                params["i{0}".format(i)] = node._id
                values.append("n{0}".format(i))

        def append_rel(i, rel):
            if rel._properties:
                path.append("-[r{0}:`{1}` {{q{0}}}]->".format(i, rel._type))
                params["q{0}".format(i)] = compact(rel._properties)
                values.append("r{0}".format(i))
            else:
                path.append("-[r{0}:`{1}`]->".format(i, rel._type))
                values.append("r{0}".format(i))

        append_node(0, self._nodes[0])
        for i, rel in enumerate(self._relationships):
            append_rel(i, rel)
            append_node(i + 1, self._nodes[i + 1])
        clauses = []
        if nodes:
            clauses.append("START {0}".format(",".join(nodes)))
        if unique:
            clauses.append("CREATE UNIQUE p={0}".format("".join(path)))
        else:
            clauses.append("CREATE p={0}".format("".join(path)))
        #clauses.append("RETURN {0}".format(",".join(values)))
        clauses.append("RETURN p")
        query = " ".join(clauses)
        return query, params

    def _create(self, graph_db, unique):
        query, params = self._create_query(unique=unique)
        try:
            results = CypherQuery(graph_db, query).execute(**params)
        except CypherError:
            raise NotImplementedError(
                "The Neo4j server at <{0}> does not support "
                "Cypher CREATE UNIQUE clauses or the query contains "
                "an unsupported property type".format(graph_db.__uri__)
            )
        else:
            for row in results:
                return row[0]

    def create(self, graph_db):
        """ Construct a path within the specified `graph_db` from the nodes
        and relationships within this :py:class:`Path` instance. This makes
        use of Cypher's ``CREATE`` clause.
        """
        return self._create(graph_db, unique=False)

    def get_or_create(self, graph_db):
        """ Construct a unique path within the specified `graph_db` from the
        nodes and relationships within this :py:class:`Path` instance. This
        makes use of Cypher's ``CREATE UNIQUE`` clause.
        """
        return self._create(graph_db, unique=True)


class Index(Cacheable, Resource):
    """ Searchable database index which can contain either nodes or
    relationships.

    .. seealso:: :py:func:`GraphDatabaseService.get_or_create_index`
    """

    def __init__(self, content_type, uri, name=None):
        self._content_type = content_type
        key_value_pos = uri.find("/{key}/{value}")
        if key_value_pos >= 0:
            self._searcher = ResourceTemplate(uri)
            Resource.__init__(self, URI(uri[:key_value_pos]))
        else:
            Resource.__init__(self, uri)
            self._searcher = ResourceTemplate(uri.string + "/{key}/{value}")
        uri = URI(self)
        if self.graph_db.neo4j_version >= (1, 9):
            self._create_or_fail = Resource(uri.resolve("?uniqueness=create_or_fail"))
            self._get_or_create = Resource(uri.resolve("?uniqueness=get_or_create"))
        else:
            self._create_or_fail = None
            self._get_or_create = Resource(uri.resolve("?unique"))
        self._query_template = ResourceTemplate(uri.string + "{?query,order}")
        self._name = name or uri.path.segments[-1]

    def __repr__(self):
        return "{0}({1}, {2})".format(
            self.__class__.__name__,
            self._content_type.__name__,
            repr(URI(self).string)
        )

    def add(self, key, value, entity):
        """ Add an entity to this index under the `key`:`value` pair supplied::

            # create a node and obtain a reference to the "People" node index
            alice, = graph_db.create({"name": "Alice Smith"})
            people = graph_db.get_or_create_index(neo4j.Node, "People")

            # add the node to the index
            people.add("family_name", "Smith", alice)

        Note that while Neo4j indexes allow multiple entities to be added under
        a particular key:value, the same entity may only be represented once;
        this method is therefore idempotent.
        """
        self._post({
            "key": key,
            "value": value,
            "uri": str(URI(entity))
        })
        return entity

    def add_if_none(self, key, value, entity):
        """ Add an entity to this index under the `key`:`value` pair
        supplied if no entry already exists at that point::

            # obtain a reference to the "Rooms" node index and
            # add node `alice` to room 100 if empty
            rooms = graph_db.get_or_create_index(neo4j.Node, "Rooms")
            rooms.add_if_none("room", 100, alice)

        If added, this method returns the entity, otherwise :py:const:`None`
        is returned.
        """
        rs = self._get_or_create._post({
            "key": key,
            "value": value,
            "uri": str(URI(entity))
        })
        if rs.status_code == CREATED:
            return entity
        else:
            return None

    @property
    def content_type(self):
        """ Return the type of entity contained within this index. Will return
        either :py:class:`Node` or :py:class:`Relationship`.
        """
        return self._content_type

    @property
    def name(self):
        """ Return the name of this index.
        """
        return self._name

    def get(self, key, value):
        """ Fetch a list of all entities from the index which are associated
        with the `key`:`value` pair supplied::

            # obtain a reference to the "People" node index and
            # get all nodes where `family_name` equals "Smith"
            people = graph_db.get_or_create_index(neo4j.Node, "People")
            smiths = people.get("family_name", "Smith")

        ..
        """
        return [
            _hydrated(assembled(result))
            for i, result in grouped(self._searcher.expand(key=key, value=value)._get())
        ]

    def create(self, key, value, abstract):
        """ Create and index a new node or relationship using the abstract
        provided.
        """
        batch = WriteBatch(self.service_root.graph_db)
        if self._content_type is Node:
            batch.create(abstract)
            batch.add_indexed_node(self, key, value, 0)
        elif self._content_type is Relationship:
            batch.create(abstract)
            batch.add_indexed_relationship(self, key, value, 0)
        else:
            raise TypeError(self._content_type)
        entity, index_entry = batch.submit()
        return entity

    def _create_unique(self, key, value, abstract):
        """ Internal method to support `get_or_create` and `create_if_none`.
        """
        if self._content_type is Node:
            body = {
                "key": key,
                "value": value,
                "properties": abstract
            }
        elif self._content_type is Relationship:
            body = {
                "key": key,
                "value": value,
                "start": str(abstract[0].__uri__),
                "type": abstract[1],
                "end": str(abstract[2].__uri__),
                "properties": abstract[3] if len(abstract) > 3 else None
            }
        else:
            raise TypeError(self._content_type)
        return self._get_or_create._post(body)

    def get_or_create(self, key, value, abstract):
        """ Fetch a single entity from the index which is associated with the
        `key`:`value` pair supplied, creating a new entity with the supplied
        details if none exists::

            # obtain a reference to the "Contacts" node index and
            # ensure that Alice exists therein
            contacts = graph_db.get_or_create_index(neo4j.Node, "Contacts")
            alice = contacts.get_or_create("name", "SMITH, Alice", {
                "given_name": "Alice Jane", "family_name": "Smith",
                "phone": "01234 567 890", "mobile": "07890 123 456"
            })

            # obtain a reference to the "Friendships" relationship index and
            # ensure that Alice and Bob's friendship is registered (`alice`
            # and `bob` refer to existing nodes)
            friendships = graph_db.get_or_create_index(neo4j.Relationship, "Friendships")
            alice_and_bob = friendships.get_or_create(
                "friends", "Alice & Bob", (alice, "KNOWS", bob)
            )

        ..
        """
        return _hydrated(assembled(self._create_unique(key, value, abstract)))

    def create_if_none(self, key, value, abstract):
        """ Create a new entity with the specified details within the current
        index, under the `key`:`value` pair supplied, if no such entity already
        exists. If creation occurs, the new entity will be returned, otherwise
        :py:const:`None` will be returned::

            # obtain a reference to the "Contacts" node index and
            # create a node for Alice if one does not already exist
            contacts = graph_db.get_or_create_index(neo4j.Node, "Contacts")
            alice = contacts.create_if_none("name", "SMITH, Alice", {
                "given_name": "Alice Jane", "family_name": "Smith",
                "phone": "01234 567 890", "mobile": "07890 123 456"
            })

        ..
        """
        rs = self._create_unique(key, value, abstract)
        if rs.status_code == CREATED:
            return _hydrated(assembled(rs))
        else:
            return None

    def remove(self, key=None, value=None, entity=None):
        """ Remove any entries from the index which match the parameters
        supplied. The allowed parameter combinations are:

        `key`, `value`, `entity`
            remove a specific entity indexed under a given key-value pair

        `key`, `value`
            remove all entities indexed under a given key-value pair

        `key`, `entity`
            remove a specific entity indexed against a given key but with
            any value

        `entity`
            remove all occurrences of a specific entity regardless of
            key and value

        """
        if key and value and entity:
            t = ResourceTemplate(URI(self).string + "/{key}/{value}/{entity}")
            t.expand(key=key, value=value, entity=entity._id)._delete()
        elif key and value:
            uris = [
                URI(entity.__metadata__["indexed"])
                for entity in self.get(key, value)
            ]
            batch = WriteBatch(self.service_root.graph_db)
            for uri in uris:
                batch.append_delete(uri)
            batch.run()
        elif key and entity:
            t = ResourceTemplate(URI(self).string + "/{key}/{entity}")
            t.expand(key=key, entity=entity._id)._delete()
        elif entity:
            t = ResourceTemplate(URI(self).string + "/{entity}")
            t.expand(entity=entity._id)._delete()
        else:
            raise TypeError("Illegal parameter combination for index removal")

    def query(self, query):
        """ Query the index according to the supplied query criteria, returning
        a list of matched entities::

            # obtain a reference to the "People" node index and
            # get all nodes where `family_name` equals "Smith"
            people = graph_db.get_or_create_index(neo4j.Node, "People")
            s_people = people.query("family_name:S*")

        The query syntax used should be appropriate for the configuration of
        the index being queried. For indexes with default configuration, this
        should be Apache Lucene query syntax.
        """
        resource = self._query_template.expand(query=query)
        for i, result in grouped(resource._get()):
            yield _hydrated(assembled(result))

    def _query_with_score(self, query, order):
        resource = self._query_template.expand(query=query, order=order)
        for i, result in grouped(resource._get()):
            meta = assembled(result)
            yield _hydrated(meta), meta["score"]

    def query_by_index(self, query):
        return self._query_with_score(query, "index")

    def query_by_relevance(self, query):
        return self._query_with_score(query, "relevance")

    def query_by_score(self, query):
        return self._query_with_score(query, "score")


def _cast(obj, cls=(Node, Relationship), abstract=None):
    if obj is None:
        return None
    elif isinstance(obj, Node) or isinstance(obj, dict):
        entity = _node(obj)
    elif isinstance(obj, Relationship) or isinstance(obj, tuple):
        entity = _rel(obj)
    else:
        raise TypeError(obj)
    if not isinstance(entity, cls):
        raise TypeError(obj)
    if abstract is not None and bool(abstract) != bool(entity.is_abstract):
        raise TypeError(obj)
    return entity


class BatchRequest(object):
    """ Individual batch request.
    """

    def __init__(self, method, uri, body=None):
        self._method = method
        self._uri = uri
        self._body = body

    def __eq__(self, other):
        return id(self) == id(other)

    def __ne__(self, other):
        return id(self) != id(other)

    def __hash__(self):
        return hash(id(self))

    @property
    def method(self):
        return self._method

    @property
    def uri(self):
        return self._uri

    @property
    def body(self):
        return self._body


class BatchResponse(object):
    """ Individual batch response.
    """

    @classmethod
    def __hydrate(cls, result):
        body = result.get("body")
        if isinstance(body, dict):
            if has_all(body, CypherResults.signature):
                records = CypherResults._hydrated(body)
                if len(records) == 0:
                    return None
                elif len(records) == 1:
                    if len(records[0]) == 1:
                        return records[0][0]
                    else:
                        return records[0]
                else:
                    return records
            elif has_all(body, ("exception", "stacktrace")):
                err = ServerException(body)
                try:
                    CustomBatchError = type(err.exception, (BatchError,), {})
                except TypeError:
                    # for Python 2.x
                    CustomBatchError = type(str(err.exception), (BatchError,), {})
                raise CustomBatchError(err)
            else:
                return _hydrated(body)
        else:
            return _hydrated(body)

    def __init__(self, result, raw=False):
        self.id_ = result.get("id")
        self.uri = result.get("from")
        self.body = result.get("body")
        self.status_code = result.get("status", 200)
        self.location = URI(result.get("location"))
        if __debug__:
            batch_log.debug("<<< {{{0}}} {1} {2} {3}".format(self.id_, self.status_code, self.location, self.body))
        # We need to hydrate on construction to catch any errors in the batch
        # responses contained in the body
        if raw:
            self.__hydrated = None
        else:
            self.__hydrated = self.__hydrate(result)

    @property
    def __uri__(self):
        return self.uri

    @property
    def hydrated(self):
        return self.__hydrated


class BatchRequestList(object):

    def __init__(self, graph_db):
        self._graph_db = graph_db
        self._batch = graph_db._subresource("batch")
        self._cypher = graph_db._subresource("cypher")
        self.clear()

    def __len__(self):
        return len(self._requests)

    def __nonzero__(self):
        return bool(self._requests)

    def append(self, request):
        self._requests.append(request)
        return request

    def append_get(self, uri):
        return self.append(BatchRequest("GET", uri))

    def append_put(self, uri, body=None):
        return self.append(BatchRequest("PUT", uri, body))

    def append_post(self, uri, body=None):
        return self.append(BatchRequest("POST", uri, body))

    def append_delete(self, uri):
        return self.append(BatchRequest("DELETE", uri))

    def append_cypher(self, query, params=None):
        """ Append a Cypher query to this batch. Resources returned from Cypher
        queries cannot be referenced by other batch requests.

        :param query: Cypher query
        :type query: :py:class:`str`
        :param params: query parameters
        :type params: :py:class:`dict`
        :return: batch request object
        :rtype: :py:class:`_Batch.Request`
        """
        if params:
            body = {"query": str(query), "params": dict(params)}
        else:
            body = {"query": str(query)}
        return self.append_post(self._uri_for(self._cypher), body)

    @property
    def _body(self):
        return [
            {
                "id": i,
                "method": request.method,
                "to": str(request.uri),
                "body": request.body,
            }
            for i, request in enumerate(self._requests)
        ]

    def clear(self):
        """ Clear all requests from this batch.
        """
        self._requests = []

    def find(self, request):
        """ Find the position of a request within this batch.
        """
        for i, req in pendulate(self._requests):
            if req == request:
                return i
        raise ValueError("Request not found")

    def _uri_for(self, resource, *segments, **kwargs):
        """ Return a relative URI in string format for the entity specified
        plus extra path segments.
        """
        if isinstance(resource, int):
            uri = "{{{0}}}".format(resource)
        elif isinstance(resource, BatchRequest):
            uri = "{{{0}}}".format(self.find(resource))
        else:
            offset = len(resource.service_root.graph_db.__uri__)
            uri = str(resource.__uri__)[offset:]
        if segments:
            if not uri.endswith("/"):
                uri += "/"
            uri += "/".join(map(percent_encode, segments))
        query = kwargs.get("query")
        if query is not None:
            uri += "?" + query
        return uri

    def _execute(self):
        request_count = len(self)
        request_text = "request" if request_count == 1 else "requests"
        batch_log.info("Executing batch with {0} {1}".format(request_count, request_text))
        if __debug__:
            for id_, request in enumerate(self._requests):
                batch_log.debug(">>> {{{0}}} {1} {2} {3}".format(id_, request.method, request.uri, request.body))
        try:
            response = self._batch._post(self._body)
        except (ClientError, ServerError) as e:
            if e.exception:
                # A CustomBatchError is a dynamically created subclass of
                # BatchError with the same name as the underlying server
                # exception
                CustomBatchError = type(str(e.exception), (BatchError,), {})
                raise CustomBatchError(e)
            else:
                raise BatchError(e)
        else:
            return response

    def run(self):
        """ Execute the batch on the server and discard the results. If the
        batch results are not required, this will generally be the fastest
        execution method.
        """
        return self._execute().close()

    def stream(self):
        """ Execute the batch on the server and return iterable results. This
        method allows handling of results as they are received from the server.

        :return: iterable results
        :rtype: :py:class:`BatchResponseList`
        """
        return BatchResponseList(self._execute())

    def submit(self):
        """ Execute the batch on the server and return a list of results. This
        method blocks until all results are received.

        :return: result records
        :rtype: :py:class:`list`
        """
        responses = self._execute()
        try:
            return [BatchResponse(rs).hydrated for rs in responses.json]
        finally:
            responses.close()

    def _index(self, content_type, index):
        """ Fetch an Index object.
        """
        if isinstance(index, Index):
            if content_type == index._content_type:
                return index
            else:
                raise TypeError("Index is not for {0}s".format(content_type))
        else:
            return self._graph_db.get_or_create_index(content_type, str(index))


class BatchResponseList(object):

    def __init__(self, response):
        self._response = response

    def __iter__(self):
        for i, result in grouped(self._response):
            yield BatchResponse(assembled(result)).hydrated
        self.close()

    @property
    def closed(self):
        return self._response.closed

    def close(self):
        self._response.close()


class ReadBatch(BatchRequestList):
    """ Generic batch execution facility for data read requests,
    """

    def __init__(self, graph_db):
        BatchRequestList.__init__(self, graph_db)

    def get_indexed_nodes(self, index, key, value):
        """ Fetch all nodes indexed under a given key-value pair.

        :param index: index name or instance
        :type index: :py:class:`str` or :py:class:`Index`
        :param key: key under which nodes are indexed
        :type key: :py:class:`str`
        :param value: value under which nodes are indexed
        :return: batch request object
        """
        index = self._index(Node, index)
        searcher = index._searcher.expand(key=key, value=value)
        return self.append_get(self._uri_for(searcher))


class WriteBatch(BatchRequestList):
    """ Generic batch execution facility for data write requests. Most methods
    return a :py:class:`BatchRequest <py2neo.neo4j.BatchRequest>` object that
    can be used as a reference in other methods. See the
    :py:meth:`create <py2neo.neo4j.WriteBatch.create>` method for an example
    of this.
    """

    def __init__(self, graph_db):
        BatchRequestList.__init__(self, graph_db)
        self.__new_uniqueness_modes = None

    @property
    def supports_index_uniqueness_modes(self):
        return self._graph_db.supports_index_uniqueness_modes

    def _assert_can_create_or_fail(self):
        if not self.supports_index_uniqueness_modes:
            raise NotImplementedError("Uniqueness mode `create_or_fail` "
                                      "requires version 1.9 or above")

    def create(self, abstract):
        """ Create a node or relationship based on the abstract entity
        provided. For example::

            batch = WriteBatch(graph_db)
            a = batch.create(node(name="Alice"))
            b = batch.create(node(name="Bob"))
            batch.create(rel(a, "KNOWS", b))
            results = batch.submit()

        :param abstract: node or relationship
        :type abstract: abstract
        :return: batch request object
        """
        entity = _cast(abstract, abstract=True)
        if isinstance(entity, Node):
            uri = self._uri_for(self._graph_db._subresource("node"))
            body = compact(entity._properties)
        elif isinstance(entity, Relationship):
            uri = self._uri_for(entity.start_node, "relationships")
            body = {
                "type": entity._type,
                "to": self._uri_for(entity.end_node)
            }
            if entity._properties:
                body["data"] = compact(entity._properties)
        else:
            raise TypeError(entity)
        return self.append_post(uri, body)

    def create_path(self, node, *rels_and_nodes):
        """ Construct a path across a specified set of nodes and relationships.
        Nodes may be existing concrete node instances, abstract nodes or
        :py:const:`None` but references to other requests are not supported.

        :param node: start node
        :type node: concrete, abstract or :py:const:`None`
        :param rels_and_nodes: alternating relationships and nodes
        :type rels_and_nodes: concrete, abstract or :py:const:`None`
        :return: batch request object
        """
        query, params = Path(node, *rels_and_nodes)._create_query(unique=False)
        self.append_cypher(query, params)

    def get_or_create_path(self, node, *rels_and_nodes):
        """ Construct a unique path across a specified set of nodes and
        relationships, adding only parts that are missing. Nodes may be
        existing concrete node instances, abstract nodes or :py:const:`None`
        but references to other requests are not supported.

        :param node: start node
        :type node: concrete, abstract or :py:const:`None`
        :param rels_and_nodes: alternating relationships and nodes
        :type rels_and_nodes: concrete, abstract or :py:const:`None`
        :return: batch request object
        """
        query, params = Path(node, *rels_and_nodes)._create_query(unique=True)
        self.append_cypher(query, params)

    @deprecated("WriteBatch.get_or_create is deprecated, please use "
                "get_or_create_path instead")
    def get_or_create(self, rel_abstract):
        """ Use the abstract supplied to create a new relationship if one does
        not already exist.

        :param rel_abstract: relationship abstract to be fetched or created
        """
        rel = _cast(rel_abstract, cls=Relationship, abstract=True)
        if not (isinstance(rel._start_node, Node) or rel._start_node is None):
            raise TypeError("Relationship start node must be a "
                            "Node instance or None")
        if not (isinstance(rel._end_node, Node) or rel._end_node is None):
            raise TypeError("Relationship end node must be a "
                            "Node instance or None")
        if rel._start_node and rel._end_node:
            query = (
                "START a=node({A}), b=node({B}) "
                "CREATE UNIQUE (a)-[ab:`" + str(rel._type) + "` {P}]->(b) "
                "RETURN ab"
            )
        elif rel._start_node:
            query = (
                "START a=node({A}) "
                "CREATE UNIQUE (a)-[ab:`" + str(rel._type) + "` {P}]->() "
                "RETURN ab"
            )
        elif rel._end_node:
            query = (
                "START b=node({B}) "
                "CREATE UNIQUE ()-[ab:`" + str(rel._type) + "` {P}]->(b) "
                "RETURN ab"
            )
        else:
            raise ValueError("Either start node or end node must be "
                             "specified for a unique relationship")
        params = {"P": compact(rel._properties or {})}
        if rel._start_node:
            params["A"] = rel._start_node._id
        if rel._end_node:
            params["B"] = rel._end_node._id
        return self.append_cypher(query, params)

    def delete(self, entity):
        """ Delete a node or relationship from the graph.

        :param entity: node or relationship to delete
        :type entity: concrete or reference
        :return: batch request object
        """
        return self.append_delete(self._uri_for(entity))

    def set_property(self, entity, key, value):
        """ Set a single property on a node or relationship.

        :param entity: node or relationship on which to set property
        :type entity: concrete or reference
        :param key: property key
        :type key: :py:class:`str`
        :param value: property value
        :return: batch request object
        """
        if value is None:
            self.delete_property(entity, key)
        else:
            uri = self._uri_for(entity, "properties", key)
            return self.append_put(uri, value)

    def set_properties(self, entity, properties):
        """ Replace all properties on a node or relationship.

        :param entity: node or relationship on which to set properties
        :type entity: concrete or reference
        :param properties: properties
        :type properties: :py:class:`dict`
        :return: batch request object
        """
        uri = self._uri_for(entity, "properties")
        return self.append_put(uri, compact(properties))

    def delete_property(self, entity, key):
        """ Delete a single property from a node or relationship.

        :param entity: node or relationship from which to delete property
        :type entity: concrete or reference
        :param key: property key
        :type key: :py:class:`str`
        :return: batch request object
        """
        uri = self._uri_for(entity, "properties", key)
        return self.append_delete(uri)

    def delete_properties(self, entity):
        """ Delete all properties from a node or relationship.

        :param entity: node or relationship from which to delete properties
        :type entity: concrete or reference
        :return: batch request object
        """
        uri = self._uri_for(entity, "properties")
        return self.append_delete(uri)

    def add_labels(self, node, *labels):
        """ Add labels to a node.

        :param node: node to which to add labels
        :type entity: concrete or reference
        :param labels: text labels
        :type labels: :py:class:`str`
        :return: batch request object
        """
        uri = self._uri_for(node, "labels")
        return self.append_post(uri, list(labels))

    def remove_label(self, node, label):
        """ Remove a label from a node.

        :param node: node from which to remove labels (can be a reference to
            another request within the same batch)
        :param label: text label
        :type label: :py:class:`str`
        :return: batch request object
        """
        uri = self._uri_for(node, "labels", label)
        return self.append_delete(uri)

    def set_labels(self, node, *labels):
        """ Replace all labels on a node.

        :param node: node on which to replace labels (can be a reference to
            another request within the same batch)
        :param labels: text labels
        :type labels: :py:class:`str`
        :return: batch request object
        """
        uri = self._uri_for(node, "labels")
        return self.append_put(uri, list(labels))

    ### ADD TO INDEX ###

    def _add_to_index(self, cls, index, key, value, entity, query=None):
        uri = self._uri_for(self._index(cls, index), query=query)
        return self.append_post(uri, {
            "key": key,
            "value": value,
            "uri": self._uri_for(entity),
        })

    def add_to_index(self, cls, index, key, value, entity):
        """ Add an existing node or relationship to an index.

        :param cls: the type of indexed entity
        :type cls: :py:class:`Node <py2neo.neo4j.Node>` or
                   :py:class:`Relationship <py2neo.neo4j.Relationship>`
        :param index: index or index name
        :type index: :py:class:`Index <py2neo.neo4j.Index>` or :py:class:`str`
        :param key: index entry key
        :type key: :py:class:`str`
        :param value: index entry value
        :param entity: node or relationship to add to the index
        :type entity: concrete or reference
        :return: batch request object
        """
        return self._add_to_index(cls, index, key, value, entity)

    def add_to_index_or_fail(self, cls, index, key, value, entity):
        """ Add an existing node or relationship uniquely to an index, failing
        the entire batch if such an entry already exists.

        .. warning::
            Uniqueness modes for legacy indexes have been broken in recent
            server versions and therefore this method may not work as expected.

        :param cls: the type of indexed entity
        :type cls: :py:class:`Node <py2neo.neo4j.Node>` or
                   :py:class:`Relationship <py2neo.neo4j.Relationship>`
        :param index: index or index name
        :type index: :py:class:`Index <py2neo.neo4j.Index>` or :py:class:`str`
        :param key: index entry key
        :type key: :py:class:`str`
        :param value: index entry value
        :param entity: node or relationship to add to the index
        :type entity: concrete or reference
        :return: batch request object
        """
        self._assert_can_create_or_fail()
        query = "uniqueness=create_or_fail"
        return self._add_to_index(cls, index, key, value, entity, query)

    def get_or_add_to_index(self, cls, index, key, value, entity):
        """ Fetch a uniquely indexed node or relationship if one exists,
        otherwise add an existing entity to the index.

        :param cls: the type of indexed entity
        :type cls: :py:class:`Node <py2neo.neo4j.Node>` or
                   :py:class:`Relationship <py2neo.neo4j.Relationship>`
        :param index: index or index name
        :type index: :py:class:`Index <py2neo.neo4j.Index>` or :py:class:`str`
        :param key: index entry key
        :type key: :py:class:`str`
        :param value: index entry value
        :param entity: node or relationship to add to the index
        :type entity: concrete or reference
        :return: batch request object
        """
        if self.supports_index_uniqueness_modes:
            query = "uniqueness=get_or_create"
        else:
            query = "unique"
        return self._add_to_index(cls, index, key, value, entity, query)

    ### CREATE IN INDEX ###

    def _create_in_index(self, cls, index, key, value, abstract, query=None):
        uri = self._uri_for(self._index(cls, index), query=query)
        abstract = _cast(abstract, cls=cls, abstract=True)
        if cls is Node:
            return self.append_post(uri, {
                "key": key,
                "value": value,
                "properties": compact(abstract._properties or {}),
            })
        elif cls is Relationship:
            return self.append_post(uri, {
                "key": key,
                "value": value,
                "start": self._uri_for(abstract._start_node),
                "type": str(abstract._type),
                "end": self._uri_for(abstract._end_node),
                "properties": abstract._properties or {},
            })
        else:
            raise TypeError(cls)

    # Removed create_in_index as parameter combination not supported by server

    def create_in_index_or_fail(self, cls, index, key, value, abstract=None):
        """ Create a new node or relationship and add it uniquely to an index,
        failing the entire batch if such an entry already exists.

        .. warning::
            Uniqueness modes for legacy indexes have been broken in recent
            server versions and therefore this method may not work as expected.

        :param cls: the type of indexed entity
        :type cls: :py:class:`Node <py2neo.neo4j.Node>` or
                   :py:class:`Relationship <py2neo.neo4j.Relationship>`
        :param index: index or index name
        :type index: :py:class:`Index <py2neo.neo4j.Index>` or :py:class:`str`
        :param key: index entry key
        :type key: :py:class:`str`
        :param value: index entry value
        :param abstract: abstract node or relationship to create
        :return: batch request object
        """
        self._assert_can_create_or_fail()
        query = "uniqueness=create_or_fail"
        return self._create_in_index(cls, index, key, value, abstract, query)

    def get_or_create_in_index(self, cls, index, key, value, abstract=None):
        """ Fetch a uniquely indexed node or relationship if one exists,
        otherwise create a new entity and add that to the index.

        :param cls: the type of indexed entity
        :type cls: :py:class:`Node <py2neo.neo4j.Node>` or
                   :py:class:`Relationship <py2neo.neo4j.Relationship>`
        :param index: index or index name
        :type index: :py:class:`Index <py2neo.neo4j.Index>` or :py:class:`str`
        :param key: index entry key
        :type key: :py:class:`str`
        :param value: index entry value
        :param abstract: abstract node or relationship to create
        :return: batch request object
        """
        if self.supports_index_uniqueness_modes:
            query = "uniqueness=get_or_create"
        else:
            query = "unique"
        return self._create_in_index(cls, index, key, value, abstract, query)

    ### REMOVE FROM INDEX ###

    def remove_from_index(self, cls, index, key=None, value=None, entity=None):
        """ Remove any nodes or relationships from an index that match a
        particular set of criteria. Allowed parameter combinations are:

        `key`, `value`, `entity`
            remove a specific node or relationship indexed under a given
            key-value pair

        `key`, `entity`
            remove a specific node or relationship indexed against a given key
            and with any value

        `entity`
            remove all occurrences of a specific node or relationship
            regardless of key or value

        :param cls: the type of indexed entity
        :type cls: :py:class:`Node <py2neo.neo4j.Node>` or
                   :py:class:`Relationship <py2neo.neo4j.Relationship>`
        :param index: index or index name
        :type index: :py:class:`Index <py2neo.neo4j.Index>` or :py:class:`str`
        :param key: index entry key
        :type key: :py:class:`str`
        :param value: index entry value
        :param entity: node or relationship to remove from the index
        :type entity: concrete or reference
        :return: batch request object
        """
        index = self._index(cls, index)
        if key and value and entity:
            uri = self._uri_for(index, key, value, entity._id)
        elif key and entity:
            uri = self._uri_for(index, key, entity._id)
        elif entity:
            uri = self._uri_for(index, entity._id)
        else:
            raise TypeError("Illegal parameter combination for index removal")
        return self.append_delete(uri)

    ### START OF DEPRECATED METHODS ###

    @deprecated("WriteBatch.add_indexed_node is deprecated, "
                "use add_to_index instead")
    def add_indexed_node(self, index, key, value, node):
        return self.add_to_index(Node, index, key, value, node)

    @deprecated("WriteBatch.add_indexed_relationship is deprecated, "
                "use add_to_index instead")
    def add_indexed_relationship(self, index, key, value, relationship):
        return self.add_to_index(Relationship, index, key, value, relationship)

    @deprecated("WriteBatch.add_indexed_node_or_fail is deprecated, "
                "use add_to_index_or_fail instead")
    def add_indexed_node_or_fail(self, index, key, value, node):
        return self.add_to_index_or_fail(Node, index, key, value, node)

    @deprecated("WriteBatch.add_indexed_relationship_or_fail is deprecated, "
                "use add_to_index_or_fail instead")
    def add_indexed_relationship_or_fail(self, index, key, value, relationship):
        return self.add_to_index_or_fail(Relationship, index, key, value,
                                         relationship)

    @deprecated("WriteBatch.create_indexed_node_or_fail is deprecated, "
                "use create_in_index_or_fail instead")
    def create_indexed_node_or_fail(self, index, key, value, properties=None):
        self._assert_can_create_or_fail()
        abstract = properties or {}
        return self.create_in_index_or_fail(Node, index, key, value, abstract)

    @deprecated("WriteBatch.create_indexed_relationship_or_fail is deprecated, "
                "use create_in_index_or_fail instead")
    def create_indexed_relationship_or_fail(self, index, key, value,
                                            start_node, type_, end_node,
                                            properties=None):
        self._assert_can_create_or_fail()
        if properties:
            abstract = _rel(start_node, (type_, properties), end_node)
        else:
            abstract = _rel(start_node, type_, end_node)
        return self.create_in_index_or_fail(Relationship, index, key, value,
                                            abstract)

    @deprecated("WriteBatch.get_or_add_indexed_node is deprecated, "
                "use get_or_add_to_index instead")
    def get_or_add_indexed_node(self, index, key, value, node):
        self.get_or_add_to_index(Node, index, key, value, node)

    @deprecated("WriteBatch.get_or_add_indexed_relationship is deprecated, "
                "use get_or_add_to_index instead")
    def get_or_add_indexed_relationship(self, index, key, value, relationship):
        self.get_or_add_to_index(Relationship, index, key, value, relationship)

    @deprecated("WriteBatch.get_or_create_indexed_node is deprecated, "
                "use get_or_create_in_index instead")
    def get_or_create_indexed_node(self, index, key, value, properties=None):
        abstract = properties or {}
        return self.get_or_create_in_index(Node, index, key, value, abstract)

    @deprecated("WriteBatch.get_or_create_indexed_relationship is deprecated, "
                "use get_or_create_indexed instead")
    def get_or_create_indexed_relationship(self, index, key, value, start_node,
                                           type_, end_node, properties=None):
        if properties:
            abstract = _rel(start_node, (type_, properties), end_node)
        else:
            abstract = _rel(start_node, type_, end_node)
        return self.get_or_create_in_index(Relationship, index, key, value,
                                           abstract)

    @deprecated("WriteBatch.remove_indexed_node is deprecated, "
                "use remove_indexed instead")
    def remove_indexed_node(self, index, key=None, value=None, node=None):
        return self.remove_from_index(Node, index, key, value, node)

    @deprecated("WriteBatch.remove_indexed_relationship is deprecated, "
                "use remove_indexed instead")
    def remove_indexed_relationship(self, index, key=None, value=None,
                                    relationship=None):
        return self.remove_from_index(Relationship, index, key, value,
                                      relationship)

    ### END OF DEPRECATED METHODS ###
