diff --git a/docs/source/index.rst b/docs/source/index.rst index 56384a37..71588bda 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -32,7 +32,7 @@ Session API .. autoclass:: neo4j.v1.ResultSummary :members: -.. autoclass:: neo4j.v1.StatementStatistics +.. autoclass:: neo4j.v1.Counters :members: diff --git a/examples/test_examples.py b/examples/test_examples.py index b3b38de6..4ad9acd9 100644 --- a/examples/test_examples.py +++ b/examples/test_examples.py @@ -43,9 +43,9 @@ def test_minimal_working_example(self): session.run("CREATE (neo:Person {name:'Neo', age:23})") - cursor = session.run("MATCH (p:Person) WHERE p.name = 'Neo' RETURN p.age") - while cursor.next(): - print("Neo is %d years old." % cursor["p.age"]) + result = session.run("MATCH (p:Person) WHERE p.name = 'Neo' RETURN p.age") + while result.next(): + print("Neo is %d years old." % result["p.age"]) session.close() # end::minimal-example[] @@ -104,11 +104,11 @@ def test_result_cursor(self): session = driver.session() # tag::result-cursor[] search_term = "hammer" - cursor = session.run("MATCH (tool:Tool) WHERE tool.name CONTAINS {term} " + result = session.run("MATCH (tool:Tool) WHERE tool.name CONTAINS {term} " "RETURN tool.name", {"term": search_term}) print("List of tools called %r:" % search_term) - while cursor.next(): - print(cursor["tool.name"]) + while result.next(): + print(result["tool.name"]) # end::result-cursor[] session.close() @@ -116,12 +116,12 @@ def test_cursor_nesting(self): driver = GraphDatabase.driver("bolt://localhost") session = driver.session() # tag::retain-result-query[] - cursor = session.run("MATCH (person:Person) WHERE person.dept = {dept} " + result = session.run("MATCH (person:Person) WHERE person.dept = {dept} " "RETURN id(person) AS minion", {"dept": "IT"}) - while cursor.next(): + while result.next(): session.run("MATCH (person) WHERE id(person) = {id} " "MATCH (boss:Person) WHERE boss.name = {boss} " - "CREATE (person)-[:REPORTS_TO]->(boss)", {"id": cursor["minion"], "boss": "Bob"}) + "CREATE (person)-[:REPORTS_TO]->(boss)", {"id": result["minion"], "boss": "Bob"}) # end::retain-result-query[] session.close() @@ -129,9 +129,9 @@ def test_result_retention(self): driver = GraphDatabase.driver("bolt://localhost") session = driver.session() # tag::retain-result-process[] - cursor = session.run("MATCH (person:Person) WHERE person.dept = {dept} " + result = session.run("MATCH (person:Person) WHERE person.dept = {dept} " "RETURN id(person) AS minion", {"dept": "IT"}) - minion_records = list(cursor.stream()) + minion_records = list(result.stream()) for record in minion_records: session.run("MATCH (person) WHERE id(person) = {id} " @@ -148,10 +148,10 @@ def test_transaction_commit(self): tx.run("CREATE (p:Person {name: 'The One'})") tx.commit() # end::transaction-commit[] - cursor = session.run("MATCH (p:Person {name: 'The One'}) RETURN count(p)") - assert cursor.next() - assert cursor["count(p)"] == 1 - assert cursor.at_end() + result = session.run("MATCH (p:Person {name: 'The One'}) RETURN count(p)") + assert result.next() + assert result["count(p)"] == 1 + assert result.at_end session.close() def test_transaction_rollback(self): @@ -162,21 +162,22 @@ def test_transaction_rollback(self): tx.run("CREATE (p:Person {name: 'The One'})") tx.rollback() # end::transaction-rollback[] - cursor = session.run("MATCH (p:Person {name: 'The One'}) RETURN count(p)") - assert cursor.next() - assert cursor["count(p)"] == 0 - assert cursor.at_end() + result = session.run("MATCH (p:Person {name: 'The One'}) RETURN count(p)") + assert result.next() + assert result["count(p)"] == 0 + assert result.at_end session.close() def test_result_summary_query_profile(self): driver = GraphDatabase.driver("bolt://localhost") session = driver.session() # tag::result-summary-query-profile[] - cursor = session.run("PROFILE MATCH (p:Person {name: {name}}) " + result = session.run("PROFILE MATCH (p:Person {name: {name}}) " "RETURN id(p)", {"name": "The One"}) - summary = cursor.summarize() - print(summary.statement_type) - print(summary.profile) + while result.next(): + pass # skip the records to get to the summary + print(result.summary.statement_type) + print(result.summary.profile) # end::result-summary-query-profile[] session.close() @@ -184,8 +185,10 @@ def test_result_summary_notifications(self): driver = GraphDatabase.driver("bolt://localhost") session = driver.session() # tag::result-summary-notifications[] - summary = session.run("EXPLAIN MATCH (a), (b) RETURN a,b").summarize() - for notification in summary.notifications: + result = session.run("EXPLAIN MATCH (a), (b) RETURN a,b") + while result.next(): + pass # skip the records to get to the summary + for notification in result.summary.notifications: print(notification) # end::result-summary-notifications[] session.close() diff --git a/neo4j/__main__.py b/neo4j/__main__.py index 13d5db8c..57896c05 100644 --- a/neo4j/__main__.py +++ b/neo4j/__main__.py @@ -75,8 +75,8 @@ def main(): stdout.write("%s\r\n" % "\t".join(map(repr, record))) if has_results: stdout.write("\r\n") - if args.summarize: - summary = cursor.summarize() + if args.summary: + summary = cursor.summary stdout.write("Statement : %r\r\n" % summary.statement) stdout.write("Parameters : %r\r\n" % summary.parameters) stdout.write("Statement Type : %r\r\n" % summary.statement_type) diff --git a/neo4j/v1/exceptions.py b/neo4j/v1/exceptions.py index 8f18e31b..42bbdc5f 100644 --- a/neo4j/v1/exceptions.py +++ b/neo4j/v1/exceptions.py @@ -38,3 +38,10 @@ def __init__(self, data): for key, value in data.items(): if not key.startswith("_"): setattr(self, key, value) + + +class ResultError(Exception): + """ Raised when the cursor encounters a problem. + """ + + pass diff --git a/neo4j/v1/session.py b/neo4j/v1/session.py index 3098d7cc..4d548a19 100644 --- a/neo4j/v1/session.py +++ b/neo4j/v1/session.py @@ -32,7 +32,7 @@ class which can be used to obtain `Driver` instances that are used for from .compat import integer, string, urlparse from .connection import connect, Response, RUN, PULL_ALL -from .exceptions import CypherError +from .exceptions import CypherError, ResultError from .typesystem import hydrated @@ -170,11 +170,13 @@ def record(self): """ return self._current + @property def position(self): """ Return the current cursor position. """ return self._position + @property def at_end(self): """ Return ``True`` if at the end of the record stream, ``False`` otherwise. @@ -185,7 +187,7 @@ def at_end(self): return True else: self._connection.fetch() - return self.at_end() + return self.at_end def stream(self): """ Yield all subsequent records. @@ -216,13 +218,19 @@ def get(self, item, default=None): except (IndexError, KeyError): return default - def summarize(self): - """ Consume the remainder of this result and produce a summary. + @property + def summary(self): + """ Return the summary from the trailing metadata. Note that this is + only available once the entire result stream has been consumed. + Attempting to access the summary before then will raise an error. :rtype: ResultSummary + :raises ResultError: if the entire result has not yet been consumed """ - self._consume() - return self._summary + if self._consumed: + return self._summary + else: + raise ResultError("Summary not available until the entire result has been consumed") def _consume(self): # Consume the remainder of this result, triggering all appropriate callback functions. @@ -262,8 +270,8 @@ class ResultSummary(object): #: The type of statement (``'r'`` = read-only, ``'rw'`` = read/write). statement_type = None - #: A set of statistical information held in a :class:`.StatementStatistics` instance. - statistics = None + #: A set of statistical information held in a :class:`.Counters` instance. + counters = None #: A :class:`.Plan` instance plan = None @@ -281,7 +289,7 @@ def __init__(self, statement, parameters, **metadata): self.statement = statement self.parameters = parameters self.statement_type = metadata.get("type") - self.statistics = StatementStatistics(metadata.get("stats", {})) + self.counters = Counters(metadata.get("stats", {})) if "plan" in metadata: self.plan = make_plan(metadata["plan"]) if "profile" in metadata: @@ -296,7 +304,7 @@ def __init__(self, statement, parameters, **metadata): notification["description"], position)) -class StatementStatistics(object): +class Counters(object): """ Set of statistics from a Cypher statement execution. """ diff --git a/neokit b/neokit index 8fc30fd9..225f07d2 160000 --- a/neokit +++ b/neokit @@ -1 +1 @@ -Subproject commit 8fc30fd9fa5145fdd8b6627f3525a66b0213becc +Subproject commit 225f07d2d8cd8082ac1bfb8229c8a413be82c546 diff --git a/runtests.sh b/runtests.sh index 4cf62408..833464e6 100755 --- a/runtests.sh +++ b/runtests.sh @@ -62,6 +62,7 @@ echo "Running tests with $(python --version)" pip install --upgrade -r ${DRIVER_HOME}/test_requirements.txt echo "" TEST_RUNNER="coverage run -m ${UNITTEST} discover -vfs ${TEST}" +EXAMPLES_RUNNER="coverage run -m ${UNITTEST} discover -vfs examples" BEHAVE_RUNNER="behave --tags=-db --tags=-in_dev test/tck" if [ ${RUNNING} -eq 1 ] then diff --git a/test/test_session.py b/test/test_session.py index 0f60d3ae..4cd8b118 100644 --- a/test/test_session.py +++ b/test/test_session.py @@ -22,6 +22,7 @@ from unittest import TestCase from mock import patch +from neo4j.v1.exceptions import ResultError from neo4j.v1.session import GraphDatabase, CypherError, Record, record from neo4j.v1.typesystem import Node, Relationship, Path @@ -85,7 +86,9 @@ def test_sessions_are_not_reused_if_still_in_use(self): def test_can_run_simple_statement(self): session = GraphDatabase.driver("bolt://localhost").session() count = 0 - for record in session.run("RETURN 1 AS n").stream(): + cursor = session.run("RETURN 1 AS n") + assert cursor.position == -1 + for record in cursor.stream(): assert record[0] == 1 assert record["n"] == 1 with self.assertRaises(KeyError): @@ -97,6 +100,7 @@ def test_can_run_simple_statement(self): _ = record[object()] assert repr(record) assert len(record) == 1 + assert cursor.position == count count += 1 session.close() assert count == 1 @@ -191,25 +195,59 @@ def test_can_handle_cypher_error(self): with self.assertRaises(CypherError): session.run("X").close() - def test_can_obtain_summary_info(self): + def test_keys_are_available_before_and_after_stream(self): + with GraphDatabase.driver("bolt://localhost").session() as session: + cursor = session.run("UNWIND range(1, 10) AS n RETURN n") + assert list(cursor.keys()) == ["n"] + _ = list(cursor.stream()) + assert list(cursor.keys()) == ["n"] + + def test_keys_with_an_error(self): + with GraphDatabase.driver("bolt://localhost").session() as session: + cursor = session.run("X") + with self.assertRaises(CypherError): + _ = list(cursor.keys()) + + +class SummaryTestCase(TestCase): + + def test_can_obtain_summary_after_consuming_result(self): with GraphDatabase.driver("bolt://localhost").session() as session: cursor = session.run("CREATE (n) RETURN n") - summary = cursor.summarize() + list(cursor.stream()) + summary = cursor.summary assert summary.statement == "CREATE (n) RETURN n" assert summary.parameters == {} assert summary.statement_type == "rw" - assert summary.statistics.nodes_created == 1 + assert summary.counters.nodes_created == 1 + + def test_cannot_obtain_summary_without_consuming_result(self): + with GraphDatabase.driver("bolt://localhost").session() as session: + cursor = session.run("CREATE (n) RETURN n") + with self.assertRaises(ResultError): + _ = cursor.summary + + # def test_can_obtain_summary_immediately_if_empty_result(self): + # with GraphDatabase.driver("bolt://localhost").session() as session: + # cursor = session.run("CREATE (n)") + # summary = cursor.summary + # assert summary.statement == "CREATE (n)" + # assert summary.parameters == {} + # assert summary.statement_type == "rw" + # assert summary.counters.nodes_created == 1 def test_no_plan_info(self): with GraphDatabase.driver("bolt://localhost").session() as session: cursor = session.run("CREATE (n) RETURN n") - assert cursor.summarize().plan is None - assert cursor.summarize().profile is None + list(cursor.stream()) + assert cursor.summary.plan is None + assert cursor.summary.profile is None def test_can_obtain_plan_info(self): with GraphDatabase.driver("bolt://localhost").session() as session: cursor = session.run("EXPLAIN CREATE (n) RETURN n") - plan = cursor.summarize().plan + list(cursor.stream()) + plan = cursor.summary.plan assert plan.operator_type == "ProduceResults" assert plan.identifiers == ["n"] assert plan.arguments == {"planner": "COST", "EstimatedRows": 1.0, "version": "CYPHER 3.0", @@ -220,7 +258,8 @@ def test_can_obtain_plan_info(self): def test_can_obtain_profile_info(self): with GraphDatabase.driver("bolt://localhost").session() as session: cursor = session.run("PROFILE CREATE (n) RETURN n") - profile = cursor.summarize().profile + list(cursor.stream()) + profile = cursor.summary.profile assert profile.db_hits == 0 assert profile.rows == 1 assert profile.operator_type == "ProduceResults" @@ -232,14 +271,16 @@ def test_can_obtain_profile_info(self): def test_no_notification_info(self): with GraphDatabase.driver("bolt://localhost").session() as session: - result = session.run("CREATE (n) RETURN n") - notifications = result.summarize().notifications + cursor = session.run("CREATE (n) RETURN n") + list(cursor.stream()) + notifications = cursor.summary.notifications assert notifications == [] def test_can_obtain_notification_info(self): with GraphDatabase.driver("bolt://localhost").session() as session: - result = session.run("EXPLAIN MATCH (n), (m) RETURN n, m") - notifications = result.summarize().notifications + cursor = session.run("EXPLAIN MATCH (n), (m) RETURN n, m") + list(cursor.stream()) + notifications = cursor.summary.notifications assert len(notifications) == 1 notification = notifications[0] @@ -261,19 +302,6 @@ def test_can_obtain_notification_info(self): assert position.line == 1 assert position.column == 1 - def test_keys_are_available_before_and_after_stream(self): - with GraphDatabase.driver("bolt://localhost").session() as session: - cursor = session.run("UNWIND range(1, 10) AS n RETURN n") - assert list(cursor.keys()) == ["n"] - _ = list(cursor.stream()) - assert list(cursor.keys()) == ["n"] - - def test_keys_with_an_error(self): - with GraphDatabase.driver("bolt://localhost").session() as session: - cursor = session.run("X") - with self.assertRaises(CypherError): - _ = list(cursor.keys()) - class ResetTestCase(TestCase):